/** * Signing in for real, without the API. * * This is not a mock. It runs the actual atproto OAuth flow from the browser * against the player's actual PDS and ends up holding a real DID for a real * account. The only thing it skips is headquarters-api: the tokens live in this * tab's IndexedDB rather than in a session on our server. * * That is a development convenience and must never become the production path. * The reason the real client keeps tokens server-side is that a match outlives * the tab that started it — the control plane has to publish a player's result * after they have closed the page, and a token that only exists in a browser * cannot do that. See docs/identity-and-sessions.md. * * Loopback client rules, which are not optional and are easy to get wrong: * * * The app must be served from `http://127.0.0.1:`, never * `http://localhost:`. For a loopback origin the authorization server * supplies hard-coded client metadata and only accepts the IP form. (The * `localhost` spelling that appears in the OAuth spec is about the * `client_id` string, which is a different field and is synthesised for * us — the two are easy to confuse.) * * Refresh tokens are deliberately short-lived, on the order of a day, so * this session lapses sooner than a real one would. * * Silent sign-in is not available. * * Quarantined from production by the call sites in api.ts, each of which sits * inside `if (import.meta.env.DEV)`. That is a literal false in a production * build, so the branch and the dynamic import inside it are both eliminated and * neither this module nor the OAuth client reaches the bundle. Guarding on the * `devAuthEnabled` constant alone was not enough - the import stayed reachable * and Rollup emitted a 200KB chunk for it. * * npm run dev:local-auth */ import type { Session } from "./api"; /** Dev build, and explicitly asked for. Never true in a production bundle. */ export const devAuthEnabled: boolean = import.meta.env.DEV && import.meta.env.VITE_LOCAL_AUTH === "1"; /** Resolves handles to DIDs during sign-in. Any public resolver will do. */ const HANDLE_RESOLVER = "https://bsky.social"; type BrowserClient = { init(): Promise<{ session: { sub: string } } | undefined>; signIn(handle: string, options?: { scope?: string }): Promise; revoke(sub: string): Promise; }; let clientPromise: Promise | null = null; let current: Session | null = null; let initialised = false; async function getClient(): Promise { clientPromise ??= (async () => { const { BrowserOAuthClient } = await import("@atproto/oauth-client-browser"); return new BrowserOAuthClient({ handleResolver: HANDLE_RESOLVER, // No clientMetadata on purpose: for a loopback origin the authorization // server provides a hard-coded one, which is what makes this work with // nothing deployed. }) as unknown as BrowserClient; })(); return clientPromise; } /** * The OAuth response carries a DID, not a handle. Ask the public appview for * one, and settle for the DID alone if that fails: showing an unverified name * would be worse than showing none. */ async function resolveHandle(did: string): Promise { try { const response = await fetch( `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}`, ); if (!response.ok) return null; const { handle } = (await response.json()) as { handle?: string }; return handle ?? null; } catch { return null; } } /** * Restore a session, or complete one that a redirect has just come back from. * `init()` must run exactly once per page load, so the result is cached. */ export async function devSession(): Promise { if (!devAuthEnabled) return null; if (initialised) return current; initialised = true; try { const client = await getClient(); const result = await client.init(); if (!result) return null; const did = result.session.sub; current = { did, handle: await resolveHandle(did) }; return current; } catch (error) { console.warn("local auth: could not restore a session", error); return null; } } /** * Start a real sign-in. Navigates away to the player's own PDS and does not * return, which is exactly what the production flow does. */ export async function devSignIn(handle: string): Promise { const trimmed = handle.trim().replace(/^@/, ""); if (!trimmed) throw new Error("Enter a handle."); const client = await getClient(); return client.signIn(trimmed, { scope: "atproto" }); } export async function devSignOut(): Promise { const session = current; current = null; if (!session) return; try { const client = await getClient(); await client.revoke(session.did); } catch { /* the local session is going away regardless */ } }