// Signers here use @pdsjs/core/crypto's sign(), which normalizes to low-S. // atproto rejects high-S signatures as malleable and raw crypto.subtle.sign // emits them about half the time, so a test signing the raw way would verify // fine under WebCrypto while the reference implementation rejected it. import { compressPublicKey, sign } from '@pdsjs/core/crypto'; import { publicKeyToDidKey } from '@pdsjs/core/plc'; import { beforeEach, describe, expect, it } from 'vitest'; import { ATPROTO_PDS_ID } from '../src/authority.js'; import { createSpaceSigHeaders, generateSpaceSigKey, } from '../src/http-signature.js'; import { createMemoryBlobContext, createMemorySpaceStorage, } from '../src/memory-storage.js'; import { createSpaceRoutes } from '../src/routes.js'; import { nextTid } from '../src/sequence.js'; import { createServiceAuth } from '../src/service-auth.js'; import { makeSpaceRow } from '../src/space-row.js'; import { createSpaceToken } from '../src/token.js'; import { createVerifier } from '../src/verifier.js'; const OWNER = 'did:plc:owner'; const MEMBER = 'did:plc:member'; const TYPE = 'com.example.forum'; const SPACE = `at://${OWNER}/space/${TYPE}/general`; // Full management plus reads, so the manage tests exercise the handlers rather // than the scope grammar (which has its own suite). const SCOPE = `space:${TYPE}?authority=${OWNER}&manage=create&manage=update&manage=delete`; async function makeIdentity() { const keys = await crypto.subtle.generateKey( { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'], ); const raw = new Uint8Array( await crypto.subtle.exportKey('raw', keys.publicKey), ); const didKey = publicKeyToDidKey(compressPublicKey(raw)); return { multibase: didKey.slice('did:key:'.length), signer: { /** @param {Uint8Array} bytes */ sign: (bytes) => sign(keys.privateKey, bytes), }, }; } /** @type {any} */ let owner; /** @type {any} */ let member; /** @type {any} */ let didDocs; /** @type {any} */ let routes; /** @type {any} */ let store; /** @type {any[]} */ let sent; /** The application's key, which every credential here is bound to. */ /** @type {import('../src/http-signature.js').SpaceSigKey} */ let holder; beforeEach(async () => { owner = await makeIdentity(); member = await makeIdentity(); holder = await generateSpaceSigKey(); sent = []; const doc = (/** @type {string} */ did, /** @type {any} */ id) => ({ id: did, verificationMethod: [ { id: '#atproto', type: 'Multikey', publicKeyMultibase: id.multibase }, ], service: [ { id: ATPROTO_PDS_ID, type: 'AtprotoPersonalDataServer', serviceEndpoint: `https://${did.split(':').pop()}.example`, }, ], }); didDocs = { [OWNER]: doc(OWNER, owner), [MEMBER]: doc(MEMBER, member) }; store = createMemorySpaceStorage(); routes = createSpaceRoutes({ spaceStorage: store, ...createMemoryBlobContext(), getDid: async () => OWNER, getKemKey: async () => null, getSigner: async () => owner.signer, resolveDid: async (/** @type {string} */ d) => didDocs[d] ?? null, verifier: createVerifier(), fetch: async (/** @type {any} */ url, /** @type {any} */ init) => { sent.push({ url: String(url), init }); return new Response('{}', { status: 200 }); }, }); }); /** @param {string} path @param {any} body @param {any} [auth] @param {any} [headers] */ function post(path, body, auth = { did: OWNER, scope: SCOPE }, headers = {}) { const url = new URL(`https://pds.example${path}`); return routes[path].handler( new Request(url, { method: 'POST', body: JSON.stringify(body), headers }), url, auth, ); } /** @param {string} path @param {Record} params @param {any} [auth] */ function get(path, params = {}, auth = { did: OWNER, scope: SCOPE }) { const url = new URL(`https://pds.example${path}`); for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); return routes[path].handler(new Request(url), url, auth); } /** * Call an endpoint with a space credential, presented the only way a bound * credential can be: the Atproto-Space-Credential scheme, signed together with the * audience by the key the credential names. Every endpoint here answers for * the hosted account, which is also the authority. * * @param {string} path * @param {Object} opts * @param {any} [opts.body] - a JSON body, which makes this a POST * @param {Record} [opts.params] - query parameters, for a GET * @param {string} [opts.iss] - the issuing authority * @param {string} [opts.sub] - the space the credential is for * @param {any} [opts.signer] - the authority's signing key * @param {string} [opts.audience] - who the request is addressed to * @returns {Promise} */ async function withCredential( path, { body, params, iss = OWNER, sub = SPACE, signer, audience = OWNER } = {}, ) { const credential = await createSpaceToken( 'credential', { iss, sub, keyId: holder.did }, signer ?? owner.signer, ); const url = new URL(`https://pds.example${path}`); for (const [k, v] of Object.entries(params ?? {})) { url.searchParams.set(k, v); } const method = body === undefined ? 'GET' : 'POST'; return routes[path].handler( new Request(url, { method, ...(body === undefined ? {} : { body: JSON.stringify(body) }), headers: await createSpaceSigHeaders(holder, { authorization: `Atproto-Space-Credential ${credential}`, audience, }), }), url, null, ); } /** Repo revs, as TIDs, in order. */ const REV = '3lkaaaaaaaa22'; const LATER_REV = '3lkbbbbbbbb22'; /** @param {number} ms - epoch ms */ function tidAt(ms) { const chars = '234567abcdefghijklmnopqrstuvwxyz'; let micros = ms * 1000; let out = ''; for (let i = 0; i < 11; i++) { out = chars[micros % 32] + out; micros = Math.floor(micros / 32); } return `${out}22`; } /** A stand-in commit data, the 32 bytes an LtHash digest is. */ const DATA_BYTES = new Uint8Array(32).fill(7); /** The same, as notifyWrite carries it. */ const DATA = { $bytes: btoa(String.fromCharCode(...DATA_BYTES)) }; const MEMBERSPACE = { $type: 'com.atproto.memberspace.policy' }; /** The memberspace policy with its defaults filled in, as stored. */ const MEMBERSPACE_STORED = { $type: 'com.atproto.memberspace.policy', publicRead: false, publicWrite: false, publishPolicy: false, publishMembers: false, }; describe('createSpace', () => { it('creates a space owned by the hosted account', async () => { const res = await post('/xrpc/com.atproto.space.createSpace', { spaceType: TYPE, skey: 'general', policy: MEMBERSPACE, }); expect(res.status).toBe(200); expect((await res.json()).uri).toBe(SPACE); const row = await store.getSpace(SPACE); expect(row.isOwner).toBe(true); expect(row.policy).toEqual(MEMBERSPACE_STORED); }); it('requires a space type', async () => { const res = await post('/xrpc/com.atproto.space.createSpace', { skey: 'general', policy: MEMBERSPACE, }); expect(res.status).toBe(400); }); it('requires a policy', async () => { const res = await post('/xrpc/com.atproto.space.createSpace', { spaceType: TYPE, }); expect(res.status).toBe(400); expect((await res.json()).error).toBe('InvalidRequest'); }); it('leaves the owner able to mint a credential for it', async () => { // The authority is admitted under every policy without being a member, so // the owner reads the space it has just made. await post('/xrpc/com.atproto.space.createSpace', { spaceType: TYPE, skey: 'general', policy: MEMBERSPACE, }); expect(await store.getMember(SPACE, OWNER)).toBeNull(); const url = new URL( 'https://pds.example/xrpc/com.atproto.space.getDelegationToken', ); url.searchParams.set('space', SPACE); const { token } = await ( await routes['/xrpc/com.atproto.space.getDelegationToken'].handler( new Request(url), url, { did: OWNER, scope: `space:${TYPE}?authority=${OWNER}` }, ) ).json(); const credUrl = new URL( 'https://pds.example/xrpc/com.atproto.space.getSpaceCredential', ); const res = await routes[ '/xrpc/com.atproto.space.getSpaceCredential' ].handler( new Request(credUrl, { method: 'POST', body: JSON.stringify({ space: SPACE, credentialKid: holder.did, delegationToken: token, }), }), credUrl, null, ); expect(res.status).toBe(200); expect(typeof (await res.json()).credential).toBe('string'); }); it('generates a skey when none is given', async () => { const { uri } = await ( await post('/xrpc/com.atproto.space.createSpace', { spaceType: TYPE, policy: MEMBERSPACE, }) ).json(); expect(uri.split('/').pop()).toHaveLength(13); }); it('fills in a memberspace configuration', async () => { await post('/xrpc/com.atproto.space.createSpace', { spaceType: TYPE, skey: 'general', policy: { $type: 'com.atproto.memberspace.policy', publicRead: true, allowedApps: ['https://app.example/client-metadata.json'], unknownField: 1, }, }); expect((await store.getSpace(SPACE)).policy).toEqual({ ...MEMBERSPACE_STORED, publicRead: true, allowedApps: ['https://app.example/client-metadata.json'], }); }); it('takes a personalspace and a managedspace', async () => { await post('/xrpc/com.atproto.space.createSpace', { spaceType: TYPE, skey: 'mine', policy: { $type: 'com.atproto.personalspace.policy' }, }); await post('/xrpc/com.atproto.space.createSpace', { spaceType: TYPE, skey: 'managed', policy: { $type: 'com.atproto.managedspace.policy', managingApp: 'did:web:app.example#forum', }, }); expect( (await store.getSpace(`at://${OWNER}/space/${TYPE}/mine`)).policy, ).toEqual({ $type: 'com.atproto.personalspace.policy' }); expect( (await store.getSpace(`at://${OWNER}/space/${TYPE}/managed`)).policy, ).toEqual({ $type: 'com.atproto.managedspace.policy', managingApp: 'did:web:app.example#forum', publishPolicy: false, }); }); it('rejects a managedspace whose managing app is not a DID', async () => { const res = await post('/xrpc/com.atproto.space.createSpace', { spaceType: TYPE, policy: { $type: 'com.atproto.managedspace.policy', managingApp: 'https://app.example', }, }); expect(res.status).toBe(400); expect((await res.json()).error).toBe('InvalidRequest'); }); it('rejects a policy it does not implement', async () => { const res = await post('/xrpc/com.atproto.space.createSpace', { spaceType: TYPE, policy: { $type: 'com.example.inviteOnly.policy' }, }); expect((await res.json()).error).toBe('UnsupportedPolicy'); }); it('rejects a malformed field', async () => { const res = await post('/xrpc/com.atproto.space.createSpace', { spaceType: TYPE, policy: { ...MEMBERSPACE, allowedApps: 'https://app.example' }, }); expect((await res.json()).error).toBe('InvalidRequest'); }); it('publishes the policy when it asks to be', async () => { const policy = { ...MEMBERSPACE_STORED, publishPolicy: true }; await post('/xrpc/com.atproto.space.createSpace', { spaceType: TYPE, skey: 'general', policy, }); const record = await store.getSpaceRecord( SPACE, 'com.atproto.space.policy', 'self', ); expect(record).not.toBeNull(); // The commit puts the authority in the writer set, as any write does. expect(await store.getSpaceWriter(SPACE, OWNER)).not.toBeNull(); }); it('refuses a caller who is not the hosted account', async () => { const res = await post( '/xrpc/com.atproto.space.createSpace', { spaceType: TYPE, policy: MEMBERSPACE }, { did: MEMBER, scope: SCOPE }, ); expect(res.status).toBe(403); expect((await res.json()).error).toBe('NotSpaceOwner'); }); it('rejects a type that is not an NSID', async () => { const res = await post('/xrpc/com.atproto.space.createSpace', { spaceType: 'nodots', policy: MEMBERSPACE, }); expect((await res.json()).error).toBe('InvalidType'); }); it('rejects a duplicate', async () => { const body = { spaceType: TYPE, skey: 'general', policy: MEMBERSPACE }; await post('/xrpc/com.atproto.space.createSpace', body); const res = await post('/xrpc/com.atproto.space.createSpace', body); expect((await res.json()).error).toBe('SpaceAlreadyExists'); }); it('requires a manage=create grant', async () => { const res = await post( '/xrpc/com.atproto.space.createSpace', { spaceType: TYPE, policy: MEMBERSPACE }, { did: OWNER, scope: `space:${TYPE}?authority=${OWNER}` }, ); expect(res.status).toBe(403); expect((await res.json()).error).toBe('InvalidToken'); }); it('no longer serves the simplespace methods', () => { expect( Object.keys(routes).filter((path) => path.includes('simplespace')), ).toEqual([]); }); }); describe('members', () => { beforeEach(async () => { await store.putSpace(makeSpaceRow(SPACE, { isOwner: true })); }); /** @param {string} did */ const member = (did, read = true, write = true) => ({ space: SPACE, did, read, write, }); it('adds, lists and removes', async () => { await post('/xrpc/com.atproto.memberspace.putMember', member(MEMBER)); let body = await ( await get('/xrpc/com.atproto.memberspace.listMembers', { space: SPACE }) ).json(); expect(body.members).toEqual([{ did: MEMBER, read: true, write: true }]); await post('/xrpc/com.atproto.memberspace.removeMember', { space: SPACE, did: MEMBER, }); body = await ( await get('/xrpc/com.atproto.memberspace.listMembers', { space: SPACE }) ).json(); expect(body.members).toEqual([]); }); it('requires both rights', async () => { const res = await post('/xrpc/com.atproto.memberspace.putMember', { space: SPACE, did: MEMBER, }); expect(res.status).toBe(400); }); it('drops a member from the writer set when it loses write', async () => { await store.putSpaceWriter(SPACE, MEMBER, REV, DATA_BYTES, REV); await post( '/xrpc/com.atproto.memberspace.putMember', member(MEMBER, true, false), ); expect(await store.getSpaceWriter(SPACE, MEMBER)).toBeNull(); expect(await store.getMember(SPACE, MEMBER)).toEqual({ read: true, write: false, }); }); it('drops a removed member from the writer set', async () => { await post('/xrpc/com.atproto.memberspace.putMember', member(MEMBER)); await store.putSpaceWriter(SPACE, MEMBER, REV, DATA_BYTES, REV); await post('/xrpc/com.atproto.memberspace.removeMember', { space: SPACE, did: MEMBER, }); expect(await store.getSpaceWriter(SPACE, MEMBER)).toBeNull(); }); it('publishes and withdraws member records when the policy asks', async () => { await store.putSpace( makeSpaceRow(SPACE, { isOwner: true, policy: { ...MEMBERSPACE_STORED, publishMembers: true }, }), ); await post('/xrpc/com.atproto.memberspace.putMember', member(MEMBER)); expect( await store.getSpaceRecord( SPACE, 'com.atproto.memberspace.member', MEMBER, ), ).not.toBeNull(); await post('/xrpc/com.atproto.memberspace.removeMember', { space: SPACE, did: MEMBER, }); expect( await store.getSpaceRecord( SPACE, 'com.atproto.memberspace.member', MEMBER, ), ).toBeNull(); }); it('refuses a space under another policy', async () => { await store.putSpace( makeSpaceRow(SPACE, { isOwner: true, policy: { $type: 'com.atproto.personalspace.policy' }, }), ); for (const res of [ await post('/xrpc/com.atproto.memberspace.putMember', member(MEMBER)), await post('/xrpc/com.atproto.memberspace.removeMember', { space: SPACE, did: MEMBER, }), await get('/xrpc/com.atproto.memberspace.listMembers', { space: SPACE }), ]) { expect(res.status).toBe(400); expect((await res.json()).error).toBe('PolicyMismatch'); } }); it('denies on scope before revealing whether a foreign space exists', async () => { // SCOPE is granted for OWNER's spaces only, so a space under another // authority fails the scope check first — the caller learns nothing about // whether it exists. const other = `at://${MEMBER}/space/${TYPE}/x`; const res = await post('/xrpc/com.atproto.memberspace.putMember', { ...member(MEMBER), space: other, }); expect(res.status).toBe(403); expect((await res.json()).error).toBe('InvalidToken'); }); it('404s a space that does not exist but is covered by scope', async () => { const res = await post('/xrpc/com.atproto.memberspace.putMember', { ...member(MEMBER), space: `at://${OWNER}/space/${TYPE}/nonexistent`, }); expect(res.status).toBe(404); expect((await res.json()).error).toBe('SpaceNotFound'); }); it('403s a space present locally but owned elsewhere', async () => { const foreign = `at://${OWNER}/space/${TYPE}/foreign`; // isOwner false: we hold a repo in it, but we are not the authority. await store.putSpace(makeSpaceRow(foreign, { isOwner: false })); const res = await post('/xrpc/com.atproto.memberspace.putMember', { ...member(MEMBER), space: foreign, }); expect(res.status).toBe(403); expect((await res.json()).error).toBe('NotSpaceOwner'); }); }); describe('removeWriter', () => { it('drops an account from the writer set', async () => { await store.putSpace(makeSpaceRow(SPACE, { isOwner: true })); await store.putSpaceWriter(SPACE, MEMBER, REV, DATA_BYTES, REV); const res = await post('/xrpc/com.atproto.space.removeWriter', { space: SPACE, did: MEMBER, }); expect(res.status).toBe(200); expect(await store.getSpaceWriter(SPACE, MEMBER)).toBeNull(); }); it('404s a deleted space', async () => { await store.putSpace( makeSpaceRow(SPACE, { isOwner: true, deletedAt: '2026-10-01' }), ); const res = await post('/xrpc/com.atproto.space.removeWriter', { space: SPACE, did: MEMBER, }); expect(res.status).toBe(404); }); }); describe('updateSpace', () => { beforeEach(async () => { await store.putSpace( makeSpaceRow(SPACE, { isOwner: true, policy: { ...MEMBERSPACE_STORED, allowedApps: ['https://a.example/c.json'], }, }), ); await store.putMember(SPACE, MEMBER, { read: true, write: true }); }); it('replaces the policy wholesale', async () => { await post('/xrpc/com.atproto.space.updateSpace', { space: SPACE, policy: { $type: 'com.atproto.memberspace.policy', publicRead: true }, }); expect((await store.getSpace(SPACE)).policy).toEqual({ ...MEMBERSPACE_STORED, publicRead: true, }); // The same policy keeps its state. expect(await store.getMember(SPACE, MEMBER)).not.toBeNull(); }); it('discards the member list when the policy changes type', async () => { await post('/xrpc/com.atproto.space.updateSpace', { space: SPACE, policy: { $type: 'com.atproto.personalspace.policy' }, }); expect((await store.getSpace(SPACE)).policy).toEqual({ $type: 'com.atproto.personalspace.policy', }); expect(await store.getMember(SPACE, MEMBER)).toBeNull(); }); it('publishes the member list when publishMembers turns on, and withdraws it', async () => { await post('/xrpc/com.atproto.space.updateSpace', { space: SPACE, policy: { ...MEMBERSPACE, publishMembers: true, publishPolicy: true }, }); const listed = async () => (await store.listAllSpaceRecords(SPACE)).map( (/** @type {any} */ r) => `${r.collection}/${r.rkey}`, ); expect(await listed()).toEqual([ `com.atproto.memberspace.member/${MEMBER}`, 'com.atproto.space.policy/self', ]); await post('/xrpc/com.atproto.space.updateSpace', { space: SPACE, policy: { $type: 'com.atproto.personalspace.policy' }, }); expect(await listed()).toEqual([]); }); it('rejects a policy it does not implement', async () => { const res = await post('/xrpc/com.atproto.space.updateSpace', { space: SPACE, policy: { $type: 'com.example.inviteOnly.policy' }, }); expect((await res.json()).error).toBe('UnsupportedPolicy'); }); }); describe('getSpace', () => { it('describes a space it owns', async () => { await store.putSpace(makeSpaceRow(SPACE, { isOwner: true })); const res = await get('/xrpc/com.atproto.space.getSpace', { space: SPACE, }); expect(await res.json()).toEqual({ uri: SPACE, policy: MEMBERSPACE_STORED, }); }); it('404s a space it does not own', async () => { await store.putSpace(makeSpaceRow(SPACE, { isOwner: false })); const res = await get('/xrpc/com.atproto.space.getSpace', { space: SPACE, }); expect(res.status).toBe(404); }); it('refuses a caller with no session', async () => { await store.putSpace(makeSpaceRow(SPACE, { isOwner: true })); const res = await get( '/xrpc/com.atproto.space.getSpace', { space: SPACE }, null, ); expect(res.status).toBe(401); expect((await res.json()).error).toBe('AuthenticationRequired'); }); it('refuses another account', async () => { await store.putSpace(makeSpaceRow(SPACE, { isOwner: true })); const res = await get( '/xrpc/com.atproto.space.getSpace', { space: SPACE }, { did: MEMBER, scope: SCOPE }, ); expect(res.status).toBe(403); }); it('tells a caller with no session that a space is not there', async () => { // Existence comes before who may read: a space's address is its // owner's DID and a key, so a miss is an answer anyone may have. The // git helper asks this to tell a private repository from a public one. const res = await get( '/xrpc/com.atproto.space.getSpace', { space: `at://${OWNER}/space/com.example.forum/nowhere` }, null, ); expect(res.status).toBe(404); expect((await res.json()).error).toBe('SpaceNotFound'); }); it('refuses a space credential', async () => { await store.putSpace(makeSpaceRow(SPACE, { isOwner: true })); const res = await withCredential('/xrpc/com.atproto.space.getSpace', { params: { space: SPACE }, }); expect(res.status).toBe(401); }); }); describe('deleteSpace', () => { it('soft-deletes and fans out to registered syncers', async () => { await store.putSpace(makeSpaceRow(SPACE, { isOwner: true })); await store.putCredentialRecipient( SPACE, MEMBER, 'https://syncer.example', new Date(Date.now() + 3600_000).toISOString(), ); const res = await post('/xrpc/com.atproto.space.deleteSpace', { space: SPACE, }); expect(res.status).toBe(200); expect((await store.getSpace(SPACE)).deletedAt).toBeTruthy(); expect(sent).toHaveLength(1); expect(sent[0].url).toBe( 'https://syncer.example/xrpc/com.atproto.space.notifySpaceDeleted', ); expect(JSON.parse(sent[0].init.body).space).toBe(SPACE); expect(sent[0].init.headers.authorization).toMatch(/^Bearer /); expect(await store.nextSpaceNotifyAt()).toBeNull(); }); it('owes a syncer that could not be reached the notice, until it lands', async () => { await store.putSpace(makeSpaceRow(SPACE, { isOwner: true })); await store.putCredentialRecipient( SPACE, MEMBER, 'https://down.example', new Date(Date.now() + 3600_000).toISOString(), ); routes = createSpaceRoutes({ spaceStorage: store, ...createMemoryBlobContext(), getDid: async () => OWNER, getKemKey: async () => null, getSigner: async () => owner.signer, resolveDid: async (/** @type {string} */ d) => didDocs[d] ?? null, verifier: createVerifier(), fetch: async () => new Response('{}', { status: 503 }), }); const res = await post('/xrpc/com.atproto.space.deleteSpace', { space: SPACE, }); expect(res.status).toBe(200); expect(await store.nextSpaceNotifyAt()).not.toBeNull(); const [owed] = await store.listDueSpaceDeletedNotify( new Date(Date.now() + 3600_000).toISOString(), 10, ); expect(owed).toMatchObject({ space: SPACE, service: MEMBER, serviceEndpoint: 'https://down.example', attempts: 1, }); }); }); describe('notifyWrite', () => { beforeEach(async () => { await store.putSpace(makeSpaceRow(SPACE, { isOwner: true })); await store.putMember(SPACE, MEMBER, { read: true, write: true }); }); /** @param {string} iss @param {any} identity @param {string} [lxm] */ async function serviceToken( iss, identity, lxm = 'com.atproto.space.notifyWrite', ) { return createServiceAuth({ iss, aud: OWNER, lxm, signer: identity.signer, }); } it('records the writer', async () => { const token = await serviceToken(MEMBER, member); const res = await post( '/xrpc/com.atproto.space.notifyWrite', { space: SPACE, repo: MEMBER, repoRev: REV, data: DATA, }, null, { authorization: `Bearer ${token}` }, ); expect(res.status).toBe(200); const { writers } = await store.listSpaceWriters(SPACE, null, 50); expect(writers[0].did).toBe(MEMBER); expect(writers[0].repoRev).toBe(REV); expect(writers[0].spaceRev).toMatch(/^[2-7a-z]{13}$/); }); it('forwards the notice to every registered syncer', async () => { await store.putCredentialRecipient( SPACE, `${MEMBER}${ATPROTO_PDS_ID}`, 'https://syncer.example', new Date(Date.now() + 3600_000).toISOString(), ); const token = await serviceToken(MEMBER, member); await post( '/xrpc/com.atproto.space.notifyWrite', { space: SPACE, repo: MEMBER, repoRev: REV, data: DATA, }, null, { authorization: `Bearer ${token}` }, ); const forwarded = sent.find((c) => c.url.includes('notifyWrite')); expect(forwarded.url).toBe( 'https://syncer.example/xrpc/com.atproto.space.notifyWrite', ); const body = JSON.parse(forwarded.init.body); expect(body.space).toBe(SPACE); expect(body.repo).toBe(MEMBER); expect(body.repoRev).toBe(REV); expect(body.spaceRev).toMatch(/^[2-7a-z]{13}$/); // The space's first update has no predecessor to name. expect(body).not.toHaveProperty('prevSpaceRev'); // The data arrives as it came in. `$bytes` carries no padding, so the // strings differ where the bytes do not. expect( Uint8Array.from(atob(body.data.$bytes), (c) => c.charCodeAt(0)), ).toEqual(DATA_BYTES); // Addressed to the syncer and signed by the authority, which is the only // party that knows the registration exists. expect(forwarded.init.headers.authorization).toMatch(/^Bearer /); }); it('refuses a writer the space does not admit', async () => { await store.putMember(SPACE, MEMBER, { read: true, write: false }); const token = await serviceToken(MEMBER, member); const res = await post( '/xrpc/com.atproto.space.notifyWrite', { space: SPACE, repo: MEMBER, repoRev: REV, data: DATA }, null, { authorization: `Bearer ${token}` }, ); expect(res.status).toBe(403); const { writers } = await store.listSpaceWriters(SPACE, null, 50); expect(writers).toEqual([]); }); it('admits a writer under a public write policy', async () => { await store.putSpace( makeSpaceRow(SPACE, { isOwner: true, policy: { ...MEMBERSPACE_STORED, publicWrite: true }, }), ); await store.removeMember(SPACE, MEMBER); const token = await serviceToken(MEMBER, member); const res = await post( '/xrpc/com.atproto.space.notifyWrite', { space: SPACE, repo: MEMBER, repoRev: REV, data: DATA }, null, { authorization: `Bearer ${token}` }, ); expect(res.status).toBe(200); }); it('does not forward to a lapsed registration', async () => { await store.putCredentialRecipient( SPACE, `${MEMBER}${ATPROTO_PDS_ID}`, 'https://syncer.example', new Date(Date.now() - 1000).toISOString(), ); const token = await serviceToken(MEMBER, member); await post( '/xrpc/com.atproto.space.notifyWrite', { space: SPACE, repo: MEMBER, repoRev: REV, data: DATA }, null, { authorization: `Bearer ${token}` }, ); expect(sent.filter((c) => c.url.includes('notifyWrite'))).toHaveLength(0); }); it('rejects an unsigned call', async () => { const res = await post( '/xrpc/com.atproto.space.notifyWrite', { space: SPACE, repo: MEMBER, repoRev: REV, data: DATA }, null, ); expect(res.status).toBe(401); }); it('rejects a token minted for a different method', async () => { const token = await serviceToken( MEMBER, member, 'com.atproto.space.getRepo', ); const res = await post( '/xrpc/com.atproto.space.notifyWrite', { space: SPACE, repo: MEMBER, repoRev: REV, data: DATA }, null, { authorization: `Bearer ${token}` }, ); expect(res.status).toBe(401); expect((await res.json()).error).toBe('BadJwtLexiconMethod'); }); it('refuses to let one account report another account writes', async () => { const token = await serviceToken(MEMBER, member); const res = await post( '/xrpc/com.atproto.space.notifyWrite', { space: SPACE, repo: 'did:plc:someone-else', repoRev: REV, data: DATA }, null, { authorization: `Bearer ${token}` }, ); expect(res.status).toBe(403); }); /** * @param {Record} body * @param {string} [aud] */ async function notify(body, aud = OWNER) { const token = await createServiceAuth({ iss: MEMBER, aud, lxm: 'com.atproto.space.notifyWrite', signer: member.signer, }); return post( '/xrpc/com.atproto.space.notifyWrite', { space: SPACE, repo: MEMBER, data: DATA, ...body }, null, { authorization: `Bearer ${token}` }, ); } it('accepts a token addressed to the space host service', async () => { const res = await notify({ repoRev: REV }, `${OWNER}${ATPROTO_PDS_ID}`); expect(res.status).toBe(200); }); it('requires the commit data', async () => { const token = await serviceToken(MEMBER, member); for (const data of [undefined, { $bytes: '!!' }, 'AAAA']) { const res = await post( '/xrpc/com.atproto.space.notifyWrite', { space: SPACE, repo: MEMBER, repoRev: REV, data }, null, { authorization: `Bearer ${token}` }, ); expect(res.status).toBe(400); expect((await res.json()).error).toBe('InvalidRequest'); } }); it('refuses a repoRev that is not a TID', async () => { const res = await notify({ repoRev: '3lkaaa' }); expect(res.status).toBe(400); }); it('refuses a repoRev too far in the future', async () => { const future = nextTid(null); // Ten minutes ahead: the TID's timestamp is microseconds in its first // eleven characters, so rebuild one from a later clock. const later = tidAt(Date.now() + 10 * 60 * 1000); expect(later > future).toBe(true); const res = await notify({ repoRev: later }); expect(res.status).toBe(400); expect((await res.json()).error).toBe('FutureRev'); }); it('answers SpaceNotFound for a deleted space', async () => { await store.deleteSpace(SPACE, new Date().toISOString()); const res = await notify({ repoRev: REV }); expect(res.status).toBe(404); expect((await res.json()).error).toBe('SpaceNotFound'); }); it('sequences updates across writers, each naming the one before', async () => { await store.putSpaceWriter(SPACE, OWNER, REV, DATA_BYTES, '3lkaaaaaaaa23'); await store.putCredentialRecipient( SPACE, `${MEMBER}${ATPROTO_PDS_ID}`, 'https://syncer.example', new Date(Date.now() + 3600_000).toISOString(), ); await notify({ repoRev: REV }); await notify({ repoRev: LATER_REV }); const bodies = sent .filter((c) => c.url.includes('notifyWrite')) .map((c) => JSON.parse(c.init.body)); expect(bodies).toHaveLength(2); expect(bodies[0].prevSpaceRev).toBe('3lkaaaaaaaa23'); expect(bodies[0].spaceRev > bodies[0].prevSpaceRev).toBe(true); expect(bodies[1].prevSpaceRev).toBe(bodies[0].spaceRev); expect(bodies[1].spaceRev > bodies[0].spaceRev).toBe(true); }); it('neither resequences nor forwards a repeated or older rev', async () => { await store.putCredentialRecipient( SPACE, `${MEMBER}${ATPROTO_PDS_ID}`, 'https://syncer.example', new Date(Date.now() + 3600_000).toISOString(), ); expect((await notify({ repoRev: LATER_REV })).status).toBe(200); const before = await store.getSpaceWriter(SPACE, MEMBER); expect((await notify({ repoRev: LATER_REV })).status).toBe(200); expect((await notify({ repoRev: REV })).status).toBe(200); const after = await store.getSpaceWriter(SPACE, MEMBER); expect(after).toEqual(before); expect(sent.filter((c) => c.url.includes('notifyWrite'))).toHaveLength(1); }); }); describe('notifyCredentialRevoked', () => { const PATH = '/xrpc/com.atproto.space.notifyCredentialRevoked'; /** A space MEMBER governs, which OWNER holds a repo in. */ const FOREIGN = `at://${MEMBER}/space/${TYPE}/general`; /** * @param {Record} body * @param {{iss?: string, identity?: any, aud?: string}} [opts] */ async function revoke(body, { iss = MEMBER, identity, aud = OWNER } = {}) { const token = await createServiceAuth({ iss, aud, lxm: 'com.atproto.space.notifyCredentialRevoked', signer: (identity ?? member).signer, }); return post(PATH, body, null, { authorization: `Bearer ${token}` }); } it('records revocations from the space authority', async () => { const res = await revoke({ space: FOREIGN, credentials: ['j1', 'j2'] }); expect(res.status).toBe(200); expect(await store.isCredentialRevoked(FOREIGN, 'j1')).toBe(true); expect(await store.isCredentialRevoked(FOREIGN, 'j2')).toBe(true); expect(await store.isCredentialRevoked(FOREIGN, 'j3')).toBe(false); }); it('takes the same revocation twice', async () => { const body = { space: FOREIGN, credentials: ['j1', 'j1'] }; expect((await revoke(body)).status).toBe(200); expect((await revoke(body)).status).toBe(200); expect(await store.isCredentialRevoked(FOREIGN, 'j1')).toBe(true); }); it('refuses anybody but the space authority', async () => { const res = await revoke( { space: SPACE, credentials: ['j1'] }, { iss: MEMBER }, ); expect(res.status).toBe(403); expect(await store.isCredentialRevoked(SPACE, 'j1')).toBe(false); }); it('refuses a token addressed to another account', async () => { const res = await revoke( { space: FOREIGN, credentials: ['j1'] }, { aud: 'did:plc:someone-else' }, ); expect(res.status).toBe(401); }); it('refuses a body without 1 to 100 credential ids', async () => { for (const credentials of [ [], [''], Array.from({ length: 101 }, (_, i) => `j${i}`), 'j1', ]) { const res = await revoke({ space: FOREIGN, credentials }); expect(res.status).toBe(400); } }); }); describe('registerNotify', () => { // The test DID documents publish one space-host entry each, so a subscriber // names itself with that fragment. const SYNCER = `${MEMBER}${ATPROTO_PDS_ID}`; beforeEach(async () => { await store.putSpace(makeSpaceRow(SPACE, { isOwner: true })); }); it('records a subscriber holding a valid credential', async () => { const res = await withCredential('/xrpc/com.atproto.space.registerNotify', { body: { space: SPACE, service: SYNCER }, }); expect(res.status).toBe(200); expect((await res.json()).expiresAt).toMatch(/^\d{4}-/); const list = await store.listCredentialRecipients(SPACE); expect(list[0].serviceDid).toBe(SYNCER); // Resolved from the subscriber's DID document, not taken from the request. expect(list[0].serviceEndpoint).toBe('https://member.example'); }); it('refuses a service it cannot resolve to an endpoint', async () => { const res = await withCredential('/xrpc/com.atproto.space.registerNotify', { body: { space: SPACE, service: 'did:web:nowhere.example#syncer' }, }); expect((await res.json()).error).toBe('ServiceNotResolvable'); }); it('refuses a space this server does not govern', async () => { const foreign = `at://${MEMBER}/space/${TYPE}/general`; await store.putSpace(makeSpaceRow(foreign, { isOwner: false })); const res = await withCredential('/xrpc/com.atproto.space.registerNotify', { body: { space: foreign, service: SYNCER }, iss: MEMBER, sub: foreign, signer: member.signer, audience: MEMBER, }); expect(res.status).toBe(404); expect((await res.json()).error).toBe('SpaceNotFound'); }); it('refuses without a credential', async () => { const res = await post( '/xrpc/com.atproto.space.registerNotify', { space: SPACE, service: SYNCER }, null, ); expect(res.status).toBe(401); }); }); describe('unregisterNotify', () => { const SYNCER = `${MEMBER}${ATPROTO_PDS_ID}`; beforeEach(async () => { await store.putSpace(makeSpaceRow(SPACE, { isOwner: true })); await store.putCredentialRecipient( SPACE, SYNCER, 'https://member.example', new Date(Date.now() + 3600_000).toISOString(), ); }); /** @param {any} body */ const unregister = (body) => withCredential('/xrpc/com.atproto.space.unregisterNotify', { body }); it('withdraws a registration', async () => { const res = await unregister({ space: SPACE, service: SYNCER }); expect(res.status).toBe(200); expect(await store.listCredentialRecipients(SPACE)).toEqual([]); }); it('succeeds when nothing was registered', async () => { const res = await unregister({ space: SPACE, service: 'did:web:stranger.example#syncer', }); expect(res.status).toBe(200); expect(await store.listCredentialRecipients(SPACE)).toHaveLength(1); }); it('refuses without a credential', async () => { const res = await post( '/xrpc/com.atproto.space.unregisterNotify', { space: SPACE, service: SYNCER }, null, ); expect(res.status).toBe(401); }); }); describe('notifySpaceDeleted', () => { it('flags the local space when its authority says so', async () => { const foreign = `at://${MEMBER}/space/${TYPE}/general`; await store.putSpace(makeSpaceRow(foreign, { isOwner: false })); const token = await createServiceAuth({ iss: MEMBER, aud: OWNER, lxm: 'com.atproto.space.notifySpaceDeleted', signer: member.signer, }); const res = await post( '/xrpc/com.atproto.space.notifySpaceDeleted', { space: foreign }, null, { authorization: `Bearer ${token}` }, ); expect(res.status).toBe(200); // Flagged, not erased — the records are the member's own. const row = await store.getSpace(foreign); expect(row).not.toBeNull(); expect(row.deletedAt).toBeTruthy(); }); it('refuses a caller that is not the space authority', async () => { const foreign = `at://${MEMBER}/space/${TYPE}/general`; await store.putSpace(makeSpaceRow(foreign, { isOwner: false })); const impostor = await makeIdentity(); didDocs['did:plc:impostor'] = { id: 'did:plc:impostor', verificationMethod: [ { id: '#atproto', type: 'Multikey', publicKeyMultibase: impostor.multibase, }, ], }; const token = await createServiceAuth({ iss: 'did:plc:impostor', aud: OWNER, lxm: 'com.atproto.space.notifySpaceDeleted', signer: impostor.signer, }); const res = await post( '/xrpc/com.atproto.space.notifySpaceDeleted', { space: foreign }, null, { authorization: `Bearer ${token}` }, ); expect(res.status).toBe(403); }); }); describe('outbound notifyWrite', () => { it('tells the space authority after a write', async () => { // A space owned by MEMBER, written to by us (OWNER). const foreign = `at://${MEMBER}/space/${TYPE}/general`; const url = new URL( 'https://pds.example/xrpc/com.atproto.space.createRecord', ); const res = await routes['/xrpc/com.atproto.space.createRecord'].handler( new Request(url, { method: 'POST', body: JSON.stringify({ space: foreign, repo: OWNER, collection: 'com.example.post', rkey: 'a', record: { v: 1 }, }), }), url, { did: OWNER, scope: `space:${TYPE}?authority=*&collection=com.example.post`, }, ); expect(res.status).toBe(200); // fireNotifyWrite swallows its own errors by design, so assert the call // actually happened — otherwise a broken notifier looks identical to a // working one. const notify = sent.find((c) => c.url.endsWith('/xrpc/com.atproto.space.notifyWrite'), ); expect(notify).toBeTruthy(); expect(notify.url).toBe( 'https://member.example/xrpc/com.atproto.space.notifyWrite', ); const body = JSON.parse(notify.init.body); expect(body.space).toBe(foreign); expect(body.repo).toBe(OWNER); expect(body.repoRev).toBeTruthy(); // The data is sha256 of the LtHash state, i.e. the commit data. expect(atob(body.data.$bytes)).toHaveLength(32); expect(notify.init.headers.authorization).toMatch(/^Bearer /); const token = notify.init.headers.authorization.slice('Bearer '.length); const claims = JSON.parse(atob(token.split('.')[1])); expect(claims.aud).toBe(`${MEMBER}${ATPROTO_PDS_ID}`); // Delivered, so nothing is left owed. expect(await store.nextSpaceNotifyAt()).toBeNull(); }); /** Write one record into the hosted account's own space. */ async function writeToOwnSpace() { const url = new URL( 'https://pds.example/xrpc/com.atproto.space.createRecord', ); return routes['/xrpc/com.atproto.space.createRecord'].handler( new Request(url, { method: 'POST', body: JSON.stringify({ space: SPACE, repo: OWNER, collection: 'com.example.post', rkey: 'a', record: { v: 1 }, }), }), url, { did: OWNER, scope: `space:${TYPE}?authority=${OWNER}&collection=com.example.post`, }, ); } it('records itself as a writer in its own space without HTTP', async () => { await store.putSpace(makeSpaceRow(SPACE, { isOwner: true })); await writeToOwnSpace(); // No notification round-trip to ourselves... expect(sent.filter((c) => c.url.includes('notifyWrite'))).toHaveLength(0); // ...but the writer set must include us, or listRepos would omit the // authority's own repo and syncers walking it would miss this repo. const { writers } = await store.listSpaceWriters(SPACE, null, 50); expect(writers.map((/** @type {any} */ w) => w.did)).toEqual([OWNER]); }); // The case an application actually meets: its own space, on its own PDS, with // one syncer following it. Nothing arrives over HTTP for this server to // forward, so the write path is the only thing that can send the notice. it('notifies a syncer of a write into the authority own space', async () => { await store.putSpace(makeSpaceRow(SPACE, { isOwner: true })); await store.putCredentialRecipient( SPACE, `${MEMBER}${ATPROTO_PDS_ID}`, 'https://syncer.example', new Date(Date.now() + 3600_000).toISOString(), ); await writeToOwnSpace(); const forwarded = sent.filter((c) => c.url.includes('notifyWrite')); expect(forwarded).toHaveLength(1); expect(forwarded[0].url).toBe( 'https://syncer.example/xrpc/com.atproto.space.notifyWrite', ); const body = JSON.parse(forwarded[0].init.body); expect(body.space).toBe(SPACE); expect(body.repo).toBe(OWNER); expect(body.repoRev).toBeTruthy(); // The same data the writer set records, which is what a syncer compares. const { writers } = await store.listSpaceWriters(SPACE, null, 50); expect(atob(body.data.$bytes)).toHaveLength(32); expect(body.repoRev).toBe(writers[0].repoRev); expect(body.spaceRev).toBe(writers[0].spaceRev); }); /** * Rebuild the routes with a background hook, and answer the work it holds. * @returns {Promise[]} */ function withBackground() { /** @type {Promise[]} */ const held = []; routes = createSpaceRoutes({ spaceStorage: store, ...createMemoryBlobContext(), getDid: async () => OWNER, getKemKey: async () => null, getSigner: async () => owner.signer, resolveDid: async (/** @type {string} */ d) => didDocs[d] ?? null, verifier: createVerifier(), fetch: async (/** @type {any} */ url, /** @type {any} */ init) => { sent.push({ url: String(url), init }); return new Response('{}', { status: 200 }); }, background: (/** @type {Promise} */ work) => { held.push(work); }, }); return held; } it('answers a write into its own space before notifying the syncers', async () => { const held = withBackground(); await store.putSpace(makeSpaceRow(SPACE, { isOwner: true })); await store.putCredentialRecipient( SPACE, `${MEMBER}${ATPROTO_PDS_ID}`, 'https://syncer.example', new Date(Date.now() + 3600_000).toISOString(), ); const res = await writeToOwnSpace(); expect(res.status).toBe(200); expect(held).toHaveLength(1); await Promise.all(held); const forwarded = sent.filter((c) => c.url.includes('notifyWrite')); expect(forwarded).toHaveLength(1); expect(forwarded[0].init.signal).toBeInstanceOf(AbortSignal); }); it('owes the authority a write before trying it after the answer', async () => { const held = withBackground(); const foreign = `at://${MEMBER}/space/${TYPE}/general`; const url = new URL( 'https://pds.example/xrpc/com.atproto.space.createRecord', ); const res = await routes['/xrpc/com.atproto.space.createRecord'].handler( new Request(url, { method: 'POST', body: JSON.stringify({ space: foreign, repo: OWNER, collection: 'com.example.post', rkey: 'a', record: { v: 1 }, }), }), url, { did: OWNER, scope: `space:${TYPE}?authority=*&collection=com.example.post`, }, ); expect(res.status).toBe(200); expect(held).toHaveLength(1); expect(await store.nextSpaceNotifyAt()).not.toBeNull(); await Promise.all(held); expect( sent.filter((c) => c.url.endsWith('com.atproto.space.notifyWrite')), ).toHaveLength(1); expect(await store.nextSpaceNotifyAt()).toBeNull(); }); });