/** * E2E tests for PDS - runs against local wrangler dev, Node.js, or Deno server * Uses Vitest and fetch */ import { spawn } from 'node:child_process'; import { randomBytes } from 'node:crypto'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createServer } from '@pdsjs/node'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import WebSocket from 'ws'; import { startDenoServer, stopDenoServer } from './helpers/deno-server.js'; import { ensureDockerServices, RELAY_URL } from './helpers/docker-services.js'; import { DpopClient, seedDpopNonce } from './helpers/dpop.js'; import { createTestIdentity, PLC_URL } from './helpers/identity.js'; import { startNodeServer, stopNodeServer, USE_LOCAL_INFRA, } from './helpers/node-server.js'; import { getOAuthTokenWithScope, setBaseUrl } from './helpers/oauth.js'; import { TEST_PORT } from './helpers/test-port.js'; const PLATFORM = process.env.PLATFORM || 'cloudflare'; const BASE = PLATFORM === 'node' || PLATFORM === 'deno' ? `http://localhost:${TEST_PORT}` : 'http://localhost:8787'; // Configure oauth helper to use the same BASE setBaseUrl(BASE); // Generate random base32-lower string (a-z, 2-7) for valid did:plc format /** @param {number} length */ function randomBase32(length) { const chars = 'abcdefghijklmnopqrstuvwxyz234567'; const bytes = randomBytes(length); return Array.from(bytes) .map((b) => chars[b % 32]) .join(''); } // DID, private key, and handle - set during test setup let DID = `did:plc:${randomBase32(24)}`; let PRIVATE_KEY_HEX = randomBytes(32).toString('hex'); let HANDLE = 'test.local'; const PASSWORD = 'test-password'; /** * Wait for server to be ready */ async function waitForServer(maxAttempts = 30) { for (let i = 0; i < maxAttempts; i++) { try { const res = await fetch(`${BASE}/`); if (res.ok) return; } catch { // Server not ready yet } await new Promise((r) => setTimeout(r, 500)); } throw new Error('Server failed to start'); } /** * Make JSON request helper (with retry for flaky wrangler dev 5xx errors) * @param {string} path * @param {any} body * @param {Record} [headers] * @returns {Promise<{status: number, data: any}>} */ async function jsonPost(path, body, headers = {}) { /** @type {{status: number, data: any}|undefined} */ let result; for (let attempt = 0; attempt < 3; attempt++) { const res = await fetch(`${BASE}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body), }); // Retry on 5xx errors (wrangler dev flakiness) if (res.status >= 500 && attempt < 2) { await new Promise((r) => setTimeout(r, 100 * (attempt + 1))); continue; } const text = await res.text(); let data = null; try { data = text ? JSON.parse(text) : null; } catch { // Not JSON } result = { status: res.status, data }; break; } if (!result) throw new Error('All retry attempts failed'); return result; } /** * Make form-encoded POST (with retry for flaky wrangler dev 5xx errors) * @param {string} path * @param {Record} params * @param {Record} [headers] * @returns {Promise<{status: number, data: any}>} */ async function formPost(path, params, headers = {}) { /** @type {{status: number, data: any}|undefined} */ let result; for (let attempt = 0; attempt < 3; attempt++) { const res = await fetch(`${BASE}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers, }, body: new URLSearchParams(params).toString(), }); // Retry on 5xx errors (wrangler dev flakiness) if (res.status >= 500 && attempt < 2) { await new Promise((r) => setTimeout(r, 100 * (attempt + 1))); continue; } const text = await res.text(); let data = null; try { data = JSON.parse(text); } catch { data = text; } result = { status: res.status, data }; break; } if (!result) throw new Error('All retry attempts failed'); return result; } describe('E2E Tests', () => { /** @type {import('node:child_process').ChildProcess|null} */ let wrangler = null; /** @type {{close: () => Promise}|null} */ let nodeServer = null; /** @type {{close: () => Promise}|null} */ let denoServer = null; /** @type {string} */ let token = ''; /** @type {string} */ let refreshToken = ''; /** @type {string} */ let testRkey = ''; beforeAll(async () => { // Start docker services if enabled (PLC, relay) if (USE_LOCAL_INFRA) { await ensureDockerServices(); } if (PLATFORM === 'node') { // Start Node.js server nodeServer = await startNodeServer(); } else if (PLATFORM === 'deno') { // Start Deno server denoServer = await startDenoServer(); } else { // Clear wrangler state for clean slate (like docker reset for node) const { rmSync } = await import('node:fs'); try { rmSync('.wrangler/state', { recursive: true, force: true }); } catch { // Directory may not exist } // Wait for any previous dev server to release the port, otherwise // waitForServer() can bind to a dying instance from an earlier run for (let i = 0; i < 30; i++) { try { await fetch('http://127.0.0.1:8787/'); } catch { break; } await new Promise((r) => setTimeout(r, 1000)); } // Start wrangler wrangler = spawn( 'npx', [ 'wrangler', 'dev', '--port', '8787', '--persist-to', '.wrangler/state', ], { stdio: 'pipe', cwd: process.cwd(), }, ); } await waitForServer(); // Learn the server's DPoP nonce so all DPoP proofs carry it await seedDpopNonce(BASE); // Initialize PDS - use proper PLC registration when local infra is available if (USE_LOCAL_INFRA) { // Create identity registered with local PLC const identity = await createTestIdentity({ pdsUrl: 'https://host.docker.internal:3443', handle: 'test', }); DID = identity.did; PRIVATE_KEY_HEX = identity.privateKeyHex; HANDLE = identity.handle; console.log(`Created identity: ${DID} with handle ${HANDLE}`); const res = await fetch(`${BASE}/init?did=${DID}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ did: DID, privateKey: PRIVATE_KEY_HEX, handle: HANDLE, password: PASSWORD, }), }); expect(res.ok).toBeTruthy(); } else { // Simple initialization without PLC for non-relay tests HANDLE = 'test.local'; const res = await fetch(`${BASE}/init?did=${DID}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ did: DID, privateKey: PRIVATE_KEY_HEX, handle: HANDLE, password: PASSWORD, }), }); expect(res.ok).toBeTruthy(); } }, 120000); afterAll(async () => { if (PLATFORM === 'node') { if (nodeServer) await stopNodeServer(nodeServer); } else if (PLATFORM === 'deno') { if (denoServer) await stopDenoServer(denoServer); } else { if (wrangler) wrangler.kill(); } }, 120000); // Match beforeAll timeout describe('Server endpoints', () => { it('root returns ASCII art', async () => { const res = await fetch(`${BASE}/`); const text = await res.text(); expect(text.includes('PDS')).toBeTruthy(); // Root should contain PDS; }); it('describeServer returns DID and availableUserDomains', async () => { const res = await fetch(`${BASE}/xrpc/com.atproto.server.describeServer`); const data = await res.json(); expect(data.did).toBeTruthy(); expect(Array.isArray(data.availableUserDomains)).toBeTruthy(); expect(data.availableUserDomains.length).toBeGreaterThan(0); expect(data.availableUserDomains[0].startsWith('.')).toBeTruthy(); expect(data.inviteCodeRequired).toBe(false); expect(data.phoneVerificationRequired).toBe(false); expect(data.links).toBeDefined(); expect(data.contact).toBeDefined(); }); it('resolveHandle returns DID', async () => { const res = await fetch( `${BASE}/xrpc/com.atproto.identity.resolveHandle?handle=${HANDLE}`, ); const data = await res.json(); expect(data.did).toBeTruthy(); }); }); describe('CORS headers', () => { it('OPTIONS preflight returns CORS headers', async () => { const res = await fetch( `${BASE}/xrpc/com.atproto.server.describeServer`, { method: 'OPTIONS', }, ); expect(res.status).toBe(200); expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*'); expect(res.headers.get('Access-Control-Allow-Methods')).toBe( 'GET, POST, OPTIONS', ); expect(res.headers.get('Access-Control-Allow-Headers')).toContain( 'Content-Type', ); expect(res.headers.get('Access-Control-Allow-Headers')).toContain( 'Authorization', ); expect(res.headers.get('Access-Control-Allow-Headers')).toContain('DPoP'); }); it('GET requests include CORS headers', async () => { const res = await fetch(`${BASE}/xrpc/com.atproto.server.describeServer`); expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*'); }); it('POST requests include CORS headers', async () => { const res = await fetch(`${BASE}/xrpc/com.atproto.server.createSession`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier: 'test', password: 'wrong' }), }); // Even failed requests should have CORS headers expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*'); }); it('error responses include CORS headers', async () => { const res = await fetch(`${BASE}/xrpc/nonexistent.endpoint`); expect(res.status).toBe(501); expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*'); }); it('.well-known endpoints include CORS headers', async () => { const res = await fetch(`${BASE}/.well-known/atproto-did`); expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*'); }); it('OAuth endpoints include CORS headers', async () => { const res = await fetch(`${BASE}/.well-known/oauth-authorization-server`); expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*'); }); }); describe('Authentication', () => { it('createSession returns tokens', async () => { const { status, data } = await jsonPost( '/xrpc/com.atproto.server.createSession', { identifier: DID, password: PASSWORD, }, ); expect(status).toBe(200); expect(data.accessJwt).toBeTruthy(); expect(data.refreshJwt).toBeTruthy(); token = data.accessJwt; refreshToken = data.refreshJwt; }); it('getSession with valid token', async () => { const res = await fetch(`${BASE}/xrpc/com.atproto.server.getSession`, { headers: { Authorization: `Bearer ${token}` }, }); const data = await res.json(); expect(data.did).toBeTruthy(); }); it('refreshSession returns new tokens', async () => { const res = await fetch( `${BASE}/xrpc/com.atproto.server.refreshSession`, { method: 'POST', headers: { Authorization: `Bearer ${refreshToken}` }, }, ); const data = await res.json(); expect(data.accessJwt).toBeTruthy(); expect(data.refreshJwt).toBeTruthy(); token = data.accessJwt; // Use new token }); it('refreshSession rejects access token', async () => { const res = await fetch( `${BASE}/xrpc/com.atproto.server.refreshSession`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, }, ); expect(res.status).toBe(400); }); it('refreshSession rejects missing auth', async () => { const res = await fetch( `${BASE}/xrpc/com.atproto.server.refreshSession`, { method: 'POST', }, ); expect(res.status).toBe(401); }); it('createRecord rejects without auth', async () => { const { status } = await jsonPost('/xrpc/com.atproto.repo.createRecord', { repo: 'x', collection: 'x', record: {}, }); expect(status).toBe(401); }); it('getPreferences works', async () => { const res = await fetch(`${BASE}/xrpc/app.bsky.actor.getPreferences`, { headers: { Authorization: `Bearer ${token}` }, }); const data = await res.json(); expect(data.preferences).toBeTruthy(); }); it('putPreferences works', async () => { const { status } = await jsonPost( '/xrpc/app.bsky.actor.putPreferences', { preferences: [{ $type: 'app.bsky.actor.defs#savedFeedsPrefV2' }] }, { Authorization: `Bearer ${token}` }, ); expect(status).toBe(200); }); }); describe('Record operations', () => { it('createRecord with auth', async () => { const { status, data } = await jsonPost( '/xrpc/com.atproto.repo.createRecord', { repo: DID, collection: 'app.bsky.feed.post', record: { text: 'test', createdAt: new Date().toISOString() }, }, { Authorization: `Bearer ${token}` }, ); expect(status).toBe(200); expect(data.uri).toBeTruthy(); testRkey = data.uri.split('/').pop(); }); it('createRecord rejects JSON bodies over 1MB', async () => { const { status, data } = await jsonPost( '/xrpc/com.atproto.repo.createRecord', { repo: DID, collection: 'app.bsky.feed.post', record: { text: 'x'.repeat(1_000_001), createdAt: new Date().toISOString(), }, }, { Authorization: `Bearer ${token}` }, ); expect(status).toBe(413); expect(data.error).toBe('PayloadTooLarge'); }); it('getRecord returns record', async () => { const res = await fetch( `${BASE}/xrpc/com.atproto.repo.getRecord?repo=${DID}&collection=app.bsky.feed.post&rkey=${testRkey}`, ); const data = await res.json(); expect(data.value?.text).toBeTruthy(); }); it('putRecord updates record', async () => { const { status, data } = await jsonPost( '/xrpc/com.atproto.repo.putRecord', { repo: DID, collection: 'app.bsky.feed.post', rkey: testRkey, record: { text: 'updated', createdAt: new Date().toISOString() }, }, { Authorization: `Bearer ${token}` }, ); expect(status).toBe(200); expect(data.uri).toBeTruthy(); }); it('listRecords returns records', async () => { const res = await fetch( `${BASE}/xrpc/com.atproto.repo.listRecords?repo=${DID}&collection=app.bsky.feed.post`, ); const data = await res.json(); expect(data.records?.length > 0).toBeTruthy(); // Like the reference PDS, a final page omits cursor rather than emitting // `cursor: null`, which strict lexicon validators reject. expect(data.cursor === null).toBe(false); expect('cursor' in data ? typeof data.cursor : 'string').toBe('string'); }); it('describeRepo returns did', async () => { const res = await fetch( `${BASE}/xrpc/com.atproto.repo.describeRepo?repo=${DID}`, ); const data = await res.json(); expect(data.did).toBeTruthy(); }); it('applyWrites create returns proper format', async () => { const { status, data } = await jsonPost( '/xrpc/com.atproto.repo.applyWrites', { repo: DID, writes: [ { $type: 'com.atproto.repo.applyWrites#create', collection: 'app.bsky.feed.post', rkey: 'applytest', value: { text: 'batch', createdAt: new Date().toISOString() }, }, ], }, { Authorization: `Bearer ${token}` }, ); expect(status).toBe(200); // Verify commit info expect(data.commit).toBeTruthy(); expect(data.commit.cid).toBeTruthy(); expect(data.commit.rev).toBeTruthy(); // Verify results format expect(data.results).toBeTruthy(); expect(data.results.length).toBe(1); expect(data.results[0].$type).toBe( 'com.atproto.repo.applyWrites#createResult', ); expect(data.results[0].uri).toBeTruthy(); expect(data.results[0].cid).toBeTruthy(); // Node has lexiconResolver configured with post schema; other // platforms depend on live resolution, which needs network access if (PLATFORM === 'node') { expect(data.results[0].validationStatus).toBe('valid'); } else { expect(['valid', 'unknown']).toContain( data.results[0].validationStatus, ); } }); it('applyWrites returns unknown for unregistered collection', async () => { const { status, data } = await jsonPost( '/xrpc/com.atproto.repo.applyWrites', { repo: DID, writes: [ { $type: 'com.atproto.repo.applyWrites#create', collection: 'com.example.unknown', rkey: 'unknowntest', value: { foo: 'bar' }, }, ], }, { Authorization: `Bearer ${token}` }, ); expect(status).toBe(200); // Unknown collection should always return 'unknown' validationStatus expect(data.results[0].validationStatus).toBe('unknown'); // Cleanup await jsonPost( '/xrpc/com.atproto.repo.applyWrites', { repo: DID, writes: [ { $type: 'com.atproto.repo.applyWrites#delete', collection: 'com.example.unknown', rkey: 'unknowntest', }, ], }, { Authorization: `Bearer ${token}` }, ); }); it('createRecord validates app.bsky.feed.like via live lexicon resolution', async () => { // This test verifies live lexicon resolution: DNS -> DID -> PDS -> lexicon fetch // Uses app.bsky.feed.like which is NOT in the static schemas - must resolve live const { status, data } = await jsonPost( '/xrpc/com.atproto.repo.createRecord', { repo: DID, collection: 'app.bsky.feed.like', record: { $type: 'app.bsky.feed.like', subject: { uri: 'at://did:plc:test/app.bsky.feed.post/abc123', cid: 'bafyreig2fjxi3a2vwkanxj4jna4mlwabm4zkeakslfoqxxktsq3nqkgzoi', }, createdAt: new Date().toISOString(), }, }, { Authorization: `Bearer ${token}` }, ); expect(status).toBe(200); // Node has full network access for live DNS resolution. // Cloudflare/Deno resolve live too when the env allows network, // otherwise they fall back to 'unknown' if (PLATFORM === 'node') { expect(data.validationStatus).toBe('valid'); } else { expect(['valid', 'unknown']).toContain(data.validationStatus); } // Cleanup const rkey = data.uri.split('/').pop(); await jsonPost( '/xrpc/com.atproto.repo.deleteRecord', { repo: DID, collection: 'app.bsky.feed.like', rkey }, { Authorization: `Bearer ${token}` }, ); }); it('applyWrites delete returns proper format', async () => { const { status, data } = await jsonPost( '/xrpc/com.atproto.repo.applyWrites', { repo: DID, writes: [ { $type: 'com.atproto.repo.applyWrites#delete', collection: 'app.bsky.feed.post', rkey: 'applytest', }, ], }, { Authorization: `Bearer ${token}` }, ); expect(status).toBe(200); // Verify commit info expect(data.commit).toBeTruthy(); expect(data.commit.cid).toBeTruthy(); expect(data.commit.rev).toBeTruthy(); // Verify results format expect(data.results).toBeTruthy(); expect(data.results.length).toBe(1); expect(data.results[0].$type).toBe( 'com.atproto.repo.applyWrites#deleteResult', ); }); it('applyWrites rejects unknown operation type', async () => { const { status, data } = await jsonPost( '/xrpc/com.atproto.repo.applyWrites', { repo: DID, writes: [ { $type: 'com.atproto.repo.applyWrites#unknownOp', collection: 'app.bsky.feed.post', rkey: 'test', }, ], }, { Authorization: `Bearer ${token}` }, ); expect(status).toBe(400); expect(data.error).toBe('InvalidRequest'); expect(data.message).toContain('Unknown write operation type'); }); }); describe('Sync endpoints', () => { it('getLatestCommit returns cid', async () => { const res = await fetch( `${BASE}/xrpc/com.atproto.sync.getLatestCommit?did=${DID}`, ); const data = await res.json(); expect(data.cid).toBeTruthy(); }); it('getRepoStatus returns did', async () => { const res = await fetch( `${BASE}/xrpc/com.atproto.sync.getRepoStatus?did=${DID}`, ); const data = await res.json(); expect(data.did).toBeTruthy(); }); it('getRepo returns CAR', async () => { const res = await fetch( `${BASE}/xrpc/com.atproto.sync.getRepo?did=${DID}`, ); const data = await res.arrayBuffer(); expect(data.byteLength > 100).toBeTruthy(); }); it('getRecord returns record CAR', async () => { const res = await fetch( `${BASE}/xrpc/com.atproto.sync.getRecord?did=${DID}&collection=app.bsky.feed.post&rkey=${testRkey}`, ); const data = await res.arrayBuffer(); expect(data.byteLength > 50).toBeTruthy(); }); it('listRepos returns repos', async () => { const res = await fetch(`${BASE}/xrpc/com.atproto.sync.listRepos`); const data = await res.json(); expect(data.repos?.length > 0).toBeTruthy(); }); }); describe('Error handling', () => { it('invalid password rejected (401)', async () => { const { status } = await jsonPost( '/xrpc/com.atproto.server.createSession', { identifier: DID, password: 'wrong-password', }, ); expect(status).toBe(401); }); it('wrong repo rejected (403)', async () => { const { status } = await jsonPost( '/xrpc/com.atproto.repo.createRecord', { repo: 'did:plc:z72i7hdynmk6r22z27h6tvur', collection: 'app.bsky.feed.post', record: { text: 'x', createdAt: '2024-01-01T00:00:00Z' }, }, { Authorization: `Bearer ${token}` }, ); expect(status).toBe(403); }); it('non-existent record errors', async () => { const res = await fetch( `${BASE}/xrpc/com.atproto.repo.getRecord?repo=${DID}&collection=app.bsky.feed.post&rkey=nonexistent`, ); expect([400, 404].includes(res.status)).toBeTruthy(); }); }); describe('Blob endpoints', () => { /** @type {string} */ let blobCid = ''; /** @type {string} */ let blobPostRkey = ''; // Create minimal PNG const pngBytes = new Uint8Array([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, ]); it('uploadBlob rejects without auth', async () => { const res = await fetch(`${BASE}/xrpc/com.atproto.repo.uploadBlob`, { method: 'POST', headers: { 'Content-Type': 'image/png' }, body: pngBytes, }); expect(res.status).toBe(401); }); it('uploadBlob returns CID', async () => { const res = await fetch(`${BASE}/xrpc/com.atproto.repo.uploadBlob`, { method: 'POST', headers: { 'Content-Type': 'image/png', Authorization: `Bearer ${token}`, }, body: pngBytes, }); const data = await res.json(); expect(data.blob?.ref?.$link).toBeTruthy(); expect(data.blob?.mimeType).toBe('image/png'); blobCid = data.blob.ref.$link; }); it('listBlobs includes uploaded blob', async () => { const res = await fetch( `${BASE}/xrpc/com.atproto.sync.listBlobs?did=${DID}`, ); const data = await res.json(); expect(data.cids?.includes(blobCid)).toBeTruthy(); // Like the reference PDS, a final page omits cursor rather than `null`. expect(data.cursor === null).toBe(false); expect('cursor' in data ? typeof data.cursor : 'string').toBe('string'); }); it('getBlob retrieves data', async () => { const res = await fetch( `${BASE}/xrpc/com.atproto.sync.getBlob?did=${DID}&cid=${blobCid}`, ); expect(res.ok).toBeTruthy(); expect(res.headers.get('content-type')).toBe('image/png'); expect(res.headers.get('x-content-type-options')).toBe('nosniff'); }); it('getBlob forces download with security headers', async () => { const res = await fetch( `${BASE}/xrpc/com.atproto.sync.getBlob?did=${DID}&cid=${blobCid}`, ); expect(res.ok).toBeTruthy(); expect(res.headers.get('content-disposition')).toBe( `attachment; filename="${blobCid}"`, ); expect(res.headers.get('content-security-policy')).toBe( "default-src 'none'; sandbox", ); }); it('uploadBlob rejects blobs over the size limit', async () => { const oversized = new Uint8Array(5 * 1024 * 1024 + 1); const res = await fetch(`${BASE}/xrpc/com.atproto.repo.uploadBlob`, { method: 'POST', headers: { 'Content-Type': 'application/octet-stream', Authorization: `Bearer ${token}`, }, body: oversized, }); expect(res.status).toBe(413); const data = await res.json(); expect(data.error).toBe('PayloadTooLarge'); }); it('getBlob rejects wrong DID', async () => { const res = await fetch( `${BASE}/xrpc/com.atproto.sync.getBlob?did=did:plc:wrongdid&cid=${blobCid}`, ); expect(res.status).toBe(400); }); it('getBlob rejects invalid CID', async () => { const res = await fetch( `${BASE}/xrpc/com.atproto.sync.getBlob?did=${DID}&cid=invalid`, ); expect(res.status).toBe(400); }); it('getBlob 404 for missing blob', async () => { const res = await fetch( `${BASE}/xrpc/com.atproto.sync.getBlob?did=${DID}&cid=bafkreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa`, ); expect(res.status).toBe(404); }); it('createRecord with blob ref', async () => { const { status, data } = await jsonPost( '/xrpc/com.atproto.repo.createRecord', { repo: DID, collection: 'app.bsky.feed.post', record: { text: 'post with image', createdAt: new Date().toISOString(), embed: { $type: 'app.bsky.embed.images', images: [ { image: { $type: 'blob', ref: { $link: blobCid }, mimeType: 'image/png', size: pngBytes.length, }, alt: 'test', }, ], }, }, }, { Authorization: `Bearer ${token}` }, ); expect(status).toBe(200); blobPostRkey = data.uri.split('/').pop(); }); it('blob persists after record creation', async () => { const res = await fetch( `${BASE}/xrpc/com.atproto.sync.listBlobs?did=${DID}`, ); const data = await res.json(); expect(data.cids?.includes(blobCid)).toBeTruthy(); }); it('deleteRecord with blob cleans up', async () => { const { status } = await jsonPost( '/xrpc/com.atproto.repo.deleteRecord', { repo: DID, collection: 'app.bsky.feed.post', rkey: blobPostRkey }, { Authorization: `Bearer ${token}` }, ); expect(status).toBe(200); const res = await fetch( `${BASE}/xrpc/com.atproto.sync.listBlobs?did=${DID}`, ); const data = await res.json(); expect(data.cids?.length).toBe(0); }); }); describe('PLC identity operations', () => { it('getRecommendedDidCredentials describes the current identity', async () => { const res = await fetch( `${BASE}/xrpc/com.atproto.identity.getRecommendedDidCredentials`, { headers: { Authorization: `Bearer ${token}` } }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.alsoKnownAs).toEqual([`at://${HANDLE}`]); expect(data.verificationMethods?.atproto).toMatch(/^did:key:zDn/); expect(data.rotationKeys?.[0]).toMatch(/^did:key:zDn/); expect(data.services?.atproto_pds?.type).toBe( 'AtprotoPersonalDataServer', ); expect(data.services?.atproto_pds?.endpoint).toBeTruthy(); }); it('requestPlcOperationSignature succeeds without an email channel', async () => { const { status } = await jsonPost( '/xrpc/com.atproto.identity.requestPlcOperationSignature', {}, { Authorization: `Bearer ${token}` }, ); expect(status).toBe(200); }); it('signPlcOperation returns a signed operation chained to the current one', async () => { const { status, data } = await jsonPost( '/xrpc/com.atproto.identity.signPlcOperation', { alsoKnownAs: [`at://${HANDLE}`] }, { Authorization: `Bearer ${token}` }, ); expect(status).toBe(200); expect(data.operation).toBeTruthy(); expect(data.operation.type).toBe('plc_operation'); expect(typeof data.operation.sig).toBe('string'); expect(data.operation.alsoKnownAs).toEqual([`at://${HANDLE}`]); // Chained to the existing genesis operation, not a new genesis expect(data.operation.prev).toBeTruthy(); }); it('signPlcOperation requires authentication', async () => { const { status } = await jsonPost( '/xrpc/com.atproto.identity.signPlcOperation', {}, ); expect(status).toBe(401); }); it('submitPlcOperation publishes to the PLC directory', async () => { const signed = await jsonPost( '/xrpc/com.atproto.identity.signPlcOperation', { alsoKnownAs: [`at://${HANDLE}`] }, { Authorization: `Bearer ${token}` }, ); expect(signed.status).toBe(200); const { status } = await jsonPost( '/xrpc/com.atproto.identity.submitPlcOperation', { operation: signed.data.operation }, { Authorization: `Bearer ${token}` }, ); expect(status).toBe(200); // The directory now serves the updated document const doc = await fetch(`http://localhost:2582/${DID}`); expect(doc.status).toBe(200); const docData = await doc.json(); expect(docData.alsoKnownAs).toContain(`at://${HANDLE}`); }); // These assertions are deliberately non-mutating: the account keeps HANDLE, // which later tests (e.g. subscribeRepos) rely on. A real change needs a // handle that resolves to the DID, which no fixture domain does. it('updateHandle accepts the current handle as a no-op', async () => { const { status } = await jsonPost( '/xrpc/com.atproto.identity.updateHandle', { handle: HANDLE }, { Authorization: `Bearer ${token}` }, ); expect(status).toBe(200); const repo = await fetch( `${BASE}/xrpc/com.atproto.repo.describeRepo?repo=${DID}`, ); expect((await repo.json()).handle).toBe(HANDLE); }); it('updateHandle refuses a subdomain of the server, explaining why', async () => { // A subdomain of the PDS's own domain — never routable on a single-user PDS const internal = `renamed.${HANDLE.split('.').slice(1).join('.')}`; const { status, data } = await jsonPost( '/xrpc/com.atproto.identity.updateHandle', { handle: internal }, { Authorization: `Bearer ${token}` }, ); expect(status).toBe(400); expect(data.error).toBe('InvalidRequest'); expect(data.message).toContain('subdomain'); }); it('updateHandle refuses an external handle that does not resolve here', async () => { const { status, data } = await jsonPost( '/xrpc/com.atproto.identity.updateHandle', { handle: 'someone.example.com' }, { Authorization: `Bearer ${token}` }, ); expect(status).toBe(400); expect(data.error).toBe('InvalidRequest'); // Tells the caller exactly which DNS record would make it resolve expect(data.message).toContain('_atproto.someone.example.com'); }); it('updateHandle rejects an invalid handle', async () => { const { status, data } = await jsonPost( '/xrpc/com.atproto.identity.updateHandle', { handle: 'not a handle' }, { Authorization: `Bearer ${token}` }, ); expect(status).toBe(400); expect(data.error).toBe('InvalidRequest'); }); it('updateHandle requires authentication', async () => { const { status } = await jsonPost( '/xrpc/com.atproto.identity.updateHandle', { handle: 'someone.example.com' }, ); expect(status).toBe(401); }); }); describe('Account migration', () => { it('getServiceAuth mints a service token for full-access sessions', async () => { const res = await fetch( `${BASE}/xrpc/com.atproto.server.getServiceAuth?aud=did:web:api.bsky.app&lxm=app.bsky.feed.getTimeline`, { headers: { Authorization: `Bearer ${token}` } }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.token).toBeTruthy(); const [, payloadB64] = data.token.split('.'); const payload = JSON.parse( Buffer.from(payloadB64, 'base64url').toString(), ); expect(payload.iss).toBe(DID); expect(payload.aud).toBe('did:web:api.bsky.app'); expect(payload.lxm).toBe('app.bsky.feed.getTimeline'); }); it('getServiceAuth denies granular tokens without a matching rpc scope', async () => { const { accessToken, dpop } = await getOAuthTokenWithScope( 'repo:app.bsky.feed.like?action=create', DID, PASSWORD, ); const target = `${BASE}/xrpc/com.atproto.server.getServiceAuth?aud=did:web:api.bsky.app&lxm=app.bsky.feed.getTimeline`; const proof = await dpop.createProof('GET', target, accessToken); const res = await fetch(target, { headers: { Authorization: `DPoP ${accessToken}`, DPoP: proof }, }); expect(res.status).toBe(403); }); it('checkAccountStatus reports repo and blob counts', async () => { const created = await jsonPost( '/xrpc/com.atproto.repo.createRecord', { repo: DID, collection: 'app.bsky.feed.post', record: { text: 'status check', createdAt: new Date().toISOString() }, }, { Authorization: `Bearer ${token}` }, ); expect(created.status).toBe(200); const res = await fetch( `${BASE}/xrpc/com.atproto.server.checkAccountStatus`, { headers: { Authorization: `Bearer ${token}` } }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.activated).toBe(true); expect(data.validDid).toBe(true); expect(data.repoCommit).toBeTruthy(); expect(data.repoRev).toBeTruthy(); expect(data.repoBlocks).toBeGreaterThan(0); expect(data.indexedRecords).toBeGreaterThan(0); expect(data.privateStateValues).toBe(0); expect(typeof data.expectedBlobs).toBe('number'); expect(typeof data.importedBlobs).toBe('number'); const rkey = created.data.uri.split('/').pop(); await jsonPost( '/xrpc/com.atproto.repo.deleteRecord', { repo: DID, collection: 'app.bsky.feed.post', rkey }, { Authorization: `Bearer ${token}` }, ); }); it('listMissingBlobs reports blob refs with no stored blob', async () => { // Reference a blob CID that was never uploaded const missingCid = 'bafkreiabcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqr'; const created = await jsonPost( '/xrpc/com.atproto.repo.createRecord', { repo: DID, collection: 'app.bsky.feed.post', record: { text: 'missing blob', createdAt: new Date().toISOString(), embed: { $type: 'app.bsky.embed.images', images: [ { alt: 'missing', image: { $type: 'blob', ref: { $link: missingCid }, mimeType: 'image/png', size: 1, }, }, ], }, }, }, { Authorization: `Bearer ${token}` }, ); expect(created.status).toBe(200); const res = await fetch( `${BASE}/xrpc/com.atproto.repo.listMissingBlobs`, { headers: { Authorization: `Bearer ${token}` } }, ); expect(res.status).toBe(200); const data = await res.json(); const missing = data.blobs?.find( (/** @type {{cid: string}} */ b) => b.cid === missingCid, ); expect(missing).toBeTruthy(); expect(missing.recordUri).toBe(created.data.uri); const rkey = created.data.uri.split('/').pop(); await jsonPost( '/xrpc/com.atproto.repo.deleteRecord', { repo: DID, collection: 'app.bsky.feed.post', rkey }, { Authorization: `Bearer ${token}` }, ); }); it('importRepo restores a repo from a CAR export', async () => { const { cborDecodeFirst } = await import('../packages/core/src/repo.js'); // Seed a record and export the repo const created = await jsonPost( '/xrpc/com.atproto.repo.createRecord', { repo: DID, collection: 'app.bsky.feed.post', record: { text: 'migration payload', createdAt: new Date().toISOString(), }, }, { Authorization: `Bearer ${token}` }, ); expect(created.status).toBe(200); const rkey = created.data.uri.split('/').pop(); const exportRes = await fetch( `${BASE}/xrpc/com.atproto.sync.getRepo?did=${DID}`, ); expect(exportRes.status).toBe(200); const car = new Uint8Array(await exportRes.arrayBuffer()); expect(car.length).toBeGreaterThan(0); // Re-import the same CAR const importRes = await fetch( `${BASE}/xrpc/com.atproto.repo.importRepo`, { method: 'POST', headers: { 'Content-Type': 'application/vnd.ipld.car', Authorization: `Bearer ${token}`, }, body: car, }, ); expect(importRes.status).toBe(200); // The imported record is readable const recordRes = await fetch( `${BASE}/xrpc/com.atproto.repo.getRecord?repo=${DID}&collection=app.bsky.feed.post&rkey=${rkey}`, ); expect(recordRes.status).toBe(200); const recordData = await recordRes.json(); expect(recordData.value.text).toBe('migration payload'); // Import announced repo state with a #sync event const wsUrl = BASE.replace('http', 'ws'); const ws = new WebSocket( `${wsUrl}/xrpc/com.atproto.sync.subscribeRepos?cursor=0`, ); /** @type {Buffer[]} */ const raw = []; await /** @type {Promise} */ ( new Promise((resolve, reject) => { let settle = setTimeout(resolve, 3000); ws.on('message', (/** @type {Buffer} */ data) => { raw.push(data); clearTimeout(settle); settle = setTimeout(resolve, 500); }); ws.on('error', reject); setTimeout(() => reject(new Error('WebSocket timeout')), 10000); }) ); ws.close(); const syncFrames = raw .map((buf) => { const bytes = new Uint8Array(buf); const [header, consumed] = cborDecodeFirst(bytes); const [body] = cborDecodeFirst(bytes.slice(consumed)); return { header, body }; }) .filter((f) => f.header.t === '#sync'); expect(syncFrames.length).toBeGreaterThan(0); const lastSync = syncFrames[syncFrames.length - 1]; expect(lastSync.body.did).toBe(DID); expect(lastSync.body.rev).toBeTruthy(); expect(lastSync.body.blocks).toBeInstanceOf(Uint8Array); await jsonPost( '/xrpc/com.atproto.repo.deleteRecord', { repo: DID, collection: 'app.bsky.feed.post', rkey }, { Authorization: `Bearer ${token}` }, ); }); }); describe('Account status', () => { it('deactivate blocks writes, reports status, and emits #account events', async () => { const { cborDecodeFirst } = await import('../packages/core/src/repo.js'); // Seed a commit so repo status has a rev to report const seedRes = await jsonPost( '/xrpc/com.atproto.repo.createRecord', { repo: DID, collection: 'app.bsky.feed.post', record: { text: 'seed', createdAt: new Date().toISOString() }, }, { Authorization: `Bearer ${token}` }, ); expect(seedRes.status).toBe(200); const seedRkey = seedRes.data.uri.split('/').pop(); // Deactivate const deactivateRes = await jsonPost( '/xrpc/com.atproto.server.deactivateAccount', {}, { Authorization: `Bearer ${token}` }, ); expect(deactivateRes.status).toBe(200); // Writes are blocked while deactivated const writeRes = await jsonPost( '/xrpc/com.atproto.repo.createRecord', { repo: DID, collection: 'app.bsky.feed.post', record: { text: 'nope', createdAt: new Date().toISOString() }, }, { Authorization: `Bearer ${token}` }, ); expect(writeRes.status).toBe(409); expect(writeRes.data.error).toBe('AccountDeactivated'); // Blob upload is still allowed while deactivated: an incoming migration // stages its blobs before activateAccount. const blobRes = await fetch(`${BASE}/xrpc/com.atproto.repo.uploadBlob`, { method: 'POST', headers: { 'Content-Type': 'application/octet-stream', Authorization: `Bearer ${token}`, }, body: new Uint8Array([1, 2, 3, 4]), }); expect(blobRes.status).toBe(200); // Status is reflected const statusRes = await fetch( `${BASE}/xrpc/com.atproto.sync.getRepoStatus?did=${DID}`, ); const statusData = await statusRes.json(); expect(statusData.active).toBe(false); expect(statusData.status).toBe('deactivated'); // Reactivate const activateRes = await jsonPost( '/xrpc/com.atproto.server.activateAccount', {}, { Authorization: `Bearer ${token}` }, ); expect(activateRes.status).toBe(200); // Writes work again const okRes = await jsonPost( '/xrpc/com.atproto.repo.createRecord', { repo: DID, collection: 'app.bsky.feed.post', record: { text: 'back', createdAt: new Date().toISOString() }, }, { Authorization: `Bearer ${token}` }, ); expect(okRes.status).toBe(200); const rkey = okRes.data.uri.split('/').pop(); for (const cleanupRkey of [rkey, seedRkey]) { await jsonPost( '/xrpc/com.atproto.repo.deleteRecord', { repo: DID, collection: 'app.bsky.feed.post', rkey: cleanupRkey }, { Authorization: `Bearer ${token}` }, ); } // Both transitions were sequenced as #account events const wsUrl = BASE.replace('http', 'ws'); const ws = new WebSocket( `${wsUrl}/xrpc/com.atproto.sync.subscribeRepos?cursor=0`, ); /** @type {Buffer[]} */ const raw = []; await /** @type {Promise} */ ( new Promise((resolve, reject) => { let settle = setTimeout(resolve, 3000); ws.on('message', (/** @type {Buffer} */ data) => { raw.push(data); clearTimeout(settle); settle = setTimeout(resolve, 500); }); ws.on('error', reject); setTimeout(() => reject(new Error('WebSocket timeout')), 10000); }) ); ws.close(); const accountFrames = raw .map((buf) => { const bytes = new Uint8Array(buf); const [header, consumed] = cborDecodeFirst(bytes); const [body] = cborDecodeFirst(bytes.slice(consumed)); return { header, body }; }) .filter((f) => f.header.t === '#account'); const deactivated = accountFrames.find((f) => f.body.active === false); expect(deactivated).toBeTruthy(); expect(deactivated?.body.status).toBe('deactivated'); const reactivated = accountFrames.find( (f) => f.body.active === true && f.body.seq > deactivated?.body.seq, ); expect(reactivated).toBeTruthy(); }); }); describe('OAuth endpoints', () => { it('AS metadata', async () => { const res = await fetch(`${BASE}/.well-known/oauth-authorization-server`); const data = await res.json(); expect(data.issuer).toBe(BASE); expect(data.authorization_endpoint).toBe(`${BASE}/oauth/authorize`); expect(data.token_endpoint).toBe(`${BASE}/oauth/token`); expect(data.pushed_authorization_request_endpoint).toBe( `${BASE}/oauth/par`, ); expect(data.revocation_endpoint).toBe(`${BASE}/oauth/revoke`); expect(data.jwks_uri).toBe(`${BASE}/oauth/jwks`); expect(data.scopes_supported).toEqual([ 'atproto', 'transition:generic', 'transition:email', ]); expect(data.dpop_signing_alg_values_supported).toEqual(['ES256']); expect(data.require_pushed_authorization_requests).toBe(true); expect(data.token_endpoint_auth_methods_supported).toEqual([ 'none', 'private_key_jwt', ]); expect(data.token_endpoint_auth_signing_alg_values_supported).toEqual([ 'ES256', ]); expect(data.client_id_metadata_document_supported).toBe(true); expect(data.protected_resources).toEqual([BASE]); }); it('PR metadata', async () => { const res = await fetch(`${BASE}/.well-known/oauth-protected-resource`); const data = await res.json(); expect(data.resource).toBe(BASE); expect(data.authorization_servers).toEqual([BASE]); }); it('JWKS endpoint', async () => { const res = await fetch(`${BASE}/oauth/jwks`); const data = await res.json(); expect(data.keys?.length > 0).toBeTruthy(); const key = data.keys[0]; expect(key.kty).toBe('EC'); expect(key.crv).toBe('P-256'); expect(key.alg).toBe('ES256'); expect(key.use).toBe('sig'); expect(key.x && key.y).toBeTruthy(); expect(!key.d).toBeTruthy(); }); it('PAR rejects missing DPoP', async () => { const { status, data } = await formPost('/oauth/par', { client_id: 'http://localhost:3000', redirect_uri: 'http://localhost:3000/callback', response_type: 'code', scope: 'atproto', code_challenge: 'test', code_challenge_method: 'S256', }); expect(status).toBe(400); expect(data.error).toBe('invalid_dpop_proof'); }); it('token rejects missing DPoP', async () => { const { status, data } = await formPost('/oauth/token', { grant_type: 'authorization_code', code: 'fake', client_id: 'http://localhost:3000', }); expect(status).toBe(400); expect(data.error).toBe('invalid_dpop_proof'); }); it('revoke returns 200 for invalid token', async () => { const { status } = await formPost('/oauth/revoke', { token: 'nonexistent', client_id: 'http://localhost:3000', }); expect(status).toBe(200); }); }); describe('OAuth flow with DPoP', () => { it('full PAR -> authorize -> token flow', async () => { const dpop = await DpopClient.create(); const clientId = 'http://localhost:3000'; const redirectUri = 'http://localhost:3000/callback'; const codeVerifier = randomBytes(32).toString('base64url'); // Generate code_challenge from verifier (S256) const challengeBuffer = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(codeVerifier), ); const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); // Step 1: PAR request const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); const parRes = await fetch(`${BASE}/oauth/par`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: parProof, }, body: new URLSearchParams({ client_id: clientId, redirect_uri: redirectUri, response_type: 'code', scope: 'atproto', code_challenge: codeChallenge, code_challenge_method: 'S256', state: 'test-state', login_hint: DID, }).toString(), }); expect(parRes.status).toBe(201); const parData = await parRes.json(); expect(parData.request_uri).toBeTruthy(); expect(parData.expires_in > 0).toBeTruthy(); // Step 2: Authorization (simulate user consent by POSTing to authorize) const authRes = await fetch(`${BASE}/oauth/authorize`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ request_uri: parData.request_uri, client_id: clientId, password: PASSWORD, }).toString(), redirect: 'manual', }); expect(authRes.status).toBe(302); const location = authRes.headers.get('location'); expect(location).toBeTruthy(); const redirectUrl = new URL(location || ''); const authCode = redirectUrl.searchParams.get('code'); expect(authCode).toBeTruthy(); expect(redirectUrl.searchParams.get('state')).toBe('test-state'); expect(redirectUrl.searchParams.get('iss')).toBe(BASE); // Step 3: Token exchange const tokenProof = await dpop.createProof('POST', `${BASE}/oauth/token`); const tokenRes = await fetch(`${BASE}/oauth/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: tokenProof, }, body: new URLSearchParams({ grant_type: 'authorization_code', code: authCode || '', client_id: clientId, redirect_uri: redirectUri, code_verifier: codeVerifier, }).toString(), }); expect(tokenRes.status).toBe(200); const tokenData = await tokenRes.json(); expect(tokenData.access_token).toBeTruthy(); expect(tokenData.refresh_token).toBeTruthy(); expect(tokenData.token_type).toBe('DPoP'); expect(tokenData.scope).toBe('atproto'); expect(tokenData.sub).toBeTruthy(); // Step 4: Use access token with DPoP for protected endpoint const resourceProof = await dpop.createProof( 'GET', `${BASE}/xrpc/com.atproto.server.getSession`, tokenData.access_token, ); const sessionRes = await fetch( `${BASE}/xrpc/com.atproto.server.getSession`, { headers: { Authorization: `DPoP ${tokenData.access_token}`, DPoP: resourceProof, }, }, ); expect(sessionRes.status).toBe(200); const sessionData = await sessionRes.json(); expect(sessionData.did).toBeTruthy(); // Step 5: Refresh token const refreshProof = await dpop.createProof( 'POST', `${BASE}/oauth/token`, ); const refreshRes = await fetch(`${BASE}/oauth/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: refreshProof, }, body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: tokenData.refresh_token, client_id: clientId, }).toString(), }); expect(refreshRes.status).toBe(200); const refreshData = await refreshRes.json(); expect(refreshData.access_token).toBeTruthy(); expect(refreshData.refresh_token).toBeTruthy(); // Step 6: Revoke token const revokeRes = await fetch(`${BASE}/oauth/revoke`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ token: refreshData.refresh_token, client_id: clientId, }).toString(), }); expect(revokeRes.status).toBe(200); }); it('DPoP key mismatch rejected', async () => { const dpop1 = await DpopClient.create(); const dpop2 = await DpopClient.create(); const clientId = 'http://localhost:3000'; const redirectUri = 'http://localhost:3000/callback'; const codeVerifier = randomBytes(32).toString('base64url'); const challengeBuffer = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(codeVerifier), ); const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); // PAR with first key const parProof = await dpop1.createProof('POST', `${BASE}/oauth/par`); const parRes = await fetch(`${BASE}/oauth/par`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: parProof, }, body: new URLSearchParams({ client_id: clientId, redirect_uri: redirectUri, response_type: 'code', scope: 'atproto', code_challenge: codeChallenge, code_challenge_method: 'S256', login_hint: DID, }).toString(), }); const parData = await parRes.json(); // Authorize const authRes = await fetch(`${BASE}/oauth/authorize`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ request_uri: parData.request_uri, client_id: clientId, password: PASSWORD, }).toString(), redirect: 'manual', }); const location = authRes.headers.get('location'); const authCode = new URL(location || '').searchParams.get('code'); // Token with DIFFERENT key should fail const tokenProof = await dpop2.createProof('POST', `${BASE}/oauth/token`); const tokenRes = await fetch(`${BASE}/oauth/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: tokenProof, }, body: new URLSearchParams({ grant_type: 'authorization_code', code: authCode || '', client_id: clientId, redirect_uri: redirectUri, code_verifier: codeVerifier, }).toString(), }); expect(tokenRes.status).toBe(400); const tokenData = await tokenRes.json(); expect(tokenData.error).toBe('invalid_dpop_proof'); }); it('fragment response_mode returns code in fragment', async () => { const dpop = await DpopClient.create(); const clientId = 'http://localhost:3000'; const redirectUri = 'http://localhost:3000/callback'; const codeVerifier = randomBytes(32).toString('base64url'); const challengeBuffer = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(codeVerifier), ); const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); // PAR with response_mode=fragment const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); const parRes = await fetch(`${BASE}/oauth/par`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: parProof, }, body: new URLSearchParams({ client_id: clientId, redirect_uri: redirectUri, response_type: 'code', response_mode: 'fragment', scope: 'atproto', code_challenge: codeChallenge, code_challenge_method: 'S256', login_hint: DID, }).toString(), }); const parData = await parRes.json(); expect(parData.request_uri).toBeTruthy(); // Authorize const authRes = await fetch(`${BASE}/oauth/authorize`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ request_uri: parData.request_uri, client_id: clientId, password: PASSWORD, }).toString(), redirect: 'manual', }); expect(authRes.status).toBe(302); const location = authRes.headers.get('location'); expect(location).toBeTruthy(); // For fragment mode, code should be in hash fragment expect(location?.includes('#')).toBeTruthy(); // Should use fragment; const url = new URL(location || ''); const fragment = new URLSearchParams(url.hash.slice(1)); expect(fragment.get('code')).toBeTruthy(); // Code should be in fragment; expect(fragment.get('iss')).toBeTruthy(); // Issuer should be in fragment; }); it('PKCE failure - wrong code_verifier rejected', async () => { const dpop = await DpopClient.create(); const clientId = 'http://localhost:3000'; const redirectUri = 'http://localhost:3000/callback'; const codeVerifier = randomBytes(32).toString('base64url'); const wrongVerifier = randomBytes(32).toString('base64url'); const challengeBuffer = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(codeVerifier), ); const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); // PAR const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); const parRes = await fetch(`${BASE}/oauth/par`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: parProof, }, body: new URLSearchParams({ client_id: clientId, redirect_uri: redirectUri, response_type: 'code', scope: 'atproto', code_challenge: codeChallenge, code_challenge_method: 'S256', login_hint: DID, }).toString(), }); const parData = await parRes.json(); // Authorize const authRes = await fetch(`${BASE}/oauth/authorize`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ request_uri: parData.request_uri, client_id: clientId, password: PASSWORD, }).toString(), redirect: 'manual', }); const location = authRes.headers.get('location'); const authCode = new URL(location || '').searchParams.get('code'); // Token with WRONG code_verifier should fail const tokenProof = await dpop.createProof('POST', `${BASE}/oauth/token`); const tokenRes = await fetch(`${BASE}/oauth/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: tokenProof, }, body: new URLSearchParams({ grant_type: 'authorization_code', code: authCode || '', client_id: clientId, redirect_uri: redirectUri, code_verifier: wrongVerifier, }).toString(), }); expect(tokenRes.status).toBe(400); const tokenData = await tokenRes.json(); expect(tokenData.error).toBe('invalid_grant'); expect(tokenData.message?.includes('code_verifier')).toBeTruthy(); }); it('redirect_uri mismatch rejected', async () => { const dpop = await DpopClient.create(); const clientId = 'http://localhost:3000'; const codeVerifier = randomBytes(32).toString('base64url'); const challengeBuffer = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(codeVerifier), ); const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); // PAR with unregistered redirect_uri const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); const parRes = await fetch(`${BASE}/oauth/par`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: parProof, }, body: new URLSearchParams({ client_id: clientId, redirect_uri: 'http://attacker.com/callback', response_type: 'code', scope: 'atproto', code_challenge: codeChallenge, code_challenge_method: 'S256', login_hint: DID, }).toString(), }); expect(parRes.status).toBe(400); const parData = await parRes.json(); expect(parData.error).toBe('invalid_request'); expect(parData.message?.includes('redirect_uri')).toBeTruthy(); }); it('DPoP jti replay rejected', async () => { const dpop = await DpopClient.create(); const clientId = 'http://localhost:3000'; const redirectUri = 'http://localhost:3000/callback'; const codeVerifier = randomBytes(32).toString('base64url'); const challengeBuffer = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(codeVerifier), ); const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); // Create a single DPoP proof const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); // First request should succeed const parRes1 = await fetch(`${BASE}/oauth/par`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: parProof, }, body: new URLSearchParams({ client_id: clientId, redirect_uri: redirectUri, response_type: 'code', scope: 'atproto', code_challenge: codeChallenge, code_challenge_method: 'S256', login_hint: DID, }).toString(), }); expect(parRes1.status).toBe(201); // Second request with SAME proof should be rejected const parRes2 = await fetch(`${BASE}/oauth/par`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: parProof, }, body: new URLSearchParams({ client_id: clientId, redirect_uri: redirectUri, response_type: 'code', scope: 'atproto', code_challenge: codeChallenge, code_challenge_method: 'S256', login_hint: DID, }).toString(), }); expect(parRes2.status).toBe(400); const data = await parRes2.json(); expect(data.error).toBe('invalid_dpop_proof'); expect(data.message?.includes('replay')).toBeTruthy(); }); }); describe('Scope Enforcement', () => { it('createRecord denied with insufficient scope', async () => { // Get token that only allows creating likes, not posts const { accessToken, dpop } = await getOAuthTokenWithScope( 'repo:app.bsky.feed.like?action=create', DID, PASSWORD, ); const proof = await dpop.createProof( 'POST', `${BASE}/xrpc/com.atproto.repo.createRecord`, accessToken, ); const res = await fetch(`${BASE}/xrpc/com.atproto.repo.createRecord`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `DPoP ${accessToken}`, DPoP: proof, }, body: JSON.stringify({ repo: DID, collection: 'app.bsky.feed.post', // Not allowed by scope record: { text: 'test', createdAt: new Date().toISOString() }, }), }); expect(res.status).toBe(403); const body = await res.json(); expect(body.message?.includes('Missing required scope')).toBeTruthy(); // Error should mention missing scope }); it('createRecord allowed with matching scope', async () => { // Get token that allows creating posts const { accessToken, dpop } = await getOAuthTokenWithScope( 'repo:app.bsky.feed.post?action=create', DID, PASSWORD, ); const proof = await dpop.createProof( 'POST', `${BASE}/xrpc/com.atproto.repo.createRecord`, accessToken, ); const res = await fetch(`${BASE}/xrpc/com.atproto.repo.createRecord`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `DPoP ${accessToken}`, DPoP: proof, }, body: JSON.stringify({ repo: DID, collection: 'app.bsky.feed.post', record: { text: 'scope test', createdAt: new Date().toISOString() }, }), }); expect(res.status).toBe(200); const body = await res.json(); expect(body.uri).toBeTruthy(); // Note: We don't clean up here because our token only has create scope // The record will be cleaned up by subsequent tests with full-access tokens }); it('createRecord allowed with wildcard collection scope', async () => { // Get token that allows creating any record type const { accessToken, dpop } = await getOAuthTokenWithScope( 'repo:*?action=create', DID, PASSWORD, ); const proof = await dpop.createProof( 'POST', `${BASE}/xrpc/com.atproto.repo.createRecord`, accessToken, ); const res = await fetch(`${BASE}/xrpc/com.atproto.repo.createRecord`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `DPoP ${accessToken}`, DPoP: proof, }, body: JSON.stringify({ repo: DID, collection: 'app.bsky.feed.post', record: { text: 'wildcard scope test', createdAt: new Date().toISOString(), }, }), }); expect(res.status).toBe(200); }); it('deleteRecord denied without delete scope', async () => { // Get token that only has create scope const { accessToken, dpop } = await getOAuthTokenWithScope( 'repo:app.bsky.feed.post?action=create', DID, PASSWORD, ); const proof = await dpop.createProof( 'POST', `${BASE}/xrpc/com.atproto.repo.deleteRecord`, accessToken, ); const res = await fetch(`${BASE}/xrpc/com.atproto.repo.deleteRecord`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `DPoP ${accessToken}`, DPoP: proof, }, body: JSON.stringify({ repo: DID, collection: 'app.bsky.feed.post', rkey: 'nonexistent', // Doesn't matter, should fail on scope first }), }); expect(res.status).toBe(403); }); it('uploadBlob denied with mismatched MIME scope', async () => { // Get token that only allows image uploads const { accessToken, dpop } = await getOAuthTokenWithScope( 'blob:image/*', DID, PASSWORD, ); const proof = await dpop.createProof( 'POST', `${BASE}/xrpc/com.atproto.repo.uploadBlob`, accessToken, ); // Try to upload a video (not allowed by scope) const res = await fetch(`${BASE}/xrpc/com.atproto.repo.uploadBlob`, { method: 'POST', headers: { 'Content-Type': 'video/mp4', Authorization: `DPoP ${accessToken}`, DPoP: proof, }, body: new Uint8Array([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70]), // Fake MP4 header }); expect(res.status).toBe(403); const body = await res.json(); expect(body.message?.includes('Missing required scope')).toBeTruthy(); // Error should mention missing scope }); it('uploadBlob allowed with matching MIME scope', async () => { // Get token that allows image uploads const { accessToken, dpop } = await getOAuthTokenWithScope( 'blob:image/*', DID, PASSWORD, ); const proof = await dpop.createProof( 'POST', `${BASE}/xrpc/com.atproto.repo.uploadBlob`, accessToken, ); // Minimal PNG const pngBytes = new Uint8Array([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, ]); const res = await fetch(`${BASE}/xrpc/com.atproto.repo.uploadBlob`, { method: 'POST', headers: { 'Content-Type': 'image/png', Authorization: `DPoP ${accessToken}`, DPoP: proof, }, body: pngBytes, }); expect(res.status).toBe(200); }); it('transition:generic grants full access', async () => { // Get token with transition:generic scope (full access) const { accessToken, dpop } = await getOAuthTokenWithScope( 'transition:generic', DID, PASSWORD, ); const proof = await dpop.createProof( 'POST', `${BASE}/xrpc/com.atproto.repo.createRecord`, accessToken, ); const res = await fetch(`${BASE}/xrpc/com.atproto.repo.createRecord`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `DPoP ${accessToken}`, DPoP: proof, }, body: JSON.stringify({ repo: DID, collection: 'app.bsky.feed.post', record: { text: 'transition scope test', createdAt: new Date().toISOString(), }, }), }); expect(res.status).toBe(200); }); it('PAR without DPoP nonce returns use_dpop_nonce with a fresh nonce', async () => { const dpop = await DpopClient.create(); const proof = await dpop.createProof( 'POST', `${BASE}/oauth/par`, undefined, { omitNonce: true, }, ); const res = await fetch(`${BASE}/oauth/par`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: proof, }, body: new URLSearchParams({ client_id: 'http://localhost:3000', response_type: 'code', redirect_uri: 'http://localhost:3000/callback', scope: 'atproto', state: 'nonce-test', code_challenge: 'x'.repeat(43), code_challenge_method: 'S256', }).toString(), }); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toBe('use_dpop_nonce'); expect(res.headers.get('DPoP-Nonce')).toBeTruthy(); }); it('appview proxy denied without rpc scope', async () => { const { accessToken, dpop } = await getOAuthTokenWithScope( 'repo:app.bsky.feed.like?action=create', DID, PASSWORD, ); const target = `${BASE}/xrpc/app.bsky.actor.getProfile?actor=${DID}`; const proof = await dpop.createProof('GET', target, accessToken); const res = await fetch(target, { headers: { Authorization: `DPoP ${accessToken}`, DPoP: proof }, }); expect(res.status).toBe(403); const body = await res.json(); expect(body.error).toBe('InvalidScope'); }); it('appview proxy passes scope gate with matching rpc scope', async () => { const { accessToken, dpop } = await getOAuthTokenWithScope( 'rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app%23bsky_appview', DID, PASSWORD, ); const target = `${BASE}/xrpc/app.bsky.actor.getProfile?actor=${DID}`; const proof = await dpop.createProof('GET', target, accessToken); const res = await fetch(target, { headers: { Authorization: `DPoP ${accessToken}`, DPoP: proof }, }); // Upstream AppView may reject our test DID, but the local scope // gate must not expect(res.status).not.toBe(403); }); }); describe('Consent page display', () => { it('consent page shows permissions table for granular scopes', async () => { const dpop = await DpopClient.create(); const clientId = 'http://localhost:3000'; const redirectUri = 'http://localhost:3000/callback'; const codeVerifier = randomBytes(32).toString('base64url'); const challengeBuffer = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(codeVerifier), ); const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); // PAR request with granular scopes const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); const parRes = await fetch(`${BASE}/oauth/par`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: parProof, }, body: new URLSearchParams({ client_id: clientId, redirect_uri: redirectUri, response_type: 'code', scope: 'atproto repo:app.bsky.feed.post?action=create&action=update blob:image/*', code_challenge: codeChallenge, code_challenge_method: 'S256', state: 'test-state', login_hint: DID, }).toString(), }); expect(parRes.status).toBe(201); const { request_uri } = await parRes.json(); // GET the authorize page const authorizeRes = await fetch( `${BASE}/oauth/authorize?client_id=${encodeURIComponent(clientId)}&request_uri=${encodeURIComponent(request_uri)}`, ); const html = await authorizeRes.text(); // Verify permissions table is rendered expect(html.includes('Repository permissions:')).toBeTruthy(); // Should show repo permissions section expect(html.includes('app.bsky.feed.post')).toBeTruthy(); // Should show collection name expect(html.includes('Upload permissions:')).toBeTruthy(); // Should show upload permissions section expect(html.includes('image/*')).toBeTruthy(); // Should show blob MIME type; }); it('consent page shows identity message for atproto-only scope', async () => { const dpop = await DpopClient.create(); const clientId = 'http://localhost:3000'; const redirectUri = 'http://localhost:3000/callback'; const codeVerifier = randomBytes(32).toString('base64url'); const challengeBuffer = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(codeVerifier), ); const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); // PAR request with atproto only (identity-only) const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); const parRes = await fetch(`${BASE}/oauth/par`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: parProof, }, body: new URLSearchParams({ client_id: clientId, redirect_uri: redirectUri, response_type: 'code', scope: 'atproto', code_challenge: codeChallenge, code_challenge_method: 'S256', state: 'test-state', login_hint: DID, }).toString(), }); expect(parRes.status).toBe(201); const { request_uri } = await parRes.json(); // GET the authorize page const authorizeRes = await fetch( `${BASE}/oauth/authorize?client_id=${encodeURIComponent(clientId)}&request_uri=${encodeURIComponent(request_uri)}`, ); const html = await authorizeRes.text(); // Verify identity-only message expect(html.includes('wants to uniquely identify you')).toBeTruthy(); // Should show identity-only message expect(!html.includes('Repository permissions:')).toBeTruthy(); // Should NOT show permissions table }); it('consent page shows warning for transition:generic scope', async () => { const dpop = await DpopClient.create(); const clientId = 'http://localhost:3000'; const redirectUri = 'http://localhost:3000/callback'; const codeVerifier = randomBytes(32).toString('base64url'); const challengeBuffer = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(codeVerifier), ); const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); // PAR request with transition:generic (full access) const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); const parRes = await fetch(`${BASE}/oauth/par`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: parProof, }, body: new URLSearchParams({ client_id: clientId, redirect_uri: redirectUri, response_type: 'code', scope: 'atproto transition:generic', code_challenge: codeChallenge, code_challenge_method: 'S256', state: 'test-state', login_hint: DID, }).toString(), }); expect(parRes.status).toBe(201); const { request_uri } = await parRes.json(); // GET the authorize page const authorizeRes = await fetch( `${BASE}/oauth/authorize?client_id=${encodeURIComponent(clientId)}&request_uri=${encodeURIComponent(request_uri)}`, ); const html = await authorizeRes.text(); // Verify warning banner expect(html.includes('Full repository access requested')).toBeTruthy(); // Should show full access warning }); it('supports direct authorization without PAR', async () => { const clientId = 'http://localhost:3000'; const redirectUri = 'http://localhost:3000/callback'; const codeVerifier = 'test-verifier-for-direct-auth-flow-min-43-chars!!'; const challengeBuffer = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(codeVerifier), ); const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); const state = 'test-direct-auth-state'; // Step 1: GET authorize with direct parameters (no PAR) const authorizeUrl = new URL(`${BASE}/oauth/authorize`); authorizeUrl.searchParams.set('client_id', clientId); authorizeUrl.searchParams.set('redirect_uri', redirectUri); authorizeUrl.searchParams.set('response_type', 'code'); authorizeUrl.searchParams.set('scope', 'atproto'); authorizeUrl.searchParams.set('code_challenge', codeChallenge); authorizeUrl.searchParams.set('code_challenge_method', 'S256'); authorizeUrl.searchParams.set('state', state); authorizeUrl.searchParams.set('login_hint', DID); const getRes = await fetch(authorizeUrl.toString()); expect(getRes.status).toBe(200); const html = await getRes.text(); expect(html.includes('Authorize')).toBeTruthy(); // Should show consent page; expect(html.includes('request_uri')).toBeTruthy(); // Should include request_uri in form }); it('completes full direct authorization flow', async () => { const clientId = 'http://localhost:3000'; const redirectUri = 'http://localhost:3000/callback'; const codeVerifier = 'test-verifier-for-direct-auth-flow-min-43-chars!!'; const challengeBuffer = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(codeVerifier), ); const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); const state = 'test-direct-auth-state'; // Step 1: GET authorize with direct parameters const authorizeUrl = new URL(`${BASE}/oauth/authorize`); authorizeUrl.searchParams.set('client_id', clientId); authorizeUrl.searchParams.set('redirect_uri', redirectUri); authorizeUrl.searchParams.set('response_type', 'code'); authorizeUrl.searchParams.set('scope', 'atproto'); authorizeUrl.searchParams.set('code_challenge', codeChallenge); authorizeUrl.searchParams.set('code_challenge_method', 'S256'); authorizeUrl.searchParams.set('state', state); authorizeUrl.searchParams.set('login_hint', DID); const getRes = await fetch(authorizeUrl.toString()); expect(getRes.status).toBe(200); const html = await getRes.text(); // Extract request_uri from the form const requestUriMatch = html.match(/name="request_uri" value="([^"]+)"/); expect(requestUriMatch).toBeTruthy(); const requestUri = requestUriMatch ? requestUriMatch[1] : ''; // Step 2: POST to authorize (user approval) const authRes = await fetch(`${BASE}/oauth/authorize`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ request_uri: requestUri, client_id: clientId, password: PASSWORD, }).toString(), redirect: 'manual', }); expect(authRes.status).toBe(302); const location = authRes.headers.get('location'); expect(location).toBeTruthy(); const locationUrl = new URL(location || ''); const code = locationUrl.searchParams.get('code'); expect(code).toBeTruthy(); expect(locationUrl.searchParams.get('state')).toBe(state); // Step 3: Exchange code for tokens const dpop = await DpopClient.create(); const dpopProof = await dpop.createProof('POST', `${BASE}/oauth/token`); const tokenRes = await fetch(`${BASE}/oauth/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: dpopProof, }, body: new URLSearchParams({ grant_type: 'authorization_code', code: code || '', redirect_uri: redirectUri, client_id: clientId, code_verifier: codeVerifier, }).toString(), }); expect(tokenRes.status).toBe(200); const tokenData = await tokenRes.json(); expect(tokenData.access_token).toBeTruthy(); expect(tokenData.token_type).toBe('DPoP'); }); it('consent page shows profile card when login_hint is provided', async () => { const clientId = 'http://localhost:3000'; const redirectUri = 'http://localhost:3000/callback'; const codeVerifier = 'test-verifier-for-profile-card-test-min-43-chars!!'; const challengeBuffer = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(codeVerifier), ); const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); const authorizeUrl = new URL(`${BASE}/oauth/authorize`); authorizeUrl.searchParams.set('client_id', clientId); authorizeUrl.searchParams.set('redirect_uri', redirectUri); authorizeUrl.searchParams.set('response_type', 'code'); authorizeUrl.searchParams.set('scope', 'atproto'); authorizeUrl.searchParams.set('code_challenge', codeChallenge); authorizeUrl.searchParams.set('code_challenge_method', 'S256'); authorizeUrl.searchParams.set('state', 'test-state'); authorizeUrl.searchParams.set('login_hint', 'test.handle.example'); const res = await fetch(authorizeUrl.toString()); const html = await res.text(); expect(html.includes('profile-card')).toBeTruthy(); // Should include profile card element expect(html.includes('@test.handle.example')).toBeTruthy(); // Should show handle with @ prefix expect(html.includes('app.bsky.actor.getProfile')).toBeTruthy(); // Should include profile fetch script }); it('consent page does not show profile card when login_hint is omitted', async () => { const clientId = 'http://localhost:3000'; const redirectUri = 'http://localhost:3000/callback'; const codeVerifier = 'test-verifier-for-no-profile-test-min-43-chars!!'; const challengeBuffer = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(codeVerifier), ); const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); const authorizeUrl = new URL(`${BASE}/oauth/authorize`); authorizeUrl.searchParams.set('client_id', clientId); authorizeUrl.searchParams.set('redirect_uri', redirectUri); authorizeUrl.searchParams.set('response_type', 'code'); authorizeUrl.searchParams.set('scope', 'atproto'); authorizeUrl.searchParams.set('code_challenge', codeChallenge); authorizeUrl.searchParams.set('code_challenge_method', 'S256'); authorizeUrl.searchParams.set('state', 'test-state'); // No login_hint parameter const res = await fetch(authorizeUrl.toString()); const html = await res.text(); // Check for the actual element (id="profile-card"), not the CSS class selector expect(!html.includes('id="profile-card"')).toBeTruthy(); // Should NOT include profile card element expect(!html.includes('app.bsky.actor.getProfile')).toBeTruthy(); // Should NOT include profile fetch script }); it('consent page escapes dangerous characters in login_hint', async () => { const clientId = 'http://localhost:3000'; const redirectUri = 'http://localhost:3000/callback'; const codeVerifier = 'test-verifier-for-xss-test-minimum-43-chars!!!!!'; const challengeBuffer = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(codeVerifier), ); const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); // Attempt XSS via login_hint with double quotes to break out of JSON.stringify const maliciousHint = 'user");alert("xss'; const authorizeUrl = new URL(`${BASE}/oauth/authorize`); authorizeUrl.searchParams.set('client_id', clientId); authorizeUrl.searchParams.set('redirect_uri', redirectUri); authorizeUrl.searchParams.set('response_type', 'code'); authorizeUrl.searchParams.set('scope', 'atproto'); authorizeUrl.searchParams.set('code_challenge', codeChallenge); authorizeUrl.searchParams.set('code_challenge_method', 'S256'); authorizeUrl.searchParams.set('state', 'test-state'); authorizeUrl.searchParams.set('login_hint', maliciousHint); const res = await fetch(authorizeUrl.toString()); const html = await res.text(); // JSON.stringify escapes double quotes, so the payload should be escaped // The raw ");alert(" should NOT appear - it should be escaped as \");alert(\" expect( !html.includes('").toBeTruthy();alert("'), 'Should escape double quotes to prevent XSS breakout', ); // Verify the escaped version is present (backslash before the quote) expect(html.includes('\\"')).toBeTruthy(); // Should contain escaped characters from JSON.stringify }); }); describe('Foreign DID proxying', () => { it('proxies to AppView when atproto-proxy header present', async () => { // Use a known public DID (bsky.app official account) // We expect 200 (record exists) or 400 (record deleted/not found) from AppView // A 502 would indicate proxy failure, 404 would indicate local handling const res = await fetch( `${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:z72i7hdynmk6r22z27h6tvur&collection=app.bsky.feed.post&rkey=3juzlwllznd24`, { headers: { 'atproto-proxy': 'did:web:api.bsky.app#bsky_appview', }, }, ); // AppView returns 200 (found) or 400 (RecordNotFound), not 404 or 502 expect( res.status === 200 || res.status === 400, `Expected 200 or 400 from AppView, got ${res.status}`, ).toBeTruthy(); // Verify we got a JSON response (not an error page) const contentType = res.headers.get('content-type'); expect(contentType?.includes('application/json')).toBeTruthy(); // Should return JSON }); it('handles foreign repo locally without header (returns not found)', async () => { // Foreign DID without atproto-proxy header is handled locally // This returns an error since the foreign DID doesn't exist on this PDS const res = await fetch( `${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:z72i7hdynmk6r22z27h6tvur&collection=app.bsky.feed.post&rkey=3juzlwllznd24`, ); // Local PDS returns 404 for non-existent record/DID expect(res.status).toBe(404); }); it('returns error for unknown proxy service', async () => { const res = await fetch( `${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:test&collection=test&rkey=test`, { headers: { 'atproto-proxy': 'did:web:unknown.service#unknown', }, }, ); expect(res.status).toBe(400); const data = await res.json(); expect(data.message.includes('Unknown proxy service')).toBeTruthy(); }); it('returns error for malformed atproto-proxy header', async () => { // Header without fragment separator const res1 = await fetch( `${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:test&collection=test&rkey=test`, { headers: { 'atproto-proxy': 'did:web:api.bsky.app', // missing #serviceId }, }, ); expect(res1.status).toBe(400); const data1 = await res1.json(); expect( data1.message.includes('Malformed atproto-proxy header'), ).toBeTruthy(); // Header with only fragment const res2 = await fetch( `${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:test&collection=test&rkey=test`, { headers: { 'atproto-proxy': '#bsky_appview', // missing DID }, }, ); expect(res2.status).toBe(400); const data2 = await res2.json(); expect( data2.message.includes('Malformed atproto-proxy header'), ).toBeTruthy(); }); it('returns local record for local DID without proxy header', async () => { // Create a record first const { data: created } = await jsonPost( '/xrpc/com.atproto.repo.createRecord', { repo: DID, collection: 'app.bsky.feed.post', record: { $type: 'app.bsky.feed.post', text: 'Test post for local DID test', createdAt: new Date().toISOString(), }, }, { Authorization: `Bearer ${token}` }, ); // Fetch without proxy header - should get local record const rkey = created.uri.split('/').pop(); const res = await fetch( `${BASE}/xrpc/com.atproto.repo.getRecord?repo=${DID}&collection=app.bsky.feed.post&rkey=${rkey}`, ); expect(res.status).toBe(200); const data = await res.json(); expect( data.value.text.includes('Test post for local DID test'), ).toBeTruthy(); // Cleanup - verify success to ensure test isolation const { status: cleanupStatus } = await jsonPost( '/xrpc/com.atproto.repo.deleteRecord', { repo: DID, collection: 'app.bsky.feed.post', rkey }, { Authorization: `Bearer ${token}` }, ); expect(cleanupStatus).toBe(200); }); it('describeRepo handles foreign DID locally', async () => { // Without proxy header, foreign DID is handled locally (returns error) const res = await fetch( `${BASE}/xrpc/com.atproto.repo.describeRepo?repo=did:plc:z72i7hdynmk6r22z27h6tvur`, ); // Local PDS returns 404 for non-existent DID expect(res.status).toBe(404); }); it('listRecords handles foreign DID locally', async () => { // Without proxy header, foreign DID is handled locally // listRecords returns 200 with empty records for non-existent collection const res = await fetch( `${BASE}/xrpc/com.atproto.repo.listRecords?repo=did:plc:z72i7hdynmk6r22z27h6tvur&collection=app.bsky.feed.post&limit=1`, ); // Local PDS returns 200 with empty records (or 404 for completely unknown DID) expect( res.status === 200 || res.status === 404, `Expected 200 or 404, got ${res.status}`, ).toBeTruthy(); }); }); describe('subscribeRepos WebSocket', () => { it('rejects non-WebSocket requests with 426', async () => { const res = await fetch(`${BASE}/xrpc/com.atproto.sync.subscribeRepos`); expect(res.status).toBe(426); }); it('connects via WebSocket', async () => { const wsUrl = BASE.replace('http', 'ws'); const ws = new WebSocket(`${wsUrl}/xrpc/com.atproto.sync.subscribeRepos`); await /** @type {Promise} */ ( new Promise((resolve, reject) => { ws.on('open', () => { ws.close(); resolve(); }); ws.on('error', reject); setTimeout(() => reject(new Error('WebSocket timeout')), 5000); }) ); }); it('receives events after cursor', async () => { // Get a fresh token for this test const sessionRes = await jsonPost( '/xrpc/com.atproto.server.createSession', { identifier: DID, password: PASSWORD }, ); expect(sessionRes.status).toBe(200); const testToken = sessionRes.data.accessJwt; // First create a record to generate an event const { status } = await jsonPost( '/xrpc/com.atproto.repo.createRecord', { repo: DID, collection: 'app.bsky.feed.post', record: { text: 'ws test', createdAt: new Date().toISOString() }, }, { Authorization: `Bearer ${testToken}` }, ); expect(status).toBe(200); // Get current commit to use as cursor baseline const commitRes = await fetch( `${BASE}/xrpc/com.atproto.sync.getLatestCommit?did=${DID}`, ); const { rev } = await commitRes.json(); expect(rev).toBeTruthy(); // Connect with cursor=0 to get all events const wsUrl = BASE.replace('http', 'ws'); const ws = new WebSocket( `${wsUrl}/xrpc/com.atproto.sync.subscribeRepos?cursor=0`, ); /** @type {unknown[]} */ const events = []; await /** @type {Promise} */ ( new Promise((resolve, reject) => { ws.on('message', (/** @type {unknown} */ data) => { events.push(data); // Close after receiving at least one event if (events.length >= 1) { ws.close(); resolve(); } }); ws.on('error', reject); ws.on('open', () => { // Give it time to receive events, then close setTimeout(() => { ws.close(); resolve(); }, 2000); }); setTimeout(() => reject(new Error('WebSocket timeout')), 10000); }) ); // Should have received at least one event expect(events.length).toBeGreaterThan(0); // Events should be binary (Uint8Array/Buffer) expect( Buffer.isBuffer(events[0]) || events[0] instanceof Uint8Array, ).toBeTruthy(); }); it('emits #identity and #account events on account initialization', async () => { const { cborDecodeFirst } = await import('../packages/core/src/repo.js'); const wsUrl = BASE.replace('http', 'ws'); const ws = new WebSocket( `${wsUrl}/xrpc/com.atproto.sync.subscribeRepos?cursor=0`, ); /** @type {Buffer[]} */ const raw = []; await /** @type {Promise} */ ( new Promise((resolve, reject) => { let settle = setTimeout(resolve, 3000); ws.on('message', (/** @type {Buffer} */ data) => { raw.push(data); clearTimeout(settle); settle = setTimeout(resolve, 500); }); ws.on('error', reject); setTimeout(() => reject(new Error('WebSocket timeout')), 10000); }) ); ws.close(); const frames = raw.map((buf) => { const bytes = new Uint8Array(buf); const [header, consumed] = cborDecodeFirst(bytes); const [body] = cborDecodeFirst(bytes.slice(consumed)); return { header, body }; }); // Account bootstrap emits #identity then #account before any commit const identity = frames.find((f) => f.header.t === '#identity'); const account = frames.find((f) => f.header.t === '#account'); expect(identity).toBeTruthy(); expect(identity?.body.did).toBe(DID); expect(identity?.body.handle).toBe(HANDLE); expect(identity?.body.time).toBeTruthy(); expect(account).toBeTruthy(); expect(account?.body.did).toBe(DID); expect(account?.body.active).toBe(true); expect(identity?.body.seq).toBeLessThan(account?.body.seq); // Commits share the same sequence and come after the bootstrap events const firstCommit = frames.find((f) => f.header.t === '#commit'); expect(firstCommit).toBeTruthy(); expect(firstCommit?.body.seq).toBeGreaterThan(account?.body.seq); }); it('commit frames carry since and prevData for inductive sync', async () => { const { cborDecodeFirst, cidToString } = await import( '../packages/core/src/repo.js' ); const sessionRes = await jsonPost( '/xrpc/com.atproto.server.createSession', { identifier: DID, password: PASSWORD }, ); const testToken = sessionRes.data.accessJwt; // Two back-to-back commits so the second has a predecessor const markers = [`sync-v11-a-${Date.now()}`, `sync-v11-b-${Date.now()}`]; for (const text of markers) { const { status } = await jsonPost( '/xrpc/com.atproto.repo.createRecord', { repo: DID, collection: 'app.bsky.feed.post', record: { text, createdAt: new Date().toISOString() }, }, { Authorization: `Bearer ${testToken}` }, ); expect(status).toBe(200); } // Replay everything and decode commit frames const wsUrl = BASE.replace('http', 'ws'); const ws = new WebSocket( `${wsUrl}/xrpc/com.atproto.sync.subscribeRepos?cursor=0`, ); /** @type {Buffer[]} */ const raw = []; await /** @type {Promise} */ ( new Promise((resolve, reject) => { let settle = setTimeout(resolve, 3000); ws.on('message', (/** @type {Buffer} */ data) => { raw.push(data); clearTimeout(settle); settle = setTimeout(resolve, 500); }); ws.on('error', reject); setTimeout(() => reject(new Error('WebSocket timeout')), 10000); }) ); ws.close(); const commits = raw .map((buf) => { const bytes = new Uint8Array(buf); const [header, consumed] = cborDecodeFirst(bytes); const [body] = cborDecodeFirst(bytes.slice(consumed)); return { header, body }; }) .filter((f) => f.header.t === '#commit'); const first = commits.find((f) => f.body.ops?.some((/** @type {{path: string}} */ op) => op.path?.startsWith('app.bsky.feed.post'), ), ); expect(first).toBeTruthy(); // Every commit frame declares `since`; commits with a predecessor // carry the previous rev and the previous MST root const withPrev = commits.filter((f) => f.body.since != null); expect(withPrev.length).toBeGreaterThan(0); for (const frame of withPrev) { const prevFrame = commits.find((f) => f.body.rev === frame.body.since); expect(prevFrame).toBeTruthy(); expect(frame.body.prevData).toBeInstanceOf(Uint8Array); expect(cidToString(frame.body.prevData).startsWith('b')).toBeTruthy(); } }); it('receives real-time events when record is created after connecting', async () => { // Get a fresh token for this test const sessionRes = await jsonPost( '/xrpc/com.atproto.server.createSession', { identifier: DID, password: PASSWORD }, ); expect(sessionRes.status).toBe(200); const testToken = sessionRes.data.accessJwt; // Connect to WebSocket first (no cursor - only real-time events) const wsUrl = BASE.replace('http', 'ws'); const ws = new WebSocket(`${wsUrl}/xrpc/com.atproto.sync.subscribeRepos`); /** @type {unknown[]} */ const events = []; let wsReady = false; const eventPromise = /** @type {Promise} */ ( new Promise((resolve, reject) => { ws.on('message', (/** @type {unknown} */ data) => { events.push(data); // Got an event, test passes ws.close(); resolve(); }); ws.on('error', reject); ws.on('open', () => { wsReady = true; }); // Timeout after waiting for event setTimeout(() => { ws.close(); reject(new Error('No real-time event received within timeout')); }, 10000); }) ); // Wait for WebSocket to be ready for (let i = 0; i < 50 && !wsReady; i++) { await new Promise((r) => setTimeout(r, 100)); } expect(wsReady).toBe(true); // Small delay to ensure subscription is active await new Promise((r) => setTimeout(r, 200)); // Create a record AFTER WebSocket is connected const { status, data } = await jsonPost( '/xrpc/com.atproto.repo.createRecord', { repo: DID, collection: 'app.bsky.feed.post', record: { text: `real-time ws test ${Date.now()}`, createdAt: new Date().toISOString(), }, }, { Authorization: `Bearer ${testToken}` }, ); expect(status).toBe(200); // Wait for the event to be received await eventPromise; // Should have received the real-time event expect(events.length).toBeGreaterThan(0); // Event should be binary expect( Buffer.isBuffer(events[0]) || events[0] instanceof Uint8Array, ).toBeTruthy(); // Cleanup const rkey = data.uri.split('/').pop(); await jsonPost( '/xrpc/com.atproto.repo.deleteRecord', { repo: DID, collection: 'app.bsky.feed.post', rkey }, { Authorization: `Bearer ${testToken}` }, ); }); }); describe('Relay sync (requires docker)', () => { // Skip entire suite if docker infrastructure is disabled or using cloudflare // Cloudflare wrangler isn't accessible from Docker through Caddy const skipRelaySyncTests = !USE_LOCAL_INFRA || (PLATFORM !== 'node' && PLATFORM !== 'deno'); beforeAll(() => { if (skipRelaySyncTests) { console.log( 'Skipping relay sync tests (requires USE_LOCAL_INFRA=true and PLATFORM=node or deno)', ); } }); it('relay receives repo after PDS commits', async () => { if (skipRelaySyncTests) return; // Create a record (triggers notifyRelay internally) const { status } = await jsonPost( '/xrpc/com.atproto.repo.createRecord', { repo: DID, collection: 'app.bsky.feed.post', record: { text: 'relay sync test', createdAt: new Date().toISOString(), }, }, { Authorization: `Bearer ${token}` }, ); expect(status).toBe(200); // Poll relay until it has synced the repo (or timeout) let synced = false; for (let i = 0; i < 30; i++) { try { const res = await fetch( `${RELAY_URL}/xrpc/com.atproto.sync.getLatestCommit?did=${DID}`, ); if (res.ok) { const data = await res.json(); if (data.rev && data.rev.length > 0) { synced = true; console.log(`Relay synced after ${i + 1} attempts:`, data); break; } } } catch { // Relay not ready or error } await new Promise((r) => setTimeout(r, 500)); } expect(synced, 'Relay should have synced the repo').toBe(true); }); it('relay firehose emits commit events', async () => { if (skipRelaySyncTests) return; // Connect to relay firehose const wsUrl = RELAY_URL.replace('http', 'ws'); const ws = new WebSocket( `${wsUrl}/xrpc/com.atproto.sync.subscribeRepos?cursor=0`, ); /** @type {unknown[]} */ const events = []; await /** @type {Promise} */ ( new Promise((resolve, reject) => { ws.on('message', (/** @type {unknown} */ data) => { events.push(data); if (events.length >= 1) { ws.close(); resolve(); } }); ws.on('error', (/** @type {Error} */ err) => { console.error('Relay WebSocket error:', err); reject(err); }); ws.on('open', () => { // Create a record to generate an event jsonPost( '/xrpc/com.atproto.repo.createRecord', { repo: DID, collection: 'app.bsky.feed.post', record: { text: 'firehose test', createdAt: new Date().toISOString(), }, }, { Authorization: `Bearer ${token}` }, ); // Give time for events, then close setTimeout(() => { ws.close(); resolve(); }, 5000); }); setTimeout(() => reject(new Error('Relay firehose timeout')), 15000); }) ); console.log(`Received ${events.length} events from relay firehose`); expect(events.length).toBeGreaterThan(0); }); }); // A full account migration between two independent pds.js servers, driven // through their fetch handlers with the local PLC directory as the only // shared external. Node-only: it builds servers with @pdsjs/node's // createServer. It reuses the docker reset already done by the outer // beforeAll rather than resetting again, which is why it lives in this file // instead of a standalone one — two files resetting docker in parallel tear // each other's services down. describe('Account migration between two PDSes', () => { // Node-only, and needs the local PLC directory. Guarded per-hook and // per-test rather than with describe.skipIf so the block is a plain // describe (which the lint rule and the rest of this file expect). const shouldSkip = !USE_LOCAL_INFRA || PLATFORM !== 'node'; const SOURCE_HOST = 'source.migration.test'; const DEST_HOST = 'dest.migration.test'; const MIGRATE_PASSWORD = 'test-password'; const MIGRATE_SECRET = 'migration-test-secret'; /** * Drive a server through its fetch handler. * @param {Awaited>} server * @param {string} host */ const client = (server, host) => { const b = `https://${host}`; return { /** * @param {string} path * @param {Record} body * @param {Record} [headers] */ async json(path, body, headers = {}) { const res = await server.pds.fetch( new Request(`${b}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body), }), ); const text = await res.text(); return { status: res.status, data: text ? JSON.parse(text) : null }; }, /** * @param {string} path * @param {Record} [headers] */ get(path, headers = {}) { return server.pds.fetch(new Request(`${b}${path}`, { headers })); }, /** * @param {string} path * @param {Uint8Array} bytes * @param {string} contentType * @param {Record} [headers] */ async postRaw(path, bytes, contentType, headers = {}) { const res = await server.pds.fetch( new Request(`${b}${path}`, { method: 'POST', headers: { 'Content-Type': contentType, ...headers }, body: /** @type {BodyInit} */ (bytes), }), ); const text = await res.text(); return { status: res.status, data: text ? JSON.parse(text) : null }; }, }; }; /** @type {string} */ let tmp; /** @type {Awaited>} */ let source; /** @type {Awaited>} */ let dest; /** @type {ReturnType} */ let src; /** @type {ReturnType} */ let dst; /** @type {{did: string, privateKeyHex: string, handle: string}} */ let identity; beforeAll(async () => { if (shouldSkip) return; tmp = mkdtempSync(join(tmpdir(), 'pdsjs-migration-')); source = await createServer({ dbPath: join(tmp, 'source.db'), blobsDir: join(tmp, 'source-blobs'), jwtSecret: MIGRATE_SECRET, password: MIGRATE_PASSWORD, hostname: SOURCE_HOST, plcUrl: PLC_URL, }); dest = await createServer({ dbPath: join(tmp, 'dest.db'), blobsDir: join(tmp, 'dest-blobs'), jwtSecret: MIGRATE_SECRET, password: MIGRATE_PASSWORD, hostname: DEST_HOST, plcUrl: PLC_URL, }); src = client(source, SOURCE_HOST); dst = client(dest, DEST_HOST); // Register a real identity in the local PLC directory and initialize // the source server with it. identity = await createTestIdentity({ pdsUrl: `https://${SOURCE_HOST}`, handle: 'alice', }); const initRes = await source.pds.fetch( new Request(`https://${SOURCE_HOST}/init?did=${identity.did}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ did: identity.did, privateKey: identity.privateKeyHex, handle: identity.handle, }), }), ); expect(initRes.status).toBe(200); }, 60000); afterAll(async () => { if (shouldSkip) return; // Driven via pds.fetch and never listen()ed, so close() rejects at // server.close() — but only after clearing the cleanup interval and // closing the database, which is the cleanup that matters. await source?.close?.().catch(() => {}); await dest?.close?.().catch(() => {}); if (tmp) rmSync(tmp, { recursive: true, force: true }); }); it('migrates repo, blobs, and identity end to end', async () => { if (shouldSkip) return; const { did, handle } = identity; // Sign in to the source and seed content. const login = await src.json('/xrpc/com.atproto.server.createSession', { identifier: handle, password: MIGRATE_PASSWORD, }); expect(login.status).toBe(200); const srcAuth = { Authorization: `Bearer ${login.data.accessJwt}` }; const post = await src.json( '/xrpc/com.atproto.repo.createRecord', { repo: did, collection: 'app.bsky.feed.post', record: { text: 'hello from the old PDS', createdAt: '2026-01-01T00:00:00.000Z', }, }, srcAuth, ); expect(post.status).toBe(200); const postRkey = post.data.uri.split('/').pop(); // A blob referenced by a record, to give the migration a missing-blob // to reconcile. Leading bytes are the PNG magic number. const blobBytes = new Uint8Array([137, 80, 78, 71, 1, 2, 3, 4, 5, 6]); const blobUpload = await src.postRaw( '/xrpc/com.atproto.repo.uploadBlob', blobBytes, 'image/png', srcAuth, ); expect(blobUpload.status).toBe(200); const blobRef = blobUpload.data.blob; const withBlob = await src.json( '/xrpc/com.atproto.repo.createRecord', { repo: did, collection: 'app.bsky.actor.profile', rkey: 'self', record: { $type: 'app.bsky.actor.profile', displayName: 'Alice', avatar: blobRef, }, }, srcAuth, ); expect(withBlob.status).toBe(200); // 1. Mint a service token at the source authorizing createAccount. const serviceAuthRes = await src.get( `/xrpc/com.atproto.server.getServiceAuth?aud=did:web:${DEST_HOST}&lxm=com.atproto.server.createAccount`, srcAuth, ); expect(serviceAuthRes.status).toBe(200); const { token: serviceJwt } = await serviceAuthRes.json(); expect(serviceJwt).toBeTruthy(); // 2. Create the account on the destination. const created = await dst.json( '/xrpc/com.atproto.server.createAccount', { did, handle }, { Authorization: `Bearer ${serviceJwt}` }, ); expect(created.status).toBe(200); expect(created.data.did).toBe(did); const destAuth = { Authorization: `Bearer ${created.data.accessJwt}` }; // The destination account exists but is not yet serving. const statusBefore = await dst.get( '/xrpc/com.atproto.server.checkAccountStatus', destAuth, ); expect((await statusBefore.json()).activated).toBe(false); // 3. Export the repo from source, import into dest. const repoRes = await src.get( `/xrpc/com.atproto.sync.getRepo?did=${did}`, ); expect(repoRes.status).toBe(200); const car = new Uint8Array(await repoRes.arrayBuffer()); const imported = await dst.postRaw( '/xrpc/com.atproto.repo.importRepo', car, 'application/vnd.ipld.car', destAuth, ); expect(imported.status).toBe(200); const readback = await dst.get( `/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=app.bsky.feed.post&rkey=${postRkey}`, ); expect(readback.status).toBe(200); expect((await readback.json()).value.text).toBe('hello from the old PDS'); // 4. Reconcile blobs while the destination is still deactivated. const missingRes = await dst.get( '/xrpc/com.atproto.repo.listMissingBlobs', destAuth, ); const missing = (await missingRes.json()).blobs; expect(missing.map((/** @type {{cid: string}} */ b) => b.cid)).toContain( blobRef.ref.$link, ); const blobData = await src.get( `/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${blobRef.ref.$link}`, ); expect(blobData.status).toBe(200); const reupload = await dst.postRaw( '/xrpc/com.atproto.repo.uploadBlob', new Uint8Array(await blobData.arrayBuffer()), 'image/png', destAuth, ); expect(reupload.status).toBe(200); const missingAfter = await dst.get( '/xrpc/com.atproto.repo.listMissingBlobs', destAuth, ); expect((await missingAfter.json()).blobs).toHaveLength(0); // 5. Move the identity onto the destination's credentials. const recRes = await dst.get( '/xrpc/com.atproto.identity.getRecommendedDidCredentials', destAuth, ); const recommended = await recRes.json(); expect(recommended.services.atproto_pds.endpoint).toBe( `https://${DEST_HOST}`, ); // No email channel here, so no confirmation code is required. await src.json( '/xrpc/com.atproto.identity.requestPlcOperationSignature', {}, srcAuth, ); const signed = await src.json( '/xrpc/com.atproto.identity.signPlcOperation', { rotationKeys: recommended.rotationKeys, verificationMethods: recommended.verificationMethods, alsoKnownAs: recommended.alsoKnownAs, services: recommended.services, }, srcAuth, ); expect(signed.status).toBe(200); const submitted = await dst.json( '/xrpc/com.atproto.identity.submitPlcOperation', { operation: signed.data.operation }, destAuth, ); expect(submitted.status).toBe(200); // The PLC directory now points the DID at the destination. const doc = await (await fetch(`${PLC_URL}/${did}`)).json(); const pdsService = doc.service.find( (/** @type {{id: string}} */ s) => s.id === '#atproto_pds', ); expect(pdsService.serviceEndpoint).toBe(`https://${DEST_HOST}`); // 6. Activate the destination, deactivate the source. const activate = await dst.json( '/xrpc/com.atproto.server.activateAccount', {}, destAuth, ); expect(activate.status).toBe(200); const deactivate = await src.json( '/xrpc/com.atproto.server.deactivateAccount', {}, srcAuth, ); expect(deactivate.status).toBe(200); // 7. The destination is now the live home of the account. const statusAfter = await dst.get( '/xrpc/com.atproto.server.checkAccountStatus', destAuth, ); expect((await statusAfter.json()).activated).toBe(true); const destRepoStatus = await dst.get( `/xrpc/com.atproto.sync.getRepoStatus?did=${did}`, ); expect((await destRepoStatus.json()).active).toBe(true); const srcRepoStatus = await src.get( `/xrpc/com.atproto.sync.getRepoStatus?did=${did}`, ); expect((await srcRepoStatus.json()).active).toBe(false); }, 60000); }); describe('Cleanup', () => { it('deleteRecord (cleanup)', async () => { const { status } = await jsonPost( '/xrpc/com.atproto.repo.deleteRecord', { repo: DID, collection: 'app.bsky.feed.post', rkey: testRkey }, { Authorization: `Bearer ${token}` }, ); expect(status).toBe(200); }); }); });