// Account page tests - sign-in form, cookie session, account screen import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createAccountJwt } from '../packages/core/src/auth.js'; import { PersonalDataServer } from '../packages/core/src/pds.js'; import { cborEncodeDagCbor } from '../packages/core/src/repo.js'; const DID = 'did:plc:test123'; const HANDLE = 'test.handle'; const PASSWORD = 'test-password'; const AVATAR_CID = 'bafkreiabc123def456ghi789jkl012mno345pqr678stu901vwx234yz'; // The storage key for an OAuth session is the refresh token itself const REFRESH_TOKEN = 'refresh-token-that-must-never-be-rendered'; /** * @param {Object} [options] * @param {Record|null} [options.profile] - The account's * app.bsky.actor.profile record, or null when it has none * @param {string[]} [options.records] - Record keys, as `collection/rkey` * @param {string[]} [options.blobs] - Stored blob CIDs * @param {string} [options.status] - Account status */ function createMockStorage({ profile = null, records = [], blobs = [], status = 'active', } = {}) { let currentStatus = status; /** @type {import('../packages/core/src/ports.js').EmailState|null} */ let email = null; /** @type {import('../packages/core/src/ports.js').PlcToken|null} */ let plcToken = null; /** @type {import('../packages/core/src/ports.js').AccountUiState|null} */ let accountUiState = null; /** @type {import('../packages/core/src/ports.js').AccountPasswordState|null} */ let accountPassword = null; return { async getDid() { return DID; }, async getAccountPassword() { return accountPassword; }, async setAccountPassword(/** @type {any} */ state) { accountPassword = state; }, async getEmail() { return email; }, async setEmail(/** @type {any} */ state) { email = state; }, async getPlcToken() { return plcToken; }, async setPlcToken(/** @type {any} */ token) { plcToken = token; }, async getHandle() { return HANDLE; }, async getAccountStatus() { return currentStatus; }, async setAccountStatus(/** @type {string} */ next) { currentStatus = next; }, async getPrivateKey() { return null; }, async getRecord(/** @type {string} */ uri) { if (!profile || uri !== `at://${DID}/app.bsky.actor.profile/self`) { return null; } return { cid: 'bafytest', value: cborEncodeDagCbor(profile) }; }, async listAllRecords() { return records.map((key) => ({ key, cid: 'bafytest' })); }, async listRecords( /** @type {string} */ collection, /** @type {string|null} */ _cursor, /** @type {number} */ limit, ) { const matching = records .filter((key) => key.startsWith(`${collection}/`)) .slice(0, limit) .map((key) => ({ uri: `at://${DID}/${key}`, cid: 'bafyrecord', value: cborEncodeDagCbor({ $type: collection, text: `record ${key.split('/')[1]}`, }), })); return { records: matching, cursor: null }; }, async listBlobs() { return { cids: blobs, cursor: null }; }, async getLatestCommit() { return { seq: 7, cid: 'bafycommit', rev: '3lrev' }; }, async getEvents() { return { events: [], cursor: 0 }; }, async getLatestSeq() { return 7; }, async putEvent() {}, async getAccountUiState() { return accountUiState; }, async setAccountUiState( /** @type {import('../packages/core/src/ports.js').AccountUiState} */ state, ) { accountUiState = state; }, }; } /** * @param {Object} [options] * @param {Array<{name: string, privileged: boolean, createdAt: string}>} [options.appPasswords] * @param {Array>} [options.oauthTokens] */ function createMockSharedStorage({ appPasswords = [], oauthTokens = [] } = {}) { /** @type {any[]} */ const passwords = [...appPasswords]; /** @type {any[]} */ const passkeys = []; /** @type {any[]} */ const sessions = []; /** @type {Map} */ const oauthRequests = new Map(); let tokens = [...oauthTokens]; return { get tokens() { return tokens; }, async deleteOAuthToken(/** @type {string} */ tokenId) { tokens = tokens.filter((token) => token.tokenId !== tokenId); }, async listAppPasswords() { // Hashes stay in storage, as the real adapters keep them return passwords.map(({ hash: _hash, ...entry }) => entry); }, async getAppPasswordByHash( /** @type {string} */ _did, /** @type {string} */ hash, ) { const found = passwords.find((entry) => entry.hash === hash); if (!found) return null; const { hash: _stored, ...record } = found; return record; }, async putAppPassword( /** @type {string} */ _did, /** @type {string} */ name, /** @type {string} */ hash, /** @type {boolean} */ privileged, /** @type {string} */ createdAt, ) { if (passwords.some((entry) => entry.name === name)) return false; passwords.push({ name, privileged, createdAt, hash }); return true; }, async deleteAppPassword( /** @type {string} */ _did, /** @type {string} */ name, ) { const index = passwords.findIndex((entry) => entry.name === name); if (index === -1) return false; passwords.splice(index, 1); return true; }, async listOAuthTokensByDid() { return tokens; }, async getOAuthRequest(/** @type {string} */ uri) { return oauthRequests.get(uri) || null; }, async putOAuthRequest(/** @type {string} */ uri, /** @type {any} */ data) { oauthRequests.set(uri, data); }, async deleteOAuthRequest(/** @type {string} */ uri) { oauthRequests.delete(uri); }, async putSession(/** @type {any} */ session) { const index = sessions.findIndex((s) => s.jti === session.jti); if (index === -1) sessions.push(session); else sessions[index] = session; }, async getSession(/** @type {string} */ jti) { return sessions.find((s) => s.jti === jti) || null; }, async listSessions() { return sessions; }, async deleteSession(/** @type {string} */ jti) { const index = sessions.findIndex((s) => s.jti === jti); if (index === -1) return false; sessions.splice(index, 1); return true; }, async putPasskey(/** @type {any} */ record) { if (passkeys.some((p) => p.credentialId === record.credentialId)) { return false; } passkeys.push(record); return true; }, async getPasskey(/** @type {string} */ credentialId) { return passkeys.find((p) => p.credentialId === credentialId) || null; }, async listPasskeys() { return passkeys; }, async deletePasskey( /** @type {string} */ _did, /** @type {string} */ credentialId, ) { const index = passkeys.findIndex((p) => p.credentialId === credentialId); if (index === -1) return false; passkeys.splice(index, 1); return true; }, async touchPasskey( /** @type {string} */ credentialId, /** @type {number} */ signCount, /** @type {string} */ lastUsedAt, ) { const passkey = passkeys.find((p) => p.credentialId === credentialId); if (passkey) Object.assign(passkey, { signCount, lastUsedAt }); }, }; } /** * A stand-in authenticator: a P-256 key pair plus the byte layouts a real one * produces, so the ceremonies can run end to end without a browser. * @returns {Promise<{credentialId: string, publicKey: string, privateKey: CryptoKey}>} */ async function createTestCredential() { const keyPair = await crypto.subtle.generateKey( { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'], ); const spki = new Uint8Array( await crypto.subtle.exportKey('spki', keyPair.publicKey), ); return { credentialId: b64url(crypto.getRandomValues(new Uint8Array(16))), publicKey: b64url(spki), privateKey: keyPair.privateKey, }; } /** * @param {{credentialId: string, privateKey: CryptoKey}} credential * @param {string} challenge */ async function signTestAssertion(credential, challenge) { const rpIdHash = new Uint8Array( await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode('pds.example.com'), ), ); const authData = new Uint8Array(37); authData.set(rpIdHash, 0); authData[32] = 0x01; // user present const clientDataJSON = encodeClientData({ type: 'webauthn.get', challenge, origin: 'https://pds.example.com', }); const clientDataHash = new Uint8Array( await crypto.subtle.digest('SHA-256', fromB64url(clientDataJSON)), ); const signed = new Uint8Array(authData.length + clientDataHash.length); signed.set(authData, 0); signed.set(clientDataHash, authData.length); const raw = new Uint8Array( await crypto.subtle.sign( { name: 'ECDSA', hash: 'SHA-256' }, credential.privateKey, signed, ), ); return { credentialId: credential.credentialId, authenticatorData: b64url(authData), clientDataJSON, signature: b64url(toDer(raw)), }; } /** WebCrypto signs raw r||s; a browser sends DER. */ function toDer(/** @type {Uint8Array} */ raw) { const encodeInt = (/** @type {Uint8Array} */ bytes) => { let start = 0; while (start < bytes.length - 1 && bytes[start] === 0) start++; let value = bytes.slice(start); if (value[0] & 0x80) value = Uint8Array.from([0, ...value]); return Uint8Array.from([0x02, value.length, ...value]); }; const r = encodeInt(raw.slice(0, 32)); const s = encodeInt(raw.slice(32)); return Uint8Array.from([0x30, r.length + s.length, ...r, ...s]); } /** @param {object} data */ function encodeClientData(data) { return b64url(new TextEncoder().encode(JSON.stringify(data))); } /** @param {Uint8Array} bytes */ function b64url(bytes) { return Buffer.from(bytes).toString('base64url'); } /** @param {string} value */ function fromB64url(value) { return new Uint8Array(Buffer.from(value, 'base64url')); } /** * Sign in and return the cookie header value for subsequent requests. * @param {PersonalDataServer} pds * @returns {Promise} */ async function signedInCookie(pds) { const cookie = sessionCookie( await signIn(pds, { username: HANDLE, password: PASSWORD }), ); return `pdsjs_account=${encodeURIComponent(cookie)}`; } /** * @param {PersonalDataServer} pds * @param {string} path * @param {string} cookie * @returns {Promise<{status: number, html: string}>} */ async function getPage(pds, path, cookie) { const response = await pds.fetch( new Request(`https://pds.example.com${path}`, { headers: { Cookie: cookie }, }), ); return { status: response.status, html: await response.text() }; } /** * @param {PersonalDataServer} pds * @param {string} path * @param {Record} fields * @param {string} cookie * @returns {Promise} */ function postForm(pds, path, fields, cookie) { return pds.fetch( new Request(`https://pds.example.com${path}`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: cookie, }, body: new URLSearchParams(fields).toString(), }), ); } /** * @param {Response} response * @returns {string} The session cookie value, or '' when the cookie is cleared */ function sessionCookie(response) { const header = response.headers.get('set-cookie') || ''; return decodeURIComponent(header.match(/pdsjs_account=([^;]*)/)?.[1] ?? ''); } /** * @param {PersonalDataServer} pds * @param {Record} fields * @returns {Promise} */ function signIn(pds, fields) { return pds.fetch( new Request('https://pds.example.com/account/sign-in', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(fields).toString(), }), ); } describe('Account pages', () => { /** @type {PersonalDataServer} */ let pds; beforeEach(() => { pds = new PersonalDataServer({ actorStorage: /** @type {any} */ (createMockStorage()), sharedStorage: /** @type {any} */ ({}), blobs: /** @type {any} */ ({}), jwtSecret: 'test-secret', password: PASSWORD, }); }); it('serves the sign-in form when signed out', async () => { const response = await pds.fetch( new Request('https://pds.example.com/account'), ); expect(response.status).toBe(200); expect(response.headers.get('content-type')).toContain('text/html'); expect(response.headers.get('cache-control')).toBe('no-store'); const html = await response.text(); // The server knows its one account: the form names the handle in a // hidden field and asks for the password alone. expect(html).toContain(`value="${HANDLE}"`); expect(html).toContain('type="hidden"'); expect(html).not.toContain('placeholder="handle or DID"'); expect(html).toContain('name="password"'); expect(html).not.toContain(DID); }); it('signs in with the handle and redirects to the account screen', async () => { const response = await signIn(pds, { username: HANDLE, password: PASSWORD, }); expect(response.status).toBe(303); expect(response.headers.get('location')).toBe('/account'); const cookie = response.headers.get('set-cookie') || ''; expect(cookie).toContain('HttpOnly'); expect(cookie).toContain('SameSite=Lax'); expect(cookie).toContain('Secure'); expect(sessionCookie(response)).not.toBe(''); }); it('accepts the DID as identifier', async () => { const response = await signIn(pds, { username: DID, password: PASSWORD }); expect(response.status).toBe(303); }); it('rejects an unknown identifier', async () => { const response = await signIn(pds, { username: 'someone.else', password: PASSWORD, }); expect(response.status).toBe(401); expect(await response.text()).toContain('Invalid identifier'); expect(sessionCookie(response)).toBe(''); }); it('rejects a wrong password and keeps the account named', async () => { const response = await signIn(pds, { username: HANDLE, password: 'nope' }); expect(response.status).toBe(401); const html = await response.text(); expect(html).toContain('Invalid password'); expect(html).toContain(`value="${HANDLE}"`); expect(sessionCookie(response)).toBe(''); }); it('refuses to sign in when no password is configured', async () => { const noPassword = new PersonalDataServer({ actorStorage: /** @type {any} */ (createMockStorage()), sharedStorage: /** @type {any} */ ({}), blobs: /** @type {any} */ ({}), jwtSecret: 'test-secret', }); const response = await signIn(noPassword, { username: HANDLE, password: '', }); expect(response.status).toBe(401); expect(await response.text()).toContain('not configured'); }); it('shows the account screen to a session cookie holder', async () => { const cookie = sessionCookie( await signIn(pds, { username: HANDLE, password: PASSWORD }), ); const response = await pds.fetch( new Request('https://pds.example.com/account', { headers: { Cookie: `pdsjs_account=${encodeURIComponent(cookie)}` }, }), ); expect(response.status).toBe(200); const html = await response.text(); expect(html).toContain(DID); expect(html).toContain(`@${HANDLE}`); expect(html).toContain('/account/sign-out'); expect(html).not.toContain('name="password"'); }); it('shows the display name and avatar from the local profile record', async () => { const withProfile = new PersonalDataServer({ actorStorage: /** @type {any} */ ( createMockStorage({ profile: { $type: 'app.bsky.actor.profile', displayName: 'Demo Account', avatar: { $type: 'blob', ref: { $link: AVATAR_CID }, mimeType: 'image/jpeg', size: 1234, }, }, }) ), sharedStorage: /** @type {any} */ ({}), blobs: /** @type {any} */ ({}), jwtSecret: 'test-secret', password: PASSWORD, }); const cookie = sessionCookie( await signIn(withProfile, { username: HANDLE, password: PASSWORD }), ); const html = await ( await withProfile.fetch( new Request('https://pds.example.com/account', { headers: { Cookie: `pdsjs_account=${encodeURIComponent(cookie)}` }, }), ) ).text(); expect(html).toContain('Demo Account'); // Served from this PDS's own blob store, not from an AppView. The query // separator is `&` because the URL sits in an HTML attribute. expect(html).toContain( `/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(DID)}&cid=${AVATAR_CID}`, ); }); it('renders without a name or avatar when there is no profile record', async () => { const cookie = sessionCookie( await signIn(pds, { username: HANDLE, password: PASSWORD }), ); const html = await ( await pds.fetch( new Request('https://pds.example.com/account', { headers: { Cookie: `pdsjs_account=${encodeURIComponent(cookie)}` }, }), ) ).text(); expect(html).not.toContain(' { const cookie = sessionCookie( await signIn(pds, { username: HANDLE, password: PASSWORD }), ); const html = await ( await pds.fetch( new Request('https://pds.example.com/account', { headers: { Cookie: `pdsjs_account=${encodeURIComponent(cookie)}` }, }), ) ).text(); expect(html).not.toContain('bsky.app'); expect(html).not.toContain(' { const cookie = sessionCookie( await signIn(pds, { username: HANDLE, password: PASSWORD }), ); const other = new PersonalDataServer({ actorStorage: /** @type {any} */ (createMockStorage()), sharedStorage: /** @type {any} */ ({}), blobs: /** @type {any} */ ({}), jwtSecret: 'other-secret', password: PASSWORD, }); const response = await other.fetch( new Request('https://pds.example.com/account', { headers: { Cookie: `pdsjs_account=${encodeURIComponent(cookie)}` }, }), ); expect(await response.text()).toContain('name="password"'); }); it('does not accept an API access token as a session cookie', async () => { const session = await pds.fetch( new Request( 'https://pds.example.com/xrpc/com.atproto.server.createSession', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier: HANDLE, password: PASSWORD }), }, ), ); const { accessJwt } = await session.json(); const response = await pds.fetch( new Request('https://pds.example.com/account', { headers: { Cookie: `pdsjs_account=${accessJwt}` }, }), ); expect(await response.text()).toContain('name="password"'); }); it('signs out by expiring the cookie', async () => { const response = await pds.fetch( new Request('https://pds.example.com/account/sign-out', { method: 'POST', }), ); expect(response.status).toBe(303); expect(response.headers.get('location')).toBe('/account'); expect(response.headers.get('set-cookie')).toContain('Max-Age=0'); }); it('leaves a fresh session cookie alone', async () => { const cookie = await signedInCookie(pds); const response = await pds.fetch( new Request('https://pds.example.com/account', { headers: { Cookie: cookie }, }), ); expect(response.headers.get('set-cookie')).toBe(null); }); it('reissues a session cookie that has spent a day of its life', async () => { const spent = await createAccountJwt(DID, 'test-secret', 60); const response = await pds.fetch( new Request('https://pds.example.com/account', { headers: { Cookie: `pdsjs_account=${encodeURIComponent(spent)}` }, }), ); expect(response.status).toBe(200); const reissued = sessionCookie(response); expect(reissued).not.toBe(''); expect(reissued).not.toBe(spent); expect(response.headers.get('set-cookie')).toContain('Max-Age=604800'); // The replacement opens the account screen on its own const { html } = await getPage( pds, '/account', `pdsjs_account=${encodeURIComponent(reissued)}`, ); expect(html).toContain(DID); }); it('reissues nothing for a session cookie it did not sign', async () => { const forged = await createAccountJwt(DID, 'other-secret', 60); const response = await pds.fetch( new Request('https://pds.example.com/account', { headers: { Cookie: `pdsjs_account=${encodeURIComponent(forged)}` }, }), ); expect(response.headers.get('set-cookie')).toBe(null); expect(await response.text()).toContain('name="password"'); }); it('signs out a part-spent session rather than reissuing it', async () => { const spent = await createAccountJwt(DID, 'test-secret', 60); const response = await pds.fetch( new Request('https://pds.example.com/account/sign-out', { method: 'POST', headers: { Cookie: `pdsjs_account=${encodeURIComponent(spent)}` }, }), ); expect(response.headers.getSetCookie()).toHaveLength(1); expect(response.headers.get('set-cookie')).toContain('Max-Age=0'); }); it('rejects cross-origin form posts', async () => { const response = await pds.fetch( new Request('https://pds.example.com/account/sign-in', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Origin: 'https://evil.example', }, body: new URLSearchParams({ username: HANDLE, password: PASSWORD, }).toString(), }), ); expect(response.status).toBe(403); }); it('rejects a GET to the sign-in endpoint', async () => { const response = await pds.fetch( new Request('https://pds.example.com/account/sign-in'), ); expect(response.status).toBe(405); }); it('turns away every section when signed out', async () => { for (const path of [ '/account/apps', '/account/app-passwords', '/account/repo', '/account/identity', ]) { const { status, html } = await getPage(pds, path, ''); expect(status, path).toBe(401); expect(html, path).toContain('name="password"'); } }); }); describe('Account sections', () => { /** @type {PersonalDataServer} */ let pds; /** @type {string} */ let cookie; beforeEach(async () => { pds = new PersonalDataServer({ actorStorage: /** @type {any} */ ( createMockStorage({ records: [ 'app.bsky.feed.post/aaa', 'app.bsky.feed.post/bbb', 'app.bsky.actor.profile/self', ], blobs: ['bafblob1', 'bafblob2'], }) ), sharedStorage: /** @type {any} */ ( createMockSharedStorage({ appPasswords: [ { name: 'phone', privileged: false, createdAt: '2026-01-02' }, ], oauthTokens: [ { did: DID, clientId: 'https://app.example/client-metadata.json', scope: 'atproto repo:app.bsky.feed.post', dpopJkt: 'thumbprint-abcdefghijklmnop', createdAt: Date.parse('2026-02-03T00:00:00Z'), tokenId: REFRESH_TOKEN, }, ], }) ), blobs: /** @type {any} */ ({}), jwtSecret: 'test-secret', password: PASSWORD, plcUrl: 'http://localhost:2582', }); cookie = await signedInCookie(pds); }); it('counts records, blobs, apps and app passwords on the home page', async () => { const { html } = await getPage(pds, '/account', cookie); expect(html).toContain('>3
Records'); expect(html).toContain('>2
Blobs'); // Apps counts distinct app domains, not sessions: bsky.app (from the // app.bsky.* records, no session) plus app.example (the one session) = 2. expect(html).toContain('>2
Apps'); expect(html).toContain('>1
App passwords'); }); it('lists connected apps with scope and authorization date', async () => { const { html } = await getPage(pds, '/account/apps', cookie); expect(html).toContain('https://app.example/client-metadata.json'); expect(html).toContain('repo:app.bsky.feed.post'); expect(html).toContain('2026-02-03'); // Only a prefix of the DPoP thumbprint, enough to tell sessions apart expect(html).toContain('thumbprint-a'); expect(html).not.toContain('thumbprint-abcdefghijklmnop'); }); it('never renders the refresh token behind a session', async () => { const { html } = await getPage(pds, '/account/apps', cookie); expect(html).not.toContain(REFRESH_TOKEN); expect(html).toContain('name="session"'); }); it('revokes an app session', async () => { const listing = await getPage(pds, '/account/apps', cookie); const sessionId = listing.html.match(/name="session" value="([^"]+)"/)?.[1]; expect(sessionId).toBeTruthy(); const response = await postForm( pds, '/account/apps/revoke', { session: /** @type {string} */ (sessionId) }, cookie, ); expect(response.status).toBe(200); const html = await response.text(); expect(html).toContain('Revoked the session'); expect(html).toContain('No app has been authorized yet.'); // Gone from storage, not just from the page const after = await getPage(pds, '/account/apps', cookie); expect(after.html).not.toContain( 'https://app.example/client-metadata.json', ); }); it('refuses a session handle it did not issue', async () => { const response = await postForm( pds, '/account/apps/revoke', { session: 'not-a-real-session-handle' }, cookie, ); expect(response.status).toBe(400); expect(await response.text()).toContain('no longer exists'); // The real session survives a failed guess const { html } = await getPage(pds, '/account/apps', cookie); expect(html).toContain('https://app.example/client-metadata.json'); }); it('offers no revoke button when storage does not return its keys', async () => { const legacy = new PersonalDataServer({ actorStorage: /** @type {any} */ (createMockStorage()), sharedStorage: /** @type {any} */ ( createMockSharedStorage({ // An adapter predating the tokenId field oauthTokens: [{ did: DID, clientId: 'https://old.example' }], }) ), blobs: /** @type {any} */ ({}), jwtSecret: 'test-secret', password: PASSWORD, }); const { html } = await getPage( legacy, '/account/apps', await signedInCookie(legacy), ); expect(html).toContain('https://old.example'); expect(html).not.toContain('name="session"'); }); it('creates an app password and shows it exactly once', async () => { const response = await postForm( pds, '/account/app-passwords/create', { name: 'laptop' }, cookie, ); expect(response.status).toBe(200); const html = await response.text(); const shown = html.match(/class="pw">([^<]+) { await postForm( pds, '/account/app-passwords/create', { name: 'trusted', privileged: 'true' }, cookie, ); const { html } = await getPage(pds, '/account/app-passwords', cookie); expect(html).toContain('privileged'); }); it('refuses a duplicate app password name', async () => { const response = await postForm( pds, '/account/app-passwords/create', { name: 'phone' }, cookie, ); expect(response.status).toBe(400); expect(await response.text()).toContain('already exists'); }); it('refuses an app password with no name', async () => { const response = await postForm( pds, '/account/app-passwords/create', { name: ' ' }, cookie, ); expect(response.status).toBe(400); expect(await response.text()).toContain('name is required'); }); it('revokes an app password', async () => { const response = await postForm( pds, '/account/app-passwords/revoke', { name: 'phone' }, cookie, ); expect(response.status).toBe(200); const html = await response.text(); // Escaped, because the name is interpolated into the page expect(html).toContain('Revoked "phone"'); expect(html).toContain('No app passwords yet.'); }); it('reports an unknown app password on revoke', async () => { const response = await postForm( pds, '/account/app-passwords/revoke', { name: 'nothing' }, cookie, ); expect(response.status).toBe(400); expect(await response.text()).toContain('No app password named'); }); it('groups repository records by collection', async () => { const { html } = await getPage(pds, '/account/repo', cookie); expect(html).toContain('app.bsky.feed.post'); expect(html).toContain('2 records'); expect(html).toContain('app.bsky.actor.profile'); expect(html).toContain('1 record<'); expect(html).toContain('3lrev'); }); it('falls back to the request origin when no hostname is configured', async () => { const { html } = await getPage(pds, '/account/identity', cookie); // Before the hostname fallback this rendered "https://undefined", the // same string signPlcOperation would have published to the directory. expect(html).not.toContain('undefined'); expect(html).toContain('https://pds.example.com'); }); it('reads the records in a repo collection', async () => { const { status, html } = await getPage( pds, '/account/repo/records?collection=app.bsky.feed.post', cookie, ); expect(status).toBe(200); // The card is the getRecord response shape, labelled in its own terms expect(html).toContain('uri:'); expect(html).toContain(`at://${DID}/app.bsky.feed.post/aaa`); expect(html).toContain('cid:'); // Record fields as a key/value tree, not JSON text expect(html).toContain('text:'); expect(html).toContain('record aaa'); expect(html).not.toContain('"text"'); expect(html).toContain('Public records'); // Only the named collection expect(html).not.toContain('app.bsky.actor.profile'); }); it('links each repo collection to its records', async () => { const { html } = await getPage(pds, '/account/repo', cookie); expect(html).toContain( '/account/repo/records?collection=app.bsky.feed.post', ); }); it('refuses a repo record view with no collection', async () => { const { status, html } = await getPage( pds, '/account/repo/records', cookie, ); expect(status).toBe(400); expect(html).toContain('Name a collection'); }); it('turns away the repo record view when signed out', async () => { const { status } = await getPage( pds, '/account/repo/records?collection=app.bsky.feed.post', '', ); expect(status).toBe(401); }); it('shows identity and the configured directory', async () => { const { html } = await getPage(pds, '/account/identity', cookie); expect(html).toContain(DID); expect(html).toContain('http://localhost:2582'); expect(html).toContain('Active'); expect(html).toContain('value="deactivate"'); }); it('deactivates and reactivates the account', async () => { const off = await postForm( pds, '/account/status', { action: 'deactivate' }, cookie, ); expect(off.status).toBe(303); expect(off.headers.get('location')).toBe('/account/identity'); const deactivated = await getPage(pds, '/account/identity', cookie); expect(deactivated.html).toContain('Deactivated'); expect(deactivated.html).toContain('value="activate"'); await postForm(pds, '/account/status', { action: 'activate' }, cookie); const reactivated = await getPage(pds, '/account/identity', cookie); expect(reactivated.html).toContain('Active'); }); it('rejects an unknown status action', async () => { const response = await postForm( pds, '/account/status', { action: 'destroy' }, cookie, ); expect(response.status).toBe(400); expect(await response.text()).toContain('Unknown status action'); }); it('refuses account writes on a read-only server', async () => { const readOnly = new PersonalDataServer({ actorStorage: /** @type {any} */ (createMockStorage()), sharedStorage: /** @type {any} */ (createMockSharedStorage()), blobs: /** @type {any} */ ({}), jwtSecret: 'test-secret', password: PASSWORD, readOnly: true, }); const readOnlyCookie = await signedInCookie(readOnly); const created = await postForm( readOnly, '/account/app-passwords/create', { name: 'nope' }, readOnlyCookie, ); expect(created.status).toBe(403); expect(await created.text()).toContain('read-only'); // The status control is not offered at all const { html } = await getPage( readOnly, '/account/identity', readOnlyCookie, ); expect(html).not.toContain('value="deactivate"'); }); }); describe('Account email', () => { /** @type {PersonalDataServer} */ let pds; /** @type {string} */ let cookie; /** @type {Array<{to: string, subject: string, text: string}>} */ let sent; beforeEach(async () => { sent = []; pds = new PersonalDataServer({ actorStorage: /** @type {any} */ (createMockStorage()), sharedStorage: /** @type {any} */ (createMockSharedStorage()), blobs: /** @type {any} */ ({}), jwtSecret: 'test-secret', password: PASSWORD, emailer: { async send(message) { sent.push(message); }, }, }); cookie = await signedInCookie(pds); }); /** @returns {string} The code from the message just sent */ const codeFromEmail = () => /** @type {string} */ (sent.at(-1)?.text.match(/(\d{6})/)?.[1]); it('sends a code and holds the address pending', async () => { const response = await postForm( pds, '/account/email', { address: 'someone@example.com' }, cookie, ); expect(response.status).toBe(200); expect(sent).toHaveLength(1); expect(sent[0].to).toBe('someone@example.com'); expect(codeFromEmail()).toMatch(/^\d{6}$/); const html = await response.text(); expect(html).toContain('Sent a code'); // Not the account's address until the code comes back expect(html).toContain('No email address on this account.'); }); it('confirms the address with the code', async () => { await postForm( pds, '/account/email', { address: 'someone@example.com' }, cookie, ); const response = await postForm( pds, '/account/email/verify', { code: codeFromEmail() }, cookie, ); expect(response.status).toBe(200); const html = await response.text(); expect(html).toContain('someone@example.com is confirmed'); expect(html).toContain('confirmed'); }); it('rejects a wrong code and keeps the address pending', async () => { await postForm( pds, '/account/email', { address: 'someone@example.com' }, cookie, ); const response = await postForm( pds, '/account/email/verify', { code: '000000' }, cookie, ); expect(response.status).toBe(400); const html = await response.text(); expect(html).toContain('That code is wrong'); expect(html).toContain('Confirm someone@example.com'); }); it('refuses a code minted for a different address', async () => { await postForm(pds, '/account/email', { address: 'a@example.com' }, cookie); const firstCode = codeFromEmail(); // Superseded by a second request, so the first code must not confirm it await postForm(pds, '/account/email', { address: 'b@example.com' }, cookie); const response = await postForm( pds, '/account/email/verify', { code: firstCode }, cookie, ); expect(response.status).toBe(400); }); it('rejects a malformed address before sending anything', async () => { const response = await postForm( pds, '/account/email', { address: 'not-an-address' }, cookie, ); expect(response.status).toBe(400); expect(sent).toHaveLength(0); }); it('drops the pending address when sending fails', async () => { const broken = new PersonalDataServer({ actorStorage: /** @type {any} */ (createMockStorage()), sharedStorage: /** @type {any} */ (createMockSharedStorage()), blobs: /** @type {any} */ ({}), jwtSecret: 'test-secret', password: PASSWORD, emailer: { async send() { throw new Error('smtp is down'); }, }, }); const brokenCookie = await signedInCookie(broken); const response = await postForm( broken, '/account/email', { address: 'someone@example.com' }, brokenCookie, ); expect(response.status).toBe(502); // No confirmation form for a code that never went out const { html } = await getPage(broken, '/account/email', brokenCookie); expect(html).not.toContain('Confirm someone@example.com'); }); it('cancels a pending change', async () => { await postForm( pds, '/account/email', { address: 'someone@example.com' }, cookie, ); const response = await postForm(pds, '/account/email/cancel', {}, cookie); expect(await response.text()).toContain('Change cancelled'); }); it('removes a confirmed address', async () => { await postForm( pds, '/account/email', { address: 'someone@example.com' }, cookie, ); await postForm( pds, '/account/email/verify', { code: codeFromEmail() }, cookie, ); const response = await postForm(pds, '/account/email/remove', {}, cookie); expect(await response.text()).toContain( 'No email address on this account.', ); }); it('stores an unconfirmed address when no sender is configured', async () => { const noMail = new PersonalDataServer({ actorStorage: /** @type {any} */ (createMockStorage()), sharedStorage: /** @type {any} */ (createMockSharedStorage()), blobs: /** @type {any} */ ({}), jwtSecret: 'test-secret', password: PASSWORD, }); const noMailCookie = await signedInCookie(noMail); const response = await postForm( noMail, '/account/email', { address: 'someone@example.com' }, noMailCookie, ); const html = await response.text(); expect(html).toContain('stays unconfirmed'); expect(html).toContain('unconfirmed'); }); }); describe('Account passkeys', () => { /** @type {PersonalDataServer} */ let pds; /** @type {string} */ let cookie; beforeEach(async () => { pds = new PersonalDataServer({ actorStorage: /** @type {any} */ (createMockStorage()), sharedStorage: /** @type {any} */ (createMockSharedStorage()), blobs: /** @type {any} */ ({}), jwtSecret: 'test-secret', password: PASSWORD, }); cookie = await signedInCookie(pds); }); /** * Drive the browser half of a registration: read the challenge the page * embedded, then answer it with a synthetic authenticator. */ async function register(name = 'laptop') { const page = await pds.fetch( new Request('https://pds.example.com/account/passkeys', { headers: { Cookie: cookie }, }), ); const html = await page.text(); const challenge = html.match(/unb64\("([^"]+)"\)/)?.[1]; const ceremonyCookie = (page.headers.get('set-cookie') || '').split(';')[0]; const credential = await createTestCredential(); const response = await pds.fetch( new Request('https://pds.example.com/account/passkeys/register', { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: `${cookie}; ${ceremonyCookie}`, }, body: JSON.stringify({ name, credentialId: credential.credentialId, publicKey: credential.publicKey, algorithm: -7, clientDataJSON: encodeClientData({ type: 'webauthn.create', challenge, origin: 'https://pds.example.com', }), }), }), ); return { response, credential, challenge }; } it('registers a passkey and lists it', async () => { const { response } = await register('laptop'); expect(response.status).toBe(200); expect(await response.json()).toMatchObject({ ok: true, name: 'laptop' }); const { html } = await getPage(pds, '/account/passkeys', cookie); expect(html).toContain('laptop'); expect(html).toContain('never used'); }); it('refuses a registration with no challenge cookie', async () => { const credential = await createTestCredential(); const response = await pds.fetch( new Request('https://pds.example.com/account/passkeys/register', { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: cookie }, body: JSON.stringify({ credentialId: credential.credentialId, publicKey: credential.publicKey, algorithm: -7, clientDataJSON: encodeClientData({ type: 'webauthn.create', challenge: 'made-up', origin: 'https://pds.example.com', }), }), }), ); expect(response.status).toBe(400); expect((await response.json()).error).toMatch(/expired/); }); it('refuses a registration when not signed in', async () => { const response = await pds.fetch( new Request('https://pds.example.com/account/passkeys/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }), ); expect(response.status).toBe(401); }); it('signs in with a registered passkey', async () => { const { credential } = await register(); const start = await pds.fetch( new Request('https://pds.example.com/account/passkey/challenge'), ); const { challenge, rpId } = await start.json(); expect(rpId).toBe('pds.example.com'); const ceremonyCookie = (start.headers.get('set-cookie') || '').split( ';', )[0]; const assertion = await signTestAssertion(credential, challenge); const response = await pds.fetch( new Request('https://pds.example.com/account/passkey/sign-in', { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: ceremonyCookie, }, body: JSON.stringify(assertion), }), ); expect(response.status).toBe(200); // The response opens a real account session const issued = sessionCookie(response); expect(issued).not.toBe(''); const { html } = await getPage( pds, '/account', `pdsjs_account=${encodeURIComponent(issued)}`, ); expect(html).toContain(DID); }); it('refuses an assertion signed by an unregistered key', async () => { await register(); const start = await pds.fetch( new Request('https://pds.example.com/account/passkey/challenge'), ); const { challenge } = await start.json(); const ceremonyCookie = (start.headers.get('set-cookie') || '').split( ';', )[0]; const impostor = await createTestCredential(); const assertion = await signTestAssertion(impostor, challenge); const response = await pds.fetch( new Request('https://pds.example.com/account/passkey/sign-in', { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: ceremonyCookie, }, body: JSON.stringify(assertion), }), ); expect(response.status).toBe(401); expect(sessionCookie(response)).toBe(''); }); it('refuses a sign-in challenge spent as a registration', async () => { const start = await pds.fetch( new Request('https://pds.example.com/account/passkey/challenge'), ); const { challenge } = await start.json(); const ceremonyCookie = (start.headers.get('set-cookie') || '').split( ';', )[0]; const credential = await createTestCredential(); const response = await pds.fetch( new Request('https://pds.example.com/account/passkeys/register', { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: `${cookie}; ${ceremonyCookie}`, }, body: JSON.stringify({ credentialId: credential.credentialId, publicKey: credential.publicKey, algorithm: -7, clientDataJSON: encodeClientData({ type: 'webauthn.create', challenge, origin: 'https://pds.example.com', }), }), }), ); expect(response.status).toBe(400); }); it('removes a passkey', async () => { const { credential } = await register(); const response = await postForm( pds, '/account/passkeys/revoke', { credentialId: credential.credentialId }, cookie, ); expect(response.status).toBe(200); const html = await response.text(); expect(html).toContain('Passkey removed.'); expect(html).toContain('No passkeys yet.'); }); it('offers a passkey button on the sign-in page only when supported', async () => { const { html } = await getPage(pds, '/account', ''); expect(html).toContain('Use a passkey'); const noPasskeys = new PersonalDataServer({ actorStorage: /** @type {any} */ (createMockStorage()), sharedStorage: /** @type {any} */ ({}), blobs: /** @type {any} */ ({}), jwtSecret: 'test-secret', password: PASSWORD, }); const plain = await getPage(noPasskeys, '/account', ''); expect(plain.html).not.toContain('Use a passkey'); }); }); describe('PLC operation confirmation', () => { /** @type {Array<{to: string, subject: string, text: string}>} */ let sent; /** * @param {boolean} withEmail - Whether a confirmed address exists * @returns {Promise} */ async function createServer(withEmail) { sent = []; const pds = new PersonalDataServer({ actorStorage: /** @type {any} */ (createMockStorage()), sharedStorage: /** @type {any} */ (createMockSharedStorage()), blobs: /** @type {any} */ ({}), jwtSecret: 'test-secret', password: PASSWORD, emailer: withEmail ? { async send(message) { sent.push(message); }, } : undefined, }); if (withEmail) { const cookie = await signedInCookie(pds); await postForm( pds, '/account/email', { address: 'a@example.com' }, cookie, ); const code = /** @type {string} */ (sent[0].text.match(/(\d{6})/)?.[1]); await postForm(pds, '/account/email/verify', { code }, cookie); sent.length = 0; } return pds; } /** @param {PersonalDataServer} pds */ async function accessToken(pds) { const session = await pds.fetch( new Request( 'https://pds.example.com/xrpc/com.atproto.server.createSession', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier: HANDLE, password: PASSWORD }), }, ), ); return (await session.json()).accessJwt; } /** * @param {PersonalDataServer} pds * @param {string} path * @param {object} body * @param {string} token */ function xrpc(pds, path, body, token) { return pds.fetch( new Request(`https://pds.example.com/xrpc/${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, body: JSON.stringify(body), }), ); } it('mails a code when the account has a confirmed address', async () => { const pds = await createServer(true); const token = await accessToken(pds); const response = await xrpc( pds, 'com.atproto.identity.requestPlcOperationSignature', {}, token, ); expect(response.status).toBe(200); expect(sent).toHaveLength(1); expect(sent[0].subject).toMatch(/identity change/); expect(sent[0].text).toMatch(/\d{6}/); }); it('refuses to sign without the code', async () => { const pds = await createServer(true); const token = await accessToken(pds); await xrpc( pds, 'com.atproto.identity.requestPlcOperationSignature', {}, token, ); const response = await xrpc( pds, 'com.atproto.identity.signPlcOperation', { token: '000000' }, token, ); expect(response.status).toBe(400); expect((await response.json()).error).toBe('InvalidToken'); }); it('refuses to sign when no code was ever requested', async () => { const pds = await createServer(true); const token = await accessToken(pds); const response = await xrpc( pds, 'com.atproto.identity.signPlcOperation', {}, token, ); expect(response.status).toBe(400); expect((await response.json()).message).toMatch(/Ask for a confirmation/); }); it('spends the code, so it signs only one operation', async () => { const pds = await createServer(true); const token = await accessToken(pds); await xrpc( pds, 'com.atproto.identity.requestPlcOperationSignature', {}, token, ); const code = /** @type {string} */ (sent[0].text.match(/(\d{6})/)?.[1]); // Signing needs a key this mock account does not have, but the code is // checked first: a wrong-token rejection is what proves it was accepted const first = await xrpc( pds, 'com.atproto.identity.signPlcOperation', { token: code }, token, ); expect((await first.json()).error).not.toBe('InvalidToken'); const second = await xrpc( pds, 'com.atproto.identity.signPlcOperation', { token: code }, token, ); expect((await second.json()).error).toBe('InvalidToken'); }); it('keeps session-only authorization when no address is confirmed', async () => { const pds = await createServer(false); const token = await accessToken(pds); const requested = await xrpc( pds, 'com.atproto.identity.requestPlcOperationSignature', {}, token, ); expect(requested.status).toBe(200); // No code was issued, so signing must not start demanding one const response = await xrpc( pds, 'com.atproto.identity.signPlcOperation', {}, token, ); expect((await response.json()).error).not.toBe('InvalidToken'); }); }); describe('Password sessions', () => { /** @type {PersonalDataServer} */ let pds; /** @type {string} */ let cookie; beforeEach(async () => { pds = new PersonalDataServer({ actorStorage: /** @type {any} */ (createMockStorage()), sharedStorage: /** @type {any} */ (createMockSharedStorage()), blobs: /** @type {any} */ ({}), jwtSecret: 'test-secret', password: PASSWORD, }); cookie = await signedInCookie(pds); }); /** * @param {string} [password] * @param {string} [userAgent] */ async function createSession( password = PASSWORD, userAgent = 'TestBrowser/1.0', ) { const response = await pds.fetch( new Request( 'https://pds.example.com/xrpc/com.atproto.server.createSession', { method: 'POST', headers: { 'Content-Type': 'application/json', 'User-Agent': userAgent, }, body: JSON.stringify({ identifier: HANDLE, password }), }, ), ); return response.json(); } /** @param {string} refreshJwt */ function refresh(refreshJwt) { return pds.fetch( new Request( 'https://pds.example.com/xrpc/com.atproto.server.refreshSession', { method: 'POST', headers: { Authorization: `Bearer ${refreshJwt}` }, }, ), ); } it('lists a password login with its user agent', async () => { await createSession(); const { html } = await getPage(pds, '/account/sessions', cookie); expect(html).toContain('Account password'); expect(html).toContain('TestBrowser/1.0'); }); it('labels an app-password login by its name', async () => { await postForm( pds, '/account/app-passwords/create', { name: 'phone' }, cookie, ); const created = await getPage(pds, '/account/app-passwords', cookie); expect(created.html).toContain('phone'); // Reuse the password the create page revealed const revealed = await postForm( pds, '/account/app-passwords/create', { name: 'tablet' }, cookie, ); const appPassword = /** @type {string} */ ( (await revealed.text()).match(/class="pw">([^<]+) { const session = await createSession(); const refreshed = await refresh(session.refreshJwt); expect(refreshed.status).toBe(200); const { html } = await getPage(pds, '/account/sessions', cookie); expect(html.match(/Account password/g)).toHaveLength(1); expect(html).toContain('renewed'); }); it('redeems a superseded refresh token onto the same replacement', async () => { const session = await createSession(); const refreshed = await refresh(session.refreshJwt); expect(refreshed.status).toBe(200); // The superseded token stays redeemable for its grace window, so a client // that refreshed twice converges on one token instead of losing the // session. It buys no second session either. const replay = await refresh(session.refreshJwt); expect(replay.status).toBe(200); // Compared by id: the two tokens are minted a moment apart, so they differ // as strings while naming the same successor. const jti = (/** @type {string} */ jwt) => JSON.parse(atob(jwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))) .jti; expect(jti((await replay.json()).refreshJwt)).toBe( jti((await refreshed.json()).refreshJwt), ); const { html } = await getPage(pds, '/account/sessions', cookie); expect(html.match(/Account password/g)).toHaveLength(1); }); it('revoking from the page stops the session refreshing', async () => { const session = await createSession(); const { html } = await getPage(pds, '/account/sessions', cookie); const jti = /** @type {string} */ ( html.match(/name="jti" value="([^"]+)"/)?.[1] ); const response = await postForm( pds, '/account/sessions/revoke', { jti }, cookie, ); expect(response.status).toBe(200); expect(await response.text()).toContain('Session revoked'); const after = await refresh(session.refreshJwt); expect(after.status).toBe(401); }); it('deleteSession ends the session it names', async () => { const session = await createSession(); const response = await pds.fetch( new Request( 'https://pds.example.com/xrpc/com.atproto.server.deleteSession', { method: 'POST', headers: { Authorization: `Bearer ${session.refreshJwt}` }, }, ), ); expect(response.status).toBe(200); expect((await refresh(session.refreshJwt)).status).toBe(401); const { html } = await getPage(pds, '/account/sessions', cookie); expect(html).toContain('No password logins are open.'); }); it('deleteSession refuses an access token in place of a refresh token', async () => { const session = await createSession(); const response = await pds.fetch( new Request( 'https://pds.example.com/xrpc/com.atproto.server.deleteSession', { method: 'POST', headers: { Authorization: `Bearer ${session.accessJwt}` }, }, ), ); expect(response.status).toBe(401); // The session survives the bad attempt expect((await refresh(session.refreshJwt)).status).toBe(200); }); it('keeps stateless refresh on storage with no registry', async () => { const stateless = new PersonalDataServer({ actorStorage: /** @type {any} */ (createMockStorage()), sharedStorage: /** @type {any} */ ({}), blobs: /** @type {any} */ ({}), jwtSecret: 'test-secret', password: PASSWORD, }); const session = await ( await stateless.fetch( new Request( 'https://pds.example.com/xrpc/com.atproto.server.createSession', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier: HANDLE, password: PASSWORD }), }, ), ) ).json(); const refreshed = await stateless.fetch( new Request( 'https://pds.example.com/xrpc/com.atproto.server.refreshSession', { method: 'POST', headers: { Authorization: `Bearer ${session.refreshJwt}` }, }, ), ); expect(refreshed.status).toBe(200); const { html } = await getPage( stateless, '/account/sessions', await signedInCookie(stateless), ); expect(html).toContain('does not keep a session registry'); }); }); describe('Spaces section', () => { /** A space storage stand-in, shaped like @pdsjs/spaces' own. */ const OWN_SPACE = 'at://did:plc:test123/club.run.space/abc'; const JOINED_SPACE = 'at://did:plc:otheraccountotheraccount/club.run.space/xyz'; function createSpaceStorage() { return { async listSpaces() { return { spaces: [ { uri: OWN_SPACE, spaceDid: DID, spaceType: 'club.run.space', isOwner: true, policy: { $type: 'com.atproto.memberspace.policy' }, createdAt: '2026-03-04T00:00:00.000Z', deletedAt: null, }, { // Joined, not owned: the row a member's own PDS materialises // on its first write into someone else's space uri: JOINED_SPACE, spaceDid: 'did:plc:otheraccountotheraccount', spaceType: 'club.run.space', isOwner: false, policy: null, createdAt: '2026-03-05T00:00:00.000Z', deletedAt: null, }, ], cursor: null, }; }, async listMembers(/** @type {string} */ space) { // A member's PDS is never told the authority's member list return space === OWN_SPACE ? { members: [ { did: DID, read: true, write: true }, { did: 'did:plc:friend', read: true, write: true }, ], cursor: null, } : { members: [], cursor: null }; }, async listAllSpaceRecords(/** @type {string} */ space) { if (space !== OWN_SPACE) { return [{ collection: 'club.run.activity', rkey: 'z', cid: 'bafy9' }]; } return [ { collection: 'club.run.activity', rkey: 'a', cid: 'bafy1' }, { collection: 'club.run.activity', rkey: 'b', cid: 'bafy2' }, { collection: 'club.run.note', rkey: 'c', cid: 'bafy3' }, ]; }, async listSpaceRecords( /** @type {string} */ _space, /** @type {string} */ collection, /** @type {string|null} */ _cursor, /** @type {number} */ limit, ) { const values = [ { $type: collection, note: 'first', distanceMeters: 8000 }, { $type: collection, note: 'second', distanceMeters: 5200 }, ]; return { records: values.slice(0, limit).map((value, index) => ({ rkey: `rkey-${index}`, cid: `bafyrecord${index}`, indexedAt: '2026-03-06T00:00:00.000Z', value: cborEncodeDagCbor(value), })), cursor: null, }; }, }; } /** @param {boolean} enabled */ async function createServer(enabled) { const pds = new PersonalDataServer({ actorStorage: /** @type {any} */ (createMockStorage()), sharedStorage: /** @type {any} */ (createMockSharedStorage()), blobs: /** @type {any} */ ({}), jwtSecret: 'test-secret', password: PASSWORD, spaceStorage: enabled ? /** @type {any} */ (createSpaceStorage()) : undefined, }); return { pds, cookie: await signedInCookie(pds) }; } it('lists spaces with member and record counts', async () => { const { pds, cookie } = await createServer(true); const { html } = await getPage(pds, '/account/spaces', cookie); expect(html).toContain('club.run.space'); expect(html).toContain('at://did:plc:test123/club.run.space/abc'); expect(html).toContain('yours'); expect(html).toContain('2 members'); expect(html).toContain(' · memberspace · '); expect(html).toContain('club.run.activity (2)'); expect(html).toContain('club.run.note (1)'); }); it('marks a space the account joined but does not own', async () => { const { pds, cookie } = await createServer(true); const { html } = await getPage(pds, '/account/spaces', cookie); // The owned one is tagged; the joined one names its authority instead expect(html.match(/>yours { const { pds, cookie } = await createServer(true); const { status, html } = await getPage( pds, `/account/spaces/records?space=${encodeURIComponent(OWN_SPACE)}&collection=club.run.activity`, cookie, ); expect(status).toBe(200); // Space record uris carry the writer: space/did/collection/rkey expect(html).toContain(`${OWN_SPACE}/${DID}/club.run.activity/rkey-0`); expect(html).toContain('note:'); expect(html).toContain('first'); expect(html).toContain('distanceMeters:'); expect(html).toContain('8000'); expect(html).toContain('bafyrecord0'); }); it('refuses to read a space this account does not hold', async () => { const { pds, cookie } = await createServer(true); const { status, html } = await getPage( pds, '/account/spaces/records?space=at%3A%2F%2Fdid%3Aplc%3Astranger%2Fclub.run.space%2Fnope&collection=club.run.activity', cookie, ); expect(status).toBe(404); expect(html).toContain('No such space'); }); it('turns away the record view when signed out', async () => { const { pds } = await createServer(true); const { status } = await getPage( pds, `/account/spaces/records?space=${encodeURIComponent(OWN_SPACE)}&collection=club.run.activity`, '', ); expect(status).toBe(401); }); it('says permissioned data is off when no browser is injected', async () => { const { pds, cookie } = await createServer(false); const { html } = await getPage(pds, '/account/spaces', cookie); expect(html).toContain('Permissioned data is off'); expect(html).not.toContain('club.run.space'); }); }); describe('Passkey consent', () => { /** @type {PersonalDataServer} */ let pds; /** @type {string} */ let cookie; const REQUEST_URI = 'urn:ietf:params:oauth:request_uri:req-1'; const CLIENT_ID = 'http://localhost:3000'; beforeEach(async () => { pds = new PersonalDataServer({ actorStorage: /** @type {any} */ (createMockStorage()), sharedStorage: /** @type {any} */ (createMockSharedStorage()), blobs: /** @type {any} */ ({}), jwtSecret: 'test-secret', password: PASSWORD, }); cookie = await signedInCookie(pds); await pds.sharedStorage.putOAuthRequest( REQUEST_URI, { clientId: CLIENT_ID, clientMetadata: { client_name: 'Test App' }, redirectUri: 'http://localhost:3000/callback', scope: 'atproto', state: 'state-1', responseMode: null, codeChallenge: 'challenge', codeChallengeMethod: 'S256', }, Date.now() + 600000, ); }); /** Register a passkey through the account page, as a browser would. */ async function registerPasskey() { const page = await pds.fetch( new Request('https://pds.example.com/account/passkeys', { headers: { Cookie: cookie }, }), ); const challenge = (await page.text()).match(/unb64\("([^"]+)"\)/)?.[1]; const ceremonyCookie = (page.headers.get('set-cookie') || '').split(';')[0]; const credential = await createTestCredential(); await pds.fetch( new Request('https://pds.example.com/account/passkeys/register', { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: `${cookie}; ${ceremonyCookie}`, }, body: JSON.stringify({ credentialId: credential.credentialId, publicKey: credential.publicKey, algorithm: -7, clientDataJSON: encodeClientData({ type: 'webauthn.create', challenge, origin: 'https://pds.example.com', }), }), }), ); return credential; } /** Load the consent page and return its passkey challenge and cookie. */ async function loadConsent() { const response = await pds.fetch( new Request( `https://pds.example.com/oauth/authorize?request_uri=${encodeURIComponent(REQUEST_URI)}&client_id=${encodeURIComponent(CLIENT_ID)}`, ), ); const html = await response.text(); return { response, html, challenge: html.match(/unb64\("([^"]+)"\)/)?.[1], ceremonyCookie: (response.headers.get('set-cookie') || '').split(';')[0], }; } it('offers a passkey on the consent page and binds a challenge', async () => { const { response, html, challenge } = await loadConsent(); expect(response.status).toBe(200); expect(html).toContain('Authorize with a passkey'); expect(challenge).toBeTruthy(); expect(response.headers.get('set-cookie')).toContain('Path=/oauth'); // The password path is still there expect(html).toContain('name="password"'); }); it('issues the code for a valid assertion', async () => { const credential = await registerPasskey(); const { challenge, ceremonyCookie } = await loadConsent(); const assertion = await signTestAssertion( credential, /** @type {string} */ (challenge), ); const response = await pds.fetch( new Request('https://pds.example.com/oauth/authorize/passkey', { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: ceremonyCookie, }, body: JSON.stringify({ request_uri: REQUEST_URI, client_id: CLIENT_ID, ...assertion, }), }), ); expect(response.status).toBe(200); const { redirect } = await response.json(); const location = new URL(redirect); expect(location.origin).toBe('http://localhost:3000'); expect(location.searchParams.get('code')).toBeTruthy(); expect(location.searchParams.get('state')).toBe('state-1'); expect(location.searchParams.get('iss')).toBe('https://pds.example.com'); // The challenge cookie is spent alongside the approval expect(response.headers.get('set-cookie')).toContain('Max-Age=0'); }); it('refuses a sign-in challenge spent as consent', async () => { const credential = await registerPasskey(); // A challenge minted for the account sign-in ceremony, not for consent const start = await pds.fetch( new Request('https://pds.example.com/account/passkey/challenge'), ); const { challenge } = await start.json(); const wrongCookie = (start.headers.get('set-cookie') || '').split(';')[0]; const assertion = await signTestAssertion(credential, challenge); const response = await pds.fetch( new Request('https://pds.example.com/oauth/authorize/passkey', { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: wrongCookie, }, body: JSON.stringify({ request_uri: REQUEST_URI, client_id: CLIENT_ID, ...assertion, }), }), ); expect(response.status).toBe(400); expect((await response.json()).error).toMatch(/expired/); }); it('refuses an assertion from an unregistered key', async () => { await registerPasskey(); const { challenge, ceremonyCookie } = await loadConsent(); const impostor = await createTestCredential(); const assertion = await signTestAssertion( impostor, /** @type {string} */ (challenge), ); const response = await pds.fetch( new Request('https://pds.example.com/oauth/authorize/passkey', { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: ceremonyCookie, }, body: JSON.stringify({ request_uri: REQUEST_URI, client_id: CLIENT_ID, ...assertion, }), }), ); expect(response.status).toBe(401); }); it('offers no passkey button when storage cannot check one', async () => { const plain = new PersonalDataServer({ actorStorage: /** @type {any} */ (createMockStorage()), sharedStorage: /** @type {any} */ ({ async getOAuthRequest() { return { clientId: CLIENT_ID, clientMetadata: { client_name: 'Test App' }, redirectUri: 'http://localhost:3000/callback', scope: 'atproto', }; }, }), blobs: /** @type {any} */ ({}), jwtSecret: 'test-secret', password: PASSWORD, }); const response = await plain.fetch( new Request( `https://pds.example.com/oauth/authorize?request_uri=${encodeURIComponent(REQUEST_URI)}&client_id=${encodeURIComponent(CLIENT_ID)}`, ), ); const html = await response.text(); expect(html).not.toContain('passkey'); expect(html).toContain('name="password"'); }); }); describe('Account API', () => { /** @type {PersonalDataServer} */ let pds; /** @type {string} */ let cookie; beforeEach(async () => { pds = new PersonalDataServer({ actorStorage: /** @type {any} */ ( createMockStorage({ records: ['app.bsky.feed.post/aaa'] }) ), sharedStorage: /** @type {any} */ (createMockSharedStorage()), blobs: /** @type {any} */ ({}), jwtSecret: 'test-secret', password: PASSWORD, // What a platform package injects when the application is installed // A realistic shell: the sign-in bootstrap is injected before . accountApp: '
', }); cookie = await signedInCookie(pds); }); /** @param {string} path @param {string} [withCookie] */ async function api(path, withCookie = cookie) { const response = await pds.fetch( new Request(`https://pds.example.com/account/api${path}`, { headers: { Cookie: withCookie }, }), ); return { status: response.status, body: await response.json() }; } it('serves the application for a signed-in section path', async () => { for (const path of ['/account', '/account/apps', '/account/identity']) { const { html } = await getPage(pds, path, cookie); expect(html, path).toContain('
'); } }); it('boots the mounted app into sign-in mode when signed out', async () => { const { html } = await getPage(pds, '/account', ''); // The shell is served either way; signed out it carries a sign-in-mode // bootstrap rather than the authenticated application. expect(html).toContain('
'); expect(html).toContain('window.__PDS__'); expect(html).toContain('signin'); }); it('leaves the API to answer its own paths', async () => { const { status, body } = await api('/overview'); expect(status).toBe(200); expect(body.did).toBe(DID); expect(body.stats.records).toBe(1); expect(body.features).toMatchObject({ passkeys: true, sessions: true }); }); it('turns away every API path when signed out', async () => { for (const path of [ '/overview', '/apps', '/sessions', '/identity', '/version', ]) { const { status, body } = await api(path, ''); expect(status, path).toBe(401); expect(body.error).toBe('Not signed in'); } }); it('rejects a cross-origin API write', async () => { const response = await pds.fetch( new Request('https://pds.example.com/account/api/status', { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: cookie, Origin: 'https://evil.example', }, body: JSON.stringify({ action: 'deactivate' }), }), ); expect(response.status).toBe(403); }); it('creates and revokes an app password through the API', async () => { const created = await pds.fetch( new Request('https://pds.example.com/account/api/app-passwords/create', { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: cookie }, body: JSON.stringify({ name: 'laptop' }), }), ); const body = await created.json(); expect(body.password).toMatch( /^[a-z2-7]{4}-[a-z2-7]{4}-[a-z2-7]{4}-[a-z2-7]{4}$/, ); const listed = await api('/app-passwords'); expect(listed.body.passwords[0].name).toBe('laptop'); // Storage keeps a hash, so a listing cannot leak the credential expect(JSON.stringify(listed.body)).not.toContain(body.password); }); it('reads a repo collection through the API', async () => { const { status, body } = await api( '/records?collection=app.bsky.feed.post', ); expect(status).toBe(200); expect(body.records[0].uri).toBe(`at://${DID}/app.bsky.feed.post/aaa`); expect(body.records[0].value.text).toBe('record aaa'); expect(body.blobBase).toContain('com.atproto.sync.getBlob'); }); it('reports an unknown collection as an empty read, not an error', async () => { const { status, body } = await api( '/records?collection=app.bsky.feed.like', ); expect(status).toBe(200); expect(body.records).toEqual([]); }); /** @param {Record} payload */ async function postAlias(payload) { const response = await pds.fetch( new Request('https://pds.example.com/account/api/apps/alias', { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: cookie }, body: JSON.stringify(payload), }), ); return { status: response.status, body: await response.json() }; } it('combines two app rows and serves the alias back with the apps list', async () => { const combined = await postAlias({ action: 'combine', from: 'tangled.sh', to: 'tangled.org', }); expect(combined.status).toBe(200); expect(combined.body.aliases).toEqual({ 'tangled.sh': 'tangled.org' }); const { body } = await api('/apps'); expect(body.aliases).toEqual({ 'tangled.sh': 'tangled.org' }); expect(body.aliasesAvailable).toBe(true); }); it('separates a combined row again', async () => { await postAlias({ action: 'combine', from: 'tangled.sh', to: 'tangled.org', }); const { status, body } = await postAlias({ action: 'separate', from: 'tangled.sh', }); expect(status).toBe(200); expect(body.aliases).toEqual({}); }); it('remembers a dismissed offer without creating an alias', async () => { const { body } = await postAlias({ action: 'dismiss', from: 'tangled.sh', to: 'tangled.org', }); expect(body.aliases).toEqual({}); expect(body.dismissedPairs).toEqual(['tangled.sh>tangled.org']); }); it('rejects an alias that is not a hostname, or an unknown action', async () => { for (const payload of [ { action: 'combine', from: 'not a host', to: 'tangled.org' }, { action: 'combine', from: 'tangled.sh', to: 'tangled.sh' }, { action: 'shrug', from: 'tangled.sh', to: 'tangled.org' }, ]) { const { status } = await postAlias(payload); expect(status, JSON.stringify(payload)).toBe(400); } }); it('never lets a chain of aliases form', async () => { // b already folds into c; combining a into b flattens b's own fold away. await postAlias({ action: 'combine', from: 'app.org', to: 'app.dev' }); const { body } = await postAlias({ action: 'combine', from: 'app.sh', to: 'app.org', }); expect(body.aliases).toEqual({ 'app.sh': 'app.org' }); }); /** @param {Record} payload */ async function postAppearance(payload) { const response = await pds.fetch( new Request('https://pds.example.com/account/api/settings/appearance', { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: cookie }, body: JSON.stringify(payload), }), ); return { status: response.status, body: await response.json() }; } it('defaults the appearance setting to system', async () => { const { body } = await api('/overview'); expect(body.theme).toBe('system'); expect(body.features.settings).toBe(true); }); it('stores an appearance choice and serves it back everywhere', async () => { const { status, body } = await postAppearance({ theme: 'dark' }); expect(status).toBe(200); expect(body.theme).toBe('dark'); const overview = await api('/overview'); expect(overview.body.theme).toBe('dark'); // The signed-in page bootstrap carries the choice for first paint. const { html } = await getPage(pds, '/account', cookie); expect(html).toContain('window.__PDS__'); expect(html).toContain('dark'); }); it('rejects a theme outside system, light, dark', async () => { const { status } = await postAppearance({ theme: 'sepia' }); expect(status).toBe(400); }); // ── /account/api/password ── /** @param {object} payload */ async function postPassword(payload) { const response = await pds.fetch( new Request('https://pds.example.com/account/api/password', { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: cookie }, body: JSON.stringify(payload), }), ); return { status: response.status, body: await response.json() }; } it('changes the password and the old one stops signing in', async () => { const { status, body } = await postPassword({ currentPassword: PASSWORD, newPassword: 'brand-new-pw', }); expect(status).toBe(200); expect(body.ok).toBe(true); const oldTry = await signIn(pds, { username: HANDLE, password: PASSWORD }); expect(oldTry.status).toBe(401); const newTry = await signIn(pds, { username: HANDLE, password: 'brand-new-pw', }); expect(newTry.status).toBe(303); }); it('requires the current password, even inside a session', async () => { const { status, body } = await postPassword({ currentPassword: 'nope', newPassword: 'brand-new-pw', }); expect(status).toBe(400); expect(body.error).toContain('current password'); // Unchanged: the original still signs in. const check = await signIn(pds, { username: HANDLE, password: PASSWORD }); expect(check.status).toBe(303); }); it('rejects a short new password', async () => { const { status } = await postPassword({ currentPassword: PASSWORD, newPassword: 'short', }); expect(status).toBe(400); }); it('reports the feature in the overview', async () => { const { body } = await api('/overview'); expect(body.features.passwordChange).toBe(true); }); it('a rotated PDS_PASSWORD wins over a changed one', async () => { // One storage, two server generations: the first changes the password // through the API, the second starts with a rotated secret. const actorStorage = /** @type {any} */ (createMockStorage()); const shared = /** @type {any} */ (createMockSharedStorage()); const first = new PersonalDataServer({ actorStorage, sharedStorage: shared, blobs: /** @type {any} */ ({}), jwtSecret: 'test-secret', password: PASSWORD, }); const firstCookie = await signedInCookie(first); const change = await first.fetch( new Request('https://pds.example.com/account/api/password', { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: firstCookie }, body: JSON.stringify({ currentPassword: PASSWORD, newPassword: 'brand-new-pw', }), }), ); expect(change.status).toBe(200); const second = new PersonalDataServer({ actorStorage, sharedStorage: shared, blobs: /** @type {any} */ ({}), jwtSecret: 'test-secret', password: 'rotated-pw', }); const changed = await signIn(second, { username: HANDLE, password: 'brand-new-pw', }); expect(changed.status).toBe(401); const rotated = await signIn(second, { username: HANDLE, password: 'rotated-pw', }); expect(rotated.status).toBe(303); }); // ── /account/api/version, with the npm registry stubbed ── /** @param {unknown} manifest - Registry answer, or an Error to throw */ function stubRegistry(manifest) { const stub = vi.fn(async (/** @type {string} */ _url) => { if (manifest instanceof Error) throw manifest; return new Response(JSON.stringify(manifest)); }); vi.stubGlobal('fetch', stub); return stub; } afterEach(() => { vi.unstubAllGlobals(); }); /** The version _health reports, which is what the endpoint compares against. */ async function runningVersion() { const response = await pds.fetch( new Request('https://pds.example.com/xrpc/_health'), ); return (await response.json()).version; } it('reports an available update, once per cache window', async () => { const [major, minor, patch] = (await runningVersion()) .split('.') .map(Number); const newer = `${major}.${minor}.${patch + 1}`; const stub = stubRegistry({ version: newer }); const { status, body } = await api('/version'); expect(status).toBe(200); expect(body.latest).toBe(newer); expect(body.updateAvailable).toBe(true); expect(body.releaseNotesUrl).toContain('CHANGELOG'); // No update page or script name is configured on this server, so the // account page has nowhere to link. expect(body.updateUrl).toBe(null); expect(body.scriptName).toBe(null); // Cached: a second request asks the registry nothing. await api('/version'); expect(stub).toHaveBeenCalledTimes(1); }); it('names the configured script and update page', async () => { pds = new PersonalDataServer({ actorStorage: /** @type {any} */ (createMockStorage()), sharedStorage: /** @type {any} */ (createMockSharedStorage()), blobs: /** @type {any} */ ({}), jwtSecret: 'test-secret', password: PASSWORD, updateUrl: 'https://start.example.com/update', scriptName: 'pds', }); cookie = await signedInCookie(pds); stubRegistry({ version: await runningVersion() }); const { body } = await api('/version'); expect(body.updateUrl).toBe('https://start.example.com/update'); expect(body.scriptName).toBe('pds'); }); it('stays quiet when the registry answer is the running release', async () => { const current = await runningVersion(); stubRegistry({ version: current }); const { body } = await api('/version'); expect(body.current).toBe(current); expect(body.latest).toBe(current); expect(body.updateAvailable).toBe(false); }); it('answers without an update when the registry cannot be reached', async () => { stubRegistry(new Error('offline')); const { status, body } = await api('/version'); expect(status).toBe(200); expect(body.latest).toBe(null); expect(body.updateAvailable).toBe(false); }); it('treats a registry answer with no version string as no known update', async () => { stubRegistry({ 'dist-tags': {} }); const { body } = await api('/version'); expect(body.latest).toBe(null); expect(body.updateAvailable).toBe(false); }); // ── the release channel setting ── /** @param {Record} payload */ async function postChannel(payload) { const response = await pds.fetch( new Request( 'https://pds.example.com/account/api/settings/release-channel', { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: cookie }, body: JSON.stringify(payload), }, ), ); return { status: response.status, body: await response.json() }; } it('follows stable by default', async () => { stubRegistry({ version: await runningVersion() }); const { body } = await api('/version'); expect(body.channel).toBe('stable'); expect(body.releaseNotesUrl).toContain('CHANGELOG'); }); it('asks the artifacts site once the canary channel is chosen', async () => { const canary = `${await runningVersion()}-canary.abc1234`; const stub = stubRegistry({ version: canary }); const set = await postChannel({ channel: 'canary' }); expect(set.status).toBe(200); expect(set.body).toEqual({ channel: 'canary' }); const { body } = await api('/version'); expect(body.channel).toBe('canary'); expect(body.latest).toBe(canary); // A canary build differs from the running release, so it is an update // even though its dotted parts compare as older. expect(body.updateAvailable).toBe(true); expect(body.releaseNotesUrl).not.toContain('CHANGELOG'); expect(stub.mock.calls[0][0]).toContain('/canary/manifest.json'); }); it('stays quiet when the canary build is the running one', async () => { // What a canary deployment reports: the stamped build version matches // the channel's manifest exactly. stubRegistry({ version: await runningVersion() }); await postChannel({ channel: 'canary' }); const { body } = await api('/version'); expect(body.updateAvailable).toBe(false); }); it('caches each channel on its own clock', async () => { const stub = stubRegistry({ version: await runningVersion() }); await api('/version'); await postChannel({ channel: 'canary' }); // The canary answer is not served from the stable cache. await api('/version'); expect(stub).toHaveBeenCalledTimes(2); expect(stub.mock.calls[0][0]).toContain('registry.npmjs.org'); expect(stub.mock.calls[1][0]).toContain('/canary/manifest.json'); }); it('rejects a channel outside stable and canary', async () => { const { status } = await postChannel({ channel: 'nightly' }); expect(status).toBe(400); }); }); describe('Sign out all logins', () => { it('revokes every session and leaves the account page signed in', async () => { const storage = createMockSharedStorage(); const pds = new PersonalDataServer({ actorStorage: /** @type {any} */ (createMockStorage()), sharedStorage: /** @type {any} */ (storage), blobs: /** @type {any} */ ({}), jwtSecret: 'test-secret', password: PASSWORD, }); const now = new Date().toISOString(); for (const jti of ['a', 'b', 'c']) { await storage.putSession({ jti, did: DID, label: 'Account password', scope: 'atproto', userAgent: 'node', createdAt: now, refreshedAt: null, }); } const cookie = await signedInCookie(pds); const revoke = await pds.fetch( new Request('https://pds.example.com/account/api/sessions/revoke-all', { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: cookie }, body: '{}', }), ); expect(revoke.status).toBe(200); expect(await revoke.json()).toEqual({ revoked: 3 }); // The logins are gone, but the same cookie still reaches the account API, // so the caller was not signed out of the page. const after = await pds.fetch( new Request('https://pds.example.com/account/api/sessions', { headers: { Cookie: cookie }, }), ); expect(after.status).toBe(200); expect((await after.json()).sessions).toEqual([]); }); });