chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// @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<typeof import('../session.js').createSessionRegistry>} sessions
* @property {ReturnType<typeof import('../passkey.js').createPasskeyCeremony>} passkeys
* @property {string} jwtSecret
* @property {string} [hostname]
* @property {string} plcUrl
* @property {(candidate: unknown) => Promise<boolean>} verifyAccountPassword
* @property {() => Promise<boolean>} passwordConfigured
* @property {(next: string) => Promise<boolean>} 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<string, boolean>} [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<string, number>>} [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<string|null>} getDid
* @property {(request: Request) => Promise<any|Response>} readJsonBounded
* @property {(active: boolean) => Promise<void>} setAccountActive
* @property {(fallbackHostname?: string|null) => Promise<{rotationKeys: string[], alsoKnownAs: string[], verificationMethods: {atproto: string}, services: {atproto_pds: {type: string, endpoint: string}}}|null>} getRecommendedDidCredentials
* @property {() => Promise<number>} 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<string|null>,
* renewAccountSession: (request: Request) => Promise<string|null>,
* readAccountUiState: () => Promise<import('../ports.js').AccountUiState>,
* accountAppResponse: (bootstrap?: Object|null, status?: number) => Response|null,
* accountApi: (request: Request, url: URL, handler: (did: string, body: any) => Promise<unknown>, options?: {write?: boolean, raw?: boolean}) => Promise<Response>,
* listConnectedApps: (did: string) => Promise<Array<{clientId: string, scope: string, authorizedAt: string|null, lastAccessedAt: string|null, userAgent: string|null, keyThumbprint: string|null, sessionId: string|null}>>,
* }}
*/
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<import('../auth.js').JwtPayload|null>}
*/
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<string|null>}
*/
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<string|null>}
*/
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<Array<{name: string, count: number}>>} Sorted by name
*/
async function countRecordsByCollection() {
/** @type {Map<string, number>} */
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<string, number[]>}}>}
*/
async function countRecordsWithActivity({
windowMs = 365 * 86_400_000,
buckets = 52,
now = Date.now(),
} = {}) {
const start = now - windowMs;
/** @type {Map<string, number>} */
const counts = new Map();
/** @type {Map<string, number>} */
const lastAt = new Map();
/** @type {Map<string, number[]>} */
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<string>}
*/
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<Array<{clientId: string, scope: string, authorizedAt: string|null, lastAccessedAt: string|null, userAgent: string|null, keyThumbprint: string|null, sessionId: string|null}>>}
*/
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<Array<import('../ports.js').AppPasswordRecord>>}
*/
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 <head> 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(
/</g,
'\\u003c',
);
html = html.replace(
'</head>',
`<script>window.__PDS__=JSON.parse(${data})</script></head>`,
);
}
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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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<unknown>} handler
* @param {{write?: boolean, raw?: boolean}} [options] - `raw` leaves the request body unread for a handler that streams it
* @returns {Promise<Response>}
*/
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<string, {version: string|null, checkedAt: number}>} */
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<Response>}
*/
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<Response>}
*/
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<string, number>} */
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<Response>}
*/
async function handleApiLexiconStatus(request, url) {
return accountApi(request, url, async () => {
/** @type {Record<string, boolean>} */
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=<nsid> - 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<Response>}
*/
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<Response>}
*/
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<import('../ports.js').AccountUiState>}
*/
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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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<Array<import('../ports.js').PasskeyRecord>>}
*/
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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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,
};
}