// The /account/api JSON endpoints, driven through pds.fetch with a cookie // session: the wrapper's authentication and same-origin rules, then each // section endpoint against fake storage. import { describe, expect, it } from 'vitest'; import { buildCarFile, cborEncode, cborEncodeDagCbor, cidToString, createCid, } from '../packages/core/src/repo.js'; import { apiGet, apiPost, BASE, createActorStorage, createEmailer, createPds, createSharedStorage, DID, HANDLE, sessionCookie, } from './helpers/account-api-harness.js'; import { createFakeSpaceStorage } from './helpers/fake-space-storage.js'; describe('accountApi wrapper', () => { it('rejects a request with no session cookie', async () => { const pds = createPds(); const response = await pds.fetch(new Request(`${BASE}/account/api/email`)); expect(response.status).toBe(401); expect(await response.json()).toEqual({ error: 'Not signed in' }); }); it('rejects a cross-origin POST before touching the session', async () => { const pds = createPds(); const response = await apiPost( pds, '/account/api/status', { action: 'activate' }, await sessionCookie(), { origin: 'https://evil.example' }, ); expect(response.status).toBe(403); expect(await response.json()).toEqual({ error: 'Forbidden' }); }); it('rejects a write on a read-only server', async () => { const pds = createPds({ readOnly: true }); const response = await apiPost( pds, '/account/api/status', { action: 'activate' }, await sessionCookie(), ); expect(response.status).toBe(403); expect(await response.json()).toEqual({ error: 'This PDS is read-only' }); }); }); describe('/account/api/lexicon-status', () => { it('answers an empty map without a resolver', async () => { const pds = createPds(); const response = await apiGet( pds, '/account/api/lexicon-status?collections=app.bsky.feed.post', await sessionCookie(), ); expect(await response.json()).toEqual({ published: {} }); }); it('reports each collection, folding a resolver error to false', async () => { const pds = createPds({ lexiconResolver: { async isPublished(/** @type {string} */ nsid) { if (nsid === 'bad.example.record') throw new Error('resolve failed'); return nsid === 'app.bsky.feed.post'; }, }, }); const response = await apiGet( pds, '/account/api/lexicon-status?collections=app.bsky.feed.post, com.example.other,bad.example.record,', await sessionCookie(), ); expect(await response.json()).toEqual({ published: { 'app.bsky.feed.post': true, 'com.example.other': false, 'bad.example.record': false, }, }); }); }); describe('/account/api/lexicon-schema', () => { it('answers null without a collection or resolver', async () => { const pds = createPds(); const response = await apiGet( pds, '/account/api/lexicon-schema?collection=app.bsky.feed.post', await sessionCookie(), ); expect(await response.json()).toEqual({ lexicon: null }); }); it('returns the resolved document, and null when resolution throws', async () => { const doc = { lexicon: 1, id: 'app.bsky.feed.post' }; const pds = createPds({ lexiconResolver: { async getLexicon(/** @type {string} */ nsid) { if (nsid === 'bad.example.record') throw new Error('resolve failed'); return nsid === 'app.bsky.feed.post' ? doc : null; }, }, }); const cookie = await sessionCookie(); expect( await ( await apiGet( pds, '/account/api/lexicon-schema?collection=app.bsky.feed.post', cookie, ) ).json(), ).toEqual({ lexicon: doc }); expect( await ( await apiGet( pds, '/account/api/lexicon-schema?collection=bad.example.record', cookie, ) ).json(), ).toEqual({ lexicon: null }); }); }); describe('/account/api/activity', () => { it('answers empty when the storage keeps no events', async () => { const storage = createActorStorage(); // @ts-expect-error the handler feature-tests this method storage.getEventsBefore = undefined; const pds = createPds({ actorStorage: storage }); const response = await apiGet( pds, '/account/api/activity', await sessionCookie(), ); expect(await response.json()).toEqual({ events: [], cursor: null }); }); it('flattens each commit into rows and skips an unreadable event', async () => { const storage = createActorStorage(); storage.events.push( { seq: 1, evt: cborEncode({ ops: [{ path: 'app.bsky.feed.post/3aaa', action: 'create' }], time: '2026-01-01T00:00:00.000Z', }), }, { seq: 2, evt: new Uint8Array([0xff, 0xff]) }, { seq: 3, evt: cborEncode({ ops: [ { path: 'app.bsky.feed.post/3bbb', action: 'update' }, { path: 'app.bsky.feed.like/3ccc', action: 'delete' }, ], time: '2026-01-02T00:00:00.000Z', }), }, ); const pds = createPds({ actorStorage: storage }); const response = await apiGet( pds, '/account/api/activity?limit=2', await sessionCookie(), ); const body = await response.json(); expect(body.events).toEqual([ { seq: 3, action: 'update', collection: 'app.bsky.feed.post', rkey: '3bbb', time: '2026-01-02T00:00:00.000Z', }, { seq: 3, action: 'delete', collection: 'app.bsky.feed.like', rkey: '3ccc', time: '2026-01-02T00:00:00.000Z', }, ]); // A full page hands back a cursor; following it reaches the older event. expect(body.cursor).toBe('2'); const older = await apiGet( pds, '/account/api/activity?cursor=2', await sessionCookie(), ); const olderBody = await older.json(); expect(olderBody.events).toHaveLength(1); expect(olderBody.events[0].seq).toBe(1); expect(olderBody.cursor).toBeNull(); }); }); describe('/account/api/apps', () => { const token = { did: DID, clientId: 'https://client.example/metadata.json', scope: 'atproto', dpopJkt: 'abcdef123456', createdAt: Date.parse('2026-01-01T00:00:00Z'), userAgent: 'curl/8.7.1', tokenId: 'refresh-1', }; it('revokes a session by the handle the list rendered', async () => { const shared = createSharedStorage({ oauthTokens: [token] }); const pds = createPds({ sharedStorage: shared }); const cookie = await sessionCookie(); const list = await (await apiGet(pds, '/account/api/apps', cookie)).json(); expect(list.apps).toHaveLength(1); const response = await apiPost( pds, '/account/api/apps/revoke', { session: list.apps[0].sessionId }, cookie, ); expect(await response.json()).toEqual({ revoked: 'https://client.example/metadata.json', }); expect(shared.tokens).toHaveLength(0); }); it('rejects revoking a session that does not exist', async () => { const pds = createPds(); const response = await apiPost( pds, '/account/api/apps/revoke', { session: 'nonsense' }, await sessionCookie(), ); expect(response.status).toBe(400); expect((await response.json()).error).toBe( 'That session no longer exists.', ); }); }); describe('/account/api/apps/alias', () => { it('combines, dismisses, and separates rows', async () => { const pds = createPds(); const cookie = await sessionCookie(); const combined = await ( await apiPost( pds, '/account/api/apps/alias', { action: 'combine', from: 'feeds.example.com', to: 'bsky.app' }, cookie, ) ).json(); expect(combined.aliases).toEqual({ 'feeds.example.com': 'bsky.app' }); const dismissed = await ( await apiPost( pds, '/account/api/apps/alias', { action: 'dismiss', from: 'other.example.com', to: 'bsky.app' }, cookie, ) ).json(); expect(dismissed.dismissedPairs).toEqual(['other.example.com>bsky.app']); const separated = await ( await apiPost( pds, '/account/api/apps/alias', { action: 'separate', from: 'feeds.example.com' }, cookie, ) ).json(); expect(separated.aliases).toEqual({}); }); it('rejects a bad hostname, a self-pair, and an unknown action', async () => { const pds = createPds(); const cookie = await sessionCookie(); const badFrom = await apiPost( pds, '/account/api/apps/alias', { action: 'combine', from: 'not a host', to: 'bsky.app' }, cookie, ); expect((await badFrom.json()).error).toBe('from must be a hostname'); const selfPair = await apiPost( pds, '/account/api/apps/alias', { action: 'combine', from: 'bsky.app', to: 'bsky.app' }, cookie, ); expect((await selfPair.json()).error).toBe( 'to must be a different hostname', ); const unknown = await apiPost( pds, '/account/api/apps/alias', { action: 'merge', from: 'bsky.app' }, cookie, ); expect((await unknown.json()).error).toBe( 'action must be combine, separate, or dismiss', ); }); }); describe('/account/api/sessions', () => { const live = { jti: 'jti-live', did: DID, label: 'Password', scope: 'com.atproto.access', userAgent: 'curl/8.7.1', createdAt: new Date().toISOString(), refreshedAt: null, }; const expired = { jti: 'jti-expired', did: DID, label: 'Password', scope: 'com.atproto.access', userAgent: null, createdAt: '2020-01-01T00:00:00.000Z', refreshedAt: null, }; it('lists live sessions and reaps expired ones', async () => { const shared = createSharedStorage({ sessions: [live, expired] }); const pds = createPds({ sharedStorage: shared }); const body = await ( await apiGet(pds, '/account/api/sessions', await sessionCookie()) ).json(); expect(body.supported).toBe(true); expect(body.sessions.map((/** @type {any} */ s) => s.jti)).toEqual([ 'jti-live', ]); expect(shared.sessionRows.map((s) => s.jti)).toEqual(['jti-live']); }); it('revokes one session and refuses an unknown id', async () => { const shared = createSharedStorage({ sessions: [{ ...live }] }); const pds = createPds({ sharedStorage: shared }); const cookie = await sessionCookie(); const missing = await apiPost( pds, '/account/api/sessions/revoke', { jti: 'nonsense' }, cookie, ); expect(missing.status).toBe(400); const revoked = await apiPost( pds, '/account/api/sessions/revoke', { jti: 'jti-live' }, cookie, ); expect(await revoked.json()).toEqual({ revoked: 'jti-live' }); expect(shared.sessionRows).toHaveLength(0); }); it('revokes every session at once', async () => { const shared = createSharedStorage({ sessions: [ { ...live }, { ...live, jti: 'jti-two', label: 'App password: cli' }, ], }); const pds = createPds({ sharedStorage: shared }); const response = await apiPost( pds, '/account/api/sessions/revoke-all', {}, await sessionCookie(), ); expect(await response.json()).toEqual({ revoked: 2 }); expect(shared.sessionRows).toHaveLength(0); }); }); describe('/account/api/app-passwords', () => { it('creates one, lists it, and hands the secret back once', async () => { const pds = createPds(); const cookie = await sessionCookie(); const created = await ( await apiPost( pds, '/account/api/app-passwords/create', { name: 'cli', privileged: false }, cookie, ) ).json(); expect(created.name).toBe('cli'); expect(created.password).toMatch(/^[a-z0-9]{4}(-[a-z0-9]{4}){3}$/); const list = await ( await apiGet(pds, '/account/api/app-passwords', cookie) ).json(); expect(list.passwords).toHaveLength(1); expect(list.passwords[0].name).toBe('cli'); expect(list.passwords[0].password).toBeUndefined(); }); it('rejects a blank name and a duplicate', async () => { const pds = createPds(); const cookie = await sessionCookie(); const blank = await apiPost( pds, '/account/api/app-passwords/create', { name: ' ' }, cookie, ); expect((await blank.json()).error).toBe('A name is required.'); await apiPost( pds, '/account/api/app-passwords/create', { name: 'cli' }, cookie, ); const duplicate = await apiPost( pds, '/account/api/app-passwords/create', { name: 'cli' }, cookie, ); expect((await duplicate.json()).error).toBe( 'App password "cli" already exists.', ); }); it('revokes the credential and the sessions it opened', async () => { const shared = createSharedStorage({ sessions: [ { jti: 'jti-cli', did: DID, label: 'App password: cli', scope: 'com.atproto.appPass', createdAt: new Date().toISOString(), }, ], }); const pds = createPds({ sharedStorage: shared }); const cookie = await sessionCookie(); await apiPost( pds, '/account/api/app-passwords/create', { name: 'cli' }, cookie, ); const missing = await apiPost( pds, '/account/api/app-passwords/revoke', { name: 'nope' }, cookie, ); expect((await missing.json()).error).toBe('No app password named "nope".'); const revoked = await apiPost( pds, '/account/api/app-passwords/revoke', { name: 'cli' }, cookie, ); expect(await revoked.json()).toEqual({ revoked: 'cli' }); expect(shared.sessionRows).toHaveLength(0); }); }); describe('/account/api/passkeys', () => { it('lists passkeys with a registration challenge and its cookie', async () => { const shared = createSharedStorage({ passkeys: [ { credentialId: 'cred-1', name: 'Laptop', createdAt: '2026-01-01T00:00:00.000Z', lastUsedAt: null, }, ], }); const pds = createPds({ sharedStorage: shared }); const response = await apiGet( pds, '/account/api/passkeys', await sessionCookie(), ); const body = await response.json(); expect(body.supported).toBe(true); expect(body.rpId).toBe('pds.example.com'); expect(body.accountName).toBe(HANDLE); expect(body.challenge).not.toBe(''); expect(response.headers.get('set-cookie')).toContain('pdsjs_webauthn'); expect(body.passkeys).toEqual([ { credentialId: 'cred-1', name: 'Laptop', createdAt: '2026-01-01T00:00:00.000Z', lastUsedAt: null, }, ]); }); it('revokes a passkey and refuses an unknown credential', async () => { const shared = createSharedStorage({ passkeys: [{ credentialId: 'cred-1', name: 'Laptop' }], }); const pds = createPds({ sharedStorage: shared }); const cookie = await sessionCookie(); const missing = await apiPost( pds, '/account/api/passkeys/revoke', { credentialId: 'nope' }, cookie, ); expect((await missing.json()).error).toBe( 'No such passkey on this account.', ); const revoked = await apiPost( pds, '/account/api/passkeys/revoke', { credentialId: 'cred-1' }, cookie, ); expect(await revoked.json()).toEqual({ revoked: 'cred-1' }); }); }); describe('/account/api/email', () => { it('sets an address without a sender, recorded unconfirmed', async () => { const pds = createPds(); const cookie = await sessionCookie(); const set = await ( await apiPost( pds, '/account/api/email/action', { address: 'chad@example.com' }, cookie, ) ).json(); expect(set).toEqual({ ok: true, unconfirmed: true }); const state = await ( await apiGet(pds, '/account/api/email', cookie) ).json(); expect(state.address).toBe('chad@example.com'); expect(state.verified).toBe(false); expect(state.canSend).toBe(false); }); it('rejects an address that does not parse', async () => { const pds = createPds(); const response = await apiPost( pds, '/account/api/email/action', { address: 'not-an-address' }, await sessionCookie(), ); expect((await response.json()).error).toBe( 'That does not look like an email address.', ); }); it('mails a code and verifies it', async () => { const emailer = createEmailer(); const pds = createPds({ emailer }); const cookie = await sessionCookie(); const set = await ( await apiPost( pds, '/account/api/email/action', { address: 'chad@example.com' }, cookie, ) ).json(); expect(set.ok).toBe(true); expect(set.sent).toBe('chad@example.com'); expect(emailer.sent).toHaveLength(1); const pending = await ( await apiGet(pds, '/account/api/email', cookie) ).json(); expect(pending.pending.address).toBe('chad@example.com'); const wrong = await apiPost( pds, '/account/api/email/action', { action: 'verify', code: '000000' }, cookie, ); expect((await wrong.json()).error).toBe('That code is wrong.'); const code = /** @type {RegExpMatchArray} */ ( emailer.sent[0].text.match(/\d{6}/) )[0]; const verified = await ( await apiPost( pds, '/account/api/email/action', { action: 'verify', code }, cookie, ) ).json(); expect(verified).toEqual({ ok: true, address: 'chad@example.com' }); const state = await ( await apiGet(pds, '/account/api/email', cookie) ).json(); expect(state.verified).toBe(true); }); it('refuses to verify or resend with nothing pending', async () => { const emailer = createEmailer(); const pds = createPds({ emailer }); const cookie = await sessionCookie(); for (const action of ['verify', 'resend']) { const response = await apiPost( pds, '/account/api/email/action', { action }, cookie, ); expect((await response.json()).error).toBe('No address is waiting.'); } }); it('rejects an expired code and clears the pending address', async () => { const emailer = createEmailer(); const storage = createActorStorage(); const pds = createPds({ emailer, actorStorage: storage }); const cookie = await sessionCookie(); await apiPost( pds, '/account/api/email/action', { address: 'chad@example.com' }, cookie, ); const state = /** @type {any} */ (await storage.getEmail()); await storage.setEmail({ ...state, pending: { ...state.pending, expiresAt: Date.now() - 1 }, }); const response = await apiPost( pds, '/account/api/email/action', { action: 'verify', code: '123456' }, cookie, ); expect((await response.json()).error).toBe( 'That code expired. Start again.', ); expect(/** @type {any} */ (await storage.getEmail()).pending).toBeNull(); }); it('holds a resend inside the cooldown and allows it after', async () => { const emailer = createEmailer(); const storage = createActorStorage(); const pds = createPds({ emailer, actorStorage: storage }); const cookie = await sessionCookie(); await apiPost( pds, '/account/api/email/action', { address: 'chad@example.com' }, cookie, ); const early = await apiPost( pds, '/account/api/email/action', { action: 'resend' }, cookie, ); expect((await early.json()).error).toBe( 'Wait a moment before asking for another code.', ); const state = /** @type {any} */ (await storage.getEmail()); await storage.setEmail({ ...state, pending: { ...state.pending, sentAt: Date.now() - 60_000 }, }); const resent = await ( await apiPost( pds, '/account/api/email/action', { action: 'resend' }, cookie, ) ).json(); expect(resent.ok).toBe(true); expect(emailer.sent).toHaveLength(2); }); it('keeps the earlier code usable when the resend fails to send', async () => { const emailer = createEmailer(); const storage = createActorStorage(); const pds = createPds({ emailer, actorStorage: storage }); const cookie = await sessionCookie(); await apiPost( pds, '/account/api/email/action', { address: 'chad@example.com' }, cookie, ); const before = /** @type {any} */ (await storage.getEmail()); await storage.setEmail({ ...before, pending: { ...before.pending, sentAt: Date.now() - 60_000 }, }); const held = /** @type {any} */ (await storage.getEmail()); emailer.setFailing(true); const response = await apiPost( pds, '/account/api/email/action', { action: 'resend' }, cookie, ); expect((await response.json()).error).toBe( 'The message could not be sent. Try again later.', ); expect(/** @type {any} */ (await storage.getEmail()).pending.codeHash).toBe( held.pending.codeHash, ); }); it('records nothing pending when the first send fails', async () => { const emailer = createEmailer(); emailer.setFailing(true); const storage = createActorStorage(); const pds = createPds({ emailer, actorStorage: storage }); const response = await apiPost( pds, '/account/api/email/action', { address: 'chad@example.com' }, await sessionCookie(), ); expect((await response.json()).error).toBe( 'The message could not be sent. Try again later.', ); expect(/** @type {any} */ (await storage.getEmail()).pending).toBeNull(); }); it('cancels a pending address and removes a set one', async () => { const emailer = createEmailer(); const pds = createPds({ emailer }); const cookie = await sessionCookie(); await apiPost( pds, '/account/api/email/action', { address: 'chad@example.com' }, cookie, ); const cancelled = await ( await apiPost( pds, '/account/api/email/action', { action: 'cancel' }, cookie, ) ).json(); expect(cancelled).toEqual({ ok: true }); const removed = await ( await apiPost( pds, '/account/api/email/action', { action: 'remove' }, cookie, ) ).json(); expect(removed).toEqual({ ok: true }); const state = await ( await apiGet(pds, '/account/api/email', cookie) ).json(); expect(state.address).toBeNull(); }); }); describe('/account/api/identity', () => { it('describes the identity, with no signing key before init', async () => { const pds = createPds(); const body = await ( await apiGet(pds, '/account/api/identity', await sessionCookie()) ).json(); expect(body).toEqual({ did: DID, handle: HANDLE, status: 'active', signingKey: null, pdsEndpoint: 'https://pds.example.com', plcUrl: 'https://plc.directory', readOnly: false, }); }); it('surfaces a rename rejection as a caller error', async () => { const pds = createPds(); const response = await apiPost( pds, '/account/api/identity/handle', { handle: 'not a handle' }, await sessionCookie(), ); expect(response.status).toBe(400); expect((await response.json()).error).toBeTruthy(); }); }); describe('/account/api/status', () => { it('deactivates and reactivates the account', async () => { const storage = createActorStorage(); const pds = createPds({ actorStorage: storage }); const cookie = await sessionCookie(); const down = await ( await apiPost( pds, '/account/api/status', { action: 'deactivate' }, cookie, ) ).json(); expect(down).toEqual({ status: 'deactivated' }); expect(await storage.getAccountStatus()).toBe('deactivated'); const up = await ( await apiPost(pds, '/account/api/status', { action: 'activate' }, cookie) ).json(); expect(up).toEqual({ status: 'active' }); }); it('rejects an unknown action', async () => { const pds = createPds(); const response = await apiPost( pds, '/account/api/status', { action: 'explode' }, await sessionCookie(), ); expect((await response.json()).error).toBe('Unknown status action.'); }); }); describe('/account/api/records', () => { const COLLECTION = 'app.bsky.feed.post'; it('requires a collection', async () => { const pds = createPds(); const response = await apiGet( pds, '/account/api/records', await sessionCookie(), ); expect((await response.json()).error).toBe('Name a collection to read.'); }); it('lists a collection, decoding each record for display', async () => { const value = cborEncodeDagCbor({ $type: COLLECTION, text: 'hello' }); const storage = createActorStorage({ async listRecords() { return { records: [ { uri: `at://${DID}/${COLLECTION}/3aaa`, cid: 'bafyaaa', value }, ], cursor: null, }; }, }); const pds = createPds({ actorStorage: storage }); const body = await ( await apiGet( pds, `/account/api/records?collection=${COLLECTION}`, await sessionCookie(), ) ).json(); expect(body.records).toEqual([ { uri: `at://${DID}/${COLLECTION}/3aaa`, cid: 'bafyaaa', value: { $type: COLLECTION, text: 'hello' }, }, ]); expect(body.blobBase).toContain(encodeURIComponent(DID)); }); it('fetches one record by rkey without listing', async () => { const value = cborEncodeDagCbor({ $type: COLLECTION, text: 'direct' }); const storage = createActorStorage({ async getRecord(/** @type {string} */ uri) { return uri === `at://${DID}/${COLLECTION}/3aaa` ? { cid: 'bafyaaa', value } : null; }, }); const pds = createPds({ actorStorage: storage }); const cookie = await sessionCookie(); const found = await ( await apiGet( pds, `/account/api/records?collection=${COLLECTION}&rkey=3aaa`, cookie, ) ).json(); expect(found.records).toHaveLength(1); expect(found.records[0].value.text).toBe('direct'); const missing = await ( await apiGet( pds, `/account/api/records?collection=${COLLECTION}&rkey=3zzz`, cookie, ) ).json(); expect(missing.records).toEqual([]); }); it('shows a record as it stood at a create, against the live row', async () => { const data = cborEncodeDagCbor({ $type: COLLECTION, text: 'v1' }); const cid = cidToString(await createCid(data)); const car = buildCarFile(cid, [{ cid, data }]); const storage = createActorStorage({ async getRecord() { return { cid, value: data }; }, }); storage.events.push({ seq: 5, evt: cborEncode({ ops: [ { path: `${COLLECTION}/3aaa`, action: 'create', cid: { $link: cid } }, ], time: '2026-01-01T00:00:00.000Z', blocks: car, }), }); const pds = createPds({ actorStorage: storage }); const body = await ( await apiGet( pds, `/account/api/records?collection=${COLLECTION}&rkey=3aaa&seq=5`, await sessionCookie(), ) ).json(); expect(body.snapshot).toEqual({ action: 'create', time: '2026-01-01T00:00:00.000Z', liveExists: true, liveCurrent: true, }); expect(body.records).toEqual([ { uri: `at://${DID}/${COLLECTION}/3aaa`, cid, value: { $type: COLLECTION, text: 'v1' }, }, ]); }); it('recovers a deleted record from the event that wrote it', async () => { const data = cborEncodeDagCbor({ $type: COLLECTION, text: 'v1' }); const cid = cidToString(await createCid(data)); const car = buildCarFile(cid, [{ cid, data }]); const storage = createActorStorage(); storage.events.push( { seq: 5, evt: cborEncode({ ops: [ { path: `${COLLECTION}/3aaa`, action: 'create', cid: { $link: cid }, }, ], time: '2026-01-01T00:00:00.000Z', blocks: car, }), }, { seq: 6, evt: cborEncode({ ops: [{ path: `${COLLECTION}/3aaa`, action: 'delete' }], time: '2026-01-02T00:00:00.000Z', blocks: new Uint8Array(0), }), }, ); const pds = createPds({ actorStorage: storage }); const body = await ( await apiGet( pds, `/account/api/records?collection=${COLLECTION}&rkey=3aaa&seq=6`, await sessionCookie(), ) ).json(); expect(body.snapshot.action).toBe('delete'); expect(body.snapshot.liveExists).toBe(false); expect(body.records[0].value.text).toBe('v1'); }); it('reports a change that is no longer available', async () => { const pds = createPds(); const response = await apiGet( pds, `/account/api/records?collection=${COLLECTION}&rkey=3aaa&seq=9`, await sessionCookie(), ); expect((await response.json()).error).toBe( 'That change is no longer available.', ); }); it('reads a space collection through the browser, refusing unknown spaces', async () => { const SPACE = 'at://space.example/1'; const pds = createPds({ spaceStorage: createFakeSpaceStorage({ spaces: [{ uri: SPACE }], records: { [SPACE]: [ { collection: COLLECTION, rkey: '3aaa', cid: 'bafyaaa', value: { text: 'in space' }, }, ], }, }), }); const cookie = await sessionCookie(); const unknown = await apiGet( pds, `/account/api/records?collection=${COLLECTION}&space=${encodeURIComponent('at://nope')}`, cookie, ); expect((await unknown.json()).error).toBe('No such space on this account.'); const body = await ( await apiGet( pds, `/account/api/records?collection=${COLLECTION}&space=${encodeURIComponent(SPACE)}`, cookie, ) ).json(); expect(body.records).toEqual([ { uri: `${SPACE}/${DID}/${COLLECTION}/3aaa`, cid: 'bafyaaa', indexedAt: '2026-01-01T00:00:00.000Z', value: { text: 'in space' }, }, ]); }); it('refuses a space query when permissioned data is off', async () => { const pds = createPds(); const response = await apiGet( pds, `/account/api/records?collection=${COLLECTION}&space=${encodeURIComponent('at://nope')}`, await sessionCookie(), ); expect((await response.json()).error).toBe('Permissioned data is off.'); }); }); describe('/account/api/blobs', () => { const blob = { cid: 'bafyblob', mimeType: 'image/jpeg', size: 2048, createdAt: Date.parse('2026-01-01T00:00:00Z'), time: Date.parse('2026-01-02T00:00:00Z'), timeFromRecord: true, records: [{ uri: `at://${DID}/app.bsky.feed.post/3aaa` }], }; const facets = { total: { count: 2, bytes: 4096 }, unused: { count: 1, bytes: 2048 }, kinds: [{ kind: 'image', count: 2, bytes: 4096 }], apps: [{ authority: 'app.bsky', count: 2, bytes: 4096 }], }; /** @param {(cursor: any, limit: any, options: any) => void} [onList] */ function blobPds(onList) { const storage = createActorStorage({ async listBlobDetails( /** @type {any} */ cursor, /** @type {any} */ limit, /** @type {any} */ options, ) { onList?.(cursor, limit, options); return { blobs: [blob], cursor: 'next-page' }; }, async getBlobFacets() { return facets; }, }); return createPds({ actorStorage: storage }); } it('answers an error where the storage cannot list files', async () => { const pds = createPds(); const response = await apiGet( pds, '/account/api/blobs', await sessionCookie(), ); expect((await response.json()).error).toBe( 'This server cannot list files.', ); }); it('lists files with facets on the first page only', async () => { /** @type {any[]} */ const calls = []; const pds = blobPds((...args) => calls.push(args)); const cookie = await sessionCookie(); const first = await ( await apiGet(pds, '/account/api/blobs', cookie) ).json(); expect(first.facets.apps).toEqual([ { domain: 'bsky.app', count: 2, bytes: 4096 }, ]); expect(first.blobs).toEqual([ { cid: 'bafyblob', mimeType: 'image/jpeg', size: 2048, createdAt: '2026-01-01T00:00:00.000Z', time: '2026-01-02T00:00:00.000Z', timeFromRecord: true, records: [{ uri: `at://${DID}/app.bsky.feed.post/3aaa` }], }, ]); expect(first.cursor).toBe('next-page'); const second = await ( await apiGet(pds, '/account/api/blobs?cursor=next-page', cookie) ).json(); expect(second.facets).toBeUndefined(); expect(calls[1][0]).toBe('next-page'); }); it('translates the filter params for the storage', async () => { /** @type {any} */ let seen; const pds = blobPds((_cursor, _limit, options) => { seen = options; }); await apiGet( pds, '/account/api/blobs?kind=image&app=bsky.app&unused=1&sort=largest', await sessionCookie(), ); expect(seen).toEqual({ kind: 'image', appAuthority: 'app.bsky', unused: true, sort: 'largest', }); }); }); describe('/account/api/blobs/relink', () => { it('rebuilds the link table and reports the reference count', async () => { const pds = createPds(); const response = await apiPost( pds, '/account/api/blobs/relink', {}, await sessionCookie(), ); expect(await response.json()).toEqual({ references: 0 }); }); }); describe('extensions on the account page', () => { it('merges the flags an extension reports into features', async () => { const pds = createPds({ extensions: [() => ({ name: 'demo', features: { demo: true } })], }); const overview = await apiGet( pds, '/account/api/overview', await sessionCookie(), ); const { features } = await overview.json(); expect(features.demo).toBe(true); expect(features.settings).toBe(true); }); it('merges the counts an extension reports, across the public repo and spaces', async () => { const SPACE = 'at://did:plc:abcdefghij234567abcdefgh/space/app.example.team/main'; const pds = createPds({ actorStorage: createActorStorage({ async listAllRecords() { return ['1', '2', '3'].map((rkey) => ({ key: `app.example.note/${rkey}`, cid: `bafy${rkey}`, })); }, }), spaceStorage: createFakeSpaceStorage({ spaces: [ { uri: SPACE, deletedAt: null }, { uri: `${SPACE}-gone`, deletedAt: 'x' }, ], records: { [SPACE]: [ { collection: 'app.example.note', rkey: 'a', cid: 'bafya', value: {}, }, { collection: 'app.example.note', rkey: 'b', cid: 'bafyb', value: {}, }, ], [`${SPACE}-gone`]: [ { collection: 'app.example.note', rkey: 'c', cid: 'bafyc', value: {}, }, ], }, }), extensions: [ () => ({ name: 'notes', stats: (count) => ({ notes: count('app.example.note') }), }), ], }); const overview = await apiGet( pds, '/account/api/overview', await sessionCookie(), ); const { stats } = await overview.json(); expect(stats.notes).toBe(5); expect(stats.records).toBe(3); }); it('answers an extension route behind the owner session', async () => { const pds = createPds({ extensions: [ (host) => ({ name: 'demo', routes: { '/account/api/demo/list': { handler: (request, url) => host.accountApi(request, url, async (did) => ({ did })), }, }, }), ], }); const anonymous = await pds.fetch( new Request(`${BASE}/account/api/demo/list`), ); expect(anonymous.status).toBe(401); const res = await apiGet( pds, '/account/api/demo/list', await sessionCookie(), ); expect(res.status).toBe(200); expect(await res.json()).toEqual({ did: DID }); }); });