chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
/**
* 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<string, string>} [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<string, string>} params
* @param {Record<string, string>} [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<void>}|null} */
let nodeServer = null;
/** @type {{close: () => Promise<void>}|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<void>} */ (
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<void>} */ (
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<void>} */ (
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<void>} */ (
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<void>} */ (
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<void>} */ (
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<void>} */ (
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<void>} */ (
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<ReturnType<typeof createServer>>} server
* @param {string} host
*/
const client = (server, host) => {
const b = `https://${host}`;
return {
/**
* @param {string} path
* @param {Record<string, unknown>} body
* @param {Record<string, string>} [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<string, string>} [headers]
*/
get(path, headers = {}) {
return server.pds.fetch(new Request(`${b}${path}`, { headers }));
},
/**
* @param {string} path
* @param {Uint8Array} bytes
* @param {string} contentType
* @param {Record<string, string>} [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<ReturnType<typeof createServer>>} */
let source;
/** @type {Awaited<ReturnType<typeof createServer>>} */
let dest;
/** @type {ReturnType<typeof client>} */
let src;
/** @type {ReturnType<typeof client>} */
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);
});
});
});