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
| Store | Owns | On the hot path |
|---|---|---|
| PostgreSQL | Applications, grants, groups, sessions, audit, revocations, credentials, clients | No |
| Gate snapshot | An immutable projection of the above, refreshed every 30s | Yes |
| Dragonfly | Handoff JTIs, CSRF tokens, and login-path rate limits | Login 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
| Route | Owner | Purpose |
|---|---|---|
GET /gate/check | gate | The subrequest. Local only, never 5xx. |
/start, /callback | gate | Platform-login handoff out and back |
/_da/gate/password | gate | Account-less shared-password form and submission |
/_da/gate/redeem | gate | Signed share-link redemption |
/_da/gate/bypass | gate | Query bypass exchange for automation |
POST /internal/revoke | gate | Receives HMAC-signed revocation pushes |
GET /internal/revocations | control plane | Paged revocation cursor the gate polls |
/healthz, /readyz, /metrics | gate | Liveness, 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:
| Service | Provided from |
|---|---|
AppConfig | Environment, validated once |
Prisma | Process-owned operations over the shared client |
ProviderClients | AppConfig, transport built on systemFetch |
AuditWriter | AppConfig and Prisma |
TokenVault | ProviderClients, plus a VaultRepository supplied per call |
RevocationBus | AppConfig, Prisma, and AuditWriter, over the push notifier |
Resend | AppConfig, 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.
ProviderConnectionis 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 globalAccounttoken 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.
- 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.
- 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.
- Publication is stopped.
@davidapps/protocol,@davidapps/sdk-core,@davidapps/sdk-next, and@davidapps/mcpare pack- and install-verified locally but unpublished. Registry credentials are an explicit approval. - 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.
- 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. - The vault limiter is process-local. It is not a cross-replica quota. A fleet-wide quota needs a shared limiter added at deployment.
- 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.