From 56ececcb85afa7608b28ddbb8d55e5f06326ad38 Mon Sep 17 00:00:00 2001 From: Chad Miller Date: Mon, 10 Aug 2026 09:01:48 -0700 Subject: [PATCH] refactor(core): move the account surface into a handler module The last cluster in the pds.js decomposition: the /account pages, the cookie session behind them, and the /account/api JSON endpoints move to handlers/account.js as one project, accountApi and its 29 routes with them. The factory exposes the five functions the rest of the server reaches for: readAccountSession, readAccountUiState and accountAppResponse for dispatch and the OAuth consent flow, accountApi for the backup routes, and listConnectedApps for its unit test, which now builds the factory directly. The ACCOUNT_* limits, BLOB_KINDS, countAppDomains and plausibleHost had no caller outside the surface and move with it. Three locals collided with context member names and are renamed: passkeysAvailable, liveSessions and connectedApps; the context takes accountPassword for the same reason, matching the OAuth handler context. Co-Authored-By: Claude Fable 5 --- packages/core/src/handlers/account.js | 2998 ++++++++++++++++++++++ packages/core/src/pds.js | 3281 ++----------------------- test/oauth-session-device.test.js | 104 +- vitest.config.js | 5 + 4 files changed, 3233 insertions(+), 3155 deletions(-) create mode 100644 packages/core/src/handlers/account.js diff --git a/packages/core/src/handlers/account.js b/packages/core/src/handlers/account.js new file mode 100644 index 0000000..f527fb1 --- /dev/null +++ b/packages/core/src/handlers/account.js @@ -0,0 +1,2998 @@ +// @pdsjs/core/handlers/account - The account surface: the server-rendered +// /account pages, the cookie session behind them, and the /account/api JSON +// endpoints that drive the mounted account application. + +import { + renderAccountPage, + renderAppPasswordsPage, + renderAppsPage, + renderEmailPage, + renderIdentityPage, + renderPasskeysPage, + renderRecordsPage, + renderRepoPage, + renderSessionsPage, + renderSignInPage, + renderSpacesPage, +} from '../account-ui.js'; +import { + generateAppPassword, + hashAppPassword, + isAppPasswordScope, +} from '../app-password.js'; +import { createAccountJwt, verifyAccountJwt } from '../auth.js'; +import { parseCarFile } from '../car.js'; +import { hmacSha256, timingSafeEqual } from '../crypto.js'; +import { + EMAIL_CODE_TTL_MS, + EMAIL_RESEND_COOLDOWN_MS, + generateEmailCode, + hashEmailCode, + isValidEmail, + readEmailState, + renderVerificationEmail, +} from '../email.js'; +import { + drainRequestBody, + htmlResponse, + isSameOriginPost, + readCookie, +} from '../http.js'; +import { cborDecode, cidToString, tidToMs, toDisplayValue } from '../repo.js'; +import { + ACCOUNT_COOKIE, + ACCOUNT_SESSION_TTL, + accountCookie, +} from '../session.js'; +import { verifyAssertion, verifyRegistration } from '../webauthn.js'; + +// How many records a collection view renders before it says it is showing +// a prefix. Applies to both the repo and the space views. +const ACCOUNT_SPACE_RECORD_LIMIT = 25; + +// Where the account page stops counting blobs and reports `N+` instead +const ACCOUNT_BLOB_COUNT_LIMIT = 1000; + +// How many files the account Files page loads per request +const ACCOUNT_BLOB_PAGE_LIMIT = 30; + +// The media kinds the Files page can filter by; mirrors blob-sql's bucketing +const BLOB_KINDS = ['image', 'video', 'audio', 'text', 'other']; + +/** + * How many apps the Apps page shows: one per authority domain, unioned across + * the collections that hold records and the OAuth sessions in play. An app can + * appear with data but no session (or the reverse), and several sessions of one + * app collapse into a single row — so this is the row count, not the session + * count. Mirrors nsidDomain/clientHost in the account UI's format helper. + * @param {{name: string}[]} collections + * @param {{clientId?: string}[]} sessions + * @returns {number} + */ +function countAppDomains(collections, sessions) { + const domains = new Set(); + for (const c of collections || []) { + const parts = String(c.name).split('.'); + domains.add(parts.length < 2 ? String(c.name) : `${parts[1]}.${parts[0]}`); + } + for (const s of sessions || []) { + const clientId = s.clientId || ''; + try { + domains.add(new URL(clientId).host); + } catch { + domains.add(clientId); + } + } + return domains.size; +} + +/** + * A lowercased hostname-looking string, or null. A port is allowed so a dev + * client like `localhost:5173` still names a row. + * @param {unknown} value + * @returns {string|null} + */ +function plausibleHost(value) { + if (typeof value !== 'string') return null; + const host = value.trim().toLowerCase(); + return /^[a-z0-9][a-z0-9.:-]{0,252}$/.test(host) ? host : null; +} + +/** + * These are structural types. Thus a member does not name the module that owns + * the function you give it. + * + * @typedef {Object} AccountContext + * @property {import('../ports.js').ActorStoragePort} actorStorage + * @property {import('../ports.js').SharedStoragePort} sharedStorage + * @property {ReturnType} sessions + * @property {ReturnType} passkeys + * @property {string} jwtSecret + * @property {string} [hostname] + * @property {string} plcUrl + * @property {string} [accountPassword] - The account password, for the sign-in form + * @property {boolean} readOnly + * @property {import('../ports.js').EmailPort|null} emailer + * @property {import('../ports.js').SpaceBrowserPort|null} spaceBrowser + * @property {string|null} accountApp - Built account application, as one HTML document + * @property {import('../ports.js').LexiconResolverPort} [lexiconResolver] + * @property {() => Promise} getDid + * @property {(request: Request) => Promise} readJsonBounded + * @property {(active: boolean) => Promise} setAccountActive + * @property {(fallbackHostname?: string|null) => Promise<{rotationKeys: string[], alsoKnownAs: string[], verificationMethods: {atproto: string}, services: {atproto_pds: {type: string, endpoint: string}}}|null>} getRecommendedDidCredentials + * @property {() => Promise} relinkBlobRecords + * @property {(requested: unknown, hostname?: string|null) => Promise<{handle: string, changed: boolean}>} renameHandle + * @property {() => boolean} backupsAvailable + */ + +/** + * @param {AccountContext} ctx + * @returns {{ + * routes: import('../pds.js').Routes, + * readAccountSession: (request: Request) => Promise, + * readAccountUiState: () => Promise, + * accountAppResponse: (bootstrap?: Object|null, status?: number) => Response|null, + * accountApi: (request: Request, url: URL, handler: (did: string, body: any) => Promise, options?: {write?: boolean}) => Promise, + * listConnectedApps: (did: string) => Promise>, + * }} + */ +export function createAccountHandlers(ctx) { + const { + actorStorage, + sharedStorage, + sessions, + passkeys, + jwtSecret, + hostname, + plcUrl, + accountPassword, + readOnly, + emailer, + spaceBrowser, + accountApp, + lexiconResolver, + getDid, + readJsonBounded, + setAccountActive, + getRecommendedDidCredentials, + relinkBlobRecords, + renameHandle, + backupsAvailable, + } = ctx; + + /** + * The DID of the account whose cookie session this request carries, or null + * when there is no valid session. + * @param {Request} request + * @returns {Promise} + */ + async function readAccountSession(request) { + const token = readCookie(request, ACCOUNT_COOKIE); + if (!token) return null; + try { + const payload = await verifyAccountJwt(token, jwtSecret); + // A session outlives nothing: if the PDS has since been initialized with + // a different DID, its cookies are not this account's. + const did = await getDid(); + return did && payload.sub === did ? did : null; + } catch { + return null; + } + } + + /** + * Display name and avatar from the account's own app.bsky.actor.profile + * record. Read from this repo rather than looked up at a public AppView: + * the account screen must show this account, not whichever account some + * third party knows by the same handle. + * @param {string} did + * @returns {Promise<{displayName: string|null, avatarUrl: string|null}>} + */ + async function readAccountProfile(did) { + const empty = { displayName: null, avatarUrl: null }; + try { + const stored = await actorStorage.getRecord( + `at://${did}/app.bsky.actor.profile/self`, + ); + if (!stored) return empty; + + const value = cborDecode(stored.value); + const ref = value?.avatar?.ref; + // Records written over XRPC keep the JSON `{$link}` form; ones loaded + // from a CAR carry decoded tag-42 bytes. + const cid = + typeof ref?.$link === 'string' + ? ref.$link + : ref instanceof Uint8Array + ? cidToString(ref) + : null; + + return { + displayName: + typeof value?.displayName === 'string' ? value.displayName : null, + avatarUrl: cid + ? `/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}` + : null, + }; + } catch { + // A profile record that will not decode is not worth a broken page + return empty; + } + } + + /** + * Count the repository's records per collection. Record keys are + * `collection/rkey`, so the collection is everything up to the first slash. + * @returns {Promise>} Sorted by name + */ + async function countRecordsByCollection() { + /** @type {Map} */ + const counts = new Map(); + for (const record of await actorStorage.listAllRecords()) { + const collection = record.key.split('/')[0]; + counts.set(collection, (counts.get(collection) || 0) + 1); + } + return [...counts] + .map(([name, count]) => ({ name, count })) + .sort((a, b) => a.name.localeCompare(b.name)); + } + + /** + * Per-collection counts, a creation-time histogram, and the latest write + * time, from a single pass over the repo. Each record is bucketed by the + * timestamp its rkey (a TID) encodes, across a fixed trailing window, so the + * Apps page can draw an activity sparkline over the whole window rather than + * only the newest firehose events. Records whose rkey is not a TID (e.g. + * `self`) still count toward the totals but contribute no point and no time. + * `lastRecordAt` is unbounded by the window, so a row can label how recently + * an app was active even when its newest write predates the sparkline. Only + * collections with activity in the window appear in `series`, to keep the + * payload small. + * @param {{windowMs?: number, buckets?: number, now?: number}} [opts] + * @returns {Promise<{collections: {name: string, count: number, lastRecordAt: number|null}[], activity: {start: number, end: number, buckets: number, series: Record}}>} + */ + async function countRecordsWithActivity({ + windowMs = 365 * 86_400_000, + buckets = 52, + now = Date.now(), + } = {}) { + const start = now - windowMs; + /** @type {Map} */ + const counts = new Map(); + /** @type {Map} */ + const lastAt = new Map(); + /** @type {Map} */ + const series = new Map(); + for (const record of await actorStorage.listAllRecords()) { + const slash = record.key.indexOf('/'); + const collection = slash >= 0 ? record.key.slice(0, slash) : record.key; + counts.set(collection, (counts.get(collection) || 0) + 1); + const at = tidToMs(slash >= 0 ? record.key.slice(slash + 1) : ''); + // Newest write per collection, tracked before the window filter so it + // reflects the true last activity even for records older than the window. + if (at !== null && at <= now && at > (lastAt.get(collection) || 0)) { + lastAt.set(collection, at); + } + if (at === null || at < start || at > now) continue; + let arr = series.get(collection); + if (!arr) { + arr = new Array(buckets).fill(0); + series.set(collection, arr); + } + const i = Math.min( + buckets - 1, + Math.floor(((at - start) / windowMs) * buckets), + ); + arr[i] += 1; + } + const collections = [...counts] + .map(([name, count]) => ({ + name, + count, + lastRecordAt: lastAt.get(name) ?? null, + })) + .sort((a, b) => a.name.localeCompare(b.name)); + return { + collections, + activity: { + start, + end: now, + buckets, + series: Object.fromEntries(series), + }, + }; + } + + /** + * Count stored blobs, stopping at ACCOUNT_BLOB_COUNT_LIMIT. A full count + * would walk every page of a repository that may hold thousands; the page + * reports the cap as `N+` rather than paying for an exact number. + * @returns {Promise<{count: number, truncated: boolean}>} + */ + async function countBlobs() { + let count = 0; + let cursor = null; + while (count < ACCOUNT_BLOB_COUNT_LIMIT) { + /** @type {{cids: string[], cursor: string|null}} */ + const page = await actorStorage.listBlobs(cursor, 100); + count += page.cids.length; + cursor = page.cursor; + if (!cursor || page.cids.length === 0) { + return { count, truncated: false }; + } + } + return { count: ACCOUNT_BLOB_COUNT_LIMIT, truncated: true }; + } + + /** + * A handle for one OAuth session that is safe to put in a page. + * + * The storage key for a session is the refresh token itself, so it can + * never be rendered: a hidden form field carrying it would hand a live + * credential to anything that reads the page. This is an HMAC of it under + * the server secret — stable, opaque, and useless to anyone who cannot + * already act as this account. + * @param {string} tokenId - The refresh token + * @returns {Promise} + */ + async function oauthSessionId(tokenId) { + return (await hmacSha256(`oauth-session:${tokenId}`, jwtSecret)).slice( + 0, + 22, + ); + } + + /** + * Apps holding an OAuth session, most recently active first. Refresh tokens + * are stored one per session, so a client that reauthorized appears once per + * session. lastAccessedAt tracks the session's most recent refresh; access + * tokens are verified statelessly, so it moves on refresh, not on every + * request. Older tokens have no updatedAt, so createdAt stands in. + * @param {string} did + * @returns {Promise>} + */ + async function listConnectedApps(did) { + if (!sharedStorage.listOAuthTokensByDid) return []; + const tokens = await sharedStorage.listOAuthTokensByDid(did); + const apps = await Promise.all( + tokens.map(async (token) => { + const lastAccessed = token.updatedAt ?? token.createdAt; + return { + clientId: token.clientId || 'unknown client', + scope: token.scope || 'atproto', + authorizedAt: token.createdAt + ? new Date(token.createdAt).toISOString().slice(0, 10) + : null, + lastAccessedAt: lastAccessed + ? new Date(lastAccessed).toISOString() + : null, + userAgent: token.userAgent || null, + // Enough of the DPoP thumbprint to tell two sessions apart + keyThumbprint: token.dpopJkt ? token.dpopJkt.slice(0, 12) : null, + // Absent on a storage adapter that does not return its keys, which + // costs the row its revoke button rather than breaking the page + sessionId: token.tokenId ? await oauthSessionId(token.tokenId) : null, + sortKey: lastAccessed || 0, + }; + }), + ); + return apps + .sort((a, b) => b.sortKey - a.sortKey) + .map(({ sortKey: _sortKey, ...app }) => app); + } + + /** + * Revoke one OAuth session by the handle listConnectedApps rendered. + * @param {string} did + * @param {string} sessionId + * @returns {Promise<{clientId: string}|null>} The app revoked, or null when + * no session matched + */ + async function revokeOAuthSession(did, sessionId) { + if ( + !sharedStorage.listOAuthTokensByDid || + !sharedStorage.deleteOAuthToken + ) { + return null; + } + + for (const token of await sharedStorage.listOAuthTokensByDid(did)) { + if (!token.tokenId) continue; + const candidate = await oauthSessionId(token.tokenId); + if (!(await timingSafeEqual(candidate, sessionId))) continue; + + await sharedStorage.deleteOAuthToken(token.tokenId); + return { clientId: token.clientId || 'unknown client' }; + } + return null; + } + + /** + * @param {string} did + * @returns {Promise>} + */ + async function listAccountAppPasswords(did) { + if (!sharedStorage.listAppPasswords) return []; + return sharedStorage.listAppPasswords(did); + } + + /** + * The signed-in DID, or a response to send instead — the sign-in page for a + * page request, and for a form post the same page carrying an error. + * @param {Request} request + * @param {URL} url + * @returns {Promise<{did: string}|{response: Response}>} + */ + async function requireAccountSession(request, url) { + const did = await readAccountSession(request); + if (did) return { did }; + return { + response: signInResponse(url, { + error: 'Your session has expired. Sign in again.', + status: 401, + }), + }; + } + + /** + * The account application, when this deployment mounted one. It renders + * every section client-side against the JSON API, so it is served for any + * section path and routes within itself. + * @param {Object|null} [bootstrap] - Pre-auth state injected for the app to read before mounting + * @param {number} [status] - HTTP status for the response + * @returns {Response|null} + */ + function accountAppResponse(bootstrap = null, status = 200) { + if (!accountApp) return null; + let html = accountApp; + if (bootstrap) { + // A classic script in runs before the deferred app module, so the + // app reads its pre-auth mode before mounting. `<` is escaped so no value + // can close the script element. + const data = JSON.stringify(JSON.stringify(bootstrap)).replace( + /', + ``, + ); + } + return new Response(html, { + status, + headers: { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-store', + 'Referrer-Policy': 'same-origin', + }, + }); + } + + /** + * The sign-in screen: the account app in sign-in mode when one is mounted, + * and the server-rendered form otherwise. + * @param {URL} url + * @param {{error?: string, identifier?: string, status?: number}} [opts] + * @returns {Response} + */ + function signInResponse( + url, + { error = '', identifier = '', status = 200 } = {}, + ) { + const passkeysAvailable = passkeys.passkeysAvailable(); + if (accountApp) { + // Non-null: accountAppResponse only returns null when accountApp is unset. + return /** @type {Response} */ ( + accountAppResponse( + { + mode: 'signin', + hostname: url.host, + error, + identifier, + passkeys: passkeysAvailable, + }, + status, + ) + ); + } + return htmlResponse( + renderSignInPage({ + hostname: url.host, + error, + identifier, + passkeys: passkeysAvailable, + }), + status, + ); + } + + /** + * GET /account - Account home, or the sign-in form when signed out + * @param {Request} request + * @param {URL} url + * @returns {Promise} + */ + async function handleAccountPage(request, url) { + // Onboarding runs until the account is live on this server: a fresh empty + // server (no DID), or a migration still in progress (the account exists but + // is deactivated — createAccount lands it deactivated until the move + // finishes). Either way the wizard needs to be reachable to start or resume + // the move, rather than a sign-in form. Needs the account app; without it, + // fall through to the server-rendered "not initialized" sign-in page. + const accountDid = await getDid(); + const notYetLive = + !accountDid || (await actorStorage.getAccountStatus()) === 'deactivated'; + if (notYetLive && accountApp) { + // Non-null: accountAppResponse only returns null when accountApp is unset. + return /** @type {Response} */ ( + accountAppResponse({ + // hostname, not host: a handle never carries a port, and this value + // seeds the handle field. + mode: 'onboard', + hostname: url.hostname, + plcUrl: plcUrl, + }) + ); + } + + const did = await readAccountSession(request); + if (!did) { + return signInResponse(url); + } + + const { displayName, avatarUrl } = await readAccountProfile(did); + const collections = await countRecordsByCollection(); + const blobs = await countBlobs(); + const sessions = await listConnectedApps(did); + + return htmlResponse( + renderAccountPage({ + hostname: url.host, + did, + handle: await actorStorage.getHandle(), + status: (await actorStorage.getAccountStatus()) || 'active', + displayName, + avatarUrl, + stats: { + records: collections.reduce((sum, c) => sum + c.count, 0), + blobs: blobs.count, + apps: countAppDomains(collections, sessions), + appPasswords: (await listAccountAppPasswords(did)).length, + }, + }), + ); + } + + /** + * The sessions page, carrying the outcome of a revoke. + * @param {URL} url + * @param {string} did + * @param {{error?: string, notice?: string}} [result] + * @param {number} [status=200] + * @returns {Promise} + */ + async function sessionsResponse(url, did, result = {}, status = 200) { + const liveSessions = await sessions.listLiveSessions(did); + + return htmlResponse( + renderSessionsPage({ + hostname: url.host, + sessions: liveSessions + .map((session) => ({ + jti: session.jti, + label: session.label, + userAgent: session.userAgent, + createdAt: session.createdAt.slice(0, 10), + refreshedAt: session.refreshedAt + ? session.refreshedAt.slice(0, 10) + : null, + appPassword: isAppPasswordScope(session.scope), + })) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)), + supported: sessions.sessionsAvailable(), + readOnly: readOnly, + ...result, + }), + status, + ); + } + + /** + * GET /account/sessions - Logins opened with a password + * @param {Request} request + * @param {URL} url + * @returns {Promise} + */ + async function handleAccountSessions(request, url) { + const session = await requireAccountSession(request, url); + if ('response' in session) return session.response; + return sessionsResponse(url, session.did); + } + + /** + * POST /account/sessions/revoke - End one login + * @param {Request} request + * @param {URL} url + * @returns {Promise} + */ + async function handleAccountSessionRevoke(request, url) { + const params = new URLSearchParams(await request.text()); + if (!isSameOriginPost(request, url)) { + return new Response('Forbidden', { status: 403 }); + } + + const session = await requireAccountSession(request, url); + if ('response' in session) return session.response; + const { did } = session; + + if (readOnly) { + return sessionsResponse( + url, + did, + { error: 'This PDS is read-only.' }, + 403, + ); + } + + const jti = params.get('jti') || ''; + // Looked up before deleting so one account cannot end another's session + const target = sessions.sessionsAvailable() + ? await sharedStorage.getSession(jti) + : null; + if (!target || target.did !== did) { + return sessionsResponse( + url, + did, + { error: 'That session no longer exists.' }, + 400, + ); + } + + await sessions.revokeSessionChain(did, jti); + return sessionsResponse(url, did, { + notice: + 'Session revoked. Its access token stops working when it expires, within two hours.', + }); + } + + // ── JSON API, for the account app ───────────────────────────────────── + + /** + * Answer a JSON API request, or say why not. Every route below has already + * been matched, so this only decides authentication and shape. + * @param {Request} request + * @param {URL} url + * @param {(did: string, body: any) => Promise} handler + * @param {{write?: boolean}} [options] + * @returns {Promise} + */ + async function accountApi(request, url, handler, options = {}) { + const json = (/** @type {unknown} */ body, /** @type {number} */ status) => + new Response(JSON.stringify(body), { + status, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }, + }); + + let body = {}; + if (request.method === 'POST') { + const parsed = await readJsonBounded(request); + if (parsed instanceof Response) return parsed; + body = parsed; + if (!isSameOriginPost(request, url)) { + return json({ error: 'Forbidden' }, 403); + } + } + + const did = await readAccountSession(request); + if (!did) return json({ error: 'Not signed in' }, 401); + if (options.write && readOnly) { + return json({ error: 'This PDS is read-only' }, 403); + } + + try { + const result = await handler(did, body); + // A handler that must set a header of its own answers directly + return result instanceof Response ? result : json(result, 200); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + // A handler throws to reject a request the caller can fix; anything + // unexpected still reaches the outer catch as a 500. + return json({ error: message }, 400); + } + } + + /** + * GET /account/api/overview - Who this is, and what the server holds + * @param {Request} request + * @param {URL} url + * @returns {Promise} + */ + async function handleApiOverview(request, url) { + return accountApi(request, url, async (did) => { + const { displayName, avatarUrl } = await readAccountProfile(did); + const { collections, activity } = await countRecordsWithActivity(); + // The exact aggregate when the storage can give one; the paged count, + // which caps out and reports itself truncated, only as the fallback. + const blobs = + typeof actorStorage.getBlobFacets === 'function' + ? { + count: (await actorStorage.getBlobFacets()).total.count, + truncated: false, + } + : await countBlobs(); + const commit = await actorStorage.getLatestCommit(); + const connectedApps = await listConnectedApps(did); + + return { + hostname: url.host, + did, + handle: await actorStorage.getHandle(), + status: (await actorStorage.getAccountStatus()) || 'active', + displayName, + avatarUrl, + readOnly: readOnly, + theme: (await readAccountUiState()).theme, + features: { + passkeys: passkeys.passkeysAvailable(), + sessions: sessions.sessionsAvailable(), + spaces: Boolean(spaceBrowser), + email: Boolean(actorStorage.setEmail), + emailSender: Boolean(emailer), + backups: backupsAvailable(), + settings: typeof actorStorage.setAccountUiState === 'function', + files: typeof actorStorage.listBlobDetails === 'function', + }, + stats: { + records: collections.reduce((sum, c) => sum + c.count, 0), + blobs: blobs.count, + blobsTruncated: blobs.truncated, + apps: countAppDomains(collections, connectedApps), + appPasswords: (await listAccountAppPasswords(did)).length, + passkeys: (await listAccountPasskeys(did)).length, + sessions: (await sessions.listLiveSessions(did)).length, + }, + collections, + recordActivity: activity, + commit, + }; + }); + } + + /** + * GET /account/api/lexicon-status?collections=a,b,c - For each requested NSID, + * reports whether its lexicon is published (resolvable) via the protocol: + * DNS authority → DID → com.atproto.lexicon.schema record. Drives the + * "published" check on the records list. Resolutions are cached by the shared + * lexicon resolver, so repeat checks are cheap; if no resolver is injected the + * map is empty and the UI simply shows no checks. + * @param {Request} request + * @param {URL} url + * @returns {Promise} + */ + async function handleApiLexiconStatus(request, url) { + return accountApi(request, url, async () => { + /** @type {Record} */ + const published = {}; + const resolver = lexiconResolver; + if (!resolver?.isPublished) return { published }; + // Bind now so the narrowed, defined method survives into the closure below. + const isPublished = resolver.isPublished.bind(resolver); + + const nsids = [ + ...new Set( + (url.searchParams.get('collections') || '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean), + ), + ].slice(0, 100); + + await Promise.all( + nsids.map(async (nsid) => { + try { + published[nsid] = await isPublished(nsid); + } catch { + published[nsid] = false; + } + }), + ); + + return { published }; + }); + } + + /** + * GET /account/api/lexicon-schema?collection= - The resolved lexicon + * document for one NSID, so the UI can show a published record type's actual + * schema. Returns `{ lexicon: null }` when it can't be resolved or no resolver + * is injected. + * @param {Request} request + * @param {URL} url + * @returns {Promise} + */ + async function handleApiLexiconSchema(request, url) { + return accountApi(request, url, async () => { + const resolver = lexiconResolver; + const nsid = (url.searchParams.get('collection') || '').trim(); + if (!resolver?.getLexicon || !nsid) return { lexicon: null }; + try { + return { lexicon: (await resolver.getLexicon(nsid)) ?? null }; + } catch { + return { lexicon: null }; + } + }); + } + + /** + * GET /account/api/activity - The account's write history, newest first, + * read from the sequenced commit events. Each commit's ops are flattened + * into one row apiece. Paginates by the `cursor` seq. + * @param {Request} request + * @param {URL} url + * @returns {Promise} + */ + async function handleApiActivity(request, url) { + return accountApi(request, url, async () => { + if (!actorStorage.getEventsBefore) return { events: [], cursor: null }; + const limit = Math.min( + Math.max(Number(url.searchParams.get('limit')) || 30, 1), + 100, + ); + const cursor = url.searchParams.get('cursor'); + const before = cursor + ? Number(cursor) + : (await actorStorage.getLatestSeq()) + 1; + + const rows = await actorStorage.getEventsBefore(before, limit); + const events = []; + let lowest = before; + for (const row of rows) { + lowest = Math.min(lowest, row.seq); + let decoded; + try { + decoded = cborDecode(row.evt); + } catch { + continue; + } + const ops = Array.isArray(decoded?.ops) ? decoded.ops : []; + for (const op of ops) { + const slash = String(op.path).indexOf('/'); + events.push({ + seq: row.seq, + action: op.action, + collection: slash >= 0 ? op.path.slice(0, slash) : op.path, + rkey: slash >= 0 ? op.path.slice(slash + 1) : '', + time: decoded.time || null, + }); + } + } + + return { + events, + cursor: rows.length >= limit ? String(lowest) : null, + }; + }); + } + + /** + * The account app's own preferences — the Apps screen's combined-row + * aliases, turned-down combine offers, and the appearance choice — + * normalized on read so handlers can trust the shape whatever an older + * document held. + * @returns {Promise} + */ + async function readAccountUiState() { + const stored = actorStorage.getAccountUiState + ? await actorStorage.getAccountUiState() + : null; + const aliases = stored?.appAliases; + return { + appAliases: + aliases && typeof aliases === 'object' && !Array.isArray(aliases) + ? aliases + : {}, + dismissedAppPairs: Array.isArray(stored?.dismissedAppPairs) + ? stored.dismissedAppPairs.filter((p) => typeof p === 'string') + : [], + theme: + stored?.theme === 'light' || stored?.theme === 'dark' + ? stored.theme + : 'system', + }; + } + + /** + * GET /account/api/apps, POST /account/api/apps/revoke + * @param {Request} request + * @param {URL} url + * @returns {Promise} + */ + async function handleApiApps(request, url) { + return accountApi(request, url, async (did) => { + const ui = await readAccountUiState(); + return { + apps: await listConnectedApps(did), + aliases: ui.appAliases, + dismissedPairs: ui.dismissedAppPairs, + aliasesAvailable: typeof actorStorage.setAccountUiState === 'function', + }; + }); + } + + /** + * @param {Request} request + * @param {URL} url + * @returns {Promise} + */ + async function handleApiAppsRevoke(request, url) { + return accountApi( + request, + url, + async (did, body) => { + const revoked = await revokeOAuthSession(did, body.session || ''); + if (!revoked) throw new Error('That session no longer exists.'); + return { revoked: revoked.clientId }; + }, + { write: true }, + ); + } + + /** + * POST /account/api/apps/alias - Fold one Apps row into another + * (action=combine), undo it (separate), or remember the offer was turned + * down (dismiss). `from` is the domain giving up its row — the record + * authority — and `to` the row that absorbs it, the live client host. The + * pairing is the browser's reading of the account's own data; here the + * strings just have to look like hostnames and the document stays bounded. + * @param {Request} request + * @param {URL} url + * @returns {Promise} + */ + async function handleApiAppsAlias(request, url) { + return accountApi( + request, + url, + async (_did, body) => { + if (typeof actorStorage.setAccountUiState !== 'function') { + throw new Error('This server cannot store app aliases'); + } + const from = plausibleHost(body.from); + if (!from) throw new Error('from must be a hostname'); + const state = await readAccountUiState(); + + if (body.action === 'combine' || body.action === 'dismiss') { + const to = plausibleHost(body.to); + if (!to || to === from) { + throw new Error('to must be a different hostname'); + } + if (body.action === 'combine') { + // A row that absorbs another may not itself be folded away, so + // no chain of aliases ever forms + delete state.appAliases[to]; + state.appAliases[from] = to; + if (Object.keys(state.appAliases).length > 50) { + throw new Error('Too many combined apps'); + } + } else { + const pair = `${from}>${to}`; + if (!state.dismissedAppPairs.includes(pair)) { + state.dismissedAppPairs.push(pair); + } + state.dismissedAppPairs = state.dismissedAppPairs.slice(-100); + } + } else if (body.action === 'separate') { + delete state.appAliases[from]; + } else { + throw new Error('action must be combine, separate, or dismiss'); + } + + await actorStorage.setAccountUiState(state); + return { + aliases: state.appAliases, + dismissedPairs: state.dismissedAppPairs, + }; + }, + { write: true }, + ); + } + + /** + * POST /account/api/settings/appearance - Store which color palette the + * console uses. The choice lives in the account's UI state document, so it + * follows the account to other browsers and devices. + * @param {Request} request + * @param {URL} url + * @returns {Promise} + */ + async function handleApiSettingsAppearance(request, url) { + return accountApi( + request, + url, + async (_did, body) => { + if (typeof actorStorage.setAccountUiState !== 'function') { + throw new Error('This server cannot store settings'); + } + if (!['system', 'light', 'dark'].includes(body.theme)) { + throw new Error('theme must be system, light, or dark'); + } + const state = await readAccountUiState(); + state.theme = body.theme; + await actorStorage.setAccountUiState(state); + return { theme: state.theme }; + }, + { write: true }, + ); + } + + /** + * The remaining section endpoints. Each is the same data the server-rendered + * page shows, in the shape the account app consumes. + * @param {Request} request + * @param {URL} url + * @returns {Promise} + */ + async function handleApiSessions(request, url) { + return accountApi(request, url, async (did) => ({ + supported: sessions.sessionsAvailable(), + sessions: sessions.sessionsAvailable() + ? (await sessions.listLiveSessions(did)) + .map((session) => ({ + jti: session.jti, + label: session.label, + userAgent: session.userAgent, + createdAt: session.createdAt, + refreshedAt: session.refreshedAt, + appPassword: isAppPasswordScope(session.scope), + })) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) + : [], + })); + } + + /** @param {Request} request @param {URL} url */ + async function handleApiSessionsRevoke(request, url) { + return accountApi( + request, + url, + async (did, body) => { + const jti = body.jti || ''; + const target = sessions.sessionsAvailable() + ? await sharedStorage.getSession(jti) + : null; + // Checked against the caller's own DID, so one account cannot end + // another's session by naming its id + if (!target || target.did !== did) { + throw new Error('That session no longer exists.'); + } + await sessions.revokeSessionChain(did, jti); + return { revoked: jti }; + }, + { write: true }, + ); + } + + /** + * POST /account/api/sessions/revoke-all - End every password/app-password + * login at once. The account page runs on its own cookie, not one of these + * records, so the caller stays signed in here. + * @param {Request} request @param {URL} url + */ + async function handleApiSessionsRevokeAll(request, url) { + return accountApi( + request, + url, + async (did) => { + if ( + !sessions.sessionsAvailable() || + typeof sharedStorage.listSessions !== 'function' + ) { + return { revoked: 0 }; + } + // Snapshot the ids before deleting: a store may hand back its own + // array by reference, and the deletes below would mutate it mid-count. + const jtis = (await sharedStorage.listSessions(did)).map( + (session) => session.jti, + ); + await Promise.all(jtis.map((jti) => sharedStorage.deleteSession(jti))); + return { revoked: jtis.length }; + }, + { write: true }, + ); + } + + /** @param {Request} request @param {URL} url */ + async function handleApiAppPasswords(request, url) { + return accountApi(request, url, async (did) => ({ + passwords: await listAccountAppPasswords(did), + })); + } + + /** @param {Request} request @param {URL} url */ + async function handleApiAppPasswordsCreate(request, url) { + return accountApi( + request, + url, + async (did, body) => { + if (!sharedStorage.putAppPassword) { + throw new Error('This server does not store app passwords.'); + } + const name = (body.name || '').trim(); + if (!name) throw new Error('A name is required.'); + + const password = generateAppPassword(); + const stored = await sharedStorage.putAppPassword( + did, + name, + await hashAppPassword(did, password), + Boolean(body.privileged), + new Date().toISOString(), + ); + if (!stored) throw new Error(`App password "${name}" already exists.`); + + // The only time it is returned; storage keeps a hash + return { name, password }; + }, + { write: true }, + ); + } + + /** @param {Request} request @param {URL} url */ + async function handleApiAppPasswordsRevoke(request, url) { + return accountApi( + request, + url, + async (did, body) => { + const name = body.name || ''; + const deleted = + sharedStorage.deleteAppPassword && + (await sharedStorage.deleteAppPassword(did, name)); + if (!deleted) throw new Error(`No app password named "${name}".`); + await sessions.revokeAppPasswordSessions(did, name); + return { revoked: name }; + }, + { write: true }, + ); + } + + /** @param {Request} request @param {URL} url */ + async function handleApiPasskeys(request, url) { + return accountApi(request, url, async (did) => { + const supported = passkeys.passkeysAvailable(); + const ceremony = supported + ? await passkeys.startPasskeyCeremony('register', url) + : null; + + const body = { + supported, + rpId: passkeys.passkeyRpId(url), + accountName: (await actorStorage.getHandle()) || did, + did, + // The challenge the browser will answer. Its cookie goes on this + // response; the copy here is what credentials.create() needs. + challenge: ceremony?.challenge || '', + passkeys: (await listAccountPasskeys(did)).map((passkey) => ({ + credentialId: passkey.credentialId, + name: passkey.name, + createdAt: passkey.createdAt, + lastUsedAt: passkey.lastUsedAt, + })), + }; + + const headers = new Headers({ + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + if (ceremony) headers.set('Set-Cookie', ceremony.cookie); + return new Response(JSON.stringify(body), { headers }); + }); + } + + /** @param {Request} request @param {URL} url */ + async function handleApiPasskeysRevoke(request, url) { + return accountApi( + request, + url, + async (did, body) => { + const deleted = + sharedStorage.deletePasskey && + (await sharedStorage.deletePasskey(did, body.credentialId || '')); + if (!deleted) throw new Error('No such passkey on this account.'); + return { revoked: body.credentialId }; + }, + { write: true }, + ); + } + + /** + * POST /account/api/email - Set, verify, cancel or remove the address + * @param {Request} request + * @param {URL} url + * @returns {Promise} + */ + async function handleApiEmailAction(request, url) { + return accountApi( + request, + url, + async (_did, body) => { + if (!actorStorage.setEmail) { + throw new Error('This server cannot store an email address.'); + } + const state = await readEmailState(actorStorage); + + if (body.action === 'cancel') { + await actorStorage.setEmail({ ...state, pending: null }); + return { ok: true }; + } + if (body.action === 'remove') { + await actorStorage.setEmail({ + address: null, + verified: false, + pending: null, + }); + return { ok: true }; + } + if (body.action === 'resend') { + const pending = state.pending; + if (!pending) throw new Error('No address is waiting.'); + if (!emailer) { + throw new Error('This server cannot send email.'); + } + const now = Date.now(); + const readyAt = (pending.sentAt || 0) + EMAIL_RESEND_COOLDOWN_MS; + if (now < readyAt) { + throw new Error('Wait a moment before asking for another code.'); + } + const code = generateEmailCode(); + await actorStorage.setEmail({ + ...state, + pending: { + address: pending.address, + codeHash: await hashEmailCode(pending.address, code, jwtSecret), + expiresAt: now + EMAIL_CODE_TTL_MS, + sentAt: now, + }, + }); + try { + await emailer.send({ + to: pending.address, + ...renderVerificationEmail({ code, hostname: url.host }), + }); + } catch (err) { + // Keep the code the earlier message already carried usable. + await actorStorage.setEmail(state); + console.error('Failed to resend verification email:', err); + throw new Error('The message could not be sent. Try again later.'); + } + return { + ok: true, + sent: pending.address, + resendAt: now + EMAIL_RESEND_COOLDOWN_MS, + }; + } + if (body.action === 'verify') { + const pending = state.pending; + if (!pending) throw new Error('No address is waiting.'); + if (pending.expiresAt <= Date.now()) { + await actorStorage.setEmail({ ...state, pending: null }); + throw new Error('That code expired. Start again.'); + } + const candidate = await hashEmailCode( + pending.address, + (body.code || '').trim(), + jwtSecret, + ); + if (!(await timingSafeEqual(candidate, pending.codeHash))) { + throw new Error('That code is wrong.'); + } + await actorStorage.setEmail({ + address: pending.address, + verified: true, + pending: null, + }); + return { ok: true, address: pending.address }; + } + + const address = (body.address || '').trim(); + if (!isValidEmail(address)) { + throw new Error('That does not look like an email address.'); + } + if (!emailer) { + await actorStorage.setEmail({ + address, + verified: false, + pending: null, + }); + return { ok: true, unconfirmed: true }; + } + + const code = generateEmailCode(); + const sentAt = Date.now(); + await actorStorage.setEmail({ + ...state, + pending: { + address, + codeHash: await hashEmailCode(address, code, jwtSecret), + expiresAt: sentAt + EMAIL_CODE_TTL_MS, + sentAt, + }, + }); + try { + await emailer.send({ + to: address, + ...renderVerificationEmail({ code, hostname: url.host }), + }); + } catch (err) { + // A pending record with no message sent offers a code that never + // arrives + await actorStorage.setEmail({ ...state, pending: null }); + console.error('Failed to send verification email:', err); + throw new Error('The message could not be sent. Try again later.'); + } + return { + ok: true, + sent: address, + resendAt: sentAt + EMAIL_RESEND_COOLDOWN_MS, + }; + }, + { write: true }, + ); + } + + /** @param {Request} request @param {URL} url */ + async function handleApiEmail(request, url) { + return accountApi(request, url, async () => { + const state = await readEmailState(actorStorage); + const pending = + state.pending && state.pending.expiresAt > Date.now() + ? { + address: state.pending.address, + resendAt: (state.pending.sentAt || 0) + EMAIL_RESEND_COOLDOWN_MS, + } + : null; + return { + address: state.address, + verified: state.verified, + pending, + canSend: Boolean(emailer), + supported: Boolean(actorStorage.setEmail), + }; + }); + } + + /** @param {Request} request @param {URL} url */ + async function handleApiSpaces(request, url) { + return accountApi(request, url, async () => { + const browser = spaceBrowser; + if (!browser) return { enabled: false, spaces: [] }; + + return { + enabled: true, + spaces: await Promise.all( + (await browser.listSpaces()).map(async (space) => ({ + uri: space.uri, + spaceType: space.spaceType, + authority: space.spaceDid, + isOwner: space.isOwner, + policy: space.policy, + createdAt: space.createdAt, + deleted: Boolean(space.deletedAt), + members: await browser.countMembers(space.uri), + collections: await browser.countRecords(space.uri), + })), + ), + }; + }); + } + + /** @param {Request} request @param {URL} url */ + async function handleApiIdentity(request, url) { + return accountApi(request, url, async (did) => { + const credentials = await getRecommendedDidCredentials(url.hostname); + return { + did, + handle: await actorStorage.getHandle(), + status: (await actorStorage.getAccountStatus()) || 'active', + signingKey: credentials?.verificationMethods?.atproto || null, + pdsEndpoint: hostname + ? `https://${hostname}` + : `${url.protocol}//${url.host}`, + plcUrl: plcUrl, + readOnly: readOnly, + }; + }); + } + + /** + * POST /account/api/identity/handle - Rename the account from the account page + * @param {Request} request + * @param {URL} url + * @returns {Promise} + */ + async function handleApiIdentityHandle(request, url) { + return accountApi( + request, + url, + async (_did, body) => { + const { handle } = await renameHandle(body.handle, url.hostname); + return { handle }; + }, + { write: true }, + ); + } + + /** @param {Request} request @param {URL} url */ + async function handleApiStatus(request, url) { + return accountApi( + request, + url, + async (_did, body) => { + if (body.action !== 'activate' && body.action !== 'deactivate') { + throw new Error('Unknown status action.'); + } + await setAccountActive(body.action === 'activate'); + return { + status: body.action === 'activate' ? 'active' : 'deactivated', + }; + }, + { write: true }, + ); + } + + /** @param {Request} request @param {URL} url */ + /** + * Pull one record's content out of a stored firehose event's CAR — the + * blocks a #commit carries include the records it touched. + * @param {unknown} carBytes - The event's `blocks` CAR + * @param {string} cid - The record block to read + * @returns {*} The display-shaped record value, or null if absent + */ + function readRecordFromCar(carBytes, cid) { + if (!(carBytes instanceof Uint8Array)) return null; + try { + const { blocks } = parseCarFile(carBytes); + const data = blocks.get(cid); + return data ? toDisplayValue(cborDecode(data)) : null; + } catch { + return null; + } + } + + /** + * A record as it stood at one point in its history — the change with sequence + * `seq`. A create or update carries its content in that event; a delete does + * not, so its prior content is recovered from the last event that wrote it. + * @param {string} collection + * @param {string} rkey + * @param {number} seq + * @returns {Promise<{action: string, time: string|null, cid: string|null, value: *}|null>} + */ + async function recordSnapshotAt(collection, rkey, seq) { + if (!actorStorage.getEventsBefore) return null; + const path = `${collection}/${rkey}`; + + const [row] = await actorStorage.getEventsBefore(seq + 1, 1); + if (!row || row.seq !== seq) return null; + let event; + try { + event = cborDecode(row.evt); + } catch { + return null; + } + const op = (event.ops || []).find( + (/** @type {any} */ entry) => entry.path === path, + ); + if (!op) return null; + const time = event.time || null; + + // A create or update keeps the content right here. + if (op.cid?.$link) { + return { + action: op.action, + time, + cid: op.cid.$link, + value: readRecordFromCar(event.blocks, op.cid.$link), + }; + } + + // A delete does not: walk back to the most recent event that wrote this + // path and read the content it carried. + let before = seq; + while (before > 0) { + const batch = await actorStorage.getEventsBefore(before, 100); + if (!batch.length) break; + for (const prior of batch) { + let ev; + try { + ev = cborDecode(prior.evt); + } catch { + continue; + } + const wrote = (ev.ops || []).find( + (/** @type {any} */ entry) => entry.path === path && entry.cid?.$link, + ); + if (wrote) + return { + action: 'delete', + time, + cid: wrote.cid.$link, + value: readRecordFromCar(ev.blocks, wrote.cid.$link), + }; + } + before = batch[batch.length - 1].seq; + } + return { action: 'delete', time, cid: null, value: null }; + } + + /** @param {Request} request @param {URL} url */ + async function handleApiRecords(request, url) { + return accountApi(request, url, async (did) => { + const collection = url.searchParams.get('collection') || ''; + const space = url.searchParams.get('space') || ''; + const rkey = url.searchParams.get('rkey') || ''; + const seqParam = url.searchParams.get('seq'); + const cursor = url.searchParams.get('cursor') || null; + if (!collection) throw new Error('Name a collection to read.'); + + const blobBase = `/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}`; + const decode = (/** @type {any} */ value) => + toDisplayValue( + cborDecode( + value instanceof Uint8Array ? value : new Uint8Array(value), + ), + ); + + // A History link names a record and the change it points at: show that + // version, not whatever the record is now (it may since have changed or + // been removed), with enough to bridge back to the live one. + if (rkey && seqParam) { + const seq = Number(seqParam); + const snap = Number.isFinite(seq) + ? await recordSnapshotAt(collection, rkey, seq) + : null; + if (!snap) throw new Error('That change is no longer available.'); + const uri = `at://${did}/${collection}/${rkey}`; + const live = await actorStorage.getRecord(uri); + return { + blobBase, + snapshot: { + action: snap.action, + time: snap.time, + liveExists: Boolean(live), + liveCurrent: live !== null && live.cid === snap.cid, + }, + records: + snap.value != null + ? [{ uri, cid: snap.cid, value: snap.value }] + : [], + }; + } + + if (space) { + const browser = spaceBrowser; + if (!browser) throw new Error('Permissioned data is off.'); + const known = await browser.listSpaces(); + // The query names what to show, not what to fetch + if (!known.some((row) => row.uri === space)) { + throw new Error('No such space on this account.'); + } + const { records, truncated } = await browser.listRecords( + space, + collection, + ACCOUNT_SPACE_RECORD_LIMIT, + ); + return { + truncated, + records: records.map((record) => ({ + uri: `${space}/${did}/${collection}/${record.rkey}`, + cid: record.cid, + indexedAt: record.indexedAt, + value: record.value, + })), + }; + } + + // A deep link names one record: fetch it directly so a large collection + // never has to be listed to find it. + if (rkey) { + const uri = `at://${did}/${collection}/${rkey}`; + const row = await actorStorage.getRecord(uri); + return { + blobBase, + cursor: null, + records: row ? [{ uri, cid: row.cid, value: decode(row.value) }] : [], + }; + } + + // Newest first — a rkey is a timestamp id, so reverse order is recency. + const { records, cursor: next } = await actorStorage.listRecords( + collection, + cursor, + ACCOUNT_SPACE_RECORD_LIMIT, + true, + ); + return { + blobBase, + cursor: next, + records: records.map((record) => ({ + uri: record.uri, + cid: record.cid, + value: decode(record.value), + })), + }; + }); + } + + /** + * GET /account/api/blobs - Files apps have uploaded, each with the records + * that use it. Narrowed by `kind`, `app` (the app's domain), and `unused`; + * ordered by `sort` (newest or largest). The first page carries the facets: + * global counts and bytes by kind, app, and usage, for the filter chips and + * the storage bar. + * @param {Request} request + * @param {URL} url + * @returns {Promise} + */ + async function handleApiBlobs(request, url) { + return accountApi(request, url, async (did) => { + const storage = actorStorage; + if (typeof storage.listBlobDetails !== 'function') { + throw new Error('This server cannot list files.'); + } + const cursor = url.searchParams.get('cursor') || null; + const kindParam = url.searchParams.get('kind') || ''; + const kind = BLOB_KINDS.includes(kindParam) + ? /** @type {import('../ports.js').BlobKind} */ (kindParam) + : undefined; + // The filter names the app's domain (`bsky.app`); records carry it + // reversed, as their collection NSID's authority (`app.bsky`). + const app = (url.searchParams.get('app') || '').toLowerCase(); + const appAuthority = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(app) + ? app.split('.').reverse().join('.') + : undefined; + const options = { + kind, + appAuthority, + unused: url.searchParams.get('unused') === '1', + sort: + url.searchParams.get('sort') === 'largest' + ? /** @type {const} */ ('largest') + : /** @type {const} */ ('newest'), + }; + + const { blobs, cursor: next } = await storage.listBlobDetails( + cursor, + ACCOUNT_BLOB_PAGE_LIMIT, + options, + ); + + /** @type {object|undefined} */ + let facets; + if (!cursor && typeof storage.getBlobFacets === 'function') { + const raw = await storage.getBlobFacets(); + facets = { + total: raw.total, + unused: raw.unused, + kinds: raw.kinds, + apps: raw.apps.map(({ authority, count, bytes }) => ({ + domain: authority.split('.').reverse().join('.'), + count, + bytes, + })), + }; + } + + return { + blobBase: `/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}`, + cursor: next, + facets, + blobs: blobs.map((blob) => ({ + cid: blob.cid, + mimeType: blob.mimeType, + size: blob.size, + createdAt: blob.createdAt + ? new Date(blob.createdAt).toISOString() + : null, + time: blob.time ? new Date(blob.time).toISOString() : null, + timeFromRecord: blob.timeFromRecord, + records: blob.records, + })), + }; + }); + } + + /** + * POST /account/api/blobs/relink - Rebuild blob usage from record content. + * For repos whose records arrived outside the write path (a migration's CAR + * import), where the link table undercounts what is really used. + * @param {Request} request + * @param {URL} url + * @returns {Promise} + */ + async function handleApiBlobsRelink(request, url) { + return accountApi( + request, + url, + async () => ({ references: await relinkBlobRecords() }), + { write: true }, + ); + } + + /** + * GET /account/apps - Apps holding an OAuth session + * @param {Request} request + * @param {URL} url + * @returns {Promise} + */ + async function handleAccountApps(request, url) { + const session = await requireAccountSession(request, url); + if ('response' in session) return session.response; + + return htmlResponse( + renderAppsPage({ + hostname: url.host, + apps: await listConnectedApps(session.did), + }), + ); + } + + /** + * POST /account/apps/revoke - End one app's OAuth session + * @param {Request} request + * @param {URL} url + * @returns {Promise} + */ + async function handleAccountAppRevoke(request, url) { + const params = new URLSearchParams(await request.text()); + if (!isSameOriginPost(request, url)) { + return new Response('Forbidden', { status: 403 }); + } + + const session = await requireAccountSession(request, url); + if ('response' in session) return session.response; + const { did } = session; + + /** @param {{error?: string, notice?: string}} result @param {number} status */ + const page = async (result, status) => + htmlResponse( + renderAppsPage({ + hostname: url.host, + apps: await listConnectedApps(did), + ...result, + }), + status, + ); + + if (readOnly) return page({ error: 'This PDS is read-only.' }, 403); + + const revoked = await revokeOAuthSession(did, params.get('session') || ''); + if (!revoked) return page({ error: 'That session no longer exists.' }, 400); + + return page( + { + notice: `Revoked the session for ${revoked.clientId}. Its access token stops working when it expires, within an hour.`, + }, + 200, + ); + } + + /** + * GET /account/app-passwords - App password list and creation form + * @param {Request} request + * @param {URL} url + * @returns {Promise} + */ + async function handleAccountAppPasswords(request, url) { + const session = await requireAccountSession(request, url); + if ('response' in session) return session.response; + + return htmlResponse( + renderAppPasswordsPage({ + hostname: url.host, + passwords: await listAccountAppPasswords(session.did), + }), + ); + } + + /** + * Re-render the app password page carrying the result of an action. + * @param {string} did + * @param {string} hostname + * @param {{created?: {name: string, password: string}|null, error?: string, notice?: string}} result + * @param {number} [status=200] + * @returns {Promise} + */ + async function appPasswordsResult(did, hostname, result, status = 200) { + return htmlResponse( + renderAppPasswordsPage({ + hostname, + passwords: await listAccountAppPasswords(did), + ...result, + }), + status, + ); + } + + /** + * POST /account/app-passwords/create + * + * Renders the result rather than redirecting: the generated password is + * shown exactly once, and a redirect would have to carry it in the URL. + * @param {Request} request + * @param {URL} url + * @returns {Promise} + */ + async function handleAccountAppPasswordCreate(request, url) { + const params = new URLSearchParams(await request.text()); + if (!isSameOriginPost(request, url)) { + return new Response('Forbidden', { status: 403 }); + } + + const session = await requireAccountSession(request, url); + if ('response' in session) return session.response; + const { did } = session; + + if (readOnly) { + return appPasswordsResult( + did, + url.host, + { error: 'This PDS is read-only.' }, + 403, + ); + } + if (!sharedStorage.putAppPassword) { + return appPasswordsResult( + did, + url.host, + { error: 'This server does not store app passwords.' }, + 400, + ); + } + + const name = (params.get('name') || '').trim(); + if (!name) { + return appPasswordsResult( + did, + url.host, + { error: 'A name is required.' }, + 400, + ); + } + + const password = generateAppPassword(); + const stored = await sharedStorage.putAppPassword( + did, + name, + await hashAppPassword(did, password), + params.get('privileged') === 'true', + new Date().toISOString(), + ); + if (!stored) { + return appPasswordsResult( + did, + url.host, + { error: `App password "${name}" already exists.` }, + 400, + ); + } + + return appPasswordsResult(did, url.host, { + created: { name, password }, + }); + } + + /** + * POST /account/app-passwords/revoke + * @param {Request} request + * @param {URL} url + * @returns {Promise} + */ + async function handleAccountAppPasswordRevoke(request, url) { + const params = new URLSearchParams(await request.text()); + if (!isSameOriginPost(request, url)) { + return new Response('Forbidden', { status: 403 }); + } + + const session = await requireAccountSession(request, url); + if ('response' in session) return session.response; + const { did } = session; + + if (readOnly) { + return appPasswordsResult( + did, + url.host, + { error: 'This PDS is read-only.' }, + 403, + ); + } + + const name = params.get('name') || ''; + const deleted = + sharedStorage.deleteAppPassword && + (await sharedStorage.deleteAppPassword(did, name)); + if (deleted) await sessions.revokeAppPasswordSessions(did, name); + + return appPasswordsResult( + did, + url.host, + deleted + ? { notice: `Revoked "${name}". Sessions opened with it stop working.` } + : { error: `No app password named "${name}".` }, + deleted ? 200 : 400, + ); + } + + // ── Passkeys ──────────────────────────────────────────────────────────── + + /** + * @param {string} did + * @returns {Promise>} + */ + async function listAccountPasskeys(did) { + if (!sharedStorage.listPasskeys) return []; + return sharedStorage.listPasskeys(did); + } + + /** + * The passkeys page, with a registration challenge ready for the browser. + * @param {URL} url + * @param {string} did + * @param {{error?: string, notice?: string}} [result] + * @param {number} [status=200] + * @returns {Promise} + */ + async function passkeysResponse(url, did, result = {}, status = 200) { + const supported = Boolean(sharedStorage.putPasskey); + const ceremony = supported + ? await passkeys.startPasskeyCeremony('register', url) + : null; + + return htmlResponse( + renderPasskeysPage({ + hostname: url.host, + passkeys: (await listAccountPasskeys(did)).map((passkey) => ({ + credentialId: passkey.credentialId, + name: passkey.name, + createdAt: passkey.createdAt, + lastUsedAt: passkey.lastUsedAt, + })), + supported, + readOnly: readOnly, + challenge: ceremony?.challenge || '', + rpId: passkeys.passkeyRpId(url), + accountName: (await actorStorage.getHandle()) || did, + did, + ...result, + }), + status, + ceremony?.cookie || null, + ); + } + + /** + * GET /account/passkeys - Registered passkeys, and the form to add one + * @param {Request} request + * @param {URL} url + * @returns {Promise} + */ + async function handleAccountPasskeys(request, url) { + const session = await requireAccountSession(request, url); + if ('response' in session) return session.response; + return passkeysResponse(url, session.did); + } + + /** + * POST /account/passkeys/register - Store a credential the browser created + * + * Answers JSON: the browser drives this from script, since only script can + * run the WebAuthn ceremony. + * @param {Request} request + * @param {URL} url + * @returns {Promise} + */ + async function handleAccountPasskeyRegister(request, url) { + const body = await readJsonBounded(request); + if (body instanceof Response) return body; + + if (!isSameOriginPost(request, url)) { + return Response.json({ error: 'Forbidden' }, { status: 403 }); + } + + const did = await readAccountSession(request); + if (!did) { + return Response.json({ error: 'Not signed in' }, { status: 401 }); + } + if (readOnly) { + return Response.json({ error: 'This PDS is read-only' }, { status: 403 }); + } + if (!sharedStorage.putPasskey) { + return Response.json( + { error: 'This server cannot store passkeys' }, + { status: 400 }, + ); + } + + const challenge = await passkeys.readPasskeyChallenge(request, 'register'); + if (!challenge) { + return Response.json( + { error: 'That registration expired. Reload and try again.' }, + { status: 400 }, + ); + } + + let credential; + try { + credential = await verifyRegistration({ + credentialId: body.credentialId, + publicKey: body.publicKey, + algorithm: Number(body.algorithm), + clientDataJSON: body.clientDataJSON, + expectedChallenge: challenge, + expectedOrigin: `${url.protocol}//${url.host}`, + }); + } catch (err) { + return Response.json( + { error: err instanceof Error ? err.message : 'Registration failed' }, + { status: 400 }, + ); + } + + const name = (body.name || '').trim() || 'Passkey'; + const stored = await sharedStorage.putPasskey({ + credentialId: credential.credentialId, + did, + name, + publicKey: credential.publicKey, + algorithm: credential.algorithm, + signCount: 0, + createdAt: new Date().toISOString(), + lastUsedAt: null, + }); + if (!stored) { + return Response.json( + { error: 'That passkey is already registered.' }, + { status: 400 }, + ); + } + + return Response.json({ ok: true, name }); + } + + /** + * POST /account/passkeys/revoke - Forget one passkey + * @param {Request} request + * @param {URL} url + * @returns {Promise} + */ + async function handleAccountPasskeyRevoke(request, url) { + const params = new URLSearchParams(await request.text()); + if (!isSameOriginPost(request, url)) { + return new Response('Forbidden', { status: 403 }); + } + + const session = await requireAccountSession(request, url); + if ('response' in session) return session.response; + const { did } = session; + + if (readOnly) { + return passkeysResponse( + url, + did, + { error: 'This PDS is read-only.' }, + 403, + ); + } + + const credentialId = params.get('credentialId') || ''; + const deleted = + sharedStorage.deletePasskey && + (await sharedStorage.deletePasskey(did, credentialId)); + + return passkeysResponse( + url, + did, + deleted + ? { notice: 'Passkey removed.' } + : { error: 'No such passkey on this account.' }, + deleted ? 200 : 400, + ); + } + + /** + * GET /account/passkey/challenge - Start a passkey sign-in + * @param {Request} _request + * @param {URL} url + * @returns {Promise} + */ + async function handleAccountPasskeyChallenge(_request, url) { + if (!sharedStorage.getPasskey) { + return Response.json( + { error: 'This server does not support passkeys' }, + { status: 400 }, + ); + } + + const { challenge, cookie } = await passkeys.startPasskeyCeremony( + 'sign-in', + url, + ); + return new Response( + JSON.stringify({ challenge, rpId: passkeys.passkeyRpId(url) }), + { + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + 'Set-Cookie': cookie, + }, + }, + ); + } + + /** + * POST /account/passkey/sign-in - Open a cookie session from an assertion + * @param {Request} request + * @param {URL} url + * @returns {Promise} + */ + async function handleAccountPasskeySignIn(request, url) { + const body = await readJsonBounded(request); + if (body instanceof Response) return body; + + if (!isSameOriginPost(request, url)) { + return Response.json({ error: 'Forbidden' }, { status: 403 }); + } + if (!sharedStorage.getPasskey) { + return Response.json( + { error: 'This server does not support passkeys' }, + { status: 400 }, + ); + } + + const challenge = await passkeys.readPasskeyChallenge(request, 'sign-in'); + if (!challenge) { + return Response.json( + { error: 'That sign-in expired. Try again.' }, + { status: 400 }, + ); + } + + const did = await getDid(); + const passkey = await sharedStorage.getPasskey(body.credentialId || ''); + // One failure message for an unknown credential and for one belonging to + // another account: which of the two it was is not the client's business. + if (!passkey || !did || passkey.did !== did) { + return Response.json( + { error: 'That passkey is not registered here.' }, + { status: 401 }, + ); + } + + let result; + try { + result = await verifyAssertion({ + publicKey: passkey.publicKey, + algorithm: passkey.algorithm, + authenticatorData: body.authenticatorData, + clientDataJSON: body.clientDataJSON, + signature: body.signature, + expectedChallenge: challenge, + expectedOrigin: `${url.protocol}//${url.host}`, + rpId: passkeys.passkeyRpId(url), + }); + } catch (err) { + return Response.json( + { error: err instanceof Error ? err.message : 'Sign-in failed' }, + { status: 401 }, + ); + } + + // A counter that goes backwards means the credential was cloned. Many + // authenticators keep no counter at all and always report 0, so this only + // fires when one that does count regresses. + if ( + result.signCount > 0 && + passkey.signCount > 0 && + result.signCount <= passkey.signCount + ) { + return Response.json( + { error: 'That passkey looks cloned and was refused.' }, + { status: 401 }, + ); + } + + if (sharedStorage.touchPasskey) { + await sharedStorage.touchPasskey( + passkey.credentialId, + result.signCount, + new Date().toISOString(), + ); + } + + const token = await createAccountJwt(did, jwtSecret, ACCOUNT_SESSION_TTL); + return new Response(JSON.stringify({ ok: true }), { + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + 'Set-Cookie': accountCookie(token, url.protocol === 'https:'), + }, + }); + } + + /** + * The email page, carrying the outcome of an action. + * @param {URL} url + * @param {{error?: string, notice?: string}} [result] + * @param {number} [status=200] + * @returns {Promise} + */ + async function emailResponse(url, result = {}, status = 200) { + const state = await readEmailState(actorStorage); + const pending = + state.pending && state.pending.expiresAt > Date.now() + ? state.pending + : null; + + return htmlResponse( + renderEmailPage({ + hostname: url.host, + address: state.address, + verified: state.verified, + pending: pending && { address: pending.address }, + canSend: Boolean(emailer), + ...result, + }), + status, + ); + } + + /** + * /account/email - The page on GET, setting the address on POST + * @param {Request} request + * @param {URL} url + * @returns {Promise} + */ + async function handleAccountEmailRoute(request, url) { + if (request.method === 'POST') { + return handleAccountEmailSet(request, url); + } + return handleAccountEmail(request, url); + } + + /** + * GET /account/email - Address, confirmation state, and the forms to change it + * @param {Request} request + * @param {URL} url + * @returns {Promise} + */ + async function handleAccountEmail(request, url) { + const session = await requireAccountSession(request, url); + if ('response' in session) return session.response; + return emailResponse(url); + } + + /** + * Shared preamble for the email actions: same-origin, signed in, writable, + * and backed by a storage adapter that can hold an address. + * @param {Request} request + * @param {URL} url + * @returns {Promise<{params: URLSearchParams}|{response: Response}>} + */ + async function beginEmailAction(request, url) { + const params = new URLSearchParams(await request.text()); + if (!isSameOriginPost(request, url)) { + return { response: new Response('Forbidden', { status: 403 }) }; + } + + const session = await requireAccountSession(request, url); + if ('response' in session) return session; + + if (readOnly) { + return { + response: await emailResponse( + url, + { error: 'This PDS is read-only.' }, + 403, + ), + }; + } + if (!actorStorage.setEmail) { + return { + response: await emailResponse( + url, + { error: 'This server cannot store an email address.' }, + 400, + ), + }; + } + return { params }; + } + + /** + * POST /account/email - Set an address, sending it a code when possible + * @param {Request} request + * @param {URL} url + * @returns {Promise} + */ + async function handleAccountEmailSet(request, url) { + const begun = await beginEmailAction(request, url); + if ('response' in begun) return begun.response; + + const address = (begun.params.get('address') || '').trim(); + if (!isValidEmail(address)) { + return emailResponse( + url, + { error: 'That does not look like an email address.' }, + 400, + ); + } + + const state = await readEmailState(actorStorage); + + // With no way to send, the address is recorded but stays unconfirmed — + // saying otherwise would claim evidence this server does not have. + if (!emailer) { + await actorStorage.setEmail({ + address, + verified: false, + pending: null, + }); + return emailResponse(url, { + notice: `Saved ${address}. It stays unconfirmed until this server can send mail.`, + }); + } + + const code = generateEmailCode(); + await actorStorage.setEmail({ + ...state, + pending: { + address, + codeHash: await hashEmailCode(address, code, jwtSecret), + expiresAt: Date.now() + EMAIL_CODE_TTL_MS, + }, + }); + + try { + const message = renderVerificationEmail({ code, hostname: url.host }); + await emailer.send({ to: address, ...message }); + } catch (err) { + // Leaving the pending record would offer a code that never arrived + await actorStorage.setEmail({ ...state, pending: null }); + console.error('Failed to send verification email:', err); + return emailResponse( + url, + { error: 'The message could not be sent. Try again later.' }, + 502, + ); + } + + return emailResponse(url, { + notice: `Sent a code to ${address}.`, + }); + } + + /** + * POST /account/email/verify - Exchange the code for a confirmed address + * @param {Request} request + * @param {URL} url + * @returns {Promise} + */ + async function handleAccountEmailVerify(request, url) { + const begun = await beginEmailAction(request, url); + if ('response' in begun) return begun.response; + + const state = await readEmailState(actorStorage); + const pending = state.pending; + if (!pending) { + return emailResponse(url, { error: 'No address is waiting.' }, 400); + } + if (pending.expiresAt <= Date.now()) { + await actorStorage.setEmail({ ...state, pending: null }); + return emailResponse( + url, + { error: 'That code expired. Start again.' }, + 400, + ); + } + + const code = (begun.params.get('code') || '').trim(); + const candidate = await hashEmailCode(pending.address, code, jwtSecret); + if (!(await timingSafeEqual(candidate, pending.codeHash))) { + return emailResponse(url, { error: 'That code is wrong.' }, 400); + } + + await actorStorage.setEmail({ + address: pending.address, + verified: true, + pending: null, + }); + return emailResponse(url, { + notice: `${pending.address} is confirmed.`, + }); + } + + /** + * POST /account/email/cancel - Drop a pending address change + * @param {Request} request + * @param {URL} url + * @returns {Promise} + */ + async function handleAccountEmailCancel(request, url) { + const begun = await beginEmailAction(request, url); + if ('response' in begun) return begun.response; + + const state = await readEmailState(actorStorage); + await actorStorage.setEmail({ ...state, pending: null }); + return emailResponse(url, { notice: 'Change cancelled.' }); + } + + /** + * POST /account/email/remove - Forget the address entirely + * @param {Request} request + * @param {URL} url + * @returns {Promise} + */ + async function handleAccountEmailRemove(request, url) { + const begun = await beginEmailAction(request, url); + if ('response' in begun) return begun.response; + + await actorStorage.setEmail({ + address: null, + verified: false, + pending: null, + }); + return emailResponse(url, { notice: 'Address removed.' }); + } + + /** + * GET /account/spaces - Permissioned-data spaces this account holds + * @param {Request} request + * @param {URL} url + * @returns {Promise} + */ + async function handleAccountSpaces(request, url) { + const session = await requireAccountSession(request, url); + if ('response' in session) return session.response; + + const browser = spaceBrowser; + const spaces = browser + ? await Promise.all( + (await browser.listSpaces()).map(async (space) => ({ + uri: space.uri, + spaceType: space.spaceType, + authority: space.spaceDid, + isOwner: space.isOwner, + policy: space.policy, + createdAt: space.createdAt.slice(0, 10), + deleted: Boolean(space.deletedAt), + members: await browser.countMembers(space.uri), + collections: await browser.countRecords(space.uri), + })), + ) + : []; + + return htmlResponse( + renderSpacesPage({ + hostname: url.host, + spaces, + enabled: Boolean(browser), + }), + ); + } + + /** + * GET /account/spaces/records - The records in one space collection + * @param {Request} request + * @param {URL} url + * @returns {Promise} + */ + async function handleAccountSpaceRecords(request, url) { + const session = await requireAccountSession(request, url); + if ('response' in session) return session.response; + + const space = url.searchParams.get('space') || ''; + const collection = url.searchParams.get('collection') || ''; + const browser = spaceBrowser; + + /** @param {string} error @param {number} status */ + const reject = (error, status) => + htmlResponse( + renderRecordsPage({ + hostname: url.host, + section: '/account/spaces', + back: { href: '/account/spaces', label: 'Spaces' }, + collection, + subtitles: space ? [space] : [], + records: [], + truncated: false, + error, + blobUrl: () => null, + }), + status, + ); + + if (!browser) + return reject('Permissioned data is off on this server.', 400); + if (!space || !collection) { + return reject('Name a space and a collection to read.', 400); + } + + // Only spaces this storage holds for the account: the query string is + // the caller's, so it names what to show rather than what to fetch. + const known = await browser.listSpaces(); + if (!known.some((row) => row.uri === space)) { + return reject('No such space on this account.', 404); + } + + const did = session.did; + const { records, truncated } = await browser.listRecords( + space, + collection, + ACCOUNT_SPACE_RECORD_LIMIT, + ); + return htmlResponse( + renderRecordsPage({ + hostname: url.host, + section: '/account/spaces', + back: { href: '/account/spaces', label: 'Spaces' }, + collection, + subtitles: ["This account's own records in that space."], + // The uri shape com.atproto.space.listRecords answers with: + // space uri, then writer, then collection/rkey + records: records.map((record) => ({ + uri: `${space}/${did}/${collection}/${record.rkey}`, + cid: record.cid, + indexedAt: record.indexedAt, + value: record.value, + })), + truncated, + }), + ); + } + + /** + * GET /account/repo/records - The records in one repo collection + * @param {Request} request + * @param {URL} url + * @returns {Promise} + */ + async function handleAccountRepoRecords(request, url) { + const session = await requireAccountSession(request, url); + if ('response' in session) return session.response; + + const collection = url.searchParams.get('collection') || ''; + /** @param {string} error @param {number} status */ + const reject = (error, status) => + htmlResponse( + renderRecordsPage({ + hostname: url.host, + section: '/account/repo', + back: { href: '/account/repo', label: 'Repository' }, + collection, + subtitles: [], + records: [], + truncated: false, + error, + }), + status, + ); + + if (!collection) return reject('Name a collection to read.', 400); + const did = (await getDid()) || session.did; + + // One over the limit, so the page can say it is showing a prefix without + // reading a second page it will not render. + const { records } = await actorStorage.listRecords( + collection, + null, + ACCOUNT_SPACE_RECORD_LIMIT + 1, + ); + const shown = records.slice(0, ACCOUNT_SPACE_RECORD_LIMIT); + + return htmlResponse( + renderRecordsPage({ + hostname: url.host, + section: '/account/repo', + back: { href: '/account/repo', label: 'Repository' }, + collection, + subtitles: ['Public records, readable by anyone through the sync API.'], + records: shown.map((record) => ({ + uri: record.uri, + cid: record.cid, + value: toDisplayValue( + cborDecode( + record.value instanceof Uint8Array + ? record.value + : new Uint8Array(record.value), + ), + ), + })), + // Image blobs referenced by a record render inline, from this + // server's own blob store + blobUrl: (cid) => + `/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`, + truncated: records.length > ACCOUNT_SPACE_RECORD_LIMIT, + }), + ); + } + + /** + * GET /account/repo - What this server holds for the account + * @param {Request} request + * @param {URL} url + * @returns {Promise} + */ + async function handleAccountRepo(request, url) { + const session = await requireAccountSession(request, url); + if ('response' in session) return session.response; + + return htmlResponse( + renderRepoPage({ + hostname: url.host, + collections: await countRecordsByCollection(), + blobs: await countBlobs(), + commit: await actorStorage.getLatestCommit(), + }), + ); + } + + /** + * The identity page, optionally carrying the outcome of a status action. + * @param {URL} url + * @param {string} did + * @param {string} [error] + * @returns {Promise} + */ + async function identityResponse(url, did, error = '') { + const credentials = await getRecommendedDidCredentials(url.hostname); + return htmlResponse( + renderIdentityPage({ + hostname: url.host, + did, + handle: await actorStorage.getHandle(), + status: (await actorStorage.getAccountStatus()) || 'active', + signingKey: credentials?.verificationMethods?.atproto || null, + // What a PLC operation would publish. Unconfigured, that is this + // request's own origin rather than the https:// the directory wants. + pdsEndpoint: hostname + ? `https://${hostname}` + : `${url.protocol}//${url.host}`, + plcUrl: plcUrl, + readOnly: readOnly, + error, + }), + error ? 400 : 200, + ); + } + + /** + * GET /account/identity - Published identity and account status + * @param {Request} request + * @param {URL} url + * @returns {Promise} + */ + async function handleAccountIdentity(request, url) { + const session = await requireAccountSession(request, url); + if ('response' in session) return session.response; + return identityResponse(url, session.did); + } + + /** + * POST /account/status - Activate or deactivate the account + * @param {Request} request + * @param {URL} url + * @returns {Promise} + */ + async function handleAccountStatus(request, url) { + const params = new URLSearchParams(await request.text()); + if (!isSameOriginPost(request, url)) { + return new Response('Forbidden', { status: 403 }); + } + + const session = await requireAccountSession(request, url); + if ('response' in session) return session.response; + + if (readOnly) { + return identityResponse(url, session.did, 'This PDS is read-only.'); + } + + const action = params.get('action'); + if (action !== 'activate' && action !== 'deactivate') { + return identityResponse(url, session.did, 'Unknown status action.'); + } + + // The same path com.atproto.server.{activate,deactivate}Account takes, so + // the firehose sees an account event either way it was triggered. + await setAccountActive(action === 'activate'); + + return new Response(null, { + status: 303, + headers: { + Location: '/account/identity', + 'Cache-Control': 'no-store', + }, + }); + } + + /** + * POST /account/sign-in - Verify the account password, start a cookie session + * @param {Request} request + * @param {URL} url + * @returns {Promise} + */ + async function handleAccountSignIn(request, url) { + const params = new URLSearchParams(await request.text()); + if (!isSameOriginPost(request, url)) { + return new Response('Forbidden', { status: 403 }); + } + + const identifier = (params.get('username') || '').trim(); + const password = params.get('password') || ''; + + /** @param {string} error @param {number} status */ + const reject = (error, status) => + signInResponse(url, { error, identifier, status }); + + const did = await getDid(); + if (!did) return reject('This PDS is not initialized yet.', 400); + + const handle = await actorStorage.getHandle(); + const claimed = identifier.replace(/^@/, ''); + if (claimed !== did && claimed !== handle) { + return reject('Invalid identifier', 401); + } + + // Only the account password reaches these pages. App passwords hold a + // restricted session scope, and the account screen is account management. + if (!accountPassword) { + return reject( + 'Password login is not configured on this server. Set the password option (PDS_PASSWORD).', + 401, + ); + } + if (password !== accountPassword) return reject('Invalid password', 401); + + const token = await createAccountJwt(did, jwtSecret, ACCOUNT_SESSION_TTL); + return new Response(null, { + status: 303, + headers: { + Location: '/account', + 'Cache-Control': 'no-store', + 'Set-Cookie': accountCookie(token, url.protocol === 'https:'), + }, + }); + } + + /** + * POST /account/sign-out - Clear the cookie session + * @param {Request} request + * @param {URL} url + * @returns {Promise} + */ + async function handleAccountSignOut(request, url) { + await drainRequestBody(request); + if (!isSameOriginPost(request, url)) { + return new Response('Forbidden', { status: 403 }); + } + + return new Response(null, { + status: 303, + headers: { + Location: '/account', + 'Cache-Control': 'no-store', + 'Set-Cookie': accountCookie('', url.protocol === 'https:'), + }, + }); + } + + return { + routes: { + '/account': { + method: 'GET', + handler: handleAccountPage, + }, + '/account/sign-in': { + method: 'POST', + handler: handleAccountSignIn, + }, + '/account/sign-out': { + method: 'POST', + handler: handleAccountSignOut, + }, + '/account/apps': { + method: 'GET', + handler: handleAccountApps, + }, + '/account/apps/revoke': { + method: 'POST', + handler: handleAccountAppRevoke, + }, + '/account/app-passwords': { + method: 'GET', + handler: handleAccountAppPasswords, + }, + '/account/app-passwords/create': { + method: 'POST', + handler: handleAccountAppPasswordCreate, + }, + '/account/app-passwords/revoke': { + method: 'POST', + handler: handleAccountAppPasswordRevoke, + }, + '/account/api/overview': { + method: 'GET', + handler: handleApiOverview, + }, + '/account/api/activity': { + method: 'GET', + handler: handleApiActivity, + }, + '/account/api/lexicon-status': { + method: 'GET', + handler: handleApiLexiconStatus, + }, + '/account/api/lexicon-schema': { + method: 'GET', + handler: handleApiLexiconSchema, + }, + '/account/api/apps': { + method: 'GET', + handler: handleApiApps, + }, + '/account/api/apps/revoke': { + method: 'POST', + handler: handleApiAppsRevoke, + }, + '/account/api/apps/alias': { + method: 'POST', + handler: handleApiAppsAlias, + }, + '/account/api/settings/appearance': { + method: 'POST', + handler: handleApiSettingsAppearance, + }, + '/account/api/sessions': { + method: 'GET', + handler: handleApiSessions, + }, + '/account/api/sessions/revoke': { + method: 'POST', + handler: handleApiSessionsRevoke, + }, + '/account/api/sessions/revoke-all': { + method: 'POST', + handler: handleApiSessionsRevokeAll, + }, + '/account/api/app-passwords': { + method: 'GET', + handler: handleApiAppPasswords, + }, + '/account/api/app-passwords/create': { + method: 'POST', + handler: handleApiAppPasswordsCreate, + }, + '/account/api/app-passwords/revoke': { + method: 'POST', + handler: handleApiAppPasswordsRevoke, + }, + '/account/api/passkeys': { + method: 'GET', + handler: handleApiPasskeys, + }, + '/account/api/email': { + method: 'GET', + handler: handleApiEmail, + }, + '/account/api/email/action': { + method: 'POST', + handler: handleApiEmailAction, + }, + '/account/api/passkeys/revoke': { + method: 'POST', + handler: handleApiPasskeysRevoke, + }, + '/account/api/spaces': { + method: 'GET', + handler: handleApiSpaces, + }, + '/account/api/identity': { + method: 'GET', + handler: handleApiIdentity, + }, + '/account/api/identity/handle': { + method: 'POST', + handler: handleApiIdentityHandle, + }, + '/account/api/status': { + method: 'POST', + handler: handleApiStatus, + }, + '/account/api/records': { + method: 'GET', + handler: handleApiRecords, + }, + '/account/api/blobs': { + method: 'GET', + handler: handleApiBlobs, + }, + '/account/api/blobs/relink': { + method: 'POST', + handler: handleApiBlobsRelink, + }, + // /account/api/backups{,/settings,/run} come from handlers/account-backup.js + '/account/sessions': { + method: 'GET', + handler: handleAccountSessions, + }, + '/account/sessions/revoke': { + method: 'POST', + handler: handleAccountSessionRevoke, + }, + '/account/passkeys': { + method: 'GET', + handler: handleAccountPasskeys, + }, + '/account/passkeys/register': { + method: 'POST', + handler: handleAccountPasskeyRegister, + }, + '/account/passkeys/revoke': { + method: 'POST', + handler: handleAccountPasskeyRevoke, + }, + '/account/passkey/challenge': { + method: 'GET', + handler: handleAccountPasskeyChallenge, + }, + '/account/passkey/sign-in': { + method: 'POST', + handler: handleAccountPasskeySignIn, + }, + '/account/email': { + handler: handleAccountEmailRoute, + }, + '/account/email/verify': { + method: 'POST', + handler: handleAccountEmailVerify, + }, + '/account/email/cancel': { + method: 'POST', + handler: handleAccountEmailCancel, + }, + '/account/email/remove': { + method: 'POST', + handler: handleAccountEmailRemove, + }, + '/account/repo': { + method: 'GET', + handler: handleAccountRepo, + }, + '/account/spaces': { + method: 'GET', + handler: handleAccountSpaces, + }, + '/account/spaces/records': { + method: 'GET', + handler: handleAccountSpaceRecords, + }, + '/account/repo/records': { + method: 'GET', + handler: handleAccountRepoRecords, + }, + '/account/identity': { + method: 'GET', + handler: handleAccountIdentity, + }, + '/account/status': { + method: 'POST', + handler: handleAccountStatus, + }, + }, + readAccountSession, + readAccountUiState, + accountAppResponse, + accountApi, + listConnectedApps, + }; +} diff --git a/packages/core/src/pds.js b/packages/core/src/pds.js index c968932..a10517e 100644 --- a/packages/core/src/pds.js +++ b/packages/core/src/pds.js @@ -1,31 +1,7 @@ // @pdsjs/core/pds - Port-based Personal Data Server // Core business logic with constructor injection for storage and blob ports -import { - renderAccountPage, - renderAppPasswordsPage, - renderAppsPage, - renderEmailPage, - renderIdentityPage, - renderPasskeysPage, - renderRecordsPage, - renderRepoPage, - renderSessionsPage, - renderSignInPage, - renderSpacesPage, -} from './account-ui.js'; -import { - generateAppPassword, - hashAppPassword, - isAppPasswordScope, -} from './app-password.js'; -import { - createAccountJwt, - createServiceJwt, - verifyAccessJwt, - verifyAccountJwt, -} from './auth.js'; -import { parseCarFile } from './car.js'; +import { createServiceJwt, verifyAccessJwt } from './auth.js'; import { base64UrlDecode, base64UrlEncode, @@ -33,7 +9,6 @@ import { decodeStoredKey, encodeStoredKey, generateKeyPair, - hmacSha256, importPrivateKey, sign, signingKeyCurve, @@ -41,15 +16,13 @@ import { } from './crypto.js'; import { EMAIL_CODE_TTL_MS, - EMAIL_RESEND_COOLDOWN_MS, emailSessionFields, generateEmailCode, hashEmailCode, - isValidEmail, readEmailState, renderPlcOperationEmail, - renderVerificationEmail, } from './email.js'; +import { createAccountHandlers } from './handlers/account.js'; import { createBackupHandlers } from './handlers/account-backup.js'; import { createOAuthHandlers } from './handlers/oauth.js'; import { createServerMetaHandlers } from './handlers/server-meta.js'; @@ -60,14 +33,7 @@ import { createPreferencesHandlers } from './handlers/xrpc-preferences.js'; import { createRepoHandlers } from './handlers/xrpc-repo.js'; import { createXrpcServerHandlers } from './handlers/xrpc-server.js'; import { createSyncHandlers } from './handlers/xrpc-sync.js'; -import { - addCorsHeaders, - corsHeaders, - drainRequestBody, - htmlResponse, - isSameOriginPost, - readCookie, -} from './http.js'; +import { addCorsHeaders, corsHeaders, drainRequestBody } from './http.js'; import { loadRepositoryFromCar } from './loader.js'; import { buildMst, mstAdd, mstDelete, mstUpdate } from './mst.js'; import { mintDpopNonce, parseDpopProof } from './oauth.js'; @@ -96,17 +62,9 @@ import { findBlobRefs, parseCommitBlock, recordTimeOf, - tidToMs, - toDisplayValue, } from './repo.js'; import { ScopePermissions } from './scope.js'; -import { - ACCOUNT_COOKIE, - ACCOUNT_SESSION_TTL, - accountCookie, - createSessionRegistry, -} from './session.js'; -import { verifyAssertion, verifyRegistration } from './webauthn.js'; +import { createSessionRegistry } from './session.js'; /** * @typedef {import('./ports.js').ActorStoragePort} ActorStoragePort @@ -156,19 +114,6 @@ const DEFAULT_BLOB_UPLOAD_LIMIT = 5 * 1024 * 1024; // Default PLC directory for identity operations const DEFAULT_PLC_URL = 'https://plc.directory'; -// How many records a collection view renders before it says it is showing -// a prefix. Applies to both the repo and the space views. -const ACCOUNT_SPACE_RECORD_LIMIT = 25; - -// Where the account page stops counting blobs and reports `N+` instead -const ACCOUNT_BLOB_COUNT_LIMIT = 1000; - -// How many files the account Files page loads per request -const ACCOUNT_BLOB_PAGE_LIMIT = 30; - -// The media kinds the Files page can filter by; mirrors blob-sql's bucketing -const BLOB_KINDS = ['image', 'video', 'audio', 'text', 'other']; - /** * Derive the curve and compressed public key for a stored private key. * @param {Uint8Array} storedKeyBytes - As stored: bare P-256 scalar or curve-tagged @@ -194,45 +139,6 @@ async function derivePublicKey(storedKeyBytes, k256Signer = null) { return { curve, publicKey: compressPublicKey(uncompressed) }; } -/** - * How many apps the Apps page shows: one per authority domain, unioned across - * the collections that hold records and the OAuth sessions in play. An app can - * appear with data but no session (or the reverse), and several sessions of one - * app collapse into a single row — so this is the row count, not the session - * count. Mirrors nsidDomain/clientHost in the account UI's format helper. - * @param {{name: string}[]} collections - * @param {{clientId?: string}[]} sessions - * @returns {number} - */ -function countAppDomains(collections, sessions) { - const domains = new Set(); - for (const c of collections || []) { - const parts = String(c.name).split('.'); - domains.add(parts.length < 2 ? String(c.name) : `${parts[1]}.${parts[0]}`); - } - for (const s of sessions || []) { - const clientId = s.clientId || ''; - try { - domains.add(new URL(clientId).host); - } catch { - domains.add(clientId); - } - } - return domains.size; -} - -/** - * A lowercased hostname-looking string, or null. A port is allowed so a dev - * client like `localhost:5173` still names a row. - * @param {unknown} value - * @returns {string|null} - */ -function plausibleHost(value) { - if (typeof value !== 'string') return null; - const host = value.trim().toLowerCase(); - return /^[a-z0-9][a-z0-9.:-]{0,252}$/.test(host) ? host : null; -} - /** * Personal Data Server - core business logic with port-based dependencies */ @@ -398,8 +304,8 @@ export class PersonalDataServer { getPublicKeyJwk: () => this.getPublicKeyJwk(), readJsonBounded: (request) => this.readJsonBounded(request), accountAppResponse: (bootstrap, status) => - this.accountAppResponse(bootstrap, status), - readAccountUiState: () => this.readAccountUiState(), + this._account.accountAppResponse(bootstrap, status), + readAccountUiState: () => this._account.readAccountUiState(), readOnlyError: () => this.readOnlyError(), }); @@ -451,7 +357,31 @@ export class PersonalDataServer { getDid: () => this.getDid(), buildFullRepoCar: this._sync.buildFullRepoCar, accountApi: (request, url, handler, options) => - this.accountApi(request, url, handler, options), + this._account.accountApi(request, url, handler, options), + }); + + this._account = createAccountHandlers({ + actorStorage: this.actorStorage, + sharedStorage: this.sharedStorage, + sessions: this._sessions, + passkeys: this._passkeys, + jwtSecret: this.jwtSecret, + hostname: this.hostname, + plcUrl: this.plcUrl, + accountPassword: this.password, + readOnly: this.readOnly, + emailer: this.emailer, + spaceBrowser: this.spaceBrowser, + accountApp: this.accountApp, + lexiconResolver: this.lexiconResolver, + getDid: () => this.getDid(), + readJsonBounded: (request) => this.readJsonBounded(request), + setAccountActive: (active) => this.setAccountActive(active), + getRecommendedDidCredentials: (h) => this.getRecommendedDidCredentials(h), + relinkBlobRecords: () => this.relinkBlobRecords(), + renameHandle: (requested, host) => + this._identity.renameHandle(requested, host), + backupsAvailable: () => this._backup.backupsAvailable(), }); // Permissioned data (proposal 0016) is off unless a platform package builds @@ -470,6 +400,7 @@ export class PersonalDataServer { ...this._xrpcServer.routes, ...this._oauth.routes, ...this._backup.routes, + ...this._account.routes, ...(spaceRoutes ?? {}), }; this.spacesEnabled = Boolean(spaceRoutes); @@ -684,13 +615,13 @@ export class PersonalDataServer { request.method === 'GET' && (pathname === '/account' || pathname.startsWith('/account/')) && !pathname.startsWith('/account/api/') && - (await this.readAccountSession(request)) + (await this._account.readAccountSession(request)) ) { // The stored appearance choice rides along in the bootstrap so the // app paints the right palette before its first API call returns. - const { theme } = await this.readAccountUiState(); + const { theme } = await this._account.readAccountUiState(); return addCorsHeaders( - /** @type {Response} */ (this.accountAppResponse({ theme })), + /** @type {Response} */ (this._account.accountAppResponse({ theme })), ); } @@ -800,2838 +731,182 @@ export class PersonalDataServer { * responses that already carry a nonce (the OAuth endpoints set their own) * and ones that can't be rebuilt (WebSocket upgrades). * @param {Request} request - * @param {Response} response - * @returns {Promise} - */ - async withDpopNonce(request, response) { - const authHeader = request.headers.get('authorization') || ''; - if (!/^DPoP\s/i.test(authHeader)) return response; - if ( - response.status === 101 || - /** @type {{webSocket?: unknown}} */ (response).webSocket - ) { - return response; - } - if (response.headers.get('DPoP-Nonce')) return response; - - const headers = new Headers(response.headers); - headers.set('DPoP-Nonce', await mintDpopNonce(this.jwtSecret)); - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers, - }); - } - - /** - * Find route for pathname - * @param {string} pathname - * @returns {Route|null} - */ - findRoute(pathname) { - return this.routes[pathname] || null; - } - - /** - * Authenticate request from Authorization header - * @param {Request} request - * @returns {Promise<{did: string, scope: string}|null>} - */ - async authenticate(request) { - return (await this.authenticateResource(request)).auth; - } - - /** - * Authenticate a request and, for OAuth (DPoP) requests, also return the - * `WWW-Authenticate` challenge to send on a 401. The challenge carries - * `error="invalid_token"`, which tells a spec-compliant client to refresh - * its access token rather than treat the failure as a hard logout. Mirrors - * the reference PDS resource verifier (packages/pds/src/auth-verifier.ts). - * @param {Request} request - * @returns {Promise<{auth: {did: string, scope: string}|null, challenge: string|null, tokenError?: string}>} - */ - async authenticateResource(request) { - const authHeader = request.headers.get('authorization'); - if (!authHeader) return { auth: null, challenge: null }; - - // Legacy Bearer tokens (HMAC-signed) — no DPoP, no challenge. - const bearerMatch = authHeader.match(/^Bearer\s+(.+)$/i); - if (bearerMatch) { - try { - const payload = await verifyAccessJwt(bearerMatch[1], this.jwtSecret); - return { - auth: { did: payload.sub, scope: payload.scope || 'atproto' }, - challenge: null, - }; - } catch { - return { auth: null, challenge: null }; - } - } - - // OAuth DPoP tokens (signed with the account key). - const dpopMatch = authHeader.match(/^DPoP\s+(.+)$/i); - if (dpopMatch) { - const result = await this.verifyDpopToken(request, dpopMatch[1]); - if (!('error' in result)) return { auth: result, challenge: null }; - // Sanitize the reason to a safe HTTP header value before quoting it. - const description = result.description - .replace(/[^\x20-\x7E]/g, ' ') - .replace(/["\\]/g, '') - .slice(0, 200); - return { - auth: null, - challenge: `DPoP error="${result.error}", error_description="${description}"`, - // The response body carries the OAuth error code (`invalid_token`), the - // same signal as the WWW-Authenticate challenge, so clients that branch - // on the body's `error` rather than the header still know to refresh. - // Mirrors the reference PDS OAuth resource verifier. - tokenError: result.error, - }; - } - - return { auth: null, challenge: null }; - } - - /** - * Verify an OAuth DPoP-bound access token from `Authorization: DPoP `. - * Returns the auth result, or a structured `invalid_token` reason so the - * caller can build a `WWW-Authenticate` challenge. - * @param {Request} request - * @param {string} token - the access-token JWT - * @returns {Promise<{did: string, scope: string}|{error: string, description: string}>} - */ - async verifyDpopToken(request, token) { - /** @param {string} description */ - const fail = (description) => ({ error: 'invalid_token', description }); - try { - const dpopHeader = request.headers.get('DPoP'); - if (!dpopHeader) return fail('DPoP proof required'); - - // Decode and verify token - const [headerB64, payloadB64, sigB64] = token.split('.'); - if (!headerB64 || !payloadB64 || !sigB64) return fail('Malformed token'); - - const payload = JSON.parse( - new TextDecoder().decode(base64UrlDecode(payloadB64)), - ); - - // Check expiration - const now = Math.floor(Date.now() / 1000); - if (payload.exp && payload.exp < now) return fail('Access token expired'); - - // Verify DPoP proof binds to this token - const url = new URL(request.url); - const dpop = await parseDpopProof( - dpopHeader, - request.method, - `${url.protocol}//${url.host}${url.pathname}`, - null, - token, - ); - - // Check DPoP key matches token's cnf.jkt - if (payload.cnf?.jkt && payload.cnf.jkt !== dpop.jkt) { - return fail('DPoP key mismatch'); - } - - // Verify signature using our public key - const signingKey = await this.getSigningKey(); - if (!signingKey) return fail('Server signing key unavailable'); - const sigInput = new TextEncoder().encode(`${headerB64}.${payloadB64}`); - const sig = base64UrlDecode(sigB64); - let valid; - if (signingKeyCurve(signingKey) === 'secp256k1') { - // WebCrypto cannot verify secp256k1; go through the injected - // verifier, addressed by our own did:key. - if (!this.verifier) return fail('Server verifier unavailable'); - const publicKey = - await /** @type {{publicKey: () => Promise}} */ ( - signingKey - ).publicKey(); - valid = await this.verifier.verify( - publicKeyToDidKey(publicKey, 'secp256k1'), - sigInput, - sig, - ); - } else { - const publicKeyJwk = await this.getPublicKeyJwk(); - if (!publicKeyJwk) return fail('Server public key unavailable'); - const verifyKey = await crypto.subtle.importKey( - 'jwk', - publicKeyJwk, - { name: 'ECDSA', namedCurve: 'P-256' }, - false, - ['verify'], - ); - valid = await crypto.subtle.verify( - { name: 'ECDSA', hash: 'SHA-256' }, - verifyKey, - /** @type {BufferSource} */ (sig), - sigInput, - ); - } - if (!valid) return fail('Invalid token signature'); - - return { did: payload.sub, scope: payload.scope || 'atproto' }; - } catch (err) { - return fail(err instanceof Error ? err.message : 'Invalid DPoP token'); - } - } - - // ════════════════════════════════════════════════════════════════════════════ - // Account Pages - // ════════════════════════════════════════════════════════════════════════════ - - /** - * The DID of the account whose cookie session this request carries, or null - * when there is no valid session. - * @param {Request} request - * @returns {Promise} - */ - async readAccountSession(request) { - const token = readCookie(request, ACCOUNT_COOKIE); - if (!token) return null; - try { - const payload = await verifyAccountJwt(token, this.jwtSecret); - // A session outlives nothing: if the PDS has since been initialized with - // a different DID, its cookies are not this account's. - const did = await this.getDid(); - return did && payload.sub === did ? did : null; - } catch { - return null; - } - } - - /** - * Display name and avatar from the account's own app.bsky.actor.profile - * record. Read from this repo rather than looked up at a public AppView: - * the account screen must show this account, not whichever account some - * third party knows by the same handle. - * @param {string} did - * @returns {Promise<{displayName: string|null, avatarUrl: string|null}>} - */ - async readAccountProfile(did) { - const empty = { displayName: null, avatarUrl: null }; - try { - const stored = await this.actorStorage.getRecord( - `at://${did}/app.bsky.actor.profile/self`, - ); - if (!stored) return empty; - - const value = cborDecode(stored.value); - const ref = value?.avatar?.ref; - // Records written over XRPC keep the JSON `{$link}` form; ones loaded - // from a CAR carry decoded tag-42 bytes. - const cid = - typeof ref?.$link === 'string' - ? ref.$link - : ref instanceof Uint8Array - ? cidToString(ref) - : null; - - return { - displayName: - typeof value?.displayName === 'string' ? value.displayName : null, - avatarUrl: cid - ? `/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}` - : null, - }; - } catch { - // A profile record that will not decode is not worth a broken page - return empty; - } - } - - /** - * Count the repository's records per collection. Record keys are - * `collection/rkey`, so the collection is everything up to the first slash. - * @returns {Promise>} Sorted by name - */ - async countRecordsByCollection() { - /** @type {Map} */ - const counts = new Map(); - for (const record of await this.actorStorage.listAllRecords()) { - const collection = record.key.split('/')[0]; - counts.set(collection, (counts.get(collection) || 0) + 1); - } - return [...counts] - .map(([name, count]) => ({ name, count })) - .sort((a, b) => a.name.localeCompare(b.name)); - } - - /** - * Per-collection counts, a creation-time histogram, and the latest write - * time, from a single pass over the repo. Each record is bucketed by the - * timestamp its rkey (a TID) encodes, across a fixed trailing window, so the - * Apps page can draw an activity sparkline over the whole window rather than - * only the newest firehose events. Records whose rkey is not a TID (e.g. - * `self`) still count toward the totals but contribute no point and no time. - * `lastRecordAt` is unbounded by the window, so a row can label how recently - * an app was active even when its newest write predates the sparkline. Only - * collections with activity in the window appear in `series`, to keep the - * payload small. - * @param {{windowMs?: number, buckets?: number, now?: number}} [opts] - * @returns {Promise<{collections: {name: string, count: number, lastRecordAt: number|null}[], activity: {start: number, end: number, buckets: number, series: Record}}>} - */ - async countRecordsWithActivity({ - windowMs = 365 * 86_400_000, - buckets = 52, - now = Date.now(), - } = {}) { - const start = now - windowMs; - /** @type {Map} */ - const counts = new Map(); - /** @type {Map} */ - const lastAt = new Map(); - /** @type {Map} */ - const series = new Map(); - for (const record of await this.actorStorage.listAllRecords()) { - const slash = record.key.indexOf('/'); - const collection = slash >= 0 ? record.key.slice(0, slash) : record.key; - counts.set(collection, (counts.get(collection) || 0) + 1); - const at = tidToMs(slash >= 0 ? record.key.slice(slash + 1) : ''); - // Newest write per collection, tracked before the window filter so it - // reflects the true last activity even for records older than the window. - if (at !== null && at <= now && at > (lastAt.get(collection) || 0)) { - lastAt.set(collection, at); - } - if (at === null || at < start || at > now) continue; - let arr = series.get(collection); - if (!arr) { - arr = new Array(buckets).fill(0); - series.set(collection, arr); - } - const i = Math.min( - buckets - 1, - Math.floor(((at - start) / windowMs) * buckets), - ); - arr[i] += 1; - } - const collections = [...counts] - .map(([name, count]) => ({ - name, - count, - lastRecordAt: lastAt.get(name) ?? null, - })) - .sort((a, b) => a.name.localeCompare(b.name)); - return { - collections, - activity: { - start, - end: now, - buckets, - series: Object.fromEntries(series), - }, - }; - } - - /** - * Count stored blobs, stopping at ACCOUNT_BLOB_COUNT_LIMIT. A full count - * would walk every page of a repository that may hold thousands; the page - * reports the cap as `N+` rather than paying for an exact number. - * @returns {Promise<{count: number, truncated: boolean}>} - */ - async countBlobs() { - let count = 0; - let cursor = null; - while (count < ACCOUNT_BLOB_COUNT_LIMIT) { - /** @type {{cids: string[], cursor: string|null}} */ - const page = await this.actorStorage.listBlobs(cursor, 100); - count += page.cids.length; - cursor = page.cursor; - if (!cursor || page.cids.length === 0) { - return { count, truncated: false }; - } - } - return { count: ACCOUNT_BLOB_COUNT_LIMIT, truncated: true }; - } - - /** - * A handle for one OAuth session that is safe to put in a page. - * - * The storage key for a session is the refresh token itself, so it can - * never be rendered: a hidden form field carrying it would hand a live - * credential to anything that reads the page. This is an HMAC of it under - * the server secret — stable, opaque, and useless to anyone who cannot - * already act as this account. - * @param {string} tokenId - The refresh token - * @returns {Promise} - */ - async oauthSessionId(tokenId) { - return (await hmacSha256(`oauth-session:${tokenId}`, this.jwtSecret)).slice( - 0, - 22, - ); - } - - /** - * Apps holding an OAuth session, most recently active first. Refresh tokens - * are stored one per session, so a client that reauthorized appears once per - * session. lastAccessedAt tracks the session's most recent refresh; access - * tokens are verified statelessly, so it moves on refresh, not on every - * request. Older tokens have no updatedAt, so createdAt stands in. - * @param {string} did - * @returns {Promise>} - */ - async listConnectedApps(did) { - if (!this.sharedStorage.listOAuthTokensByDid) return []; - const tokens = await this.sharedStorage.listOAuthTokensByDid(did); - const apps = await Promise.all( - tokens.map(async (token) => { - const lastAccessed = token.updatedAt ?? token.createdAt; - return { - clientId: token.clientId || 'unknown client', - scope: token.scope || 'atproto', - authorizedAt: token.createdAt - ? new Date(token.createdAt).toISOString().slice(0, 10) - : null, - lastAccessedAt: lastAccessed - ? new Date(lastAccessed).toISOString() - : null, - userAgent: token.userAgent || null, - // Enough of the DPoP thumbprint to tell two sessions apart - keyThumbprint: token.dpopJkt ? token.dpopJkt.slice(0, 12) : null, - // Absent on a storage adapter that does not return its keys, which - // costs the row its revoke button rather than breaking the page - sessionId: token.tokenId - ? await this.oauthSessionId(token.tokenId) - : null, - sortKey: lastAccessed || 0, - }; - }), - ); - return apps - .sort((a, b) => b.sortKey - a.sortKey) - .map(({ sortKey: _sortKey, ...app }) => app); - } - - /** - * Revoke one OAuth session by the handle listConnectedApps rendered. - * @param {string} did - * @param {string} sessionId - * @returns {Promise<{clientId: string}|null>} The app revoked, or null when - * no session matched - */ - async revokeOAuthSession(did, sessionId) { - if ( - !this.sharedStorage.listOAuthTokensByDid || - !this.sharedStorage.deleteOAuthToken - ) { - return null; - } - - for (const token of await this.sharedStorage.listOAuthTokensByDid(did)) { - if (!token.tokenId) continue; - const candidate = await this.oauthSessionId(token.tokenId); - if (!(await timingSafeEqual(candidate, sessionId))) continue; - - await this.sharedStorage.deleteOAuthToken(token.tokenId); - return { clientId: token.clientId || 'unknown client' }; - } - return null; - } - - /** - * @param {string} did - * @returns {Promise>} - */ - async listAccountAppPasswords(did) { - if (!this.sharedStorage.listAppPasswords) return []; - return this.sharedStorage.listAppPasswords(did); - } - - /** - * The signed-in DID, or a response to send instead — the sign-in page for a - * page request, and for a form post the same page carrying an error. - * @param {Request} request - * @param {URL} url - * @returns {Promise<{did: string}|{response: Response}>} - */ - async requireAccountSession(request, url) { - const did = await this.readAccountSession(request); - if (did) return { did }; - return { - response: this.signInResponse(url, { - error: 'Your session has expired. Sign in again.', - status: 401, - }), - }; - } - - /** - * The account application, when this deployment mounted one. It renders - * every section client-side against the JSON API, so it is served for any - * section path and routes within itself. - * @param {Object|null} [bootstrap] - Pre-auth state injected for the app to read before mounting - * @param {number} [status] - HTTP status for the response - * @returns {Response|null} - */ - accountAppResponse(bootstrap = null, status = 200) { - if (!this.accountApp) return null; - let html = this.accountApp; - if (bootstrap) { - // A classic script in runs before the deferred app module, so the - // app reads its pre-auth mode before mounting. `<` is escaped so no value - // can close the script element. - const data = JSON.stringify(JSON.stringify(bootstrap)).replace( - /', - ``, - ); - } - return new Response(html, { - status, - headers: { - 'Content-Type': 'text/html; charset=utf-8', - 'Cache-Control': 'no-store', - 'Referrer-Policy': 'same-origin', - }, - }); - } - - /** - * The sign-in screen: the account app in sign-in mode when one is mounted, - * and the server-rendered form otherwise. - * @param {URL} url - * @param {{error?: string, identifier?: string, status?: number}} [opts] - * @returns {Response} - */ - signInResponse(url, { error = '', identifier = '', status = 200 } = {}) { - const passkeys = this._passkeys.passkeysAvailable(); - if (this.accountApp) { - // Non-null: accountAppResponse only returns null when accountApp is unset. - return /** @type {Response} */ ( - this.accountAppResponse( - { mode: 'signin', hostname: url.host, error, identifier, passkeys }, - status, - ) - ); - } - return htmlResponse( - renderSignInPage({ hostname: url.host, error, identifier, passkeys }), - status, - ); - } - - /** - * GET /account - Account home, or the sign-in form when signed out - * @param {Request} request - * @param {URL} url - * @returns {Promise} - */ - async handleAccountPage(request, url) { - // Onboarding runs until the account is live on this server: a fresh empty - // server (no DID), or a migration still in progress (the account exists but - // is deactivated — createAccount lands it deactivated until the move - // finishes). Either way the wizard needs to be reachable to start or resume - // the move, rather than a sign-in form. Needs the account app; without it, - // fall through to the server-rendered "not initialized" sign-in page. - const accountDid = await this.getDid(); - const notYetLive = - !accountDid || - (await this.actorStorage.getAccountStatus()) === 'deactivated'; - if (notYetLive && this.accountApp) { - // Non-null: accountAppResponse only returns null when accountApp is unset. - return /** @type {Response} */ ( - this.accountAppResponse({ - // hostname, not host: a handle never carries a port, and this value - // seeds the handle field. - mode: 'onboard', - hostname: url.hostname, - plcUrl: this.plcUrl, - }) - ); - } - - const did = await this.readAccountSession(request); - if (!did) { - return this.signInResponse(url); - } - - const { displayName, avatarUrl } = await this.readAccountProfile(did); - const collections = await this.countRecordsByCollection(); - const blobs = await this.countBlobs(); - const sessions = await this.listConnectedApps(did); - - return htmlResponse( - renderAccountPage({ - hostname: url.host, - did, - handle: await this.actorStorage.getHandle(), - status: (await this.actorStorage.getAccountStatus()) || 'active', - displayName, - avatarUrl, - stats: { - records: collections.reduce((sum, c) => sum + c.count, 0), - blobs: blobs.count, - apps: countAppDomains(collections, sessions), - appPasswords: (await this.listAccountAppPasswords(did)).length, - }, - }), - ); - } - - /** - * The sessions page, carrying the outcome of a revoke. - * @param {URL} url - * @param {string} did - * @param {{error?: string, notice?: string}} [result] - * @param {number} [status=200] - * @returns {Promise} - */ - async sessionsResponse(url, did, result = {}, status = 200) { - const sessions = await this._sessions.listLiveSessions(did); - - return htmlResponse( - renderSessionsPage({ - hostname: url.host, - sessions: sessions - .map((session) => ({ - jti: session.jti, - label: session.label, - userAgent: session.userAgent, - createdAt: session.createdAt.slice(0, 10), - refreshedAt: session.refreshedAt - ? session.refreshedAt.slice(0, 10) - : null, - appPassword: isAppPasswordScope(session.scope), - })) - .sort((a, b) => b.createdAt.localeCompare(a.createdAt)), - supported: this._sessions.sessionsAvailable(), - readOnly: this.readOnly, - ...result, - }), - status, - ); - } - - /** - * GET /account/sessions - Logins opened with a password - * @param {Request} request - * @param {URL} url - * @returns {Promise} - */ - async handleAccountSessions(request, url) { - const session = await this.requireAccountSession(request, url); - if ('response' in session) return session.response; - return this.sessionsResponse(url, session.did); - } - - /** - * POST /account/sessions/revoke - End one login - * @param {Request} request - * @param {URL} url - * @returns {Promise} - */ - async handleAccountSessionRevoke(request, url) { - const params = new URLSearchParams(await request.text()); - if (!isSameOriginPost(request, url)) { - return new Response('Forbidden', { status: 403 }); - } - - const session = await this.requireAccountSession(request, url); - if ('response' in session) return session.response; - const { did } = session; - - if (this.readOnly) { - return this.sessionsResponse( - url, - did, - { error: 'This PDS is read-only.' }, - 403, - ); - } - - const jti = params.get('jti') || ''; - // Looked up before deleting so one account cannot end another's session - const target = this._sessions.sessionsAvailable() - ? await this.sharedStorage.getSession(jti) - : null; - if (!target || target.did !== did) { - return this.sessionsResponse( - url, - did, - { error: 'That session no longer exists.' }, - 400, - ); - } - - await this._sessions.revokeSessionChain(did, jti); - return this.sessionsResponse(url, did, { - notice: - 'Session revoked. Its access token stops working when it expires, within two hours.', - }); - } - - // ── JSON API, for the account app ───────────────────────────────────── - - /** - * Answer a JSON API request, or say why not. Every route below has already - * been matched, so this only decides authentication and shape. - * @param {Request} request - * @param {URL} url - * @param {(did: string, body: any) => Promise} handler - * @param {{write?: boolean}} [options] - * @returns {Promise} - */ - async accountApi(request, url, handler, options = {}) { - const json = (/** @type {unknown} */ body, /** @type {number} */ status) => - new Response(JSON.stringify(body), { - status, - headers: { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-store', - }, - }); - - let body = {}; - if (request.method === 'POST') { - const parsed = await this.readJsonBounded(request); - if (parsed instanceof Response) return parsed; - body = parsed; - if (!isSameOriginPost(request, url)) { - return json({ error: 'Forbidden' }, 403); - } - } - - const did = await this.readAccountSession(request); - if (!did) return json({ error: 'Not signed in' }, 401); - if (options.write && this.readOnly) { - return json({ error: 'This PDS is read-only' }, 403); - } - - try { - const result = await handler(did, body); - // A handler that must set a header of its own answers directly - return result instanceof Response ? result : json(result, 200); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - // A handler throws to reject a request the caller can fix; anything - // unexpected still reaches the outer catch as a 500. - return json({ error: message }, 400); - } - } - - /** - * GET /account/api/overview - Who this is, and what the server holds - * @param {Request} request - * @param {URL} url - * @returns {Promise} - */ - async handleApiOverview(request, url) { - return this.accountApi(request, url, async (did) => { - const { displayName, avatarUrl } = await this.readAccountProfile(did); - const { collections, activity } = await this.countRecordsWithActivity(); - // The exact aggregate when the storage can give one; the paged count, - // which caps out and reports itself truncated, only as the fallback. - const blobs = - typeof this.actorStorage.getBlobFacets === 'function' - ? { - count: (await this.actorStorage.getBlobFacets()).total.count, - truncated: false, - } - : await this.countBlobs(); - const commit = await this.actorStorage.getLatestCommit(); - const sessions = await this.listConnectedApps(did); - - return { - hostname: url.host, - did, - handle: await this.actorStorage.getHandle(), - status: (await this.actorStorage.getAccountStatus()) || 'active', - displayName, - avatarUrl, - readOnly: this.readOnly, - theme: (await this.readAccountUiState()).theme, - features: { - passkeys: this._passkeys.passkeysAvailable(), - sessions: this._sessions.sessionsAvailable(), - spaces: Boolean(this.spaceBrowser), - email: Boolean(this.actorStorage.setEmail), - emailSender: Boolean(this.emailer), - backups: this._backup.backupsAvailable(), - settings: typeof this.actorStorage.setAccountUiState === 'function', - files: typeof this.actorStorage.listBlobDetails === 'function', - }, - stats: { - records: collections.reduce((sum, c) => sum + c.count, 0), - blobs: blobs.count, - blobsTruncated: blobs.truncated, - apps: countAppDomains(collections, sessions), - appPasswords: (await this.listAccountAppPasswords(did)).length, - passkeys: (await this.listAccountPasskeys(did)).length, - sessions: (await this._sessions.listLiveSessions(did)).length, - }, - collections, - recordActivity: activity, - commit, - }; - }); - } - - /** - * GET /account/api/lexicon-status?collections=a,b,c - For each requested NSID, - * reports whether its lexicon is published (resolvable) via the protocol: - * DNS authority → DID → com.atproto.lexicon.schema record. Drives the - * "published" check on the records list. Resolutions are cached by the shared - * lexicon resolver, so repeat checks are cheap; if no resolver is injected the - * map is empty and the UI simply shows no checks. - * @param {Request} request - * @param {URL} url - * @returns {Promise} - */ - async handleApiLexiconStatus(request, url) { - return this.accountApi(request, url, async () => { - /** @type {Record} */ - const published = {}; - const resolver = this.lexiconResolver; - if (!resolver?.isPublished) return { published }; - // Bind now so the narrowed, defined method survives into the closure below. - const isPublished = resolver.isPublished.bind(resolver); - - const nsids = [ - ...new Set( - (url.searchParams.get('collections') || '') - .split(',') - .map((s) => s.trim()) - .filter(Boolean), - ), - ].slice(0, 100); - - await Promise.all( - nsids.map(async (nsid) => { - try { - published[nsid] = await isPublished(nsid); - } catch { - published[nsid] = false; - } - }), - ); - - return { published }; - }); - } - - /** - * GET /account/api/lexicon-schema?collection= - The resolved lexicon - * document for one NSID, so the UI can show a published record type's actual - * schema. Returns `{ lexicon: null }` when it can't be resolved or no resolver - * is injected. - * @param {Request} request - * @param {URL} url - * @returns {Promise} - */ - async handleApiLexiconSchema(request, url) { - return this.accountApi(request, url, async () => { - const resolver = this.lexiconResolver; - const nsid = (url.searchParams.get('collection') || '').trim(); - if (!resolver?.getLexicon || !nsid) return { lexicon: null }; - try { - return { lexicon: (await resolver.getLexicon(nsid)) ?? null }; - } catch { - return { lexicon: null }; - } - }); - } - - /** - * GET /account/api/activity - The account's write history, newest first, - * read from the sequenced commit events. Each commit's ops are flattened - * into one row apiece. Paginates by the `cursor` seq. - * @param {Request} request - * @param {URL} url - * @returns {Promise} - */ - async handleApiActivity(request, url) { - return this.accountApi(request, url, async () => { - if (!this.actorStorage.getEventsBefore) - return { events: [], cursor: null }; - const limit = Math.min( - Math.max(Number(url.searchParams.get('limit')) || 30, 1), - 100, - ); - const cursor = url.searchParams.get('cursor'); - const before = cursor - ? Number(cursor) - : (await this.actorStorage.getLatestSeq()) + 1; - - const rows = await this.actorStorage.getEventsBefore(before, limit); - const events = []; - let lowest = before; - for (const row of rows) { - lowest = Math.min(lowest, row.seq); - let decoded; - try { - decoded = cborDecode(row.evt); - } catch { - continue; - } - const ops = Array.isArray(decoded?.ops) ? decoded.ops : []; - for (const op of ops) { - const slash = String(op.path).indexOf('/'); - events.push({ - seq: row.seq, - action: op.action, - collection: slash >= 0 ? op.path.slice(0, slash) : op.path, - rkey: slash >= 0 ? op.path.slice(slash + 1) : '', - time: decoded.time || null, - }); - } - } - - return { - events, - cursor: rows.length >= limit ? String(lowest) : null, - }; - }); - } - - /** - * The account app's own preferences — the Apps screen's combined-row - * aliases, turned-down combine offers, and the appearance choice — - * normalized on read so handlers can trust the shape whatever an older - * document held. - * @returns {Promise} - */ - async readAccountUiState() { - const stored = this.actorStorage.getAccountUiState - ? await this.actorStorage.getAccountUiState() - : null; - const aliases = stored?.appAliases; - return { - appAliases: - aliases && typeof aliases === 'object' && !Array.isArray(aliases) - ? aliases - : {}, - dismissedAppPairs: Array.isArray(stored?.dismissedAppPairs) - ? stored.dismissedAppPairs.filter((p) => typeof p === 'string') - : [], - theme: - stored?.theme === 'light' || stored?.theme === 'dark' - ? stored.theme - : 'system', - }; - } - - /** - * GET /account/api/apps, POST /account/api/apps/revoke - * @param {Request} request - * @param {URL} url - * @returns {Promise} - */ - async handleApiApps(request, url) { - return this.accountApi(request, url, async (did) => { - const ui = await this.readAccountUiState(); - return { - apps: await this.listConnectedApps(did), - aliases: ui.appAliases, - dismissedPairs: ui.dismissedAppPairs, - aliasesAvailable: - typeof this.actorStorage.setAccountUiState === 'function', - }; - }); - } - - /** - * @param {Request} request - * @param {URL} url - * @returns {Promise} - */ - async handleApiAppsRevoke(request, url) { - return this.accountApi( - request, - url, - async (did, body) => { - const revoked = await this.revokeOAuthSession(did, body.session || ''); - if (!revoked) throw new Error('That session no longer exists.'); - return { revoked: revoked.clientId }; - }, - { write: true }, - ); - } - - /** - * POST /account/api/apps/alias - Fold one Apps row into another - * (action=combine), undo it (separate), or remember the offer was turned - * down (dismiss). `from` is the domain giving up its row — the record - * authority — and `to` the row that absorbs it, the live client host. The - * pairing is the browser's reading of the account's own data; here the - * strings just have to look like hostnames and the document stays bounded. - * @param {Request} request - * @param {URL} url - * @returns {Promise} - */ - async handleApiAppsAlias(request, url) { - return this.accountApi( - request, - url, - async (_did, body) => { - if (typeof this.actorStorage.setAccountUiState !== 'function') { - throw new Error('This server cannot store app aliases'); - } - const from = plausibleHost(body.from); - if (!from) throw new Error('from must be a hostname'); - const state = await this.readAccountUiState(); - - if (body.action === 'combine' || body.action === 'dismiss') { - const to = plausibleHost(body.to); - if (!to || to === from) { - throw new Error('to must be a different hostname'); - } - if (body.action === 'combine') { - // A row that absorbs another may not itself be folded away, so - // no chain of aliases ever forms - delete state.appAliases[to]; - state.appAliases[from] = to; - if (Object.keys(state.appAliases).length > 50) { - throw new Error('Too many combined apps'); - } - } else { - const pair = `${from}>${to}`; - if (!state.dismissedAppPairs.includes(pair)) { - state.dismissedAppPairs.push(pair); - } - state.dismissedAppPairs = state.dismissedAppPairs.slice(-100); - } - } else if (body.action === 'separate') { - delete state.appAliases[from]; - } else { - throw new Error('action must be combine, separate, or dismiss'); - } - - await this.actorStorage.setAccountUiState(state); - return { - aliases: state.appAliases, - dismissedPairs: state.dismissedAppPairs, - }; - }, - { write: true }, - ); - } - - /** - * POST /account/api/settings/appearance - Store which color palette the - * console uses. The choice lives in the account's UI state document, so it - * follows the account to other browsers and devices. - * @param {Request} request - * @param {URL} url - * @returns {Promise} - */ - async handleApiSettingsAppearance(request, url) { - return this.accountApi( - request, - url, - async (_did, body) => { - if (typeof this.actorStorage.setAccountUiState !== 'function') { - throw new Error('This server cannot store settings'); - } - if (!['system', 'light', 'dark'].includes(body.theme)) { - throw new Error('theme must be system, light, or dark'); - } - const state = await this.readAccountUiState(); - state.theme = body.theme; - await this.actorStorage.setAccountUiState(state); - return { theme: state.theme }; - }, - { write: true }, - ); - } - - /** - * The remaining section endpoints. Each is the same data the server-rendered - * page shows, in the shape the account app consumes. - * @param {Request} request - * @param {URL} url - * @returns {Promise} - */ - async handleApiSessions(request, url) { - return this.accountApi(request, url, async (did) => ({ - supported: this._sessions.sessionsAvailable(), - sessions: this._sessions.sessionsAvailable() - ? (await this._sessions.listLiveSessions(did)) - .map((session) => ({ - jti: session.jti, - label: session.label, - userAgent: session.userAgent, - createdAt: session.createdAt, - refreshedAt: session.refreshedAt, - appPassword: isAppPasswordScope(session.scope), - })) - .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) - : [], - })); - } - - /** @param {Request} request @param {URL} url */ - async handleApiSessionsRevoke(request, url) { - return this.accountApi( - request, - url, - async (did, body) => { - const jti = body.jti || ''; - const target = this._sessions.sessionsAvailable() - ? await this.sharedStorage.getSession(jti) - : null; - // Checked against the caller's own DID, so one account cannot end - // another's session by naming its id - if (!target || target.did !== did) { - throw new Error('That session no longer exists.'); - } - await this._sessions.revokeSessionChain(did, jti); - return { revoked: jti }; - }, - { write: true }, - ); - } - - /** - * POST /account/api/sessions/revoke-all - End every password/app-password - * login at once. The account page runs on its own cookie, not one of these - * records, so the caller stays signed in here. - * @param {Request} request @param {URL} url - */ - async handleApiSessionsRevokeAll(request, url) { - return this.accountApi( - request, - url, - async (did) => { - if ( - !this._sessions.sessionsAvailable() || - typeof this.sharedStorage.listSessions !== 'function' - ) { - return { revoked: 0 }; - } - // Snapshot the ids before deleting: a store may hand back its own - // array by reference, and the deletes below would mutate it mid-count. - const jtis = (await this.sharedStorage.listSessions(did)).map( - (session) => session.jti, - ); - await Promise.all( - jtis.map((jti) => this.sharedStorage.deleteSession(jti)), - ); - return { revoked: jtis.length }; - }, - { write: true }, - ); - } - - /** @param {Request} request @param {URL} url */ - async handleApiAppPasswords(request, url) { - return this.accountApi(request, url, async (did) => ({ - passwords: await this.listAccountAppPasswords(did), - })); - } - - /** @param {Request} request @param {URL} url */ - async handleApiAppPasswordsCreate(request, url) { - return this.accountApi( - request, - url, - async (did, body) => { - if (!this.sharedStorage.putAppPassword) { - throw new Error('This server does not store app passwords.'); - } - const name = (body.name || '').trim(); - if (!name) throw new Error('A name is required.'); - - const password = generateAppPassword(); - const stored = await this.sharedStorage.putAppPassword( - did, - name, - await hashAppPassword(did, password), - Boolean(body.privileged), - new Date().toISOString(), - ); - if (!stored) throw new Error(`App password "${name}" already exists.`); - - // The only time it is returned; storage keeps a hash - return { name, password }; - }, - { write: true }, - ); - } - - /** @param {Request} request @param {URL} url */ - async handleApiAppPasswordsRevoke(request, url) { - return this.accountApi( - request, - url, - async (did, body) => { - const name = body.name || ''; - const deleted = - this.sharedStorage.deleteAppPassword && - (await this.sharedStorage.deleteAppPassword(did, name)); - if (!deleted) throw new Error(`No app password named "${name}".`); - await this._sessions.revokeAppPasswordSessions(did, name); - return { revoked: name }; - }, - { write: true }, - ); - } - - /** @param {Request} request @param {URL} url */ - async handleApiPasskeys(request, url) { - return this.accountApi(request, url, async (did) => { - const supported = this._passkeys.passkeysAvailable(); - const ceremony = supported - ? await this._passkeys.startPasskeyCeremony('register', url) - : null; - - const body = { - supported, - rpId: this._passkeys.passkeyRpId(url), - accountName: (await this.actorStorage.getHandle()) || did, - did, - // The challenge the browser will answer. Its cookie goes on this - // response; the copy here is what credentials.create() needs. - challenge: ceremony?.challenge || '', - passkeys: (await this.listAccountPasskeys(did)).map((passkey) => ({ - credentialId: passkey.credentialId, - name: passkey.name, - createdAt: passkey.createdAt, - lastUsedAt: passkey.lastUsedAt, - })), - }; - - const headers = new Headers({ - 'Content-Type': 'application/json', - 'Cache-Control': 'no-store', - }); - if (ceremony) headers.set('Set-Cookie', ceremony.cookie); - return new Response(JSON.stringify(body), { headers }); - }); - } - - /** @param {Request} request @param {URL} url */ - async handleApiPasskeysRevoke(request, url) { - return this.accountApi( - request, - url, - async (did, body) => { - const deleted = - this.sharedStorage.deletePasskey && - (await this.sharedStorage.deletePasskey( - did, - body.credentialId || '', - )); - if (!deleted) throw new Error('No such passkey on this account.'); - return { revoked: body.credentialId }; - }, - { write: true }, - ); - } - - /** - * POST /account/api/email - Set, verify, cancel or remove the address - * @param {Request} request - * @param {URL} url - * @returns {Promise} - */ - async handleApiEmailAction(request, url) { - return this.accountApi( - request, - url, - async (_did, body) => { - if (!this.actorStorage.setEmail) { - throw new Error('This server cannot store an email address.'); - } - const state = await readEmailState(this.actorStorage); - - if (body.action === 'cancel') { - await this.actorStorage.setEmail({ ...state, pending: null }); - return { ok: true }; - } - if (body.action === 'remove') { - await this.actorStorage.setEmail({ - address: null, - verified: false, - pending: null, - }); - return { ok: true }; - } - if (body.action === 'resend') { - const pending = state.pending; - if (!pending) throw new Error('No address is waiting.'); - if (!this.emailer) { - throw new Error('This server cannot send email.'); - } - const now = Date.now(); - const readyAt = (pending.sentAt || 0) + EMAIL_RESEND_COOLDOWN_MS; - if (now < readyAt) { - throw new Error('Wait a moment before asking for another code.'); - } - const code = generateEmailCode(); - await this.actorStorage.setEmail({ - ...state, - pending: { - address: pending.address, - codeHash: await hashEmailCode( - pending.address, - code, - this.jwtSecret, - ), - expiresAt: now + EMAIL_CODE_TTL_MS, - sentAt: now, - }, - }); - try { - await this.emailer.send({ - to: pending.address, - ...renderVerificationEmail({ code, hostname: url.host }), - }); - } catch (err) { - // Keep the code the earlier message already carried usable. - await this.actorStorage.setEmail(state); - console.error('Failed to resend verification email:', err); - throw new Error('The message could not be sent. Try again later.'); - } - return { - ok: true, - sent: pending.address, - resendAt: now + EMAIL_RESEND_COOLDOWN_MS, - }; - } - if (body.action === 'verify') { - const pending = state.pending; - if (!pending) throw new Error('No address is waiting.'); - if (pending.expiresAt <= Date.now()) { - await this.actorStorage.setEmail({ ...state, pending: null }); - throw new Error('That code expired. Start again.'); - } - const candidate = await hashEmailCode( - pending.address, - (body.code || '').trim(), - this.jwtSecret, - ); - if (!(await timingSafeEqual(candidate, pending.codeHash))) { - throw new Error('That code is wrong.'); - } - await this.actorStorage.setEmail({ - address: pending.address, - verified: true, - pending: null, - }); - return { ok: true, address: pending.address }; - } - - const address = (body.address || '').trim(); - if (!isValidEmail(address)) { - throw new Error('That does not look like an email address.'); - } - if (!this.emailer) { - await this.actorStorage.setEmail({ - address, - verified: false, - pending: null, - }); - return { ok: true, unconfirmed: true }; - } - - const code = generateEmailCode(); - const sentAt = Date.now(); - await this.actorStorage.setEmail({ - ...state, - pending: { - address, - codeHash: await hashEmailCode(address, code, this.jwtSecret), - expiresAt: sentAt + EMAIL_CODE_TTL_MS, - sentAt, - }, - }); - try { - await this.emailer.send({ - to: address, - ...renderVerificationEmail({ code, hostname: url.host }), - }); - } catch (err) { - // A pending record with no message sent offers a code that never - // arrives - await this.actorStorage.setEmail({ ...state, pending: null }); - console.error('Failed to send verification email:', err); - throw new Error('The message could not be sent. Try again later.'); - } - return { - ok: true, - sent: address, - resendAt: sentAt + EMAIL_RESEND_COOLDOWN_MS, - }; - }, - { write: true }, - ); - } - - /** @param {Request} request @param {URL} url */ - async handleApiEmail(request, url) { - return this.accountApi(request, url, async () => { - const state = await readEmailState(this.actorStorage); - const pending = - state.pending && state.pending.expiresAt > Date.now() - ? { - address: state.pending.address, - resendAt: (state.pending.sentAt || 0) + EMAIL_RESEND_COOLDOWN_MS, - } - : null; - return { - address: state.address, - verified: state.verified, - pending, - canSend: Boolean(this.emailer), - supported: Boolean(this.actorStorage.setEmail), - }; - }); - } - - /** @param {Request} request @param {URL} url */ - async handleApiSpaces(request, url) { - return this.accountApi(request, url, async () => { - const browser = this.spaceBrowser; - if (!browser) return { enabled: false, spaces: [] }; - - return { - enabled: true, - spaces: await Promise.all( - (await browser.listSpaces()).map(async (space) => ({ - uri: space.uri, - spaceType: space.spaceType, - authority: space.spaceDid, - isOwner: space.isOwner, - policy: space.policy, - createdAt: space.createdAt, - deleted: Boolean(space.deletedAt), - members: await browser.countMembers(space.uri), - collections: await browser.countRecords(space.uri), - })), - ), - }; - }); - } - - /** @param {Request} request @param {URL} url */ - async handleApiIdentity(request, url) { - return this.accountApi(request, url, async (did) => { - const credentials = await this.getRecommendedDidCredentials(url.hostname); - return { - did, - handle: await this.actorStorage.getHandle(), - status: (await this.actorStorage.getAccountStatus()) || 'active', - signingKey: credentials?.verificationMethods?.atproto || null, - pdsEndpoint: this.hostname - ? `https://${this.hostname}` - : `${url.protocol}//${url.host}`, - plcUrl: this.plcUrl, - readOnly: this.readOnly, - }; - }); - } - - /** - * POST /account/api/identity/handle - Rename the account from the account page - * @param {Request} request - * @param {URL} url - * @returns {Promise} - */ - async handleApiIdentityHandle(request, url) { - return this.accountApi( - request, - url, - async (_did, body) => { - const { handle } = await this._identity.renameHandle( - body.handle, - url.hostname, - ); - return { handle }; - }, - { write: true }, - ); - } - - /** @param {Request} request @param {URL} url */ - async handleApiStatus(request, url) { - return this.accountApi( - request, - url, - async (_did, body) => { - if (body.action !== 'activate' && body.action !== 'deactivate') { - throw new Error('Unknown status action.'); - } - await this.setAccountActive(body.action === 'activate'); - return { - status: body.action === 'activate' ? 'active' : 'deactivated', - }; - }, - { write: true }, - ); - } - - /** @param {Request} request @param {URL} url */ - /** - * Pull one record's content out of a stored firehose event's CAR — the - * blocks a #commit carries include the records it touched. - * @param {unknown} carBytes - The event's `blocks` CAR - * @param {string} cid - The record block to read - * @returns {*} The display-shaped record value, or null if absent - */ - readRecordFromCar(carBytes, cid) { - if (!(carBytes instanceof Uint8Array)) return null; - try { - const { blocks } = parseCarFile(carBytes); - const data = blocks.get(cid); - return data ? toDisplayValue(cborDecode(data)) : null; - } catch { - return null; - } - } - - /** - * A record as it stood at one point in its history — the change with sequence - * `seq`. A create or update carries its content in that event; a delete does - * not, so its prior content is recovered from the last event that wrote it. - * @param {string} collection - * @param {string} rkey - * @param {number} seq - * @returns {Promise<{action: string, time: string|null, cid: string|null, value: *}|null>} - */ - async recordSnapshotAt(collection, rkey, seq) { - if (!this.actorStorage.getEventsBefore) return null; - const path = `${collection}/${rkey}`; - - const [row] = await this.actorStorage.getEventsBefore(seq + 1, 1); - if (!row || row.seq !== seq) return null; - let event; - try { - event = cborDecode(row.evt); - } catch { - return null; - } - const op = (event.ops || []).find( - (/** @type {any} */ entry) => entry.path === path, - ); - if (!op) return null; - const time = event.time || null; - - // A create or update keeps the content right here. - if (op.cid?.$link) { - return { - action: op.action, - time, - cid: op.cid.$link, - value: this.readRecordFromCar(event.blocks, op.cid.$link), - }; - } - - // A delete does not: walk back to the most recent event that wrote this - // path and read the content it carried. - let before = seq; - while (before > 0) { - const batch = await this.actorStorage.getEventsBefore(before, 100); - if (!batch.length) break; - for (const prior of batch) { - let ev; - try { - ev = cborDecode(prior.evt); - } catch { - continue; - } - const wrote = (ev.ops || []).find( - (/** @type {any} */ entry) => entry.path === path && entry.cid?.$link, - ); - if (wrote) - return { - action: 'delete', - time, - cid: wrote.cid.$link, - value: this.readRecordFromCar(ev.blocks, wrote.cid.$link), - }; - } - before = batch[batch.length - 1].seq; - } - return { action: 'delete', time, cid: null, value: null }; - } - - /** @param {Request} request @param {URL} url */ - async handleApiRecords(request, url) { - return this.accountApi(request, url, async (did) => { - const collection = url.searchParams.get('collection') || ''; - const space = url.searchParams.get('space') || ''; - const rkey = url.searchParams.get('rkey') || ''; - const seqParam = url.searchParams.get('seq'); - const cursor = url.searchParams.get('cursor') || null; - if (!collection) throw new Error('Name a collection to read.'); - - const blobBase = `/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}`; - const decode = (/** @type {any} */ value) => - toDisplayValue( - cborDecode( - value instanceof Uint8Array ? value : new Uint8Array(value), - ), - ); - - // A History link names a record and the change it points at: show that - // version, not whatever the record is now (it may since have changed or - // been removed), with enough to bridge back to the live one. - if (rkey && seqParam) { - const seq = Number(seqParam); - const snap = Number.isFinite(seq) - ? await this.recordSnapshotAt(collection, rkey, seq) - : null; - if (!snap) throw new Error('That change is no longer available.'); - const uri = `at://${did}/${collection}/${rkey}`; - const live = await this.actorStorage.getRecord(uri); - return { - blobBase, - snapshot: { - action: snap.action, - time: snap.time, - liveExists: Boolean(live), - liveCurrent: live !== null && live.cid === snap.cid, - }, - records: - snap.value != null - ? [{ uri, cid: snap.cid, value: snap.value }] - : [], - }; - } - - if (space) { - const browser = this.spaceBrowser; - if (!browser) throw new Error('Permissioned data is off.'); - const known = await browser.listSpaces(); - // The query names what to show, not what to fetch - if (!known.some((row) => row.uri === space)) { - throw new Error('No such space on this account.'); - } - const { records, truncated } = await browser.listRecords( - space, - collection, - ACCOUNT_SPACE_RECORD_LIMIT, - ); - return { - truncated, - records: records.map((record) => ({ - uri: `${space}/${did}/${collection}/${record.rkey}`, - cid: record.cid, - indexedAt: record.indexedAt, - value: record.value, - })), - }; - } - - // A deep link names one record: fetch it directly so a large collection - // never has to be listed to find it. - if (rkey) { - const uri = `at://${did}/${collection}/${rkey}`; - const row = await this.actorStorage.getRecord(uri); - return { - blobBase, - cursor: null, - records: row ? [{ uri, cid: row.cid, value: decode(row.value) }] : [], - }; - } - - // Newest first — a rkey is a timestamp id, so reverse order is recency. - const { records, cursor: next } = await this.actorStorage.listRecords( - collection, - cursor, - ACCOUNT_SPACE_RECORD_LIMIT, - true, - ); - return { - blobBase, - cursor: next, - records: records.map((record) => ({ - uri: record.uri, - cid: record.cid, - value: decode(record.value), - })), - }; - }); - } - - /** - * GET /account/api/blobs - Files apps have uploaded, each with the records - * that use it. Narrowed by `kind`, `app` (the app's domain), and `unused`; - * ordered by `sort` (newest or largest). The first page carries the facets: - * global counts and bytes by kind, app, and usage, for the filter chips and - * the storage bar. - * @param {Request} request - * @param {URL} url - * @returns {Promise} - */ - async handleApiBlobs(request, url) { - return this.accountApi(request, url, async (did) => { - const storage = this.actorStorage; - if (typeof storage.listBlobDetails !== 'function') { - throw new Error('This server cannot list files.'); - } - const cursor = url.searchParams.get('cursor') || null; - const kindParam = url.searchParams.get('kind') || ''; - const kind = BLOB_KINDS.includes(kindParam) - ? /** @type {import('./ports.js').BlobKind} */ (kindParam) - : undefined; - // The filter names the app's domain (`bsky.app`); records carry it - // reversed, as their collection NSID's authority (`app.bsky`). - const app = (url.searchParams.get('app') || '').toLowerCase(); - const appAuthority = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(app) - ? app.split('.').reverse().join('.') - : undefined; - const options = { - kind, - appAuthority, - unused: url.searchParams.get('unused') === '1', - sort: - url.searchParams.get('sort') === 'largest' - ? /** @type {const} */ ('largest') - : /** @type {const} */ ('newest'), - }; - - const { blobs, cursor: next } = await storage.listBlobDetails( - cursor, - ACCOUNT_BLOB_PAGE_LIMIT, - options, - ); - - /** @type {object|undefined} */ - let facets; - if (!cursor && typeof storage.getBlobFacets === 'function') { - const raw = await storage.getBlobFacets(); - facets = { - total: raw.total, - unused: raw.unused, - kinds: raw.kinds, - apps: raw.apps.map(({ authority, count, bytes }) => ({ - domain: authority.split('.').reverse().join('.'), - count, - bytes, - })), - }; - } - - return { - blobBase: `/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}`, - cursor: next, - facets, - blobs: blobs.map((blob) => ({ - cid: blob.cid, - mimeType: blob.mimeType, - size: blob.size, - createdAt: blob.createdAt - ? new Date(blob.createdAt).toISOString() - : null, - time: blob.time ? new Date(blob.time).toISOString() : null, - timeFromRecord: blob.timeFromRecord, - records: blob.records, - })), - }; - }); - } - - /** - * POST /account/api/blobs/relink - Rebuild blob usage from record content. - * For repos whose records arrived outside the write path (a migration's CAR - * import), where the link table undercounts what is really used. - * @param {Request} request - * @param {URL} url - * @returns {Promise} - */ - async handleApiBlobsRelink(request, url) { - return this.accountApi( - request, - url, - async () => ({ references: await this.relinkBlobRecords() }), - { write: true }, - ); - } - - /** - * GET /account/apps - Apps holding an OAuth session - * @param {Request} request - * @param {URL} url - * @returns {Promise} - */ - async handleAccountApps(request, url) { - const session = await this.requireAccountSession(request, url); - if ('response' in session) return session.response; - - return htmlResponse( - renderAppsPage({ - hostname: url.host, - apps: await this.listConnectedApps(session.did), - }), - ); - } - - /** - * POST /account/apps/revoke - End one app's OAuth session - * @param {Request} request - * @param {URL} url - * @returns {Promise} - */ - async handleAccountAppRevoke(request, url) { - const params = new URLSearchParams(await request.text()); - if (!isSameOriginPost(request, url)) { - return new Response('Forbidden', { status: 403 }); - } - - const session = await this.requireAccountSession(request, url); - if ('response' in session) return session.response; - const { did } = session; - - /** @param {{error?: string, notice?: string}} result @param {number} status */ - const page = async (result, status) => - htmlResponse( - renderAppsPage({ - hostname: url.host, - apps: await this.listConnectedApps(did), - ...result, - }), - status, - ); - - if (this.readOnly) return page({ error: 'This PDS is read-only.' }, 403); - - const revoked = await this.revokeOAuthSession( - did, - params.get('session') || '', - ); - if (!revoked) return page({ error: 'That session no longer exists.' }, 400); - - return page( - { - notice: `Revoked the session for ${revoked.clientId}. Its access token stops working when it expires, within an hour.`, - }, - 200, - ); - } - - /** - * GET /account/app-passwords - App password list and creation form - * @param {Request} request - * @param {URL} url - * @returns {Promise} - */ - async handleAccountAppPasswords(request, url) { - const session = await this.requireAccountSession(request, url); - if ('response' in session) return session.response; - - return htmlResponse( - renderAppPasswordsPage({ - hostname: url.host, - passwords: await this.listAccountAppPasswords(session.did), - }), - ); - } - - /** - * Re-render the app password page carrying the result of an action. - * @param {string} did - * @param {string} hostname - * @param {{created?: {name: string, password: string}|null, error?: string, notice?: string}} result - * @param {number} [status=200] - * @returns {Promise} - */ - async appPasswordsResult(did, hostname, result, status = 200) { - return htmlResponse( - renderAppPasswordsPage({ - hostname, - passwords: await this.listAccountAppPasswords(did), - ...result, - }), - status, - ); - } - - /** - * POST /account/app-passwords/create - * - * Renders the result rather than redirecting: the generated password is - * shown exactly once, and a redirect would have to carry it in the URL. - * @param {Request} request - * @param {URL} url - * @returns {Promise} - */ - async handleAccountAppPasswordCreate(request, url) { - const params = new URLSearchParams(await request.text()); - if (!isSameOriginPost(request, url)) { - return new Response('Forbidden', { status: 403 }); - } - - const session = await this.requireAccountSession(request, url); - if ('response' in session) return session.response; - const { did } = session; - - if (this.readOnly) { - return this.appPasswordsResult( - did, - url.host, - { error: 'This PDS is read-only.' }, - 403, - ); - } - if (!this.sharedStorage.putAppPassword) { - return this.appPasswordsResult( - did, - url.host, - { error: 'This server does not store app passwords.' }, - 400, - ); - } - - const name = (params.get('name') || '').trim(); - if (!name) { - return this.appPasswordsResult( - did, - url.host, - { error: 'A name is required.' }, - 400, - ); - } - - const password = generateAppPassword(); - const stored = await this.sharedStorage.putAppPassword( - did, - name, - await hashAppPassword(did, password), - params.get('privileged') === 'true', - new Date().toISOString(), - ); - if (!stored) { - return this.appPasswordsResult( - did, - url.host, - { error: `App password "${name}" already exists.` }, - 400, - ); - } - - return this.appPasswordsResult(did, url.host, { - created: { name, password }, - }); - } - - /** - * POST /account/app-passwords/revoke - * @param {Request} request - * @param {URL} url - * @returns {Promise} - */ - async handleAccountAppPasswordRevoke(request, url) { - const params = new URLSearchParams(await request.text()); - if (!isSameOriginPost(request, url)) { - return new Response('Forbidden', { status: 403 }); - } - - const session = await this.requireAccountSession(request, url); - if ('response' in session) return session.response; - const { did } = session; - - if (this.readOnly) { - return this.appPasswordsResult( - did, - url.host, - { error: 'This PDS is read-only.' }, - 403, - ); - } - - const name = params.get('name') || ''; - const deleted = - this.sharedStorage.deleteAppPassword && - (await this.sharedStorage.deleteAppPassword(did, name)); - if (deleted) await this._sessions.revokeAppPasswordSessions(did, name); - - return this.appPasswordsResult( - did, - url.host, - deleted - ? { notice: `Revoked "${name}". Sessions opened with it stop working.` } - : { error: `No app password named "${name}".` }, - deleted ? 200 : 400, - ); - } - - // ── Passkeys ──────────────────────────────────────────────────────────── - - /** - * @param {string} did - * @returns {Promise>} - */ - async listAccountPasskeys(did) { - if (!this.sharedStorage.listPasskeys) return []; - return this.sharedStorage.listPasskeys(did); - } - - /** - * The passkeys page, with a registration challenge ready for the browser. - * @param {URL} url - * @param {string} did - * @param {{error?: string, notice?: string}} [result] - * @param {number} [status=200] - * @returns {Promise} - */ - async passkeysResponse(url, did, result = {}, status = 200) { - const supported = Boolean(this.sharedStorage.putPasskey); - const ceremony = supported - ? await this._passkeys.startPasskeyCeremony('register', url) - : null; - - return htmlResponse( - renderPasskeysPage({ - hostname: url.host, - passkeys: (await this.listAccountPasskeys(did)).map((passkey) => ({ - credentialId: passkey.credentialId, - name: passkey.name, - createdAt: passkey.createdAt, - lastUsedAt: passkey.lastUsedAt, - })), - supported, - readOnly: this.readOnly, - challenge: ceremony?.challenge || '', - rpId: this._passkeys.passkeyRpId(url), - accountName: (await this.actorStorage.getHandle()) || did, - did, - ...result, - }), - status, - ceremony?.cookie || null, - ); - } - - /** - * GET /account/passkeys - Registered passkeys, and the form to add one - * @param {Request} request - * @param {URL} url - * @returns {Promise} - */ - async handleAccountPasskeys(request, url) { - const session = await this.requireAccountSession(request, url); - if ('response' in session) return session.response; - return this.passkeysResponse(url, session.did); - } - - /** - * POST /account/passkeys/register - Store a credential the browser created - * - * Answers JSON: the browser drives this from script, since only script can - * run the WebAuthn ceremony. - * @param {Request} request - * @param {URL} url - * @returns {Promise} - */ - async handleAccountPasskeyRegister(request, url) { - const body = await this.readJsonBounded(request); - if (body instanceof Response) return body; - - if (!isSameOriginPost(request, url)) { - return Response.json({ error: 'Forbidden' }, { status: 403 }); - } - - const did = await this.readAccountSession(request); - if (!did) { - return Response.json({ error: 'Not signed in' }, { status: 401 }); - } - if (this.readOnly) { - return Response.json({ error: 'This PDS is read-only' }, { status: 403 }); - } - if (!this.sharedStorage.putPasskey) { - return Response.json( - { error: 'This server cannot store passkeys' }, - { status: 400 }, - ); - } - - const challenge = await this._passkeys.readPasskeyChallenge( - request, - 'register', - ); - if (!challenge) { - return Response.json( - { error: 'That registration expired. Reload and try again.' }, - { status: 400 }, - ); - } - - let credential; - try { - credential = await verifyRegistration({ - credentialId: body.credentialId, - publicKey: body.publicKey, - algorithm: Number(body.algorithm), - clientDataJSON: body.clientDataJSON, - expectedChallenge: challenge, - expectedOrigin: `${url.protocol}//${url.host}`, - }); - } catch (err) { - return Response.json( - { error: err instanceof Error ? err.message : 'Registration failed' }, - { status: 400 }, - ); - } - - const name = (body.name || '').trim() || 'Passkey'; - const stored = await this.sharedStorage.putPasskey({ - credentialId: credential.credentialId, - did, - name, - publicKey: credential.publicKey, - algorithm: credential.algorithm, - signCount: 0, - createdAt: new Date().toISOString(), - lastUsedAt: null, - }); - if (!stored) { - return Response.json( - { error: 'That passkey is already registered.' }, - { status: 400 }, - ); - } - - return Response.json({ ok: true, name }); - } - - /** - * POST /account/passkeys/revoke - Forget one passkey - * @param {Request} request - * @param {URL} url - * @returns {Promise} - */ - async handleAccountPasskeyRevoke(request, url) { - const params = new URLSearchParams(await request.text()); - if (!isSameOriginPost(request, url)) { - return new Response('Forbidden', { status: 403 }); - } - - const session = await this.requireAccountSession(request, url); - if ('response' in session) return session.response; - const { did } = session; - - if (this.readOnly) { - return this.passkeysResponse( - url, - did, - { error: 'This PDS is read-only.' }, - 403, - ); - } - - const credentialId = params.get('credentialId') || ''; - const deleted = - this.sharedStorage.deletePasskey && - (await this.sharedStorage.deletePasskey(did, credentialId)); - - return this.passkeysResponse( - url, - did, - deleted - ? { notice: 'Passkey removed.' } - : { error: 'No such passkey on this account.' }, - deleted ? 200 : 400, - ); - } - - /** - * GET /account/passkey/challenge - Start a passkey sign-in - * @param {Request} _request - * @param {URL} url - * @returns {Promise} - */ - async handleAccountPasskeyChallenge(_request, url) { - if (!this.sharedStorage.getPasskey) { - return Response.json( - { error: 'This server does not support passkeys' }, - { status: 400 }, - ); - } - - const { challenge, cookie } = await this._passkeys.startPasskeyCeremony( - 'sign-in', - url, - ); - return new Response( - JSON.stringify({ challenge, rpId: this._passkeys.passkeyRpId(url) }), - { - headers: { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-store', - 'Set-Cookie': cookie, - }, - }, - ); - } - - /** - * POST /account/passkey/sign-in - Open a cookie session from an assertion - * @param {Request} request - * @param {URL} url - * @returns {Promise} - */ - async handleAccountPasskeySignIn(request, url) { - const body = await this.readJsonBounded(request); - if (body instanceof Response) return body; - - if (!isSameOriginPost(request, url)) { - return Response.json({ error: 'Forbidden' }, { status: 403 }); - } - if (!this.sharedStorage.getPasskey) { - return Response.json( - { error: 'This server does not support passkeys' }, - { status: 400 }, - ); - } - - const challenge = await this._passkeys.readPasskeyChallenge( - request, - 'sign-in', - ); - if (!challenge) { - return Response.json( - { error: 'That sign-in expired. Try again.' }, - { status: 400 }, - ); - } - - const did = await this.getDid(); - const passkey = await this.sharedStorage.getPasskey( - body.credentialId || '', - ); - // One failure message for an unknown credential and for one belonging to - // another account: which of the two it was is not the client's business. - if (!passkey || !did || passkey.did !== did) { - return Response.json( - { error: 'That passkey is not registered here.' }, - { status: 401 }, - ); - } - - let result; - try { - result = await verifyAssertion({ - publicKey: passkey.publicKey, - algorithm: passkey.algorithm, - authenticatorData: body.authenticatorData, - clientDataJSON: body.clientDataJSON, - signature: body.signature, - expectedChallenge: challenge, - expectedOrigin: `${url.protocol}//${url.host}`, - rpId: this._passkeys.passkeyRpId(url), - }); - } catch (err) { - return Response.json( - { error: err instanceof Error ? err.message : 'Sign-in failed' }, - { status: 401 }, - ); - } - - // A counter that goes backwards means the credential was cloned. Many - // authenticators keep no counter at all and always report 0, so this only - // fires when one that does count regresses. - if ( - result.signCount > 0 && - passkey.signCount > 0 && - result.signCount <= passkey.signCount - ) { - return Response.json( - { error: 'That passkey looks cloned and was refused.' }, - { status: 401 }, - ); - } - - if (this.sharedStorage.touchPasskey) { - await this.sharedStorage.touchPasskey( - passkey.credentialId, - result.signCount, - new Date().toISOString(), - ); - } - - const token = await createAccountJwt( - did, - this.jwtSecret, - ACCOUNT_SESSION_TTL, - ); - return new Response(JSON.stringify({ ok: true }), { - headers: { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-store', - 'Set-Cookie': accountCookie(token, url.protocol === 'https:'), - }, - }); - } - - /** - * The email page, carrying the outcome of an action. - * @param {URL} url - * @param {{error?: string, notice?: string}} [result] - * @param {number} [status=200] - * @returns {Promise} - */ - async emailResponse(url, result = {}, status = 200) { - const state = await readEmailState(this.actorStorage); - const pending = - state.pending && state.pending.expiresAt > Date.now() - ? state.pending - : null; - - return htmlResponse( - renderEmailPage({ - hostname: url.host, - address: state.address, - verified: state.verified, - pending: pending && { address: pending.address }, - canSend: Boolean(this.emailer), - ...result, - }), - status, - ); - } - - /** - * /account/email - The page on GET, setting the address on POST - * @param {Request} request - * @param {URL} url - * @returns {Promise} - */ - async handleAccountEmailRoute(request, url) { - if (request.method === 'POST') { - return this.handleAccountEmailSet(request, url); - } - return this.handleAccountEmail(request, url); - } - - /** - * GET /account/email - Address, confirmation state, and the forms to change it - * @param {Request} request - * @param {URL} url - * @returns {Promise} - */ - async handleAccountEmail(request, url) { - const session = await this.requireAccountSession(request, url); - if ('response' in session) return session.response; - return this.emailResponse(url); - } - - /** - * Shared preamble for the email actions: same-origin, signed in, writable, - * and backed by a storage adapter that can hold an address. - * @param {Request} request - * @param {URL} url - * @returns {Promise<{params: URLSearchParams}|{response: Response}>} - */ - async beginEmailAction(request, url) { - const params = new URLSearchParams(await request.text()); - if (!isSameOriginPost(request, url)) { - return { response: new Response('Forbidden', { status: 403 }) }; - } - - const session = await this.requireAccountSession(request, url); - if ('response' in session) return session; - - if (this.readOnly) { - return { - response: await this.emailResponse( - url, - { error: 'This PDS is read-only.' }, - 403, - ), - }; - } - if (!this.actorStorage.setEmail) { - return { - response: await this.emailResponse( - url, - { error: 'This server cannot store an email address.' }, - 400, - ), - }; - } - return { params }; - } - - /** - * POST /account/email - Set an address, sending it a code when possible - * @param {Request} request - * @param {URL} url - * @returns {Promise} - */ - async handleAccountEmailSet(request, url) { - const begun = await this.beginEmailAction(request, url); - if ('response' in begun) return begun.response; - - const address = (begun.params.get('address') || '').trim(); - if (!isValidEmail(address)) { - return this.emailResponse( - url, - { error: 'That does not look like an email address.' }, - 400, - ); - } - - const state = await readEmailState(this.actorStorage); - - // With no way to send, the address is recorded but stays unconfirmed — - // saying otherwise would claim evidence this server does not have. - if (!this.emailer) { - await this.actorStorage.setEmail({ - address, - verified: false, - pending: null, - }); - return this.emailResponse(url, { - notice: `Saved ${address}. It stays unconfirmed until this server can send mail.`, - }); - } - - const code = generateEmailCode(); - await this.actorStorage.setEmail({ - ...state, - pending: { - address, - codeHash: await hashEmailCode(address, code, this.jwtSecret), - expiresAt: Date.now() + EMAIL_CODE_TTL_MS, - }, - }); - - try { - const message = renderVerificationEmail({ code, hostname: url.host }); - await this.emailer.send({ to: address, ...message }); - } catch (err) { - // Leaving the pending record would offer a code that never arrived - await this.actorStorage.setEmail({ ...state, pending: null }); - console.error('Failed to send verification email:', err); - return this.emailResponse( - url, - { error: 'The message could not be sent. Try again later.' }, - 502, - ); - } - - return this.emailResponse(url, { - notice: `Sent a code to ${address}.`, - }); - } - - /** - * POST /account/email/verify - Exchange the code for a confirmed address - * @param {Request} request - * @param {URL} url - * @returns {Promise} - */ - async handleAccountEmailVerify(request, url) { - const begun = await this.beginEmailAction(request, url); - if ('response' in begun) return begun.response; - - const state = await readEmailState(this.actorStorage); - const pending = state.pending; - if (!pending) { - return this.emailResponse(url, { error: 'No address is waiting.' }, 400); - } - if (pending.expiresAt <= Date.now()) { - await this.actorStorage.setEmail({ ...state, pending: null }); - return this.emailResponse( - url, - { error: 'That code expired. Start again.' }, - 400, - ); - } - - const code = (begun.params.get('code') || '').trim(); - const candidate = await hashEmailCode( - pending.address, - code, - this.jwtSecret, - ); - if (!(await timingSafeEqual(candidate, pending.codeHash))) { - return this.emailResponse(url, { error: 'That code is wrong.' }, 400); - } - - await this.actorStorage.setEmail({ - address: pending.address, - verified: true, - pending: null, - }); - return this.emailResponse(url, { - notice: `${pending.address} is confirmed.`, - }); - } - - /** - * POST /account/email/cancel - Drop a pending address change - * @param {Request} request - * @param {URL} url - * @returns {Promise} - */ - async handleAccountEmailCancel(request, url) { - const begun = await this.beginEmailAction(request, url); - if ('response' in begun) return begun.response; - - const state = await readEmailState(this.actorStorage); - await this.actorStorage.setEmail({ ...state, pending: null }); - return this.emailResponse(url, { notice: 'Change cancelled.' }); - } - - /** - * POST /account/email/remove - Forget the address entirely - * @param {Request} request - * @param {URL} url - * @returns {Promise} - */ - async handleAccountEmailRemove(request, url) { - const begun = await this.beginEmailAction(request, url); - if ('response' in begun) return begun.response; - - await this.actorStorage.setEmail({ - address: null, - verified: false, - pending: null, - }); - return this.emailResponse(url, { notice: 'Address removed.' }); - } - - /** - * GET /account/spaces - Permissioned-data spaces this account holds - * @param {Request} request - * @param {URL} url - * @returns {Promise} - */ - async handleAccountSpaces(request, url) { - const session = await this.requireAccountSession(request, url); - if ('response' in session) return session.response; - - const browser = this.spaceBrowser; - const spaces = browser - ? await Promise.all( - (await browser.listSpaces()).map(async (space) => ({ - uri: space.uri, - spaceType: space.spaceType, - authority: space.spaceDid, - isOwner: space.isOwner, - policy: space.policy, - createdAt: space.createdAt.slice(0, 10), - deleted: Boolean(space.deletedAt), - members: await browser.countMembers(space.uri), - collections: await browser.countRecords(space.uri), - })), - ) - : []; - - return htmlResponse( - renderSpacesPage({ - hostname: url.host, - spaces, - enabled: Boolean(browser), - }), - ); - } - - /** - * GET /account/spaces/records - The records in one space collection - * @param {Request} request - * @param {URL} url - * @returns {Promise} - */ - async handleAccountSpaceRecords(request, url) { - const session = await this.requireAccountSession(request, url); - if ('response' in session) return session.response; - - const space = url.searchParams.get('space') || ''; - const collection = url.searchParams.get('collection') || ''; - const browser = this.spaceBrowser; - - /** @param {string} error @param {number} status */ - const reject = (error, status) => - htmlResponse( - renderRecordsPage({ - hostname: url.host, - section: '/account/spaces', - back: { href: '/account/spaces', label: 'Spaces' }, - collection, - subtitles: space ? [space] : [], - records: [], - truncated: false, - error, - blobUrl: () => null, - }), - status, - ); - - if (!browser) - return reject('Permissioned data is off on this server.', 400); - if (!space || !collection) { - return reject('Name a space and a collection to read.', 400); - } - - // Only spaces this storage holds for the account: the query string is - // the caller's, so it names what to show rather than what to fetch. - const known = await browser.listSpaces(); - if (!known.some((row) => row.uri === space)) { - return reject('No such space on this account.', 404); - } - - const did = session.did; - const { records, truncated } = await browser.listRecords( - space, - collection, - ACCOUNT_SPACE_RECORD_LIMIT, - ); - return htmlResponse( - renderRecordsPage({ - hostname: url.host, - section: '/account/spaces', - back: { href: '/account/spaces', label: 'Spaces' }, - collection, - subtitles: ["This account's own records in that space."], - // The uri shape com.atproto.space.listRecords answers with: - // space uri, then writer, then collection/rkey - records: records.map((record) => ({ - uri: `${space}/${did}/${collection}/${record.rkey}`, - cid: record.cid, - indexedAt: record.indexedAt, - value: record.value, - })), - truncated, - }), - ); - } - - /** - * GET /account/repo/records - The records in one repo collection - * @param {Request} request - * @param {URL} url - * @returns {Promise} - */ - async handleAccountRepoRecords(request, url) { - const session = await this.requireAccountSession(request, url); - if ('response' in session) return session.response; - - const collection = url.searchParams.get('collection') || ''; - /** @param {string} error @param {number} status */ - const reject = (error, status) => - htmlResponse( - renderRecordsPage({ - hostname: url.host, - section: '/account/repo', - back: { href: '/account/repo', label: 'Repository' }, - collection, - subtitles: [], - records: [], - truncated: false, - error, - }), - status, - ); - - if (!collection) return reject('Name a collection to read.', 400); - const did = (await this.getDid()) || session.did; - - // One over the limit, so the page can say it is showing a prefix without - // reading a second page it will not render. - const { records } = await this.actorStorage.listRecords( - collection, - null, - ACCOUNT_SPACE_RECORD_LIMIT + 1, - ); - const shown = records.slice(0, ACCOUNT_SPACE_RECORD_LIMIT); - - return htmlResponse( - renderRecordsPage({ - hostname: url.host, - section: '/account/repo', - back: { href: '/account/repo', label: 'Repository' }, - collection, - subtitles: ['Public records, readable by anyone through the sync API.'], - records: shown.map((record) => ({ - uri: record.uri, - cid: record.cid, - value: toDisplayValue( - cborDecode( - record.value instanceof Uint8Array - ? record.value - : new Uint8Array(record.value), - ), - ), - })), - // Image blobs referenced by a record render inline, from this - // server's own blob store - blobUrl: (cid) => - `/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`, - truncated: records.length > ACCOUNT_SPACE_RECORD_LIMIT, - }), - ); - } - - /** - * GET /account/repo - What this server holds for the account - * @param {Request} request - * @param {URL} url + * @param {Response} response * @returns {Promise} */ - async handleAccountRepo(request, url) { - const session = await this.requireAccountSession(request, url); - if ('response' in session) return session.response; - - return htmlResponse( - renderRepoPage({ - hostname: url.host, - collections: await this.countRecordsByCollection(), - blobs: await this.countBlobs(), - commit: await this.actorStorage.getLatestCommit(), - }), - ); + async withDpopNonce(request, response) { + const authHeader = request.headers.get('authorization') || ''; + if (!/^DPoP\s/i.test(authHeader)) return response; + if ( + response.status === 101 || + /** @type {{webSocket?: unknown}} */ (response).webSocket + ) { + return response; + } + if (response.headers.get('DPoP-Nonce')) return response; + + const headers = new Headers(response.headers); + headers.set('DPoP-Nonce', await mintDpopNonce(this.jwtSecret)); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); } /** - * The identity page, optionally carrying the outcome of a status action. - * @param {URL} url - * @param {string} did - * @param {string} [error] - * @returns {Promise} + * Find route for pathname + * @param {string} pathname + * @returns {Route|null} */ - async identityResponse(url, did, error = '') { - const credentials = await this.getRecommendedDidCredentials(url.hostname); - return htmlResponse( - renderIdentityPage({ - hostname: url.host, - did, - handle: await this.actorStorage.getHandle(), - status: (await this.actorStorage.getAccountStatus()) || 'active', - signingKey: credentials?.verificationMethods?.atproto || null, - // What a PLC operation would publish. Unconfigured, that is this - // request's own origin rather than the https:// the directory wants. - pdsEndpoint: this.hostname - ? `https://${this.hostname}` - : `${url.protocol}//${url.host}`, - plcUrl: this.plcUrl, - readOnly: this.readOnly, - error, - }), - error ? 400 : 200, - ); + findRoute(pathname) { + return this.routes[pathname] || null; } /** - * GET /account/identity - Published identity and account status + * Authenticate request from Authorization header * @param {Request} request - * @param {URL} url - * @returns {Promise} + * @returns {Promise<{did: string, scope: string}|null>} */ - async handleAccountIdentity(request, url) { - const session = await this.requireAccountSession(request, url); - if ('response' in session) return session.response; - return this.identityResponse(url, session.did); + async authenticate(request) { + return (await this.authenticateResource(request)).auth; } /** - * POST /account/status - Activate or deactivate the account + * Authenticate a request and, for OAuth (DPoP) requests, also return the + * `WWW-Authenticate` challenge to send on a 401. The challenge carries + * `error="invalid_token"`, which tells a spec-compliant client to refresh + * its access token rather than treat the failure as a hard logout. Mirrors + * the reference PDS resource verifier (packages/pds/src/auth-verifier.ts). * @param {Request} request - * @param {URL} url - * @returns {Promise} + * @returns {Promise<{auth: {did: string, scope: string}|null, challenge: string|null, tokenError?: string}>} */ - async handleAccountStatus(request, url) { - const params = new URLSearchParams(await request.text()); - if (!isSameOriginPost(request, url)) { - return new Response('Forbidden', { status: 403 }); - } - - const session = await this.requireAccountSession(request, url); - if ('response' in session) return session.response; + async authenticateResource(request) { + const authHeader = request.headers.get('authorization'); + if (!authHeader) return { auth: null, challenge: null }; - if (this.readOnly) { - return this.identityResponse(url, session.did, 'This PDS is read-only.'); + // Legacy Bearer tokens (HMAC-signed) — no DPoP, no challenge. + const bearerMatch = authHeader.match(/^Bearer\s+(.+)$/i); + if (bearerMatch) { + try { + const payload = await verifyAccessJwt(bearerMatch[1], this.jwtSecret); + return { + auth: { did: payload.sub, scope: payload.scope || 'atproto' }, + challenge: null, + }; + } catch { + return { auth: null, challenge: null }; + } } - const action = params.get('action'); - if (action !== 'activate' && action !== 'deactivate') { - return this.identityResponse(url, session.did, 'Unknown status action.'); + // OAuth DPoP tokens (signed with the account key). + const dpopMatch = authHeader.match(/^DPoP\s+(.+)$/i); + if (dpopMatch) { + const result = await this.verifyDpopToken(request, dpopMatch[1]); + if (!('error' in result)) return { auth: result, challenge: null }; + // Sanitize the reason to a safe HTTP header value before quoting it. + const description = result.description + .replace(/[^\x20-\x7E]/g, ' ') + .replace(/["\\]/g, '') + .slice(0, 200); + return { + auth: null, + challenge: `DPoP error="${result.error}", error_description="${description}"`, + // The response body carries the OAuth error code (`invalid_token`), the + // same signal as the WWW-Authenticate challenge, so clients that branch + // on the body's `error` rather than the header still know to refresh. + // Mirrors the reference PDS OAuth resource verifier. + tokenError: result.error, + }; } - // The same path com.atproto.server.{activate,deactivate}Account takes, so - // the firehose sees an account event either way it was triggered. - await this.setAccountActive(action === 'activate'); - - return new Response(null, { - status: 303, - headers: { - Location: '/account/identity', - 'Cache-Control': 'no-store', - }, - }); + return { auth: null, challenge: null }; } /** - * POST /account/sign-in - Verify the account password, start a cookie session + * Verify an OAuth DPoP-bound access token from `Authorization: DPoP `. + * Returns the auth result, or a structured `invalid_token` reason so the + * caller can build a `WWW-Authenticate` challenge. * @param {Request} request - * @param {URL} url - * @returns {Promise} + * @param {string} token - the access-token JWT + * @returns {Promise<{did: string, scope: string}|{error: string, description: string}>} */ - async handleAccountSignIn(request, url) { - const params = new URLSearchParams(await request.text()); - if (!isSameOriginPost(request, url)) { - return new Response('Forbidden', { status: 403 }); - } - - const identifier = (params.get('username') || '').trim(); - const password = params.get('password') || ''; + async verifyDpopToken(request, token) { + /** @param {string} description */ + const fail = (description) => ({ error: 'invalid_token', description }); + try { + const dpopHeader = request.headers.get('DPoP'); + if (!dpopHeader) return fail('DPoP proof required'); - /** @param {string} error @param {number} status */ - const reject = (error, status) => - this.signInResponse(url, { error, identifier, status }); + // Decode and verify token + const [headerB64, payloadB64, sigB64] = token.split('.'); + if (!headerB64 || !payloadB64 || !sigB64) return fail('Malformed token'); - const did = await this.getDid(); - if (!did) return reject('This PDS is not initialized yet.', 400); + const payload = JSON.parse( + new TextDecoder().decode(base64UrlDecode(payloadB64)), + ); - const handle = await this.actorStorage.getHandle(); - const claimed = identifier.replace(/^@/, ''); - if (claimed !== did && claimed !== handle) { - return reject('Invalid identifier', 401); - } + // Check expiration + const now = Math.floor(Date.now() / 1000); + if (payload.exp && payload.exp < now) return fail('Access token expired'); - // Only the account password reaches these pages. App passwords hold a - // restricted session scope, and the account screen is account management. - if (!this.password) { - return reject( - 'Password login is not configured on this server. Set the password option (PDS_PASSWORD).', - 401, + // Verify DPoP proof binds to this token + const url = new URL(request.url); + const dpop = await parseDpopProof( + dpopHeader, + request.method, + `${url.protocol}//${url.host}${url.pathname}`, + null, + token, ); - } - if (password !== this.password) return reject('Invalid password', 401); - const token = await createAccountJwt( - did, - this.jwtSecret, - ACCOUNT_SESSION_TTL, - ); - return new Response(null, { - status: 303, - headers: { - Location: '/account', - 'Cache-Control': 'no-store', - 'Set-Cookie': accountCookie(token, url.protocol === 'https:'), - }, - }); - } + // Check DPoP key matches token's cnf.jkt + if (payload.cnf?.jkt && payload.cnf.jkt !== dpop.jkt) { + return fail('DPoP key mismatch'); + } - /** - * POST /account/sign-out - Clear the cookie session - * @param {Request} request - * @param {URL} url - * @returns {Promise} - */ - async handleAccountSignOut(request, url) { - await drainRequestBody(request); - if (!isSameOriginPost(request, url)) { - return new Response('Forbidden', { status: 403 }); - } + // Verify signature using our public key + const signingKey = await this.getSigningKey(); + if (!signingKey) return fail('Server signing key unavailable'); + const sigInput = new TextEncoder().encode(`${headerB64}.${payloadB64}`); + const sig = base64UrlDecode(sigB64); + let valid; + if (signingKeyCurve(signingKey) === 'secp256k1') { + // WebCrypto cannot verify secp256k1; go through the injected + // verifier, addressed by our own did:key. + if (!this.verifier) return fail('Server verifier unavailable'); + const publicKey = + await /** @type {{publicKey: () => Promise}} */ ( + signingKey + ).publicKey(); + valid = await this.verifier.verify( + publicKeyToDidKey(publicKey, 'secp256k1'), + sigInput, + sig, + ); + } else { + const publicKeyJwk = await this.getPublicKeyJwk(); + if (!publicKeyJwk) return fail('Server public key unavailable'); + const verifyKey = await crypto.subtle.importKey( + 'jwk', + publicKeyJwk, + { name: 'ECDSA', namedCurve: 'P-256' }, + false, + ['verify'], + ); + valid = await crypto.subtle.verify( + { name: 'ECDSA', hash: 'SHA-256' }, + verifyKey, + /** @type {BufferSource} */ (sig), + sigInput, + ); + } + if (!valid) return fail('Invalid token signature'); - return new Response(null, { - status: 303, - headers: { - Location: '/account', - 'Cache-Control': 'no-store', - 'Set-Cookie': accountCookie('', url.protocol === 'https:'), - }, - }); + return { did: payload.sub, scope: payload.scope || 'atproto' }; + } catch (err) { + return fail(err instanceof Error ? err.message : 'Invalid DPoP token'); + } } // ════════════════════════════════════════════════════════════════════════════ @@ -4764,206 +2039,8 @@ const routes = { // The OAuth endpoints come from handlers/oauth.js // Account pages - '/account': { - method: 'GET', - handler: PersonalDataServer.prototype.handleAccountPage, - }, - '/account/sign-in': { - method: 'POST', - handler: PersonalDataServer.prototype.handleAccountSignIn, - }, - '/account/sign-out': { - method: 'POST', - handler: PersonalDataServer.prototype.handleAccountSignOut, - }, - '/account/apps': { - method: 'GET', - handler: PersonalDataServer.prototype.handleAccountApps, - }, - '/account/apps/revoke': { - method: 'POST', - handler: PersonalDataServer.prototype.handleAccountAppRevoke, - }, - '/account/app-passwords': { - method: 'GET', - handler: PersonalDataServer.prototype.handleAccountAppPasswords, - }, - '/account/app-passwords/create': { - method: 'POST', - handler: PersonalDataServer.prototype.handleAccountAppPasswordCreate, - }, - '/account/app-passwords/revoke': { - method: 'POST', - handler: PersonalDataServer.prototype.handleAccountAppPasswordRevoke, - }, - '/account/api/overview': { - method: 'GET', - handler: PersonalDataServer.prototype.handleApiOverview, - }, - '/account/api/activity': { - method: 'GET', - handler: PersonalDataServer.prototype.handleApiActivity, - }, - '/account/api/lexicon-status': { - method: 'GET', - handler: PersonalDataServer.prototype.handleApiLexiconStatus, - }, - '/account/api/lexicon-schema': { - method: 'GET', - handler: PersonalDataServer.prototype.handleApiLexiconSchema, - }, - '/account/api/apps': { - method: 'GET', - handler: PersonalDataServer.prototype.handleApiApps, - }, - '/account/api/apps/revoke': { - method: 'POST', - handler: PersonalDataServer.prototype.handleApiAppsRevoke, - }, - '/account/api/apps/alias': { - method: 'POST', - handler: PersonalDataServer.prototype.handleApiAppsAlias, - }, - '/account/api/settings/appearance': { - method: 'POST', - handler: PersonalDataServer.prototype.handleApiSettingsAppearance, - }, - '/account/api/sessions': { - method: 'GET', - handler: PersonalDataServer.prototype.handleApiSessions, - }, - '/account/api/sessions/revoke': { - method: 'POST', - handler: PersonalDataServer.prototype.handleApiSessionsRevoke, - }, - '/account/api/sessions/revoke-all': { - method: 'POST', - handler: PersonalDataServer.prototype.handleApiSessionsRevokeAll, - }, - '/account/api/app-passwords': { - method: 'GET', - handler: PersonalDataServer.prototype.handleApiAppPasswords, - }, - '/account/api/app-passwords/create': { - method: 'POST', - handler: PersonalDataServer.prototype.handleApiAppPasswordsCreate, - }, - '/account/api/app-passwords/revoke': { - method: 'POST', - handler: PersonalDataServer.prototype.handleApiAppPasswordsRevoke, - }, - '/account/api/passkeys': { - method: 'GET', - handler: PersonalDataServer.prototype.handleApiPasskeys, - }, - '/account/api/email': { - method: 'GET', - handler: PersonalDataServer.prototype.handleApiEmail, - }, - '/account/api/email/action': { - method: 'POST', - handler: PersonalDataServer.prototype.handleApiEmailAction, - }, - '/account/api/passkeys/revoke': { - method: 'POST', - handler: PersonalDataServer.prototype.handleApiPasskeysRevoke, - }, - '/account/api/spaces': { - method: 'GET', - handler: PersonalDataServer.prototype.handleApiSpaces, - }, - '/account/api/identity': { - method: 'GET', - handler: PersonalDataServer.prototype.handleApiIdentity, - }, - '/account/api/identity/handle': { - method: 'POST', - handler: PersonalDataServer.prototype.handleApiIdentityHandle, - }, - '/account/api/status': { - method: 'POST', - handler: PersonalDataServer.prototype.handleApiStatus, - }, - '/account/api/records': { - method: 'GET', - handler: PersonalDataServer.prototype.handleApiRecords, - }, - '/account/api/blobs': { - method: 'GET', - handler: PersonalDataServer.prototype.handleApiBlobs, - }, - '/account/api/blobs/relink': { - method: 'POST', - handler: PersonalDataServer.prototype.handleApiBlobsRelink, - }, - // /account/api/backups{,/settings,/run} come from handlers/account-backup.js - '/account/sessions': { - method: 'GET', - handler: PersonalDataServer.prototype.handleAccountSessions, - }, - '/account/sessions/revoke': { - method: 'POST', - handler: PersonalDataServer.prototype.handleAccountSessionRevoke, - }, - '/account/passkeys': { - method: 'GET', - handler: PersonalDataServer.prototype.handleAccountPasskeys, - }, - '/account/passkeys/register': { - method: 'POST', - handler: PersonalDataServer.prototype.handleAccountPasskeyRegister, - }, - '/account/passkeys/revoke': { - method: 'POST', - handler: PersonalDataServer.prototype.handleAccountPasskeyRevoke, - }, - '/account/passkey/challenge': { - method: 'GET', - handler: PersonalDataServer.prototype.handleAccountPasskeyChallenge, - }, - '/account/passkey/sign-in': { - method: 'POST', - handler: PersonalDataServer.prototype.handleAccountPasskeySignIn, - }, - '/account/email': { - handler: PersonalDataServer.prototype.handleAccountEmailRoute, - }, - '/account/email/verify': { - method: 'POST', - handler: PersonalDataServer.prototype.handleAccountEmailVerify, - }, - '/account/email/cancel': { - method: 'POST', - handler: PersonalDataServer.prototype.handleAccountEmailCancel, - }, - '/account/email/remove': { - method: 'POST', - handler: PersonalDataServer.prototype.handleAccountEmailRemove, - }, - '/account/repo': { - method: 'GET', - handler: PersonalDataServer.prototype.handleAccountRepo, - }, - '/account/spaces': { - method: 'GET', - handler: PersonalDataServer.prototype.handleAccountSpaces, - }, - '/account/spaces/records': { - method: 'GET', - handler: PersonalDataServer.prototype.handleAccountSpaceRecords, - }, - '/account/repo/records': { - method: 'GET', - handler: PersonalDataServer.prototype.handleAccountRepoRecords, - }, - '/account/identity': { - method: 'GET', - handler: PersonalDataServer.prototype.handleAccountIdentity, - }, - '/account/status': { - method: 'POST', - handler: PersonalDataServer.prototype.handleAccountStatus, - }, + // The /account pages and the /account/api endpoints come from + // handlers/account.js // Sync // listRepos, getRepoStatus, getRepo, getRecord and getLatestCommit come from diff --git a/test/oauth-session-device.test.js b/test/oauth-session-device.test.js index 4e13e68..843aa38 100644 --- a/test/oauth-session-device.test.js +++ b/test/oauth-session-device.test.js @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest'; import { formatUserAgent } from '../packages/account-ui/src/lib/userAgent.js'; -import { PersonalDataServer } from '../packages/core/src/pds.js'; +import { createAccountHandlers } from '../packages/core/src/handlers/account.js'; describe('formatUserAgent', () => { it('parses common browser + OS pairs', () => { @@ -49,65 +49,63 @@ describe('formatUserAgent', () => { describe('listConnectedApps surfaces the device', () => { it('returns the stored userAgent for a session', async () => { - const pds = new PersonalDataServer({ - actorStorage: /** @type {any} */ ({}), - blobs: /** @type {any} */ ({}), - jwtSecret: 'test-secret', - hostname: 'pds.example.com', - sharedStorage: /** @type {any} */ ({ - listOAuthTokensByDid: async () => [ - { - did: 'did:plc:x', - clientId: 'https://client.example/metadata.json', - scope: 'atproto', - dpopJkt: 'abcdef123456', - createdAt: Date.now(), - userAgent: 'Mozilla/5.0 … Chrome/150 Mobile', - tokenId: 'ref-1', - }, - ], + const account = createAccountHandlers( + /** @type {any} */ ({ + jwtSecret: 'test-secret', + sharedStorage: { + listOAuthTokensByDid: async () => [ + { + did: 'did:plc:x', + clientId: 'https://client.example/metadata.json', + scope: 'atproto', + dpopJkt: 'abcdef123456', + createdAt: Date.now(), + userAgent: 'Mozilla/5.0 … Chrome/150 Mobile', + tokenId: 'ref-1', + }, + ], + }, }), - }); - const apps = await pds.listConnectedApps('did:plc:x'); + ); + const apps = await account.listConnectedApps('did:plc:x'); expect(apps).toHaveLength(1); expect(apps[0].userAgent).toBe('Mozilla/5.0 … Chrome/150 Mobile'); }); it('orders sessions by last access and falls back to createdAt', async () => { - const pds = new PersonalDataServer({ - actorStorage: /** @type {any} */ ({}), - blobs: /** @type {any} */ ({}), - jwtSecret: 'test-secret', - hostname: 'pds.example.com', - sharedStorage: /** @type {any} */ ({ - listOAuthTokensByDid: async () => [ - // Authorized first, but refreshed most recently -> sorts to the top. - { - did: 'did:plc:x', - clientId: 'https://old.example/metadata.json', - createdAt: Date.parse('2026-01-01T00:00:00Z'), - updatedAt: Date.parse('2026-08-01T00:00:00Z'), - tokenId: 'ref-old', - }, - // Authorized later but never refreshed since. - { - did: 'did:plc:x', - clientId: 'https://new.example/metadata.json', - createdAt: Date.parse('2026-07-01T00:00:00Z'), - updatedAt: Date.parse('2026-07-01T00:00:00Z'), - tokenId: 'ref-new', - }, - // Legacy token with no updatedAt: createdAt stands in. - { - did: 'did:plc:x', - clientId: 'https://legacy.example/metadata.json', - createdAt: Date.parse('2026-06-01T00:00:00Z'), - tokenId: 'ref-legacy', - }, - ], + const account = createAccountHandlers( + /** @type {any} */ ({ + jwtSecret: 'test-secret', + sharedStorage: { + listOAuthTokensByDid: async () => [ + // Authorized first, but refreshed most recently -> sorts to the top. + { + did: 'did:plc:x', + clientId: 'https://old.example/metadata.json', + createdAt: Date.parse('2026-01-01T00:00:00Z'), + updatedAt: Date.parse('2026-08-01T00:00:00Z'), + tokenId: 'ref-old', + }, + // Authorized later but never refreshed since. + { + did: 'did:plc:x', + clientId: 'https://new.example/metadata.json', + createdAt: Date.parse('2026-07-01T00:00:00Z'), + updatedAt: Date.parse('2026-07-01T00:00:00Z'), + tokenId: 'ref-new', + }, + // Legacy token with no updatedAt: createdAt stands in. + { + did: 'did:plc:x', + clientId: 'https://legacy.example/metadata.json', + createdAt: Date.parse('2026-06-01T00:00:00Z'), + tokenId: 'ref-legacy', + }, + ], + }, }), - }); - const apps = await pds.listConnectedApps('did:plc:x'); + ); + const apps = await account.listConnectedApps('did:plc:x'); expect(apps.map((a) => a.clientId)).toEqual([ 'https://old.example/metadata.json', 'https://new.example/metadata.json', diff --git a/vitest.config.js b/vitest.config.js index 0273176..7ff9aa3 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -64,6 +64,11 @@ export default defineConfig({ // A glob key adds together every file it matches. Then the total can pass // the floor while one module is far below it. Each module gets its own // entry. The two spaces entries below are separate for the same reason. + 'packages/core/src/handlers/account.js': { + statements: 60, + branches: 51, + functions: 65, + }, 'packages/core/src/handlers/account-backup.js': { statements: 97, branches: 90, -- 2.51.2