# S020 - external sign-in ("Bluesky users can log in") Date: 2026-08-02 Slice: any atproto handle signs into app0.linji.at; invite codes gate spaces, not the service. ## Policy change (user decision) - Sign-up to the service needs only an Internet handle. The app-level invite gate (`LINJI_INVITE`, `--invite-code`, the Landing invite field) is removed end to end; space invite links remain the space-join mechanism. ## How external sign-in works 1. Landing: type any handle (`alice.bsky.social`). BFF resolves handle -> DID -> home PDS (`.well-known/atproto-did` -> DID doc). 2. PAR against the home PDS with the **plain `atproto` scope** (external ASes reject our custom `space:` scope). Discovery follows the protected-resource metadata chain: bsky's regional hosts (morel.us-east.host.bsky.network) 404 `/.well-known/oauth-authorization-server` and must be read via `/.well-known/oauth-protected-resource` -> `authorization_servers[0]` (fixed in `fetchAsEndpoints`; zds falls back to self). 3. Callback: after the home-PDS token exchange, `bootstrapSpaceCreds`: a. `describeServer` on our zds -> `did:web:zds.linji.at` (aud); b. `getServiceAuth` on the home PDS (DPoP) -> service JWT proving DID ownership to zds; c. zds `createAccount` (its service-auth path) adopts the DID: external handle (resolves to the DID), placeholder email, derived password, bootstrap invite code (`LINJI_ZDS_INVITE` secret, 500-use admin code). "account already exists" = re-login, skip. d. Full-scope OAuth login against zds (scripted consent with the derived password) swaps the stored creds to zds ones; the home PDS URL is kept in the new `home_pds` field (accounts.json roundtrip). 4. From then on the account looks exactly like a zds-local one: space ops, delegation/credential ceremony, join - all against zds with the full scope. Records live on our zds (the deliberate spec departure). Derived password = HMAC-SHA256(session secret, "zds-bootstrap-password:"+did) hex - deterministic, nothing extra to store; the session secret never leaves the box. ## Verification - zig build test (new: scopeFor, derived password/email determinism), scenario-serve PASS (gate removal + new beginLogin arg), web 50/50. - Prod probes: `/api/config` has no inviteRequired; `/api/login?handle=alice.bsky.social` -> 302 to `bsky.social/oauth/authorize` with a successful PAR. - **Not verified**: the callback leg with a real bsky account (getServiceAuth at bsky, zds createAccount service-auth, zds OAuth) - no bsky credentials available to the agent. Every bootstrap stage logs a distinct failure line for the user's first run. ## Addendum: handle-resolution bug found in user testing (2026-08-02) User's handle `1a-insec.net` (did:plc:73xkqje76lj5tostbsqa74b7, custom-domain, DNS-TXT-only) got stuck on the zds consent page: PAR on zds succeeded because `resolvePdsForHandle` fell back to zds whenever well-known lookup failed - and zds's resolveHandle DOES DNS TXT, so the PAR ran with full scope against an account that did not exist on zds. Fix (app0-only, zds untouched): `resolveDidForHandle` uses `zat.HandleResolver` (well-known -> DNS TXT over DoH); the zds fallback is restricted to `isHostedHandle(handle, handle_domain)` (dot-boundary suffix match, unit-tested incl. the `evillinji.at` false-positive); unresolvable external handles are a hard 400 (HandleResolutionFailed), never a redirect to the wrong consent page. `beginLogin` gained a `handle_domain` param; `didForHandle` deleted. Verified 2026-08-02 (deploy v14): local BFF (LINJI_BASE/PDS env, store seeded with prod clientSecretHex; /etc/hosts not writable - used `curl --resolve app0.linji.at:8787:127.0.0.1`) and prod agree: - `1a-insec.net` -> 302 to bsky.social/oauth/authorize (PAR ok, `atproto` scope). - `alice.bsky.social` -> 400 (no _atproto TXT - correct, not a typo redirect; the old pre-fix 302 was the buggy fallback). - `bob.zds.linji.at` -> 302 to zds (hosted path intact). - `zzz.linji.at` -> 502 via zds fallback (ResolveFailed; acceptable). Still pending: the full callback leg with a real bsky account; user's first live retry via the tongxi invite link. ## Addendum 2: rpc-scope dead end + transition:generic (2026-08-02) First real retry failed at bootstrap: bsky's getServiceAuth 403 ScopeMissingError - the token bsky minted carried only scope="atproto" even after we requested (and declared in metadata) the spec-correct `rpc:com.atproto.server.createAccount?aud=did:web:zds.linji.at`. bsky's AS (entryway, closed) accepts the scope at PAR, never grants it, and its own PDS then demands it (permissions.assertRpc). Evidence + analysis in docs/post/000.dj. Fix: request `atproto transition:generic`. bsky grants it, and ScopePermissionsTransition.allowsRpc treats it as covering rpc permission for any non-chat lxm (OAuth creds skip the PRIVILEGED_METHODS check - type 'oauth'). Verified end to end 10:03Z: login redirect -> callback ok -> service-auth ok -> createAccount ok -> zds login ok -> bootstrap ok. User account live on zds with full scope, homePds=yellowfoot.us-west. host.bsky.network. S020 complete. zds keeps request-path debug logging (ZDS_LOG_LEVEL=debug, moved from a fly secret to fly.toml [env]) - it diagnosed the 424 bearer-auth spam. ## Follow-ups - Invite code for bootstrap is a shared 500-use code in a fly secret; fine for dogfood, revisit if sign-ups grow. - External accounts on zds carry a placeholder email and a repo signed by a zds-generated key (not the DID-doc key) - known departure, invisible inside our appview. - Unresolved: zds logs a steady ~1.3Hz `bearer auth invalid: jwt parse/verify failed token_len=424` (foreign JWT hitting a bearer XRPC route; pre-dates the fix, survived the app0 v14 restart; not our code - no 424-char token exists in BFF state, bootstrap logs different lines). ZDS_LOG_LEVEL=debug would print the request path; parked pending user retry.