User OAuth login / credential enrollment for the Compass LLM gateway (RIG-3050)
Status: Draft
Tracking: RIG-3050 Owner: compass-obs (design) → compass-server / gateway-TS / compass-ui (impl, per task)
Problem / Intent
Section titled “Problem / Intent”The frozen RIG-1715 gateway record
(docs/designs/platform/compass-server-llm-gateway/design.md) designs the
gateway_credentials value store, OAuth refresh, rotation, pools, and the
gateway READING credentials at request time — but assumes credentials are
already in the store (its T2 notes only that “A compass.proto change is
likely (today SetSecretRequest carries only kind + provider …)”,
compass-server-llm-gateway/design.md:789-791). Nothing designs how a user
authenticates their provider account INTO that store. OMP has the interactive
OAuth login flow, but it is local-CLI shaped: OAuthCallbackFlow.login()
holds the PKCE verifier and CSRF state in process memory across a single
awaited call and receives the provider redirect on a loopback Bun.serve
(forks/oh-my-pi/packages/ai/src/registry/oauth/callback-server.ts:139-180,282-290).
Compass is a server/web product: initiate and callback are two separate HTTP
requests, possibly to different Server instances — so the in-memory
verifier/state handoff must become server-persisted per-attempt state, and the
loopback callback must re-home to a Server HTTPS callback endpoint. This
record designs that enrollment half: the UI connect-provider surface, the
initiate and callback endpoints, the non-OAuth variants (API-key paste,
paste-code, device-code), and the write path into the RIG-1715
gateway_credentials store.
Approach
Section titled “Approach”The shape in one paragraph
Section titled “The shape in one paragraph”Enrollment is split across the three tiers by what each already owns. The
UI (compass-ui) gets a “Providers” settings surface driving a new
compass.v1 enrollment service. The Server (Go) owns all durable state:
a new gateway_enrollment_attempts table (the server-persisted replacement
for OMP’s in-process verifier/state), the write path into the RIG-1715
gateway_credentials store, and the compass.v1 RPCs. The gateway/TS tier
owns the provider protocol: a narrow internal HTTP surface — authenticated
by a dedicated server→gateway enroll token (§the gateway/TS internal enroll
surface) — wrapping the fork’s per-provider generateAuthUrl/exchangeToken
logic — the fork is the source of truth for provider OAuth quirks and updates
fast, so compass calls it rather than re-implementing it in Go (the same cost
argument that ratified RIG-1715 A1). The v1 completion mechanism is
paste-code (plus API-key paste); the Server HTTPS callback endpoint is
fully designed here but its shipping is gated on the redirect-URI
registration fork (OQ-1), which is a provider-relationship question, not a
code question.
Why paste-code is the v1 completion path (the redirect-URI reality)
Section titled “Why paste-code is the v1 completion path (the redirect-URI reality)”The naive web design — initiate returns an auth URL whose redirect_uri
points at a Compass HTTPS callback — does not work with the OAuth apps the
fork ships. Providers validate redirect_uri against a registered allowlist
bound to the client_id, and the fork’s client_ids are CLI apps registered
with loopback redirects:
- openai-codex pins the exact loopback redirect because “the token exchange
would fail with 403 because the redirect_uri no longer matches the
registered allowlist entry”
(
forks/oh-my-pi/packages/ai/src/registry/oauth/openai-codex.ts:147-150, which pinsredirectUri: `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`). - The flow base class refuses random-port fallback for the same reason: “The
OAuth provider validates redirect URIs against its registered callback, so
falling back to a random port would be rejected”
(
registry/oauth/callback-server.ts:216-219). - Anthropic’s client_id is a fixed CLI app id
(
CLIENT_ID = decode("OWQx..."),registry/oauth/anthropic.ts:12-13) whose fork flow targetshttp://localhost:54545/callback(CALLBACK_PORT = 54545/CALLBACK_PATH = "/callback",anthropic.ts:19-20). For anthropic, HTTPS-redirect rejection is an inference, not observed provider behavior:AnthropicOAuthFlowuses the legacy constructor (super(ctrl, CALLBACK_PORT, CALLBACK_PATH),anthropic.ts:212), which leavesallowPortFallback: true(callback-server.ts:87legacy branch;?? trueat:96) — the fork happily falls back to a random loopback port, which only works if Anthropic validates redirects against the RFC 8252 §7.3 loopback carve-out (variable port, fixed loopback host) rather than one exact URI. That carve-out never extends to arbitrary HTTPS hosts, and there is no evidence any HTTPS redirect is allowlisted — so the rejection is well-founded, but it is weaker evidence than codex’s documented 403.
So a Compass-hosted https://<host>/oauth/callback is rejected by every
day-1 provider unless Compass registers its OWN OAuth app per provider —
which is an external dependency (and, for Anthropic, may not be offered at
all; the claude.ai authorize endpoint is what grants user:inference,
anthropic.ts:21-25).
Paste-code needs no redirect registration. Every day-1 provider already
supports it: the registry marks anthropic, openai-codex, devin, gitlab-duo,
gitlab-duo-workflow, google-antigravity, google-gemini-cli, and zai
pasteCodeFlow: true (registry/anthropic.ts:24-25,
registry/openai-codex.ts:17-18; the derived set,
registry/derived.ts:7-9), and OMP’s own login synthesizes a paste prompt
for exactly this set (“Paste the authorization code (or full redirect
URL):”, auth-storage.ts:2726-2729). Anthropic even requests display-mode
explicitly — code: "true" in the authorize params
(registry/oauth/anthropic.ts:221-230) — so claude.ai SHOWS the user the
code to copy, and the pasted code#state fragment carries the CSRF state
through the exchange (exchangeToken splits it,
anthropic.ts:240-250). The fork also ships the parser for a pasted full
redirect URL (parseCallbackInput, callback-server.ts:410-438).
Therefore: v1 ships initiate + paste-code-complete + API-key paste. The
web UX is: click Connect → new tab opens the provider auth URL → user
authorizes → provider page displays the code → user pastes it into the
Compass dialog → Server exchanges and stores. This is the same UX OMP’s
setup wizard gives paste-code providers today
(packages/coding-agent/src/modes/setup-wizard/scenes/sign-in.ts:189-190),
re-homed to the web. The HTTPS callback variant (below) upgrades the UX to
zero-paste when/if Compass-registered OAuth apps exist (OQ-1).
The pending-attempt state store (the crux, resolved)
Section titled “The pending-attempt state store (the crux, resolved)”OMP’s OAuthCallbackFlow.login() holds everything in process memory: it
mints state (generateState(), callback-server.ts:120-126), the
provider flow mints the PKCE verifier into an instance field
(this.#verifier = pkce.verifier, anthropic.ts:216-219; PKCE = 96 random
bytes base64url + SHA-256 challenge, registry/oauth/pkce.ts:5-18), and
login() awaits the loopback callback before calling
exchangeToken(code, state, redirectUri) (callback-server.ts:139-180).
In Compass, initiate and complete are separate HTTP requests, so that
state becomes a Server-owned row:
-- gateway_enrollment_attempts (folded into the squashed migration per the-- store convention; names final at implementation)id UUID PRIMARY KEY, -- attempt id, returned to the UIowner_user_id TEXT NOT NULL, -- the enrolling user (tenant key)provider TEXT NOT NULL, -- e.g. "anthropic"method TEXT NOT NULL, -- "paste_code" | "callback" | "api_key"state TEXT NOT NULL UNIQUE, -- CSRF nonce (also the callback lookup key)pkce_verifier TEXT NOT NULL, -- server-side only; never leaves the Server/gateway hopredirect_uri TEXT NOT NULL, -- the exact value used at authorize time (must match at exchange)parked_code TEXT, -- callback method only: code parked by GET /oauth/callback; read + exchanged only by the owner-checked CompleteEnrollmentcreated_at TIMESTAMPTZ NOT NULL,expires_at TIMESTAMPTZ NOT NULL, -- created_at + 5 min (OMP's DEFAULT_TIMEOUT, callback-server.ts:17)consumed_at TIMESTAMPTZ -- single-use: set on first complete; re-use rejectedProperties: single-use (first completion consumes; a second submit with
the same state is rejected), TTL 5 minutes (matching OMP’s
DEFAULT_TIMEOUT = 300_000, callback-server.ts:17; expired rows are
swept lazily on lookup + a periodic sweep), user-scoped (every
completion — paste or callback — runs through the bearer-authenticated,
owner-checked CompleteEnrollment: the attempt’s owner_user_id must
equal the authenticated caller. The bearer-less callback endpoint never
completes an attempt — it only parks the authorization code on the row;
see §Server HTTPS callback endpoint for why state alone must not
authenticate a credential write). The verifier is a
credential-equivalent secret while live: it never appears in any RPC
response, only in the Server→gateway exchange call. The table carries the
tenant column shape the managed-multitenancy record prescribes
(denormalized tenant key + RLS-ready,
docs/designs/infra/runtime/compass-managed-multitenancy/design.md:66-125;
enforcement layer is that record’s OQ-2, still open — this table follows
whatever T2 there lands, and owner_user_id scoping is enforced in the
handler regardless).
Where the provider protocol runs: the gateway/TS internal enroll surface
Section titled “Where the provider protocol runs: the gateway/TS internal enroll surface”generateAuthUrl and exchangeToken are per-provider fork code with real
provider quirks (anthropic’s identity bootstrap + org resolution on
exchange, anthropic.ts:273-287; codex’s JWT parsing; the code#state
fragment splitting). Re-implementing them in Go buys nothing and creates
the upstream-chase treadmill Matt rejected for the gateway itself
(RIG-1715 ruling, compass-server-llm-gateway/design.md:23-33). So the
gateway process — already the Bun tier that owns the fork’s OAuth code and
runs OAuth REFRESH today (auth-storage.ts:1283-1284 via RIG-1715 RD-5) —
grows a narrow internal enrollment surface, mounted on its existing
listener beside /healthz and /v1/* (auth-gateway/server.ts:769-806),
authenticated by a dedicated server→gateway enroll token — NOT the
RIG-1715 stack token, which authenticates the opposite direction (the
gateway calling the Server’s RPC-store: “The gateway’s store
implementation calls a narrow, stack-token-authenticated Server surface”,
compass-server-llm-gateway/design.md:348-350; the blast-radius statement
“a compromised gateway (holding one stack token)”, design.md:333-334,
stays accurate only if that token does not also authorize inbound enroll
calls) — and never agent bearers: the gateway listener has one flat auth
gate (isAuthorized(req, tokens) before route dispatch,
auth-gateway/server.ts:772-775), so the enroll routes must check the
enroll token specifically, not membership in the shared tokens set:
POST /internal/enroll/authorize-url—{provider, state, redirectUri, pkceChallenge}→{url, instructions}. Stateless: the Server mints state + PKCE and passes the challenge in.POST /internal/enroll/exchange—{provider, code, state, redirectUri, pkceVerifier}→{credential: OAuthCredentials}(the fork shape: refresh/access/expires + orgId/orgName/accountId/email/authorizedAt,registry/oauth/types.ts:4-30).
This requires a small, seam-shaped, upstreamable fork change: today the
flow classes mint PKCE internally and carry the verifier as instance state
(AnthropicOAuthFlow.#verifier, anthropic.ts:207,216-219), which cannot
survive two separate requests. The fork extension is a stateless flow
seam: per-provider generateAuthUrl(state, redirectUri, {challenge})
and exchangeToken(code, state, redirectUri, {verifier}) entry points
(or constructor-injected PKCE) — the exchange bodies are already
effectively stateless (exchangeToken reads only this.#verifier beyond
its arguments, anthropic.ts:240-262; codex passes
this.#pkce.verifier, openai-codex.ts:167-169), so this is parameter
threading, not new protocol code. It matches the RIG-1715 fork
constraint: “compass touches the gateway only at injection points …
never the routing core” (compass-server-llm-gateway/design.md:687-690).
The Server orchestrates: it owns the attempt row, calls the gateway for the two protocol steps, and writes the resulting credential. The gateway never touches the attempt table and holds no enrollment state — a gateway restart mid-enrollment loses nothing.
The compass.v1 enrollment surface (UI ↔ Server)
Section titled “The compass.v1 enrollment surface (UI ↔ Server)”compass.v1 is the sole UI↔server door (proto/compass/v1/compass.proto:1-4),
so the UI drives enrollment through a new service (proto change +
moon run compass-proto:gen, never a hand stub). A NEW service rather than
extending SecretsService: the secrets registry is names-only by invariant
(SetSecret writes the resolver, “never values” persisted,
compass.proto:175-189; go/server/secrets_service.go:10-16), while
enrollment writes the RIG-1715 gateway_credentials VALUE store — mixing
the two services would blur the invariant RIG-1715 explicitly preserved
(compass-server-llm-gateway/design.md:314-333).
// User-only (agent tokens PermissionDenied), mirroring the// SetSecret/DeleteSecret authz posture (secrets_service.go:10-13).service ProviderEnrollmentService { // Providers enrollable + the caller's connected state per provider. rpc ListProviders(ListProvidersRequest) returns (ListProvidersResponse); // Mint state + PKCE, persist the attempt row, return the auth URL. rpc BeginEnrollment(BeginEnrollmentRequest) returns (BeginEnrollmentResponse); // Completion (owner-checked, always bearer-authenticated): a pasted code // (or full redirect URL), or — callback method — the code E4 parked. rpc CompleteEnrollment(CompleteEnrollmentRequest) returns (CompleteEnrollmentResponse); // API-key variant: value straight into gateway_credentials (redacted on wire). rpc SetProviderApiKey(SetProviderApiKeyRequest) returns (SetProviderApiKeyResponse); // Disconnect: CAS-disable the credential row (RIG-1715 Disable semantics). rpc DeleteCredential(DeleteCredentialRequest) returns (DeleteCredentialResponse);}
message BeginEnrollmentRequest { string provider = 1; }message BeginEnrollmentResponse { string attempt_id = 1; string auth_url = 2; // browser target string instructions = 3; // provider-specific paste guidance int64 expires_at_unix_ms = 4;}message CompleteEnrollmentRequest { string attempt_id = 1; string code = 2 [debug_redact = true]; // pasted code or full redirect URL (parseCallbackInput semantics); empty for callback-method attempts (server reads the parked code)}message CompleteEnrollmentResponse { ConnectedProvider credential = 1; }message SetProviderApiKeyRequest { string provider = 1; string api_key = 2 [debug_redact = true];}message ConnectedProvider { string provider = 1; string credential_id = 2; string kind = 3; // "oauth" | "api_key" string email = 4; // display identity from the exchange, when present string org_name = 5; int64 expires_unix_ms = 6; // access-token expiry (display only) int64 authorized_at_unix_ms = 7;}Token values (access/refresh/api_key) NEVER appear in any response —
ConnectedProvider is display metadata only, the same redaction posture as
SecretStatus (“names + set/unset + routing, NEVER the value”,
compass.proto:205-213).
The Server HTTPS callback endpoint (designed; shipping gated on OQ-1)
Section titled “The Server HTTPS callback endpoint (designed; shipping gated on OQ-1)”When a provider allowlists a Compass redirect (a Compass-registered OAuth
app, or a provider that accepts configurable redirects), the zero-paste
variant activates: BeginEnrollment sets method = "callback" and
redirect_uri = https://<server-host>/oauth/callback, and the Server
mounts GET /oauth/callback on the TLS network door — the precedent is
the GitHub/Linear webhook ingresses, mounted on the same mux OUTSIDE the
bearer + admin-gate interceptors because their authentication is intrinsic
to the request (go/server/network_door.go:316-337). The state nonce,
however, authenticates the ATTEMPT, not the browser user — and that
distinction is load-bearing. A callback that exchanged and wrote on a
state match alone would be open to login-CSRF in the reverse direction:
an attacker begins enrollment on THEIR account, lures the victim into
opening the attacker’s auth_url; the victim authorizes with the VICTIM’s
provider account; the provider redirects to /oauth/callback carrying the
attacker’s still-valid state; the handler would exchange and write the
victim’s provider credential under the attacker’s owner_user_id. OMP
never had this exposure because its callback lands on the enrolling user’s
own machine — a loopback Bun.serve on 127.0.0.1
(callback-server.ts:283-285) — so the loopback itself WAS the session
binding; re-homing the callback to a shared HTTPS host drops that binding,
and state cannot replace it.
So the callback is park-then-confirm: it never exchanges and never
writes. GET /oauth/callback validates state, looks up the unconsumed,
unexpired attempt by state (unique column), PARKS the authorization
code on the attempt row (parked_code, schema above) WITHOUT consuming
it, and 302s to the UI landing route (coarse reason codes only — never the
code or tokens in a query string). A state miss is a plain 404 with no
oracle detail, mirroring the fork’s posture of ignoring forgeable errors
without the expected state (callback-server.ts:342-347). The
already-open, bearer-authenticated UI then calls
CompleteEnrollment(attempt_id) — the same owner-checked handler as the
paste path — which consumes the attempt, reads the parked code, calls the
gateway exchange with the stored verifier, and writes the credential under
the AUTHENTICATED caller’s owner_user_id. The exchange and the write
therefore always happen inside a bearer-authenticated, owner-checked call;
the callback endpoint can at most park a code, and a foreign state can
never complete an attempt because completion is owner-checked.
This also makes the handler genuinely small: everything after the park is
CompleteEnrollment verbatim — same attempt consumption, same gateway
exchange, same credential write, shared by construction rather than
duplicated. It is one task (E4) behind the OQ-1 ruling, not a fork of the
design.
Writing the credential (RIG-1715 store, consumed not redesigned)
Section titled “Writing the credential (RIG-1715 store, consumed not redesigned)”The exchange result (fork OAuthCredentials,
registry/oauth/types.ts:4-30) maps onto a gateway_credentials row as
RIG-1715 defines it: OAuth-shaped payload (access/refresh/expiry), a
monotonic version for CAS, scope = owner-scoped by owner_user_id
(compass-server-llm-gateway/design.md:324-333). One gap: RIG-1715’s Go
store surface is List / UpdateOAuth / Disable
(design.md:799) — read + refresh-write-back + disable, no CREATE. This
record makes two named additions to that seam: the Create write (and
the api_key insert path) and the identity/display columns backing
upsert-by-identity + the UI list — the frozen row shape is “api_key and
OAuth-shaped payloads (access/refresh/expiry), a monotonic version per
row supplying the CAS substrate, and a scope column”
(design.md:324-331), with no identity columns.
Create(ctx, cred GatewayCredential) (id string, err error) has
upsert-by-identity semantics — a re-login for the same
(owner_user_id, provider, orgId/accountId identity) REPLACES the row’s
token payload (bumping version) rather than accreting duplicate rows,
mirroring how OMP’s re-login overwrites a credential for the same
identity. The identity key pins NULL semantics (E1): identity components
are nullable in exactly the degraded cases — anthropic’s
resolveAccountIdentity swallows bootstrap failures and returns a partial
identity (catch { return identity; }, anthropic.ts:201-203), and codex
refresh deliberately omits org fields (“Deliberately no org fields on the
result”, openai-codex.ts:374-376) — and a Postgres unique index treats
NULLs as distinct, so the key is declared UNIQUE NULLS NOT DISTINCT (or
identity coalesced to '' in the key) and degraded-identity re-logins
replace instead of accreting. Identity display fields
(email/orgName) are stored for the UI list. The
gateway’s read path (RIG-1715 T2 List) picks the new row up on its next
snapshot with zero changes — enrollment composes with the frozen read
design instead of touching it. Deletion is RIG-1715’s CAS Disable, so
disconnect never races a concurrent gateway refresh write-back.
Variants: what ships v1, what defers
Section titled “Variants: what ships v1, what defers”- API-key paste — v1.
SetProviderApiKeywrites an api_key-kindgateway_credentialsrow directly; no attempt row (nothing to persist between requests). The existingSECRET_KIND_PROVIDERregistry row (names-only) is unchanged by this record, per RIG-1715: “TheSECRET_KIND_PROVIDERregistry rows stay as they are … the value store holds the credentials the gateway actually routes with” (compass-server-llm-gateway/design.md:331-333). - Paste-code OAuth — v1. The primary OAuth mechanism (above).
- Callback OAuth — designed, gated on OQ-1 (E4; park-then-confirm).
- Device-code OAuth — deferred, but load-bearing on the OQ-2 decision
space. Device-code needs NO redirect URI, so it sidesteps OQ-1
entirely, and for codex it is a zero-paste flow the fork already ships
fully server-drivable:
loginOpenAICodexDeviceposts the client_id to the device-usercode endpoint (openai-codex.ts:251-255), shows the userEnter code: ${userCode}at the provider device page (openai-codex.ts:283-286), polls, and the provider RETURNS both the code and the verifier in the poll response (authorization_code?: string; code_verifier?: string,openai-codex.ts:319-322) — no server PKCE state, no user paste-back; the user types a short code INTO the provider page instead of copying a long code OUT of it. Not universal: anthropic has no device flow (the fork’s device-code consumers are codex and xai only; anthropic is callback/paste only). Deferred from v1 — paste-code covers the day-1 set — but surfaced as OQ-2’s third option; when promoted it is a thirdmethodon the same attempt table (the poll interval/expiry fields it needs are additive columns).
Multi-tenancy and self-host
Section titled “Multi-tenancy and self-host”Enrollment is inherently per-user: every attempt and every written
credential carries owner_user_id, resolved server-side from the
authenticated caller (never a request field — the actor posture the
multitenancy record pins, compass-managed-multitenancy/design.md:116-118).
RPCs are user-only, agent tokens rejected, mirroring SetSecret
(secrets_service.go:10-13). Self-host is the single-team degenerate case:
the same surface, the operator’s user enrolls the accounts — no separate
code path (the RIG-1717 “one architecture” posture the multitenancy record
carries, design.md:122-126). Org-shared credentials (the RIG-1715 shared
pool fallback) are out of enrollment-v1 scope: the shared-row write path is
an admin surface that lands with the managed plane’s org entity
(compass-server-llm-gateway/design.md:397-405), and nothing here blocks
it — it is one more writer to the same store.
Alternatives considered
Section titled “Alternatives considered”Re-implement token exchange in Go (rejected)
Section titled “Re-implement token exchange in Go (rejected)”The Server could speak the provider token endpoints directly (they are
plain HTTPS form posts, e.g. TOKEN_URL, anthropic.ts:15,252-265). But
each provider carries drift-prone quirks the fork already encodes and
keeps current — anthropic’s identity bootstrap fallback
(fetchBootstrapIdentity, anthropic.ts:141-176), the code#state
fragment contract (anthropic.ts:243-250), codex’s registered-redirect
403 behavior (openai-codex.ts:147-150) — and RIG-1715 already ratified
“fork is source of truth, don’t chase upstream in Go” for exactly this
code mass. The gateway internal surface costs two small routes; the Go
re-implementation costs a per-provider protocol port plus permanent sync.
Gateway hosts initiate/callback directly (rejected)
Section titled “Gateway hosts initiate/callback directly (rejected)”Let the Bun gateway own the whole enrollment HTTP surface. Rejected on two
frozen contracts: the UI may only speak compass.v1 (“the single, owned
door”, compass.proto:1-4; RIG-1715 GC: “The gateway’s HTTP listener is
agent-facing, never UI-facing”,
compass-server-llm-gateway/design.md:691-694), and the public HTTPS
ingress with TLS + operational posture is the Server’s network door
(go/server/network_door.go:3-7,60-65). The gateway would also need the
attempt store (a second Postgres writer or a new RPC surface anyway) and
would hold durable enrollment state in the one tier designed to be
restartable. Keeping the gateway stateless-protocol-only preserves the
RIG-1715 topology.
Reuse SecretsService.SetSecret for everything (rejected)
Section titled “Reuse SecretsService.SetSecret for everything (rejected)”The precedent RIG-1715 pointed at (SetSecretRequest,
compass.proto:191-198) writes the names-only registry + resolver — a
different store with a never-persist-values invariant
(secrets_service.go:10-16). OAuth enrollment cannot ride it (there is no
“value” until the exchange completes, and the result is a multi-field
token payload, not one string), and stretching SetSecret with
enrollment fields would couple the two stores RIG-1715 deliberately kept
distinct. A dedicated service keeps each invariant crisp.
Global Constraints
Section titled “Global Constraints”- Record placement + ledger: this record lives under
docs/designs/server/(RIG-2577 taxonomy: server-side domain + write paths + auth).server/is a governed ledger root: the record carriesDECISIONS.mdrows at freeze (the driver flips the ledger in the freeze PR; this draft does not write ledger rows). - Compass is PUBLIC. Never name the private monorepo or other agent
products;
moon run orion-ref-gate:checkenforces. - compass.v1 discipline: any proto change goes through
proto/compass/v1/*.proto+moon run compass-proto:gen— never a hand-written stub. compass.v1 is the sole UI↔server door (compass.proto:1-4). - Consumes, never redesigns, the frozen RIG-1715 record: the
gateway_credentialsstore shape (scope column, monotonicversionCAS), own-before-shared pools, and the gateway RPC-store read path (compass-server-llm-gateway/design.md:312-405,768-804). This record’s two named additions to that seam are theCreatewrite AND the identity/display columns backing upsert-by-identity + the UI list (Approach §Writing the credential). - Fork changes are seam-shaped and upstreamable (RIG-1715 GC,
design.md:687-690): the stateless-flow seam (explicit PKCE threading) and the internal enroll routes touch injection points only, never the routing core or provider protocol bodies. - Secrets redaction posture: token values, auth codes, and PKCE verifiers
are
[debug_redact]on the wire where client-supplied, never logged, and never returned in any response (theSecretStatusprecedent,compass.proto:205-213). Attempt rows are secret-bearing (state + PKCE verifier + any parked code): excluded from debug dumps, log statements, and general query logging. - Enrollment RPCs are user-only: agent tokens are
PermissionDenied, mirroring SetSecret/DeleteSecret (go/server/secrets_service.go:10-13).owner_user_idis always server-derived from the authenticated caller, never a request field. - Attempt rows are single-use with a 5-minute TTL (OMP’s
DEFAULT_TIMEOUT = 300_000,callback-server.ts:17). - Store discipline: DDL folds into the squashed migration; new store code
ships an in-memory reference +
pgtestsuite (DL-174 pyramid, per RIG-1715 GCdesign.md:704-706); tenant-column shape follows the managed-multitenancy record (RLS-ready, enforcement per its OQ-2 outcome). - Go code under
go/, gateway-side TS in the fork’s compass wrapper, UI inapps/ui(SolidJS v2) — per-lane owners as tasked below. - Markdownlint-clean record
(
markdownlint-cli2 --config .markdownlint.json <file>); Conventional Commits +Co-authored-by: Matt Wilkinson <matt@rigel.build>(driver-owned).
Ordering: E1 and E2 are independent and parallel; E3 depends on both; E5 depends on E3; E4 depends on E3 plus the OQ-1 ruling. Day-1 provider set follows RIG-1715 RD-6 (anthropic, openai/openai-codex, google) — for enrollment that means: anthropic + openai-codex via paste-code OAuth, google + all three via API-key paste.
E1 — Attempt store + gateway_credentials Create (Owner: compass-server)
Section titled “E1 — Attempt store + gateway_credentials Create (Owner: compass-server)”The gateway_enrollment_attempts table (schema in Approach §pending-attempt
state store) with single-use consumption and TTL sweep, plus the Create
addition to the RIG-1715 credential-store seam: insert-or-replace by
credential identity (owner_user_id, provider, orgId/accountId), bumping the
monotonic version on replace, storing display identity (email/org_name)
alongside the token payload. The identity unique key pins NULL semantics —
UNIQUE NULLS NOT DISTINCT (or coalesce to '') — because identity
degrades to partial in real paths (anthropic bootstrap catch,
anthropic.ts:201-203; codex refresh omits org fields,
openai-codex.ts:374-376) and NULLs-as-distinct would accrete duplicates.
Also the api_key-kind insert used by E3’s SetProviderApiKey.
Interfaces:
- Consumes: the RIG-1715
gateway_credentialsshape (scope column, monotonicversion,compass-server-llm-gateway/design.md:324-333); squashed-migration convention; the multitenancy tenant-column shape (compass-managed-multitenancy/design.md:66-125). - Produces (Go,
go/internal/storeor the gateway-credentials package RIG-1715 T2 creates — land beside it):type EnrollmentAttempt struct { ID, OwnerUserID, Provider, Method, State, PKCEVerifier, RedirectURI, ParkedCode string; CreatedAt, ExpiresAt time.Time; ConsumedAt *time.Time }CreateAttempt(ctx context.Context, a EnrollmentAttempt) errorConsumeAttempt(ctx context.Context, id, ownerUserID string) (EnrollmentAttempt, error)— atomically setsconsumed_atiff unconsumed + unexpired;ErrGoneotherwiseParkCallbackCode(ctx context.Context, state, code string) error— the callback-path write: setsparked_codeon the unconsumed, unexpired attempt matchingstate(unique column) WITHOUT consuming it;ErrGoneotherwise. Never exchanges, never writes a credential — completion stays with the owner-checkedConsumeAttemptpath (park-then-confirm, Approach §Server HTTPS callback endpoint)CreateCredential(ctx context.Context, c GatewayCredential) (id string, err error)— upsert-by-identity (UNIQUE NULLS NOT DISTINCTkey), version-bumping (the seam addition)
- Test cycle: in-memory ref +
pgtest(DL-174): single-use race (two concurrent consumes, one wins), TTL expiry, park-then-consume (park does not consume; consume returns the parked code; parking an expired or consumed state isErrGone), upsert-replaces-not-duplicates, version bump on re-login incl. a partial-identity re-login (NULL/absent orgId/accountId) replacing rather than duplicating.
E2 — Fork stateless-flow seam + gateway internal enroll routes (Owner: gateway-TS)
Section titled “E2 — Fork stateless-flow seam + gateway internal enroll routes (Owner: gateway-TS)”Fork side: the stateless PKCE seam — per-provider generateAuthUrl /
exchangeToken callable with an externally supplied challenge/verifier
instead of instance state (AnthropicOAuthFlow.#verifier,
anthropic.ts:207,216-219; openai-codex.ts:152,167-169), for the day-1
OAuth providers (anthropic, openai-codex). Upstreamable parameter
threading; no protocol-body changes. Gateway side: two routes on the
existing listener (auth-gateway/server.ts:769-806), authenticated by the
dedicated server→gateway enroll token (Approach §internal enroll surface —
distinct from the RIG-1715 stack token and never satisfiable by an agent
bearer):
POST /internal/enroll/authorize-url:{provider: string, state: string, redirectUri: string, pkceChallenge: string}→200 {url: string, instructions?: string}POST /internal/enroll/exchange:{provider: string, code: string, state: string, redirectUri: string, pkceVerifier: string}→200 {credential: OAuthCredentials}(registry/oauth/types.ts:4-30) |4xx {error: string}classified (provider-denied vs bad-code vs transient), body never logged
Interfaces:
- Consumes:
generatePKCE()shape (registry/oauth/pkce.ts:5-18, minting moves Server-side but the challenge format is this contract); per-provider flow classes (anthropic.ts:206-288,openai-codex.ts:115-170);parseCallbackInput(callback-server.ts:410-438) for pasted-full-URL handling at exchange. - Produces: the two internal routes (fork compass-wrapper package, beside
the RIG-1715 T1 boot entrypoint); the stateless-flow fork seam; a
route-scoped auth check for the server→gateway enroll token (not the
shared
tokensset the flat gate consults,server.ts:772-775). - Test cycle: fork tests — authorize-url golden params per provider
(client_id/scope/challenge/state round-trip, anthropic
code: "true",anthropic.ts:221-230); exchange against a fake token endpoint incl.code#statefragment splitting (anthropic.ts:243-250); 401 without the enroll token; 401 for a valid AGENT bearer on/internal/enroll/*(the negative that pins the token separation).
E3 — ProviderEnrollmentService: proto + Server handlers (Owner: compass-server; depends E1, E2)
Section titled “E3 — ProviderEnrollmentService: proto + Server handlers (Owner: compass-server; depends E1, E2)”The compass.v1 service (proto in Approach §compass.v1 enrollment surface) +
moon run compass-proto:gen, and the Go handlers wired beside
SecretsService on all three doors (socket/dev/network,
go/server/serve.go:701-743, network_door.go:285-292): user-only authz
(agent PermissionDenied per secrets_service.go:10-13); BeginEnrollment
mints state (16-byte hex, callback-server.ts:120-126 semantics) + PKCE
server-side (Go crypto; format per pkce.ts:5-18: 96-byte base64url
verifier, S256 challenge), persists the attempt, calls the gateway
authorize-url route; CompleteEnrollment consumes the attempt
(owner-checked), accepts code-or-full-redirect-URL (or, for
callback-method attempts, reads the code E4 parked), calls the gateway
exchange, writes via CreateCredential; SetProviderApiKey writes
directly; ListProviders merges the enrollable registry (paste-code +
api_key capability per provider) with the caller’s connected rows;
DeleteCredential calls RIG-1715 CAS Disable.
Interfaces:
- Consumes: E1’s store surface; E2’s internal routes (via the gateway base
URL + stack token config the RIG-1715 T1/T2 wiring already carries);
authcaller-identity interceptors (serve.go:700-704). - Produces:
proto/compass/v1/enrollment.proto(service + messages as specified; field redaction annotations) + regenerated clients;go/server/enrollment_service.goimplementingcompassv1connect.ProviderEnrollmentServiceHandler; door registration on socket/dev/network muxes;auth.classifyProcedureentries — all five proceduresauthenticatedOpen, handler enforces user-only (the SecretsService pattern: the gate admits any authenticated account and the handler does the fine authz,secrets_service.go:5-8,network_door.go:288-291). Without classification a new procedure silently fail-closes to adminOnly on the network door (internal/auth/admin_gate.go:44-46), andclassify_exhaustive_test.go:61-62reddens CI on any unclassified generated procedure. - Test cycle:
pgtesthandler tests — happy paste-code path against a fake gateway; agent-token 403; expired/consumed/foreign-owner attempt rejection; api_key path; no token value in any response (assert redaction); a network-door test that a NON-admin user clears the admin gate on all five procedures (pins the authenticatedOpen classification); proto-regen CI green.
E4 — Server HTTPS callback endpoint (Owner: compass-server; depends E3 + OQ-1 ruling)
Section titled “E4 — Server HTTPS callback endpoint (Owner: compass-server; depends E3 + OQ-1 ruling)”GET /oauth/callback on the network-door mux outside the bearer
interceptors (the webhook-ingress precedent,
network_door.go:316-337): validate state, PARK the code on the attempt
(ParkCallbackCode — no consume, no exchange, no write), 302 to the UI
landing route (coarse reason codes only); the authenticated UI finishes
via E3’s owner-checked CompleteEnrollment (park-then-confirm, Approach
§callback endpoint). Ships only for providers with a Compass-allowlisted
redirect (OQ-1); BeginEnrollment selects method per provider
capability so paste-code remains the fallback.
Interfaces:
- Consumes: E1
ParkCallbackCode; the network-door mux + TLS posture (network_door.go:246-338); a registered redirect URI per provider (external, OQ-1). - Produces: the callback
http.Handler+ mount; the server-host redirect URI configuration field. The UI landing routes are E5’s (unconditional, render-only); E4 only 302s to them. - Test cycle: httptest — valid state parks the code and 302s WITHOUT
exchanging or writing any credential (assert no gateway call, no
gateway_credentialsrow); unknown/expired/consumed state → 404/ 302-failure with no oracle detail; a parked foreign-owner attempt cannot be completed by another caller (CompleteEnrollmentowner check); providererrorparam handling (denied consent); no code/token ever logged or echoed.
E5 — UI Providers settings surface (Owner: compass-ui; depends E3)
Section titled “E5 — UI Providers settings surface (Owner: compass-ui; depends E3)”A Providers section on the settings surface (beside the tracker-config
editor, apps/ui/src/components/SettingsView.tsx:78-83): provider list
with connected state (identity email/org, expiry, kind), Connect (opens
auth_url in a new tab, then shows the paste-code dialog with
instructions, submits CompleteEnrollment), API-key entry, and
Disconnect (confirm → DeleteCredential). Attempt-expiry countdown from
expires_at_unix_ms; a failed/expired attempt offers re-begin. Clients via
the @compass/client factories/live-client seam
(apps/ui/src/live/client.ts:43-52) — the generated
ProviderEnrollmentService client added beside comms/compass; fakes via
createRouterTransport per the established test pattern
(apps/ui/src/live/query.test.ts:17-28).
Interfaces:
- Consumes: generated
ProviderEnrollmentClient(E3);LiveClientsconstruction (live/client.ts:21-52); SolidJS v2 idioms + the SettingsView draft/commit pattern (SettingsView.tsx:78-83). - Produces:
ProvidersView(or a SettingsView section) + paste-code dialog + api-key form; store accessorsstore.providers(): ConnectedProvider[],store.beginEnrollment(provider): Promise<BeginEnrollmentResponse>,store.completeEnrollment(attemptId, code),store.setProviderApiKey(provider, key),store.deleteCredential(credentialId); the OAuth success/failure landing routes (render-only, owned here unconditionally — E4, if/when it ships, only 302s to them). - Test cycle: component tests over a
createRouterTransportfake (begin→paste→connected; error surfaces on bad code; disconnect confirm); no token value ever rendered or stored client-side.
- E1 — Attempt store +
gateway_credentialsCreate (Owner: compass-server) — attempt table, single-use consume, TTL, upsert-by-identity Create, in-memory ref + pgtest. - E2 — Fork stateless-flow seam + gateway internal enroll routes
(Owner: gateway-TS) — PKCE threading seam,
/internal/enroll/*routes, server→gateway enroll-token auth, fork tests. - E3 — ProviderEnrollmentService proto + handlers (Owner: compass-server; deps: E1, E2) — enrollment.proto + regen, user-only handlers on all doors (classified authenticatedOpen), paste-code + api_key + list + disconnect.
- E4 — Server HTTPS callback endpoint (Owner: compass-server; deps: E3, OQ-1 ruling) — network-door mount, park-then-confirm callback (parks the code; completion stays owner-checked in E3), redirect-URI config.
- E5 — UI Providers settings surface (Owner: compass-ui; deps: E3) — provider list, connect/paste dialog, api-key form, disconnect, router-transport-fake tests.
Open Questions
Section titled “Open Questions”Load-bearing (block freeze; for Matt)
Section titled “Load-bearing (block freeze; for Matt)”- OQ-1 — Redirect-URI registration: do we pursue Compass-registered OAuth
apps per provider, or ship paste-code-only OAuth for v1? The fork’s
client_ids are CLI apps with loopback-registered redirects
(
openai-codex.ts:147-150;anthropic.ts:13,19-20), so a Compass HTTPS callback is rejected unless Compass registers its own OAuth app with each provider — an external relationship/approval dependency, possibly unavailable (Anthropic’suser:inferencegrant is tied to the claude.ai authorize surface,anthropic.ts:21-25, and there is no public self-service registration for it). (The ToS exposure of operating the fork’s CLI client_ids server-side is NOT this question — it bites v1 itself and is OQ-7.) Recommendation: ship v1 with paste-code (works today, zero external dependency, same UX OMP users know); file the per-provider OAuth-app registration as a follow-up that activates E4 per provider as redirects land. The design fully specifies E4 either way, so the ruling gates a task, not the architecture. - OQ-2 — Does the v1 UX bar accept paste-code? Consequence of OQ-1 but
a distinct product call: managed-plane users get “copy this code from the
provider page and paste it here” instead of a seamless redirect. Three
options, not two: (a) paste-code (works today, both day-1 OAuth
providers); (b) wait for OQ-1 redirect registration (external, may never
land for anthropic); (c) device-code for codex — a zero-paste flow with
NO redirect URI and zero external dependency (Approach §Variants: the
provider returns code + verifier in the poll response,
openai-codex.ts:319-322; the user types a short code INTO the provider page). If paste-code fails the UX bar, codex’s remedy is internal (option (c), roughly E2-sized plus a poll step); anthropic’s only remedies are (a) or (b) — it has no device flow. Recommendation: yes for v1 — every day-1 OAuth provider displays the code on a provider-branded page purpose-built for it (anthropic requests display mode explicitly,anthropic.ts:221-230), and the alternative is blocking enrollment on provider approvals we do not control; device-code for codex is the named upgrade if the paste UX bar fails. - OQ-7 — v1 operates the providers’ CLI OAuth apps from Compass
servers — accepted? Independent of OQ-1: the v1 paste-code path
already performs the token exchange server-side with the fork’s CLI
client_ids on day one (E3 calls the gateway exchange with codex’s
CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann",openai-codex.ts:13, and anthropic’s decoded CLI id,anthropic.ts:12-13), and RIG-1715 RD-5 already refreshes these tokens server-side hourly. The ToS exposure of running provider CLI apps from Compass servers therefore attaches to v1 itself, not to the E4/callback future — deferring OQ-1 does not defer this. Recommendation: accept explicitly for v1 (it is the same posture OMP users already run, re-homed to Compass servers), and revisit per provider when Compass-registered OAuth apps (OQ-1) land.
Non-load-bearing (designed-against defaults; deferrable)
Section titled “Non-load-bearing (designed-against defaults; deferrable)”- OQ-3 — Attempt TTL sweep cadence. Designed against: lazy expiry on lookup + a periodic sweep (interval free to pick at implementation; rows are tiny and single-use). No contract impact.
- OQ-4 — Device-code method. Deferred from v1 scope, but NOT
non-load-bearing: it is OQ-2’s option (c) for codex (zero-paste, no
redirect URI — Approach §Variants), so the deferral holds only while
Matt accepts paste-code (OQ-2). Mechanically additive when promoted:
poll interval/expiry columns + a third
methodon the same attempt table. - OQ-5 — Org-shared credential enrollment (admin writes the shared org
key). Deferred to the managed plane’s org entity per RIG-1715
(
design.md:397-405);SetProviderApiKeyextends with a scope argument then. Nothing in this design blocks it. - OQ-6 — RLS enforcement on the attempt table. Follows the
managed-multitenancy record’s OQ-2 outcome (RLS vs application-level,
still Matt’s to rule there); handler-level
owner_user_idscoping is enforced here regardless, so this record is correct under either ruling.