Self notes

Architecture

Internal notes on the gate/control-plane split, the trust boundaries between them, and what is still stopped.

This is the one page written in internal vocabulary. Everything else in these docs speaks the product's language on purpose; this one names the components, the wire contracts, and the boundaries as the code names them.

The split

Two processes, one database.

The gate (apps/gate, Go) is the enforcement core. Its hot path is GET /gate/check, the auth_request subrequest ingress-nginx issues before proxying to a protected app. That path performs no database and no network IO: it reads an immutable in-memory snapshot published from PostgreSQL, verifies the __Host-dagate cookie against its own Ed25519 key, matches the exact host, consults the snapshot's authorization maps, and returns 200 with identity headers or 401. Because auth_request turns any non-2xx/401/403 into a user-facing 500, /gate/check has no logic path that can 5xx; a recovered panic is a 401 like everything else.

The control plane (apps/web, Next.js 16 + Better Auth) is the OIDC issuer and the system of record. It owns hosted login, consent, the dashboard, the public REST surface, the MCP adapter, the token vault, and every write to PostgreSQL.

They share no code path at runtime. The gate reads tables the control plane writes, and the two exchange exactly two authenticated messages: a login handoff and a revocation notification.

State

StoreOwnsOn the hot path
PostgreSQLApplications, grants, groups, sessions, audit, revocations, credentials, clientsNo
Gate snapshotAn immutable projection of the above, refreshed every 30sYes
DragonflyHandoff JTIs, CSRF tokens, and login-path rate limitsLogin paths only

The snapshot is the reason the hot path is local. It is republished whole rather than mutated, and a snapshot older than five minutes is unusable: the gate then denies everything rather than serving decisions it can no longer stand behind. Snapshot loading is bounded in row count and byte size at the query, so a pathological row cannot become an unbounded allocation.

Dragonfly is deliberately confined to paths that are already doing a network round trip anyway — handoff redemption, the account-less forms, rate limiting. Nothing in /gate/check touches it.

Gate routes

RouteOwnerPurpose
GET /gate/checkgateThe subrequest. Local only, never 5xx.
/start, /callbackgatePlatform-login handoff out and back
/_da/gate/passwordgateAccount-less shared-password form and submission
/_da/gate/redeemgateSigned share-link redemption
/_da/gate/bypassgateQuery bypass exchange for automation
POST /internal/revokegateReceives HMAC-signed revocation pushes
GET /internal/revocationscontrol planePaged revocation cursor the gate polls
/healthz, /readyz, /metricsgateLiveness, snapshot readiness, Prometheus

/internal/revocations is a control-plane endpoint. The gate is its client, not its owner; the two are easy to confuse because they share a prefix and neither is public.

Handoff

Platform login cannot set a host-only __Host-dagate cookie for an application host from id.davidapps.dev, so the signed handoff is returned to https://<verified-application-host>/_da/callback. That location is the one reserved path on the app's own ingress that bypasses auth_request; it forwards no identity or bypass headers and proxies the still state- and host-bound token to the gate.

Verification, in order: EdDSA signature against the issuer's JWKS (cached fresh for five minutes and served stale for five more, with a bounded refetch on an unknown kid); the frozen iss/aud: da-gate/typ: da-handoff triple; a 300-second lifetime with 60 seconds of leeway and exp == iat + 300 enforced exactly; and a one-use jti consumed in Dragonfly. Only then is a gate cookie minted, with the resolved role, groups, and epoch.

The login state itself is HMAC-bound, so a caller cannot substitute the application, the host, the role, or an invitation id by editing what came back.

Assertions

Mode-2 apps verify a signed assertion rather than trusting headers. X-DA-Assertion is a gate-signed compact JWS with a 60-second TTL, 60 seconds of leeway, exp == iat + 60 exactly, an exact-host aud, and a pairwise sub. The gate strips every client-supplied X-DA-* header before injecting its own, and auth-response-headers enumerates the full set — a header missing from that list passes through as the client sent it, which is why the list is exhaustive and must stay in step with packages/protocol.

Consumer-side verification lives in packages/sdk-core: a pinned local key snapshot of at most eight 32-byte Ed25519 keys, no network refresh, no token-selected key URL, and a race-safe (iss, sub) identity resolution that can never select or create an identity by email. Replay protection consumes the jti atomically before any session work, and the native session must rotate and must be no longer than 900 seconds.

Effect composition

Effect is quarantined under apps/web/src/server/effect/; lint makes an effect import an error anywhere else. One ManagedRuntime singleton lives in runtime.ts, cached on globalThis so development reloads cannot duplicate daemon fibers. Effect.run* outside that file is a build failure.

The production layer graph, as production-layer.ts composes it:

ServiceProvided from
AppConfigEnvironment, validated once
PrismaProcess-owned operations over the shared client
ProviderClientsAppConfig, transport built on systemFetch
AuditWriterAppConfig and Prisma
TokenVaultProviderClients, plus a VaultRepository supplied per call
RevocationBusAppConfig, Prisma, and AuditWriter, over the push notifier
ResendAppConfig, transport built on systemFetch

TokenVault is the one service whose dependency is not fully static. makeTransactionVaultRepositoryLayer builds a VaultRepository bound to one advisory-locked transaction and provides it as a layer for that call, which is how per-request transactional state reaches an Effect program without a long-lived service holding a connection. Per-request data still travels as arguments; the exception is the repository handle itself.

Edges stay plain: resolvers call runPromise on a program whose tagged failures are already mapped, Better Auth hooks call plain async functions from edges.ts, and route handlers parse with zod before entering Effect at all.

Token vault

The vault lends an app a user's upstream provider token under an explicit grant. Its boundaries:

  • Admission before lookup. The authenticated app is rate-limited — 60 requests in a rolling 60-second window, at most 1,024 tracked apps — before any pairwise resolution happens, so the limiter cannot be used to probe which subjects exist. Capacity exhaustion fails closed.
  • Causal audit. Every lookup runs inside the connection's advisory-lock transaction. A fresh read commits its audit row before returning; a refresh writes the rotated encrypted state and its audit row in the same transaction, so an audit failure rolls the rotation back. There is no path that hands out a token whose audit record was lost.
  • App-scoped provenance. ProviderConnection is keyed by application, user, and provider, and records the issuing credential source, credential id, and OAuth client id. Refresh re-reads inside the lock. Same-client secret rotation is supported; client-id drift fails closed rather than silently reusing a connection minted under different credentials. There is no fallback to Better Auth's global Account token columns.
  • Envelopes. Provider credentials are AES-256-GCM envelopes under CREDENTIALS_ENC_KEY, decrypted server-side at use, never returned by a later read. App credential first, platform credential second.

Transport hardening

Every outbound HTTP boundary — provider refresh, Resend, revocation push, and the SDK's JWKS fetch — uses an injected fetch adapter with an Effect-owned abort signal, a declared and streamed byte cap, fatal UTF-8 decoding, a strict bounded response schema, and body cancellation before retry. Ambient fetch is rejected by an AST guard in production Effect sources. Failures are sanitized tagged errors carrying no secret.

Introspection follows RFC 7662 exactly: unknown, revoked, expired, and dead-session tokens all return 200 {"active": false}, while confidential client authentication failures stay failures. Discovery advertises client_secret_basic and client_secret_post. Body-only secrets are rewritten to Basic before Better Auth; mixed Basic-plus-body, duplicate, conflicting, malformed, and oversized credentials are rejected. The token, refresh, client-credentials, introspection, and revocation endpoints all apply that one bounded contract.

Signing and revocation

The JWT plugin runs EdDSA with an explicit 90-day rotation interval; omitting it mints a key per request. A startup assertion exercises the installed JWKS route at the one public address and requires strict JSON media, a bounded cancellation-safe body, a unique key set, and canonical 32-byte Ed25519 coordinates before any request is served.

The gate's signing key is its own and is not Better Auth's. OAuth client secrets use a versioned salted Argon2id service; legacy values fail closed and require audited rotation.

Revocation is push-first and poll-backed. The control plane writes a revocation_event row and pushes an HMAC-signed, canonically serialized request to each configured gate target; each gate replica also polls /internal/revocations?since=<cursor> every 60 seconds with a per-replica cursor and bounded pages. Access, ID, and machine tokens are bounded to 900 seconds, so a mode-3 app converges on the token lifetime rather than on the poll interval.

Generated API truth

apps/web/public/openapi.json and the HTTP API page are generated from the live route contracts by scripts/generate-api-reference.ts, and pnpm docs:check-api fails if either drifts. Eleven operations, one MCP tool each. This is current: do not treat the API surface as undocumented.

Hard stops

These are external approvals or credentials, not source gaps. Nothing below is claimed as done anywhere in these docs.

  1. Production cluster mutation is not authorized. No claim is made about live DNS, TLS, NetworkPolicies, SOPS custody, controller pods, database state, or external provider configuration.
  2. Physical PITR is undrilled. The CloudNativePG object-store, base-backup, WAL, and point-in-time path with measured RPO/RTO needs an approved isolated-cluster drill. The successful local logical restore does not substitute for it.
  3. Publication is stopped. @davidapps/protocol, @davidapps/sdk-core, @davidapps/sdk-next, and @davidapps/mcp are pack- and install-verified locally but unpublished. Registry credentials are an explicit approval.
  4. Hosted CI has not run. Every recent workflow run created its jobs and received no runner, blocked by account billing before step execution. That is an external acceptance stop, not a source or test failure; a job that never receives a runner establishes no result.
  5. Example adapters are single-host. The file-backed replay, state, session, and identity stores in examples/ preserve TTL, capacity, atomic consume, mismatch retention, and replay laws on one host with a shared volume, and are not a distributed store.
  6. The vault limiter is process-local. It is not a cross-replica quota. A fleet-wide quota needs a shared limiter added at deployment.
  7. ingress-nginx maintenance status is an open supply-chain concern. Production must evidence a patched supported controller or a reviewed replacement.

The current threat model is docs/threat-model-current.md; the Phase 0.6 document beside it is an archived design baseline, not implementation evidence.

On this page