// @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, accountSessionDueForRenewal, } 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; } // Where the update check asks for the newest published release, per channel. // The lookup runs server-side and is cached in memory (see // checkLatestVersion), so one request every few hours serves every browser // session. Canary builds are published only to the artifacts site; both // sources answer JSON with a `version` string. const VERSION_CHECK_URLS = { stable: 'https://registry.npmjs.org/@pdsjs/core/latest', canary: 'https://artifacts.pdsjs.dev/canary/manifest.json', }; // How long an answer stands before the source is asked again. A release is a // rare event, so a stable answer keeps for hours. The canary channel moves // with every push to main and its manifest is served with a five minute // cache, so remembering one for longer than the artifact claims to be fresh // reports a build that has already been replaced as the newest one. const VERSION_CHECK_TTL = { stable: 6 * 60 * 60 * 1000, canary: 5 * 60 * 1000, }; // An unreachable registry is retried sooner, so a transient outage does not // hide a release for the whole cache window. const VERSION_CHECK_RETRY = 15 * 60 * 1000; const RELEASE_NOTES_URL = 'https://tangled.org/chadtmiller.com/pds.js/blob/main/packages/core/CHANGELOG.md'; // A canary build has no changelog entry; its notes are the commit log. const CANARY_NOTES_URL = 'https://tangled.org/chadtmiller.com/pds.js'; /** * Whether `candidate` is a newer release than `current`. Releases are dotted * numerics; a missing or unparseable part compares as 0, so a malformed * registry answer never announces an update. * @param {string} current * @param {string} candidate * @returns {boolean} */ function isNewerVersion(current, candidate) { const a = current.split('.').map(Number); const b = candidate.split('.').map(Number); for (let i = 0; i < Math.max(a.length, b.length); i++) { const x = b[i] || 0; const y = a[i] || 0; if (x !== y) return x > y; } return false; } /** * Whether the published `candidate` calls for an update from `current`. * Stable releases order by isNewerVersion. A version with a prerelease * suffix belongs to the canary channel, which moves with the main branch, * so there any difference is an update: the running build is behind main, * or it is a canary build on a server switched back to stable. * @param {string} current * @param {string|null} candidate * @returns {boolean} */ function updateAvailable(current, candidate) { if (!candidate || candidate === current) return false; if (current.includes('-') || candidate.includes('-')) return true; return isNewerVersion(current, candidate); } /** * 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 {(candidate: unknown) => Promise} verifyAccountPassword * @property {() => Promise} passwordConfigured * @property {(next: string) => Promise} changeAccountPassword - False when the storage cannot hold a password * @property {() => boolean} passwordChangeable * @property {boolean} readOnly * @property {import('../ports.js').EmailPort|null} emailer * @property {import('../ports.js').SpaceBrowserPort|null} spaceBrowser * @property {Record} [extensionFeatures] - Feature flags the mounted extensions report, merged into the overview's `features` after the built-in ones * @property {Array<(count: (collection: string) => number) => Record>} [extensionStats] - Counts the mounted extensions report, merged into the overview's `stats` after the built-in ones * @property {string|null} accountApp - Built account application, as one HTML document * @property {import('../ports.js').LexiconResolverPort} [lexiconResolver] * @property {string} version - The release this server runs * @property {string|null} [updateUrl] - Where a person updates this deployment; shown when a newer release is published * @property {string|null} [scriptName] - The worker script name this deployment runs as; carried on the update link so the update page can find the worker behind a custom domain * @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, * renewAccountSession: (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, raw?: boolean}) => Promise, * listConnectedApps: (did: string) => Promise>, * }} */ export function createAccountHandlers(ctx) { const { actorStorage, sharedStorage, sessions, passkeys, jwtSecret, hostname, plcUrl, verifyAccountPassword, passwordConfigured, changeAccountPassword, passwordChangeable, readOnly, emailer, spaceBrowser, extensionFeatures = {}, extensionStats = [], accountApp, lexiconResolver, version, updateUrl, scriptName, getDid, readJsonBounded, setAccountActive, getRecommendedDidCredentials, relinkBlobRecords, renameHandle, backupsAvailable, } = ctx; /** * The claims of the session cookie this request carries, or null when it * carries none, or one this server did not sign. * @param {Request} request * @returns {Promise} */ async function readAccountCookie(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 ? payload : null; } catch { return null; } } /** * 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 payload = await readAccountCookie(request); return payload ? /** @type {string} */ (payload.sub) : null; } /** * A replacement session cookie for a request whose own cookie has spent a * day of its life. Null when the request carries no valid session, or one * still fresh. `PersonalDataServer.fetch` sets it on the response, so an * account page in use never reaches the expiry and asks for the password * again. * @param {Request} request * @returns {Promise} */ async function renewAccountSession(request) { const payload = await readAccountCookie(request); const now = Math.floor(Date.now() / 1000); if ( typeof payload?.exp !== 'number' || !accountSessionDueForRenewal(payload.exp, now) ) return null; const token = await createAccountJwt( /** @type {string} */ (payload.sub), jwtSecret, ACCOUNT_SESSION_TTL, ); return accountCookie(token, new URL(request.url).protocol === 'https:'); } /** * 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 []; // A token just rotated away is the same session as the one that replaced // it, kept only to answer a replay: it is not a session of its own. const tokens = (await sharedStorage.listOAuthTokensByDid(did)).filter( (token) => !token.rotatedTo, ); 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: await 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. A single-account server knows its * one identity, so the page names the account and asks for the password. * @param {URL} url * @param {{error?: string, status?: number}} [opts] * @returns {Promise} */ async function signInResponse(url, { error = '', status = 200 } = {}) { const passkeysAvailable = passkeys.passkeysAvailable(); const account = (await actorStorage.getHandle()) ?? (await getDid()); if (accountApp) { // Non-null: accountAppResponse only returns null when accountApp is unset. return /** @type {Response} */ ( accountAppResponse( { mode: 'signin', hostname: url.host, error, account, passkeys: passkeysAvailable, }, status, ) ); } return htmlResponse( renderSignInPage({ hostname: url.host, error, account, 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, raw?: boolean}} [options] - `raw` leaves the request body unread for a handler that streams it * @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') { // A raw handler reads the request itself, so the body stays unread and // a file upload is never buffered as JSON. if (options.raw) { if (!isSameOriginPost(request, url)) { return json({ error: 'Forbidden' }, 403); } } else { 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); } } // Last answer from the update check, per channel, kept in memory only: a // restarted server simply asks again on the next settings visit. /** @type {Record} */ const latestVersion = {}; /** * The newest version published on a channel, cached in memory. A source * that cannot be reached reads as "no known update" (version null) rather * than an error, and is retried on a shorter clock than a successful * answer. * @param {'stable'|'canary'} channel * @returns {Promise<{version: string|null, checkedAt: number}>} */ async function checkLatestVersion(channel) { const now = Date.now(); const cached = latestVersion[channel]; const ttl = cached?.version ? VERSION_CHECK_TTL[channel] : VERSION_CHECK_RETRY; if (cached && now - cached.checkedAt < ttl) { return cached; } /** @type {string|null} */ let published = null; try { const response = await fetch(VERSION_CHECK_URLS[channel], { signal: AbortSignal.timeout(5_000), }); if (response.ok) { const manifest = /** @type {{version?: unknown}} */ ( await response.json() ); if (typeof manifest.version === 'string') published = manifest.version; } } catch { // Timeout or network failure; the null result carries the meaning. } latestVersion[channel] = { version: published, checkedAt: now }; return latestVersion[channel]; } /** * GET /account/api/version - The release this server runs, the newest one * published on the channel it follows, and the channel itself. The lookup * happens server-side and is cached, so a browser session costs at most * one same-origin request and the release source sees no visitor traffic. * @param {Request} request * @param {URL} url * @returns {Promise} */ async function handleApiVersion(request, url) { return accountApi(request, url, async () => { const channel = (await readAccountUiState()).releaseChannel; const { version: latest, checkedAt } = await checkLatestVersion(channel); return { current: version, latest, updateAvailable: updateAvailable(version, latest), checkedAt: new Date(checkedAt).toISOString(), releaseNotesUrl: channel === 'canary' ? CANARY_NOTES_URL : RELEASE_NOTES_URL, updateUrl: updateUrl ?? null, scriptName: scriptName ?? null, channel, }; }); } /** * 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); // A rail count covers a collection wherever it lives: the public // repo's records plus this account's records in each live space. /** @type {Map} */ const spaceCounts = new Map(); if (spaceBrowser && extensionStats.length > 0) { for (const space of await spaceBrowser.listSpaces()) { if (space.deletedAt) continue; for (const entry of await spaceBrowser.countRecords(space.uri)) { spaceCounts.set( entry.collection, (spaceCounts.get(entry.collection) ?? 0) + entry.count, ); } } } /** @param {string} collection */ const count = (collection) => (collections.find((c) => c.name === collection)?.count ?? 0) + (spaceCounts.get(collection) ?? 0); 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', passwordChange: passwordChangeable(), files: typeof actorStorage.listBlobDetails === 'function', ...extensionFeatures, }, 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, ...Object.assign({}, ...extensionStats.map((stats) => stats(count))), }, 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', releaseChannel: stored?.releaseChannel === 'canary' ? 'canary' : 'stable', }; } /** * 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 }, ); } /** * POST /account/api/settings/release-channel - Store which release channel * the update check follows: tagged releases, or canary builds of the main * branch. The choice only steers the check and the update link; installing * a build stays a deliberate redeploy. * @param {Request} request * @param {URL} url * @returns {Promise} */ async function handleApiSettingsReleaseChannel(request, url) { return accountApi( request, url, async (_did, body) => { if (typeof actorStorage.setAccountUiState !== 'function') { throw new Error('This server cannot store settings'); } if (!['stable', 'canary'].includes(body.channel)) { throw new Error('channel must be stable or canary'); } const state = await readAccountUiState(); state.releaseChannel = body.channel; await actorStorage.setAccountUiState(state); return { channel: state.releaseChannel }; }, { write: true }, ); } /** * POST /account/api/password - Change the account password. Takes the * current password even inside a signed-in session, so a stolen cookie * alone cannot rotate the credential. Sessions stay open; app passwords * are untouched. * @param {Request} request * @param {URL} url * @returns {Promise} */ async function handleApiPassword(request, url) { return accountApi( request, url, async (_did, body) => { if (!passwordChangeable()) { throw new Error('This server cannot store a changed password'); } if ( typeof body.newPassword !== 'string' || body.newPassword.length < 8 ) { throw new Error('Use a new password of at least 8 characters'); } if (!(await verifyAccountPassword(body.currentPassword))) { throw new Error('The current password is wrong'); } if (!(await changeAccountPassword(body.newPassword))) { throw new Error('This server cannot store a changed password'); } return { ok: true }; }, { 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 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, // The policy's name, as `memberspace` for // `com.atproto.memberspace.policy`. policy: space.policy?.$type.split('.').at(-2) ?? null, 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."], // 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, 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 (!(await passwordConfigured())) { return reject( 'Password login is not configured on this server. Set the password option (PDS_PASSWORD).', 401, ); } if (!(await verifyAccountPassword(password))) { 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/version': { method: 'GET', handler: handleApiVersion, }, '/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/settings/release-channel': { method: 'POST', handler: handleApiSettingsReleaseChannel, }, '/account/api/password': { method: 'POST', handler: handleApiPassword, }, '/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/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, renewAccountSession, readAccountUiState, accountAppResponse, accountApi, listConnectedApps, }; }