A fake PDS server storing data in-memory for E2E testing
TypeScript 100.0%
Other 0.1%
11 1 0

Clone this repository

https://tangled.org/sans-self.org/fake-pds https://tangled.org/did:plc:wydyrngmxbcsqdvhmd7whmye/fake-pds
git@knot.sans-self.org:sans-self.org/fake-pds git@knot.sans-self.org:did:plc:wydyrngmxbcsqdvhmd7whmye/fake-pds

For self-hosted knots, clone URLs may differ based on your setup.

Download tar.gz
README.md

fake-pds#

A lightweight, in-memory AT Protocol PDS for testing. No database, no crypto verification, no Merkle Search Tree — just the XRPC endpoints that matter for integration tests.

Install#

npm install fake-pds

Quick Start#

import { createFakePds } from "fake-pds";

const pds = await createFakePds({
  accounts: [
    { did: "did:plc:alice", handle: "alice.test" },
    { did: "did:plc:bob", handle: "bob.test" },
  ],
});

// Seed data
pds.putRecord("did:plc:alice", "app.opake.publicKey", "self", {
  opakeVersion: 1,
  publicKey: { $bytes: "..." },
  algo: "x25519",
  createdAt: "2026-01-01T00:00:00Z",
});

// Use in tests — it's a real HTTP server
const res = await fetch(
  `${pds.url}/xrpc/com.atproto.repo.getRecord?repo=did:plc:alice&collection=app.opake.publicKey&rkey=self`,
);
const record = await res.json();

// Reset between tests
pds.reset();

// Cleanup
await pds.close();

Endpoints#

Repository (XRPC)#

Endpoint Method Auth Notes
com.atproto.repo.getRecord GET No Returns { uri, cid, value } or 404
com.atproto.repo.listRecords GET No Cursor pagination, sorted by rkey
com.atproto.repo.createRecord POST Yes Auto-generates TID rkey if omitted
com.atproto.repo.putRecord POST Yes Upsert by rkey
com.atproto.repo.deleteRecord POST Yes No-ops if missing
com.atproto.repo.uploadBlob POST Yes Returns blob ref
com.atproto.sync.getBlob GET No Returns stored blob bytes

Identity#

Endpoint Method Auth Notes
com.atproto.identity.resolveHandle GET No Returns { did } or 404

Auth (Legacy)#

Endpoint Method Notes
com.atproto.server.createSession POST Returns fake JWT tokens
com.atproto.server.refreshSession POST Invalidates old tokens

OAuth#

Endpoint Method Notes
/.well-known/oauth-protected-resource GET Static metadata
/.well-known/oauth-authorization-server GET Server metadata
/oauth/par POST PAR, returns request_uri + DPoP nonce
/oauth/token POST Code exchange or refresh, returns DPoP tokens

Auth Modes#

// No auth checking — fastest for unit tests
const pds = await createFakePds({ auth: "none" });

// Bearer token auth (legacy JWT flow)
const pds = await createFakePds({ auth: "bearer" });

// OAuth with DPoP nonce tracking
const pds = await createFakePds({ auth: "oauth" });

Legacy Auth#

const pds = await createFakePds({
  accounts: [{ did: "did:plc:alice", handle: "alice.test" }],
  auth: "bearer",
});

// Login
const loginRes = await fetch(`${pds.url}/xrpc/com.atproto.server.createSession`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ identifier: "alice.test", password: "anything" }),
});
const { accessJwt } = await loginRes.json();

// Authenticated request
await fetch(`${pds.url}/xrpc/com.atproto.repo.putRecord`, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: `Bearer ${accessJwt}`,
  },
  body: JSON.stringify({ repo: "did:plc:alice", collection: "app.test", rkey: "x", record: {} }),
});

OAuth (Direct Token Issuance)#

Skip the browser redirect flow entirely:

const pds = await createFakePds({
  accounts: [{ did: "did:plc:alice", handle: "alice.test" }],
  auth: "oauth",
});

// Get tokens directly — no browser flow needed
const { accessToken, refreshToken, dpopNonce } = pds.issueOAuthTokens("did:plc:alice");

// Use with DPoP header
await fetch(`${pds.url}/xrpc/com.atproto.repo.putRecord`, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: `DPoP ${accessToken}`,
  },
  body: JSON.stringify({ repo: "did:plc:alice", collection: "app.test", rkey: "x", record: {} }),
});

API#

createFakePds(options?): Promise<FakePds>#

Create and start a fake PDS.

Option Type Default Description
port number 0 (random) Port to listen on
accounts Account[] [] Pre-registered accounts
auth "none" | "bearer" | "oauth" "none" Auth enforcement mode

FakePds#

Method Description
url Base URL of the running server
putRecord(did, collection, rkey, value) Seed a record (bypasses auth)
listRecords(did, collection) List records for test inspection
issueOAuthTokens(did) Get OAuth tokens directly
reset() Clear all records, blobs, and sessions
close() Stop the server

Account#

interface Account {
  did: string;
  handle: string;
  password?: string; // If set, createSession validates it
}

What This Doesn't Do#

  • No Merkle Search Tree or commit history
  • No record validation against lexicon schemas
  • No federation, sync, or firehose
  • No DID document hosting
  • No PKCE or DPoP signature verification
  • CIDs are fake (sha256-based, not real IPFS CIDs)

These are intentional tradeoffs. The goal is fast, reliable integration tests — not a spec-complete PDS.

License#

AGPL-3.0-or-later