# S021 - bsky bootstrap 403: granular rpc scope for service auth **Superseded (same day):** the granular rpc scope fix below was a dead end — bsky's AS accepts it at PAR but never grants it. The working fix is `atproto transition:generic` ([s020](s020.dj) addendum 2, full evidence in [post/000](../post/000.dj)). Kept for the diagnostic trail. Date: 2026-08-02 Slice: external sign-in callback ("could not set up your account on our server - try again" on app0.linji.at after bsky OAuth consent). ## Symptom Sign-in flow: app0.linji.at -> OAuth -> bsky consent -> redirect to `/oauth/callback?code=...&state=...` -> 502 page "could not set up your account on our server - try again". This is the `bootstrapSpaceCreds` failure path in `callbackHandler` (server.zig:270-275), *after* `finishLogin` already upserted the bsky-token account into the store (the account row persisted with bsky creds and no `home_pds`; zds had no account for the DID). ## Root cause Production app0 log (2026-08-02T04:41:21Z): getServiceAuth failed: 403 {"error":"ScopeMissingError","message": "Missing required scope \"rpc:com.atproto.server.createAccount?aud=did:web:zds.linji.at\""} bootstrap failed did=did:plc:73xkqje76lj5tostbsqa74b7 pds=https://yellowfoot.us-west.host.bsky.network err=BootstrapFailed The DID-adoption contract: zds's `verifyCreateAccountServiceAuth` only accepts a service-auth JWT signed by the user's home PDS (bsky holds the account signing key; we cannot forge it). The JWT comes from `com.atproto.server.getServiceAuth` on bsky, called with the first-leg OAuth access token. bsky's granular-permission enforcement (atproto permissions spec, 2025+) requires that token to carry `rpc:com.atproto.server.createAccount?aud=`; the legacy `atproto` scope no longer implies rpc permissions. Our first leg only ever requested `atproto` (S020 step 2), so getServiceAuth refused to mint the JWT and the whole bootstrap aborted. Confirmed non-causes: zds invite gate (all three codes on zds had remaining uses; `LINJI_ZDS_INVITE` secret present on app0); zds scripted-consent JSON path (not dev-tools-gated - only `dev.zat.debug.*` routes are); zds `resolveHandle` for external handles (local lookup first, then HandleResolver). ## Fix (app0 only; zds, zat untouched) - `oauth.zig`: - `externalScope(allocator, space_did)` returns `rpc:com.atproto.server.createAccount?aud={space_did}` (no `atproto` prefix - see the duplicate-scope gotcha below). `scopeFor` / `external_scope` deleted. - `beginLoginAt`: external PDS leg (pds_url != space_pds) fetches the space host's DID via `describeServerDid` and requests `atproto `; space-host legs keep the full linji scope. `describeServerDid` made `pub`. - `clientMetadata(..., space_did)`: the metadata's `scope` declares the rpc scope too, so bsky's "scope must be declared in the client metadata" check passes. - `server.zig`: `serve()` resolves the space DID up front (`oauth.describeServerDid(allocator, io, pds)`) - fail fast, the app is useless without zds anyway - and passes it to `clientMetadata`. ### Gotchas found while verifying (both fixed) 1. bsky's PAR validates the requested scope against the client metadata: `400 invalid_scope ... not declared in the client metadata`. Declaring it in the metadata is mandatory, not just good hygiene. 2. bsky validates the metadata's declared scopes as a *set*: `400 invalid_client_metadata: Duplicate scope "atproto"` when the legacy token appears both in `scope` and as an `atproto ` prefix of the rpc scope. Hence `externalScope` returns only the rpc part and the `atproto` token is composed at the PAR site only. 3. Probing bsky's PAR directly required the *exact* zat assertion shape: `aud` = AS issuer (not the token endpoint), JWS signature raw r||s (JOSE ES256), not DER - bsky rejects DER with a misleading "signature verification failed"; zds is lenient and accepted both, which masked the format bug until the aud error surfaced. ## Verification - `zig build`, `zig build test` (new `externalScope` unit test), `zig build scenario-serve` PASS. - Deployed twice (v15, v16) to app0.linji.at; live metadata probe: 4 distinct scopes, no duplicate `atproto`, rpc scope declared. - Live bsky PAR probe with the deployed client key (clientSecretHex from the prod store + jwks x/y from the served metadata) and the new scope: **201 request_uri** (was 400 invalid_scope pre-fix). - Full callback leg with a real bsky account still needs the user's retry; the consent screen will show the extra "make authenticated API requests to zds.linji.at" permission (that is the scope working). ## Addendum: concurrent-edit incident (same day) A hand edit to `oauth.zig` after deploy v16 added an `ext_tail` slice `ext[indexOf(ext, " ").? + 1 ..]` in `clientMetadata`, deduping an `atproto ` prefix - but `externalScope` no longer returns that prefix (the dedupe fix), so `indexOf` is null and `.?` panics at startup. Removed; the dedupe intent is already satisfied by the rpc-only scope. If a similar dedupe is ever re-added, it must target the metadata composition, not `externalScope`. ## Follow-ups - User's first live retry of the full bsky callback leg (pending at time of writing). - S020's parked mystery: zds ~1.3Hz `bearer auth invalid: jwt parse/verify failed token_len=424` (foreign JWT on a bearer route) - still unresolved; ZDS_LOG_LEVEL=debug would name the path. - Revisit the shared 500-use bootstrap invite code if sign-ups grow (from S020).