What happened
Adding `AuthPresenceReadScope` (increment 1 of #92, "Set Up Integration") to `AuthStandardClientScopes` in `packages/contracts/src/auth.ts` silently broke the desktop bootstrap token exchange with `invalid_scope` (5 failing tests in `server.test.ts`'s "server router seam" suite: token exchange, websocket ticket issuance, pairing credential issuance, websocket rpc handshake via ticket, CORS headers on auth success).
Root cause
`apps/server/src/auth/http.ts`'s `/oauth/token` handler (around line 278) keeps its own hand-maintained `allowedScopes` `Set`, independent of `AuthStandardClientScopes`:
allowedScopes: new Set<AuthEnvironmentScope>([
AuthOrchestrationReadScope,
AuthOrchestrationOperateScope,
AuthTerminalOperateScope,
AuthReviewWriteScope,
AuthAccessReadScope,
AuthAccessWriteScope,
AuthRelayReadScope,
AuthRelayWriteScope,
AuthPresenceCommandScope,
AuthPresenceReadScope,
]),
This is a THIRD scope list in the codebase, distinct from both `AuthEnvironmentScope`'s own `Schema.Literals` union and `AuthStandardClientScopes` (which `AuthDesktopOwnerScopes` etc. derive from via spread). Nothing enforces that the three stay in sync. A caller requesting `AuthDesktopOwnerScopes.join(" ")` — which automatically picks up any new scope added to `AuthStandardClientScopes` — gets `invalid_scope` the moment a new scope exists in the derived list but not in this hand-copied one.
`AuthPresenceCommandScope` happening to already be present here (deliberately, per its own comment: requestable via token exchange but NOT auto-granted, since `presence:command` is desktop-owner-only) is what made increment 1's fix "just add it here too" easy this time — but that's exactly the trap: the next scope added to `AuthStandardClientScopes` won't have an obvious reason to also touch this unrelated file, and the failure mode is a same-shaped `invalid_scope` 400 with no compiler signal.
Suggested fix
Derive `allowedScopes` from `AuthStandardClientScopes` (plus whatever token-exchange-only scopes, like `AuthPresenceCommandScope`, are deliberately requestable-but-not-auto-granted) instead of hand-listing every entry — so adding a scope to the canonical list is sufficient, and a token-exchange-only addition is a single explicit union rather than a silent copy-paste site.
Where this was found
Found while building increment 1 of #92 ("Set Up Integration" detection probe) — adding the new `presence:read` scope required also fixing 3 hardcoded scope-list literals in test files (`EnvironmentAuth.test.ts`, `PairingGrantStore.test.ts`, `SessionStore.test.ts`) plus this one, real, production-code allowlist. Not fixed as part of that change beyond adding the new scope to this list — filing as its own ticket per team-lead's request, since the structural risk (three lists that must agree, no enforcement) outlives that one increment.
🤖 Generated with Claude Code
What happened
Adding `AuthPresenceReadScope` (increment 1 of #92, "Set Up Integration") to `AuthStandardClientScopes` in `packages/contracts/src/auth.ts` silently broke the desktop bootstrap token exchange with `invalid_scope` (5 failing tests in `server.test.ts`'s "server router seam" suite: token exchange, websocket ticket issuance, pairing credential issuance, websocket rpc handshake via ticket, CORS headers on auth success).
Root cause
`apps/server/src/auth/http.ts`'s `/oauth/token` handler (around line 278) keeps its own hand-maintained `allowedScopes` `Set`, independent of `AuthStandardClientScopes`:
This is a THIRD scope list in the codebase, distinct from both `AuthEnvironmentScope`'s own `Schema.Literals` union and `AuthStandardClientScopes` (which `AuthDesktopOwnerScopes` etc. derive from via spread). Nothing enforces that the three stay in sync. A caller requesting `AuthDesktopOwnerScopes.join(" ")` — which automatically picks up any new scope added to `AuthStandardClientScopes` — gets `invalid_scope` the moment a new scope exists in the derived list but not in this hand-copied one.
`AuthPresenceCommandScope` happening to already be present here (deliberately, per its own comment: requestable via token exchange but NOT auto-granted, since `presence:command` is desktop-owner-only) is what made increment 1's fix "just add it here too" easy this time — but that's exactly the trap: the next scope added to `AuthStandardClientScopes` won't have an obvious reason to also touch this unrelated file, and the failure mode is a same-shaped `invalid_scope` 400 with no compiler signal.
Suggested fix
Derive `allowedScopes` from `AuthStandardClientScopes` (plus whatever token-exchange-only scopes, like `AuthPresenceCommandScope`, are deliberately requestable-but-not-auto-granted) instead of hand-listing every entry — so adding a scope to the canonical list is sufficient, and a token-exchange-only addition is a single explicit union rather than a silent copy-paste site.
Where this was found
Found while building increment 1 of #92 ("Set Up Integration" detection probe) — adding the new `presence:read` scope required also fixing 3 hardcoded scope-list literals in test files (`EnvironmentAuth.test.ts`, `PairingGrantStore.test.ts`, `SessionStore.test.ts`) plus this one, real, production-code allowlist. Not fixed as part of that change beyond adding the new scope to this list — filing as its own ticket per team-lead's request, since the structural risk (three lists that must agree, no enforcement) outlives that one increment.
🤖 Generated with Claude Code