A from-scratch reimplementation of bluesky-social/pds plus
bluesky-social/atproto's packages/ozone, in
TanStack Start, paired with a chapter-per-subsystem book that
explains how every piece works. The goal: someone who reads it end-to-end
can build their own PDS with bundled moderation.
Unlike Bluesky's setup — where the PDS and Ozone are separate services
deployed independently — this repo bundles both into a single Node
process. The operator stands up the PDS, creates an account whose handle
matches PDS_MOD_TEAM_HANDLE (default mod.<hostname>), and the
moderation surface is live on the same host: tools.ozone.moderation.*
XRPC at /xrpc/, a /mod web UI for moderators, and a labeler service
entry in the team-lead account's DID document. See chapter 24.
The docs site is part of the app. Run it locally and read at
http://localhost:3000/docs, or read the markdown directly in
docs/.
Implemented subsystems (each pairs with a tutorial chapter):
| Subsystem | Code | Chapter |
|---|---|---|
| CIDs + DAG-CBOR | src/pds/codec/ |
05 |
| Merkle Search Trees | src/pds/repo/mst.ts |
06 |
| Signed commits | src/pds/repo/commit.ts |
07 |
| CAR encode/decode | src/pds/car/ |
08 |
| Lexicons | src/pds/lexicon/ — runtime validator, observe-only |
09 |
| XRPC dispatcher | src/pds/xrpc/server.ts |
10 |
| Database schema | src/lib/db/schema/ |
11 |
| Account creation | src/pds/account/create.ts + DID layer |
12 |
| Sessions + auth | src/pds/auth/ — sessions, app passwords, email, password reset, lifecycle |
13 |
| Records (CRUD) | src/pds/repo/writes.ts |
14 |
| Blobs | src/pds/blob/ — upload, attachment, GC |
15 |
| Sequencer + firehose | src/pds/sequencer/ — write path + WebSocket subscribeRepos |
16 |
| Sync endpoints | src/pds/repo/sync.ts + handlers |
17 |
| Production guide | KeyWrapper, structured logging, /metrics, graceful shutdown |
18 |
| Moderation XRPC + audit | src/pds/admin/ + com.atproto.admin.* handlers |
19 |
| Admin web UI | src/routes/admin/ — handle-gated /admin |
19 |
| Account migration | self-custody PLC ops + requestAccountMigrate + importRepo |
20 |
| OAuth (front half + JWT) | src/pds/oauth/ — PAR, authorize, token, revoke, JWKS, DPoP |
21 |
| Minimal client UI | src/routes/app/ — login, feed, compose, image upload |
22 |
| Backups | pds:export / pds:import CLIs |
23 |
| Ozone-shaped moderation | src/pds/mod/ + tools.ozone.moderation.* + src/routes/mod/ |
24 |
Implemented XRPC endpoints (137 + 2 WebSocket subscriptions):
| Namespace | Endpoints |
|---|---|
com.atproto.server.* (account) |
createAccount, createSession, refreshSession, deleteSession, getSession, describeServer, checkAccountStatus, deactivateAccount, activateAccount, requestAccountDelete, deleteAccount |
com.atproto.server.* (app pw) |
createAppPassword, listAppPasswords, revokeAppPassword |
com.atproto.server.* (email) |
requestEmailConfirmation, confirmEmail, requestEmailUpdate, updateEmail, requestPasswordReset, resetPassword |
com.atproto.server.* (invites) |
createInviteCode, createInviteCodes, getAccountInviteCodes, checkSignupQueue |
com.atproto.server.* (migration) |
getServiceAuth, reserveSigningKey, requestAccountMigrate |
com.atproto.identity.* |
resolveHandle, updateHandle, requestPlcOperationSignature, signPlcOperation |
com.atproto.repo.* |
createRecord, putRecord, deleteRecord, getRecord, listRecords, applyWrites, describeRepo, uploadBlob, importRepo |
com.atproto.sync.* (HTTP) |
getRepo, getBlocks, getRecord, getLatestCommit, getRepoStatus, listRepos, getBlob, listMissingBlobs |
com.atproto.sync.* (WS) |
subscribeRepos |
com.atproto.admin.* |
getAccountInfo, getAccountInfos, updateAccountStatus, updateSubjectStatus, getSubjectStatus, updateAccountHandle, updateAccountEmail, updateAccountPassword, sendEmail, deleteAccount, disableAccountInvites, enableAccountInvites, disableInviteCodes, getInviteCodes, getAuditLog |
com.atproto.moderation.* |
createReport |
com.atproto.label.* |
queryLabels, subscribeLabels (WebSocket; signed labels from the bundled labeler) |
com.atproto.temp.* |
fetchLabels (deprecated upstream; retained for older consumers) |
tools.ozone.moderation.* |
emitEvent (16 event types), queryEvents, queryStatuses, getEvent, getRepo, getRecord, getRepos, getRecords, getSubjects, getAccountTimeline, getReporterStats, searchRepos, scheduleAction, listScheduledActions, cancelScheduledActions |
tools.ozone.team.* |
listMembers, addMember, updateMember, deleteMember |
tools.ozone.setting.* |
upsertOption, listOptions, removeOptions |
tools.ozone.set.* |
upsertSet, deleteSet, querySets, getValues, addValues, deleteValues |
tools.ozone.communication.* |
createTemplate, updateTemplate, deleteTemplate, listTemplates |
tools.ozone.verification.* |
grantVerifications, revokeVerifications, listVerifications |
tools.ozone.signature.* |
searchAccounts, findRelatedAccounts, findCorrelation |
tools.ozone.safelink.* |
addRule, updateRule, removeRule, queryRules, queryEvents |
tools.ozone.queue.* |
createQueue, listQueues, updateQueue, deleteQueue, assignModerator, unassignModerator, getAssignments, routeReports |
tools.ozone.report.* |
queryReports, getReport, getLatestReport, listActivities, createActivity, assignModerator, unassignModerator, reassignQueue, getAssignments, getLiveStats, getHistoricalStats, refreshStats |
tools.ozone.server.* |
getConfig |
| OAuth routes | /oauth/par, /oauth/authorize, /oauth/token, /oauth/revoke, /oauth/jwks |
/.well-known/* |
did.json, oauth-authorization-server (RFC 8414), oauth-protected-resource (RFC 9728) |
| Operations | /metrics (Prometheus), /admin (operator UI), /mod (moderator UI), /app (in-tree client), /internal/tls-check (Caddy on-demand-TLS ask gate) |
The PDS supports the full single-user flow a Bluesky client would put it through, plus the operator surface for moderation and migration work.
- ✅ Foundation (app shell, docs UI, markdown pipeline, DB layer)
- ✅ Account creation end-to-end with did:plc (local-only in dev)
- ✅ Session lifecycle + identity + server discovery
- ✅ App passwords + email confirmation + password reset
- ✅ Full account lifecycle (deactivate/activate/delete with tombstone)
- ✅ Invite-code gate (on by default; opt out with
PDS_INVITE_REQUIRED=false) - ✅ Identity rotation (
updateHandlevia PLC chain) - ✅ Full Merkle Search Tree + commits + CAR
- ✅ Records CRUD with MST commits + blob attachment tracking + GC
- ✅ Blob storage (filesystem dev, S3 stub)
- ✅ Sequencer + WebSocket firehose (subscribeRepos)
- ✅ Sync endpoints for federation
- ✅ Lexicon runtime validator (observe-only by default)
- ✅ Admin / moderation XRPC surface (HTTP Basic, env-var hash) + DAG-CBOR audit log
- ✅ Admin web UI at
/admin(handle-gated viaPDS_ADMIN_HANDLE) - ✅ Account migration (self-custody PLC ops,
requestAccountMigrate,importRepo) - ✅ OAuth front half + JWT issuance (PAR, PKCE, DPoP with pluggable replay store, JWKS)
- ✅ Minimal client UI at
/app(login, feed, compose, image upload) - ✅ Production ergonomics:
KeyWrapperfor at-rest signing keys, structured logger,/metrics, graceful shutdown - ✅ Backups (
pnpm pds:export/pds:import) + benchmarking (pds-bench,pds-stress) - ✅ Bundled Ozone-shaped moderation: full
tools.ozone.*XRPC surface (moderation + team + setting + set + communication + verification + signature + safelink + queue + report + server — 54 endpoints),com.atproto.label.queryLabels+subscribeLabels(WebSocket),/modoperator UI, labeler DID-document service entry +app.bsky.labeler.serviceself-record auto-bootstrapped on team-lead signup, takedown enforcement onrepo.getRecord/listRecords/sync.getBlob/getRecord/getRepo/getBlocks, 10 supported event types (incl. mute / divert / email with template support), per-report auto-resolution on closing events, operator-defined moderation queues with auto-routing - ✅ Read-after-write for proxied
app.bsky.*reads: PDS reads the AppView'satproto-repo-revresponse header, queries local records newer than that rev, and runs a per-NSID munge so freshly-written records appear in the response without waiting for AppView indexing (chapter 17).getAuthorFeedmunge ships; infrastructure ready for the remaining endpoints (seesrc/pds/read_after_write/README.md)
Requirements:
- Node ≥ 20
- pnpm (
npm i -g pnpm)
pnpm install
cp .env.example .env # set PDS_JWT_SECRET to 64 random hex chars
pnpm db:migrate # apply migrations to in-process PGlite
pnpm dev # docs site + XRPC at http://localhost:3000What's at http://localhost:3000:
/— live stats dashboard for this PDS (accounts, records, blobs, firehose seq)/docs— the chapter book that pairs with the code/app— minimal in-tree client (login, feed, compose, image upload)/admin— operator console (gated byPDS_ADMIN_HANDLE)/mod— moderator console (gated by membership inmod_team; admin Basic also unlocks)/metrics— Prometheus exposition (gated byPDS_METRICS=true)/xrpc/*— the lexicon-defined HTTP surface/.well-known/did.json— this PDS's identity document
End-to-end smoke test in another shell:
scripts/demo.shThat registers a fresh account, logs in, posts, reads back, refreshes,
and logs out. Or do it by hand with curl:
curl -i -X POST http://localhost:3000/xrpc/com.atproto.server.createAccount \
-H 'content-type: application/json' \
-d '{"handle":"alice.test","email":"alice@example.com","password":"correcthorsebatterystaple","inviteCode":"..."}'pnpm admin:hash 'your-admin-password' # → scrypt hash for PDS_ADMIN_PASSWORD_HASH
pnpm pds-admin createInviteCode --uses 1 # mint a code (XRPC admin surface)
pnpm pds:export ./snapshot.car # CAR-backed backup
pnpm pds:import ./snapshot.car # restore
pnpm bench # micro-benchmark the write path
pnpm stress # concurrent-write stress harnessFor interactive operator work, set PDS_ADMIN_HANDLE to an account
handle and visit /admin — the operator logs in with that account's
password and gets a dashboard for signups and invite codes. The XRPC
admin surface (com.atproto.admin.*) stays HTTP-Basic-gated for
automation; both paths share the audit log (chapter 19).
For moderation, set PDS_MOD_TEAM_HANDLE (default mod.<hostname>)
and create that account through the normal signup flow. Its DID
auto-seeds into mod_team as the lead and the account's DID document
grows an #atproto_labeler service entry. Additional moderators can
be added by hand (INSERT INTO mod_team for now; a UI is on the
list). Visit /mod to see the reports queue, drive takedowns, and
issue labels. Chapter 24 covers the full shape.
- Dev:
@electric-sql/pglite— Postgres compiled to WASM, runs in the same process. Zero external services. - Prod: any Postgres-compatible URL. Same Drizzle schema, same migrations.
Switch by setting DATABASE_URL:
DATABASE_URL=pglite # default, ./.pglite/
DATABASE_URL=pglite:./var/pds-data # custom directory
DATABASE_URL=postgres://user:pw@host:5432/db # externalThe migration runner at src/lib/db/migrate.ts applies SQL files from
drizzle/ in order, tracked by a __migrations journal table. No
drizzle-kit runtime dependency.
pds/
├── docs/ # tutorial chapters (00–23 + README index)
├── scripts/
│ ├── demo.sh # end-to-end smoke test
│ ├── admin-hash.ts # scrypt password hasher
│ ├── pds-admin.ts # CLI against the XRPC admin surface
│ ├── pds-export.ts # CAR backup
│ ├── pds-import.ts # restore from CAR
│ ├── pds-bench.ts # micro-benchmark harness
│ └── pds-stress.ts # concurrent-write stress test
├── drizzle/ # 0000_init … 0022_mod_scheduled_actions (23 migrations)
├── src/
│ ├── routes/ # TanStack Start routes
│ │ ├── index.tsx # live stats dashboard
│ │ ├── docs/ # the chapter book
│ │ ├── app/ # minimal client UI (login, feed, compose)
│ │ ├── admin/ # handle-gated operator console
│ │ ├── mod/ # moderator console (Ozone-shaped)
│ │ ├── oauth/ # par, authorize, token, revoke, jwks
│ │ ├── xrpc/ # lexicon-defined HTTP surface
│ │ ├── .well-known/ # did.json + OAuth metadata
│ │ └── metrics.ts # Prometheus exposition
│ ├── pds/ # the PDS itself
│ │ ├── codec/ # CIDs & DAG-CBOR
│ │ ├── repo/ # MST + commits + writes + sync
│ │ ├── car/ # CAR v1 encode/decode
│ │ ├── did/ # identity (PLC, web, handle, resolver)
│ │ ├── lexicon/ # schema layer + runtime validator
│ │ ├── xrpc/ # dispatcher + per-NSID handlers + registry
│ │ ├── auth/ # JWTs, scrypt, sessions, middleware
│ │ ├── blob/ # blob storage (filesystem + S3 stub) + GC
│ │ ├── sequencer/ # firehose event log writer
│ │ ├── account/ # createAccount orchestrator + invites
│ │ ├── admin/ # admin audit log (DAG-CBOR params)
│ │ ├── mod/ # moderation surface (team, events, auth)
│ │ └── oauth/ # PAR, PKCE, DPoP, tokens, JWKS, metadata
│ ├── lib/
│ │ ├── db/ # schema barrel + factory + migrate runner
│ │ ├── admin-ui/ # shared helpers for /admin (auth, csrf, render)
│ │ ├── mod-ui/ # shared helpers for /mod (auth, csrf, render)
│ │ ├── client/ # client-side bits shared by /app
│ │ ├── config.ts # env loader (PDS_PUBLIC_URL, ...)
│ │ ├── docs.ts # markdown → HTML pipeline
│ │ ├── logger.ts # structured logger (chapter 18)
│ │ ├── metrics.ts # Prometheus collectors
│ │ ├── shutdown.ts # graceful-shutdown coordinator
│ │ ├── stats.ts # homepage/dashboard types + formatters
│ │ └── stats.server.ts # ↳ DB-touching half
│ ├── components/ # React (docs UI + shared atoms)
│ └── styles/
Each src/pds/<subsystem>/README.md points at the chapter that motivates
the subsystem and notes the contract surface.
- A drop-in production PDS. Most of the operational pieces ship (KeyWrapper
for at-rest signing keys, structured logging,
/metrics, graceful shutdown, backups). Read chapter 18 for the swap matrix that gets you the rest: managed Postgres, S3 blob backend, real PLC publishing, TLS termination, email provider. - A faithful copy of every Bluesky lexicon. We bundle the ones we validate against; everything else is stored opaquely.
- A relay or an AppView. Those are separate services; chapter 17 explains the split.
MIT — use it as a study aid, vendor pieces into your own PDS, fork it, whatever. Attribution appreciated but not required.