Skip to content

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)

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.

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 pins redirectUri: `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 targets http://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: AnthropicOAuthFlow uses the legacy constructor (super(ctrl, CALLBACK_PORT, CALLBACK_PATH), anthropic.ts:212), which leaves allowPortFallback: true (callback-server.ts:87 legacy branch; ?? true at :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 UI
owner_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 hop
redirect_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 CompleteEnrollment
created_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 rejected

Properties: 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.

  • API-key paste — v1. SetProviderApiKey writes an api_key-kind gateway_credentials row directly; no attempt row (nothing to persist between requests). The existing SECRET_KIND_PROVIDER registry row (names-only) is unchanged by this record, per RIG-1715: “The SECRET_KIND_PROVIDER registry 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: loginOpenAICodexDevice posts the client_id to the device-usercode endpoint (openai-codex.ts:251-255), shows the user Enter 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 third method on the same attempt table (the poll interval/expiry fields it needs are additive columns).

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.

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.

  • 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 carries DECISIONS.md rows 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:check enforces.
  • 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_credentials store shape (scope column, monotonic version CAS), 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 the Create write 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 (the SecretStatus precedent, 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_id is 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 + pgtest suite (DL-174 pyramid, per RIG-1715 GC design.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 in apps/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_credentials shape (scope column, monotonic version, 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/store or 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) error
    • ConsumeAttempt(ctx context.Context, id, ownerUserID string) (EnrollmentAttempt, error) — atomically sets consumed_at iff unconsumed + unexpired; ErrGone otherwise
    • ParkCallbackCode(ctx context.Context, state, code string) error — the callback-path write: sets parked_code on the unconsumed, unexpired attempt matching state (unique column) WITHOUT consuming it; ErrGone otherwise. Never exchanges, never writes a credential — completion stays with the owner-checked ConsumeAttempt path (park-then-confirm, Approach §Server HTTPS callback endpoint)
    • CreateCredential(ctx context.Context, c GatewayCredential) (id string, err error) — upsert-by-identity (UNIQUE NULLS NOT DISTINCT key), 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 is ErrGone), 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 tokens set 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#state fragment 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); auth caller-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.go implementing compassv1connect.ProviderEnrollmentServiceHandler; door registration on socket/dev/network muxes; auth.classifyProcedure entries — all five procedures authenticatedOpen, 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), and classify_exhaustive_test.go:61-62 reddens CI on any unclassified generated procedure.
  • Test cycle: pgtest handler 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_credentials row); unknown/expired/consumed state → 404/ 302-failure with no oracle detail; a parked foreign-owner attempt cannot be completed by another caller (CompleteEnrollment owner check); provider error param 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); LiveClients construction (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 accessors store.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 createRouterTransport fake (begin→paste→connected; error surfaces on bad code; disconnect confirm); no token value ever rendered or stored client-side.
  • E1 — Attempt store + gateway_credentials Create (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.
  • 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’s user:inference grant 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 method on 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); SetProviderApiKey extends 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_id scoping is enforced here regardless, so this record is correct under either ruling.