src/api/server.ts implements the central HTTP gate function that authenticates and authorizes every incoming API request before it reaches a route handler.[1] The gate function returns null to signal that a response has already been sent (reject path) and returns a GateResult object to signal success — callers must check for null before proceeding.[1] The GateResult type carries three fields: body (parsed JSON or {}), capability (decoded CapabilityClaims or null), and actor (decoded PortalIdentity or null) — exactly one of capability or actor will be non-null for authenticated requests.[1] The Wiring interface packages the dependencies threaded through gate: app, deps, signing secret, a SourceAuth instance, a requirePortalIdentity flag, and an allowUnsignedSourceAuth flag.[1] A capability token is a signed JWT encoding a scope, audience, and actor identity, allowing gate to verify request authorization without consulting a session store on every call.
Routes are classified by a RouteAuth value: "public" skips all auth checks, "either" accepts a capability token or source-auth signature, and an object { aud } requires a capability token with a specific audience.[1] For non-public routes, gate tries three authentication paths in order: capability token (via CAPABILITY_HEADER), portal identity token (via PORTAL_IDENTITY_HEADER), or HMAC source-auth signature verification.[1] When a capability token is present, gate verifies it against deps.capabilitySecret ?? secret, checks that the actor is still classified as "internal" via deps.identity, and confirms scope membership via app.authorizesCapabilityScope — returning 401 or 403 on any failure.[1] When requirePortalIdentity is enabled, user-scoped routes, admin routes, unclassified writes, and POST /v1/turns with surface: "web" all require a valid portal identity token; a missing or mismatched identity returns 401 or 403.[1] The admin plugin (plugins/admin/src/index.ts) and portal proxy (plugins/portal/src/index.ts) enforce an authorization readiness check before acting on admin requests, blocking any request that arrives before the admin auth subsystem is fully initialized. Email-auth and directory-sourced identities are unified into a single identity model, reflected consistently across src/identity/identity-service.ts, src/config.ts, src/deployment/secret-schema.ts, and src/api/routes/admin/users.ts. An email-based external-user invitation flow, managed in src/admin/invite-email.ts, allows invitations to carry a role and expiry and be issued to users outside the organization. Invitation acceptance is handled through the OIDC layer in plugins/portal/src/oidc.ts, with invitation state stored as part of the grants model. External-member scaffolding — provisioning and lifecycle management for org-external users — is implemented in plugins/chassis/src/external-members.ts. Signing key IDs in src/auth/signed-token.ts are derived using HMAC rather than a plain hash, tightening the binding between key material and its identifier and reducing collision or substitution risk across independent deployments. Key IDs generated before the HMAC-derivation scheme do not match those generated after it; any stored or cached key IDs (e.g., in in-flight JWTs) require re-derivation on next issuance.
When deps.config.getSecurityPostureDurable(capability.scopeId) returns "strict", non-GET mutations that are not on the strictPostAllowed allowlist are blocked with HTTP 403 ("Strict posture blocks direct control-plane mutations").[1] The strictPostAllowed function defines the exact set of POST/PUT paths that bypass the strict-posture mutation block, including /v1/surface-context, /v1/projects, /v1/conversations, /v1/memory/search, /v1/memory/restore, run-signals, conversation forks, project members, skill restore, and decline trigger-consent decisions.[1]
capabilityAdminDenied enforces that agent-issued capability tokens cannot access admin grant changes (/v1/admin/grants), impersonation (/v1/admin/impersonate), bulk scope imports from non-personal scopes, or the admin-session-reads flag from non-personal scopes — returning a specific denial message for each.[1] Autonomous (cron) agent turns are blocked from all admin routes because capabilityAdminDenied requires claims.liveActor === true for the CONTROL_PLANE_AUD audience — returning "admin actions through the agent require a turn the admin started themselves — autonomous turns (crons) cannot act as an admin".[1] Admin content-read routes (memory, keychain, volumes, scopes, sessions, files, runs, audit, errors, egress, crons, deployments, skills, shadow deliveries, slack-mirror, ambient-judgments, ack-emoji-picks, users) from a non-personal scope are blocked for agent tokens unless the target scope is an org scope — preventing private content reads from channel or automated turns.[1] The orchestrator in src/core/orchestrator.ts now checks service-credential grants before allowing org-level credentials delivered through environment variables to be used, closing an authorization gap where env-delivered org credentials previously bypassed grant-based authorization checks.
Raw request bodies are stored in a WeakMap<IncomingMessage, string> keyed on the Node.js IncomingMessage object, allowing the body string to be read once and reused without re-streaming.[1] src/api/server.ts augments Fastify's type system to attach gate?: GateResult to FastifyRequest and route?: Route<ApiCtx> to FastifyContextConfig, making auth results and matched routes available as typed request properties throughout route handlers.[1] Route definitions are imported from ./routes/index.ts (apiRoutes and rawRoutes); deployment subdomain proxying is imported from ./routes/deployments.ts (proxyDeploymentSubdomain) — see Routes and app assembly for how these are assembled.[1] plugins/web-ui/server/index.ts performs server-side rewriting of links in conversation messages, resolving sandbox file URLs correctly rather than stripping or misrouting them. plugins/web-ui/src/markdown-sanitize.ts sanitizes markdown in conversation messages and maintains an allow-list of permitted link schemes; sandbox file URLs are included in that allow-list.
Sources