Something went wrong. Try again.
Web frontend and supporting services for lance.blue
Something went wrong. Try again.
5.0 kB · 130 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131/** * 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:<port>`, never * `http://localhost:<port>`. 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<never>; revoke(sub: string): Promise<void>;};
let clientPromise: Promise<BrowserClient> | null = null;let current: Session | null = null;let initialised = false;
async function getClient(): Promise<BrowserClient> { 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<string | null> { 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<Session | null> { 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<never> { 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<void> { 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 */ }}