Skip to main content

Implementation Plan: Plugins Capabilities (HTTP Surface)

Feature ID: plugins-capabilities Spec: ./spec.md Tasks: ./tasks.md Status: Done (retrospective; surface already implemented on develop) Last updated: 2026-05-08


1. Architecture Summary

2. Tech Choices

ConcernChoiceRationale
HTTP shellNestJS controllers under apps/api/src/plugins-capabilities/<cap>/One sub-module per capability mirrors the agent-package facade boundary; modules are mounted in api.module.ts (lines 18-71).
AuthGlobal AuthSessionGuard via @UseGuardsSame guard the rest of the API uses; no new auth surface for plugins-capabilities.
Per-work permission gateWorkOwnershipService.ensureCanEdit / .ensureCanViewPre-existing service already used by the works controller; centralises owner-vs-member-vs-collaborator logic.
Provider resolutionPer-capability facade in @ever-works/agent/facadesKeeps plugin lookup + secret retrieval out of the controller; facades are exported via FacadesModule.
Plugin enumerationPluginRegistryService.getEnabledPluginsScoped(cap, workId, userId)Single source of truth for "which plugins are active for this scope?" — consumes the four-level cascade.
Settings readsPluginSettingsService.getSettings({userId, workId, includeSecrets})Same service the plugin admin UI uses; includeSecrets:true only ever runs server-side.
Background pollingIn-memory setInterval queue keyed by workId in DeploymentVerifierServiceVerification is short (≤13 min) and naturally fits a single-process poller; full Trigger.dev migration is OQ-3.
Cross-service notificationEventEmitter2 from @nestjs/event-emitterDecouples deploy services from ActivityLogService; the listener-side spec is pinned in activity-log.
GitHub Actions secret writesVia the github plugin's setActionSecret / setActionVariablePlugin owns the libsodium-encrypted Actions API surface; deploy service only knows "key/value/repo".
Domain validationclass-validator @Matches regex on AddDomainDto.domainReject malformed input at the DTO layer so the controller can assume a syntactically-valid hostname.
Batch deploy concurrencyRolling batches of 5 with 2 000 ms interval-sleepEmpirically chosen to stay under GitHub Actions secrets-API rate limits (5 req/s soft cap).
Activity-log emissionFire-and-forget .catch(() => {}) from controllerAudit-write failures MUST NOT propagate back to the user; the controller's success response is independent of the audit log.

3. Data Model

No new entities or migrations.

The surface reads/writes the existing tables:

  • worksdeploymentState, deploymentStartedAt, website updated by DeploymentVerifierService via WorkRepository.update.
  • auth_accounts — upserted by OAuthService.handleOAuthCallback with the plugin:-prefixed providerId.
  • plugin_settings (read-only here) — settings cascade resolved via PluginSettingsService.
  • activity_log (write-only here) — emitted via ActivityLogService.log (deploy controller) and indirectly via ActivityLogListener consuming the three deploy events.

DTOs

All under apps/api/src/plugins-capabilities/<cap>/dto/:

  • deploy/dto/deploy.dto.tsDeployWorkDto { teamScope?: string }, ValidateTokenDto { providerId: string }, GetTeamsDto { providerId: string }.
  • deploy/dto/batch-deploy.dto.tsBatchDeployItemDto { workId, teamScope? }, BatchDeployDto { works[], teamScope? }, BatchDeployItemResultDto, BatchDeployResponseDto.
  • deploy/dto/domain.dto.tsAddDomainDto { domain: string @Matches(<regex>) }.
  • search/dto/search.dto.tsSearchDto { query, maxResults?, includeDomains?, excludeDomains? }.
  • screenshot/dto/screenshot.dto.tsCaptureScreenshotDto (URL + viewport + format + behaviour flags) and GetScreenshotUrlDto (alias).

4. API Surface

Deploy (/api/deploy/*)

MethodEndpointAuthBody / QueryResponse shape
GET/api/deploy/providerssession{status, providers[]}
GET/api/deploy/providers/:providerId/configuredsessionpath{status, configured, available, enabled?, message}
POST/api/deploy/works/:idsessionDeployWorkDto{status: 'pending', slug, owner, repository, message} or 400 BadRequest
POST/api/deploy/validate-tokensession{status, valid, userInfo: null, message}
POST/api/deploy/teamssession{status, teams: [], message} (placeholder; see OQ-1)
POST/api/deploy/works/:id/teamssessionpath{status, teams[]} or 400 BadRequest
POST/api/deploy/works/:id/checksessionpath{status, canDeploy, isShared, ownerHasToken, userHasToken}
POST/api/deploy/works/:id/lookupsessionpath{status, website, deploymentState, found} or 400 BadRequest
POST/api/deploy/batchsessionBatchDeployDtoBatchDeployResponseDto
GET/api/deploy/works/:id/domainssessionpath{status, domains[]} or 400 BadRequest
POST/api/deploy/works/:id/domainssessionAddDomainDto{status, …addDomainResult} or 400 BadRequest
DELETE/api/deploy/works/:id/domains/:domainsessionpath{status, removed} or 400 BadRequest
POST/api/deploy/works/:id/domains/:domain/verifysessionpath{status, domain} or 400 BadRequest

Search (/api/search/*)

MethodEndpointBodyResponse
GET/api/search/check-availability{status, available, activeProvider | null, message?}
POST/api/search/SearchDto{status, results, provider} or 400 BadRequest

Screenshot (/api/screenshot/*)

MethodEndpointBody / QueryResponse
GET/api/screenshot/check-availability?workIdquery{status, available, providers[], activeProvider}
POST/api/screenshot/captureCaptureScreenshotDto{status, imageUrl, cacheUrl, imageBase64 | null} or 400
POST/api/screenshot/get-urlGetScreenshotUrlDto{status, imageUrl} or 400 BadRequest

OAuth (/api/oauth/*)

MethodEndpointQueryResponse
GET/api/oauth/providers{configured, providers[]}
GET/api/oauth/:providerId/connectionOAuthConnectionInfo (200 even on failure)
GET/api/oauth/:providerId/connect/urlcallbackUrl?, state?, forceConsent?{url, state} or 400 BadRequest
GET/api/oauth/:providerId/callback/pluginscode, state?OAuthConnectionInfo or 400 BadRequest
GET/api/oauth/:providerId/user{success, user? | error?} (200 always)
DELETE/api/oauth/:providerId204 (undefined body)

Git Provider (/api/git-providers/*)

MethodEndpointQueryResponse
GET/api/git-providers{configured, providers[]}
GET/api/git-providers/:providerId/connectionGitProviderConnectionInfo (200 always)
GET/api/git-providers/:providerId/organizations{success, organizations[] | error?} (200 always)
GET/api/git-providers/:providerId/repositoriespage?, perPage?{success, repositories[] | error?} (200 always)
GET/api/git-providers/:providerId/user{success, user | error?} (200 always)

Device Auth (/api/device-auth/*)

MethodEndpointBodyResponse
GET/api/device-auth/:pluginId/statusDeviceAuthStatus
POST/api/device-auth/:pluginId/startDeviceAuthStatus

5. Plugin Surface

No new capability interfaces. The optional plugin contract methods used by the deploy service:

  • IDeploymentPlugin.getWorkflowFilenames?(): string[] — list of GitHub Actions workflow filenames to try, in order. Default fallback: ['deploy_prod.yaml']. Vercel plugin currently returns ['deploy_vercel.yaml', 'deploy_prod.yaml'] to preserve legacy behavior.
  • IDeploymentPlugin.getDeploymentSecrets?(settings): Promise<Record<string, string>> — extra GitHub Actions secrets to push (e.g. k8s registry creds, custom namespace). Failure here is logged but does NOT abort the dispatch.
  • IDeploymentPlugin.providerName?: string — display name override used in activity-log summaries; falls back to plugin.name, then plugin.id.

The deploy service additionally invokes a fixed set of methods on the loaded github plugin (it reads the registered plugin via pluginRegistry.get('github') and treats it as any):

  • getRepositoryPublicKey(owner, repo, token) — GitHub Actions secrets use libsodium-encrypted boxes; the public key fetch is a prerequisite.
  • setActionSecret({key, value, owner, repo}, publicKey, token) — encrypt
    • PUT into Actions secrets.
  • setActionVariable({key, value, owner, repo}, token) — PUT into Actions variables (used for DEPLOY_PROVIDER).
  • enableDeploymentWorkflows(owner, repo, token, withDelay?) — re-enable workflows when GitHub auto-disables them after an inactive period.
  • dispatchWorkflow({workflow, inputs, branch, owner, repo}, token) — fire the workflow_dispatch event.

6. Web / CLI Surface

No new pages or CLI commands ship with this spec — the platform's existing UI consumes these endpoints (e.g. the deploy dashboard, the plugin settings page, the OAuth connect button, the search bar in the chat conversation surface).

7. Background Jobs

TriggerWhenWhat it doesIdempotency strategy
In-memory setInterval (10 s)Once per startVerification callPolls deployFacade.lookupExistingDeployment; updates work.deploymentStatePer-workId cancellation map: a new startVerification cancels the prior one; terminated boolean guard prevents duplicate terminal events.
Synchronous batch loopPer POST /api/deploy/batchRolling batches of 5; 2 000 ms sleep between batchesNone at the service layer; controller skips startVerification for non-pending results.

No Trigger.dev tasks ship in this surface today. OQ-3 documents the planned migration path for the verifier.

8. Security & Permissions

  • Auth: every endpoint behind global AuthSessionGuard (no @Public routes in plugins-capabilities/).
  • Per-work: every :id-scoped endpoint runs WorkOwnershipService.ensureCanEdit (mutating) or .ensureCanView (read-only). Non-creator callers route facade calls under the owner's userId so plugin settings resolve to the owner's configuration, not the caller's.
  • Secrets:
    • x-secret: true plugin settings are read with includeSecrets: true on the server only and never echoed in responses.
    • GitHub Actions secrets are written via libsodium-encrypted PUT through the github plugin; the deploy service only logs the count of plugin-supplied secrets, never the keys.
    • CRON_SECRET is regenerated on every dispatch (32-byte hex from crypto.randomBytes), preventing stale-secret reuse.
  • OAuth state: randomBytes(16).toString('hex') is used as the default state value when callers don't supply one. The OAuth state is passed through to the provider but its server-side verification on callback is the OAuth facade's responsibility (see auth-jwt-oauth OQ-2).
  • Rate limiting: relies on the global throttler config in config — no per-endpoint override here.
  • Domain regex: rejects path/scheme/whitespace at the DTO layer, preventing injection into provider-side domain APIs.

9. Observability

  • Activity-log emissions (controller-emitted, fire-and-forget): - POST /api/deploy/works/:idactionType: DEPLOYMENT, action: 'work.deployed', status: COMPLETED, summary: 'Triggered deployment for <work.name> via <providerName>' (only when deploymentInitiated === true). - POST /api/deploy/batchactionType: DEPLOYMENT, action: 'deployment.batch_started', status: COMPLETED, summary: 'Triggered batch deploy for <N> works', details: {workIds: [...]}.
  • Activity-log emissions (listener-emitted, via deploy events):
    • DeploymentDispatchedEvent → emitted by DeployService.deploy on successful dispatch.
    • DeploymentCompletedEvent → emitted by DeploymentVerifierService on state === 'READY'; payload includes the resolved url.
    • DeploymentFailedEvent → emitted by DeploymentVerifierService on state ∈ {ERROR, TIMEOUT, CANCELED, UNKNOWN}; payload includes terminalState and error?.
  • Logger output: every service has a per-instance Logger; key log lines:
    • Starting verification for work <id>
    • Cleaning up verification for work <id> - <state>
    • Attempting to dispatch workflow "<file>" for <owner>/<repo>
    • Successfully dispatched workflow "<file>" for <owner>/<repo>
    • Pushed <N> plugin-specific secrets for <pluginId> to <owner>/<repo>
    • Workflow dispatch failed. Updating repository for <owner>/<repo>
  • NO activity-log emission for: search, screenshot, OAuth, git-provider, device-auth — these are per-call CRUD-style endpoints and the audit-volume cost outweighs the visibility gain.

10. Phased Rollout

This is a retrospective plan: every phase below has already shipped.

  1. Phase 1 — Sub-module scaffolding (shipped): DeployModule, SearchModule, ScreenshotModule, OAuthModule, GitProviderModule, DeviceAuthModule mounted in api.module.ts with their respective facades imported via FacadesModule.
  2. Phase 2 — Capability-driven plugin contracts (shipped): optional getDeploymentSecrets + getWorkflowFilenames added to IDeploymentPlugin; DeployService now resolves them with safe fallbacks for legacy plugins.
  3. Phase 3 — Event-driven activity-log (shipped): DeployService / DeploymentVerifierService emit DeploymentDispatchedEvent / DeploymentCompletedEvent / DeploymentFailedEvent; the ActivityLogListener is the sole consumer in production.
  4. Phase 4 — Spec backfill (this PR): pin the surface in a Spec Kit feature so the hourly-tracker task can mark it Done.

11. Risks & Mitigations

RiskLikelihoodImpactMitigation
API restart during a verification poll loses in-flight verification.MediumLowDocumented in OQ-3; users can manually POST /api/deploy/works/:id/lookup to re-resolve. Migration to a Trigger.dev task is the long-term fix.
GitHub Actions API rate-limit (secrets endpoint, ~5 req/s) on batch deploys.LowMediumRolling batches of 5 with 2 000 ms sleep between them; per-work secret writes use Promise.all of 4 + N (plugin extras), well under per-call limits.
Plugin-provided getDeploymentSecrets throws and corrupts dispatch.LowHighCaught + logged + dispatch continues. Plugin authors must not assume getDeploymentSecrets is the source of truth for required secrets.
Two startVerification calls race for the same work and emit duplicate terminals.LowLowterminated boolean idempotency guard; prior poller is cancelled before the new one is registered.
OAuth callback received with stale state → silent token replacement.LowHighDocumented in OQ-2; current flow trusts the OAuth facade to verify state. Spec follow-up: see auth-jwt-oauth OQ-2.
auth_accounts row collision when the same provider is connected via two flows.LowMediumbuildPluginProviderId namespaces plugin OAuth from social OAuth (plugin:<id> vs <id>); two distinct rows can co-exist. UI may need to surface both.
Domain regex falsely rejects valid TLDs that include digits (e.g. emoji TLDs).Very lowLowDocumented limitation; current regex covers the standard ASCII TLD set.
Asymmetric error envelope (deploy/search/screenshot throw 4xx; OAuth/git-provider/device-auth wrap into 200) confuses external integrators.MediumLowDocumented in OQ-2; consistency is desirable but a migration would be a breaking change for existing UI clients.
Search resolution always picks the first configured plugin — user can't override per call.LowLowDocumented in OQ-4; screenshot already supports providerOverride, search may follow.

12. Constitution Reconciliation

GateStatusNotes
Principle I — Plugin-firstEvery capability resolves through a plugin under packages/plugins/.
Principle II — Capability-drivenEvery endpoint resolves through a facade + PluginRegistryService capability lookup.
Principle III — Source-of-truth reposDeploy writes via WebsiteUpdateService; OAuth writes to auth_accounts (not user repos).
Principle IV — Long-running via Trigger.dev⚠️ FutureIn-memory verifier is documented as a follow-up (OQ-3) for migration to Trigger.dev.
Principle V — Forward-only migrationsNo schema changes ship in this surface.
Principle VI — Tests accompany⚠️ Partial5 controller specs + 4 service specs; deploy controller spec is flagged as a follow-up (T-DEPLOY-CTRL).
Principle VII — x-secret hygieneincludeSecrets:true only on the server; secret values never echoed in responses or activity-log entries.
Principle VIII — Plugin counts canonicalNot applicable — this spec discusses the HTTP surface only.
Principle IX — Behaviour-firstSpec describes user-observable behaviour; implementation lives here in the plan.
Principle X — Backwards-compatibleOptional plugin contract methods preserve compatibility with older plugins.

13. References