chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
#!/usr/bin/env node
/**
* Local-dev seed: register a mock user in the local PLC, initialize the running
* PDS with it, and write records so the account UI has something to show —
* collections whose lexicon is published via the protocol (a check on the
* Records list) and one that isn't, plus a year of backdated activity across a
* handful of apps so the Apps page's activity sparklines have real ridges to
* draw. It also seeds a few mock OAuth sessions so the Apps page shows the
* "N sessions" badge and per-app session list. Meant to run against the dev
* stack the Justfile brings up; see `just dev`.
*
* Records are backdated by minting their own TID rkeys: an rkey encodes the
* record's creation time, and that is what the sparkline buckets, so writing a
* record with a past TID places it in the past without any clock trickery.
*
* Usage: node scripts/dev-seed.mjs [handle]
* Env: PDS_DEV_URL (default http://localhost:2471)
* PDS_DEV_PASSWORD (default test-password)
* PDS_DEV_PDS_URL (public PDS URL recorded in PLC, default https://host.docker.internal:3443)
* PDS_DEV_DB_PATH (SQLite file to seed OAuth sessions into, default .dev-pds/pds.db)
*/
import { randomBytes } from 'node:crypto';
import Database from 'better-sqlite3';
import { createTestIdentity } from '../test/helpers/identity.js';
const BASE = process.env.PDS_DEV_URL || 'http://localhost:2471';
const PASSWORD = process.env.PDS_DEV_PASSWORD || 'test-password';
const PDS_URL =
process.env.PDS_DEV_PDS_URL || 'https://host.docker.internal:3443';
const DB_PATH =
process.env.PDS_DEV_DB_PATH || process.env.PDS_DB_PATH || '.dev-pds/pds.db';
const HANDLE = process.argv[2] || 'alice';
const DAY_MS = 86_400_000;
const jsonPost = (path, body, headers = {}) =>
fetch(`${BASE}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify(body),
});
// --- TID minting -----------------------------------------------------------
// The inverse of the timestamp half of the PDS's own createTid: pack a
// millisecond time (as microseconds) into the 11 high chars, and a per-record
// sequence into the 2 clock chars, so every rkey is unique and sorts by time.
const TID_CHARS = '234567abcdefghijklmnopqrstuvwxyz';
let tidSeq = 0;
function tidForMs(ms) {
const seq = tidSeq++;
// Floor to whole microseconds: a fractional value would make `micros % 32` a
// fractional index and TID_CHARS[i] undefined, silently dropping a character.
let micros = Math.floor(ms) * 1000 + (seq % 1000);
const chars = new Array(11);
for (let i = 10; i >= 0; i--) {
chars[i] = TID_CHARS[micros % 32];
micros = Math.floor(micros / 32);
}
const clock = seq % 1024;
return `${chars.join('')}${TID_CHARS[(clock >> 5) & 31]}${TID_CHARS[clock & 31]}`;
}
// --- activity distribution -------------------------------------------------
// Scatter `count` timestamps over the past year, clustered into a few bursts so
// the sparkline reads as bursty real use rather than uniform noise.
function gaussian() {
let u = 0;
let v = 0;
while (u === 0) u = Math.random();
while (v === 0) v = Math.random();
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
}
function scatter(count, { bursts = 4, spreadDays = 12, now }) {
const centers = Array.from({ length: bursts }, () => Math.random() * 350);
const times = [];
for (let i = 0; i < count; i++) {
const center = centers[Math.floor(Math.random() * centers.length)];
const day = Math.max(0, Math.min(363, center + gaussian() * spreadDays));
const ms = now - day * DAY_MS - Math.floor(Math.random() * DAY_MS);
times.push(Math.floor(ms));
}
return times;
}
// 1. Register a real did:plc in the local PLC, pointing at the PDS behind Caddy.
const identity = await createTestIdentity({ pdsUrl: PDS_URL, handle: HANDLE });
console.log(`Registered in local PLC: ${identity.did} (${identity.handle})`);
// 2. Initialize the PDS with that identity. A server started with a password
// requires it here, and the dev stack starts with one.
const initRes = await jsonPost(`/init?did=${identity.did}`, {
did: identity.did,
privateKey: identity.privateKeyHex,
handle: identity.handle,
password: PASSWORD,
});
if (!initRes.ok) {
throw new Error(`init failed: ${initRes.status} ${await initRes.text()}`);
}
// 3. Open an atproto session to write records.
const sessRes = await jsonPost('/xrpc/com.atproto.server.createSession', {
identifier: identity.did,
password: PASSWORD,
});
const { accessJwt } = await sessRes.json();
if (!accessJwt) throw new Error('createSession returned no accessJwt');
const auth = { Authorization: `Bearer ${accessJwt}` };
// 4. Profile, and two records that show the Records list's published-lexicon
// check: app.bsky.* resolves via the protocol, com.example.* does not.
const now = Date.now();
const nowIso = new Date(now).toISOString();
// Backdate the unpublished-lexicon note into the past year like the activity
// below, so it doesn't read as the account's single most recent write.
const noteMs = scatter(1, { now })[0];
const writes = [
{
collection: 'app.bsky.actor.profile',
rkey: 'self',
value: {
displayName: `${HANDLE} (local dev)`,
description: 'mock user',
createdAt: nowIso,
},
},
{
collection: 'com.example.note',
rkey: tidForMs(noteMs),
value: {
text: 'a record whose lexicon is not published',
createdAt: new Date(noteMs).toISOString(),
},
},
];
// 5. A year of backdated activity across several apps, so every one of these
// rows gets an activity sparkline on the Apps page. Each collection folds
// into an app by its authority domain (app.bsky.* -> bsky.app, and so on).
const iso = (ms) => new Date(ms).toISOString();
const backfill = [
{
collection: 'app.bsky.feed.post',
count: 80,
bursts: 6,
value: (ms, i) => ({ text: `dev post #${i}`, createdAt: iso(ms) }),
},
{
collection: 'app.bsky.feed.like',
count: 150,
bursts: 5,
value: (ms) => ({
subject: { uri: `at://did:plc:dev/app.bsky.feed.post/x`, cid: 'bafydev' },
createdAt: iso(ms),
}),
},
{
collection: 'app.bsky.graph.follow',
count: 55,
bursts: 4,
value: (ms) => ({ subject: 'did:plc:someoneelse', createdAt: iso(ms) }),
},
{
collection: 'social.grain.photo',
count: 110,
bursts: 5,
value: (ms, i) => ({ alt: `photo ${i}`, createdAt: iso(ms) }),
},
{
collection: 'fm.teal.alpha.feed.play',
count: 70,
bursts: 4,
value: (ms, i) => ({ trackName: `track ${i}`, playedTime: iso(ms) }),
},
{
collection: 'is.currents.note',
count: 12,
bursts: 2,
value: (ms, i) => ({ text: `note ${i}`, createdAt: iso(ms) }),
},
{
collection: 'sh.tangled.feed.star',
count: 40,
bursts: 3,
value: (ms) => ({
subject: 'at://did:plc:dev/sh.tangled.repo/x',
createdAt: iso(ms),
}),
},
];
for (const { collection, count, bursts, value } of backfill) {
const times = scatter(count, { bursts, now });
for (let i = 0; i < times.length; i++) {
writes.push({
collection,
rkey: tidForMs(times[i]),
value: value(times[i], i),
});
}
}
// 6. Write everything in batches, one commit per batch, skipping lexicon
// validation so the placeholder shapes above are accepted as-is.
const toWrite = writes.map((w) => ({
$type: 'com.atproto.repo.applyWrites#create',
collection: w.collection,
rkey: w.rkey,
value: { $type: w.collection, ...w.value },
}));
const BATCH = 100;
for (let i = 0; i < toWrite.length; i += BATCH) {
const chunk = toWrite.slice(i, i + BATCH);
const res = await jsonPost(
'/xrpc/com.atproto.repo.applyWrites',
{ repo: identity.did, validate: false, writes: chunk },
auth,
);
if (!res.ok) {
throw new Error(`applyWrites failed: ${res.status} ${await res.text()}`);
}
}
const counts = writes.reduce((acc, w) => {
acc[w.collection] = (acc[w.collection] || 0) + 1;
return acc;
}, {});
console.log(`Seeded ${writes.length} records across a year:`);
for (const [collection, n] of Object.entries(counts)) {
console.log(` ${collection}: ${n}`);
}
// 7. Mock OAuth sessions, written straight to shared storage the way the token
// endpoint does. The Apps page reads these back as the "N sessions" badge and
// each app's session list. A real authorize flow would need every client's
// metadata document to resolve, which local dev can't reach, so the
// refresh-token rows go in directly. Client-id hosts line up with the record
// authorities above, so the sessions land on those same app rows — bsky.app
// gets two, to show the plural badge. The last is a records-free app with a
// deliberately long name, to exercise the row title's truncation.
const oauthSessions = [
{
clientId: 'https://bsky.app/oauth/client-metadata.json',
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36',
authorizedDaysAgo: 46,
activeDaysAgo: 0.02,
},
{
clientId: 'https://bsky.app/oauth/client-metadata.json',
userAgent:
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1',
authorizedDaysAgo: 12,
activeDaysAgo: 1.5,
},
{
clientId: 'https://grain.social/oauth/client-metadata.json',
userAgent:
'Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0',
authorizedDaysAgo: 30,
activeDaysAgo: 6,
},
{
clientId: 'https://teal.fm/oauth/client-metadata.json',
userAgent: 'teal.fm/1.4 (+https://teal.fm)',
authorizedDaysAgo: 64,
activeDaysAgo: 20,
},
{
// A client hosted on a subdomain of the authority its records are named
// by. The combine offer folds this sign-in into the currents.is row.
clientId: 'https://api.currents.is/oauth/client-metadata.json',
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36',
scope: 'atproto repo:is.currents.note blob:image/*',
authorizedDaysAgo: 25,
activeDaysAgo: 0.8,
},
{
// Signs in from the rebranded domain while its records above keep the
// sh.tangled.* authority — the split the Apps page offers to combine.
// Granular scopes, as the real tangled grants, so the offer's scope
// check has something to verify.
clientId: 'https://tangled.org/oauth/client-metadata.json',
userAgent: 'tangled-cli/0.3 (+https://tangled.org)',
scope:
'atproto repo:sh.tangled.feed.star?action=create repo:sh.tangled.repo rpc:sh.tangled.ci.triggerPipeline?aud=*',
authorizedDaysAgo: 70,
activeDaysAgo: 21,
},
{
clientId:
'https://really-long-application-handle.example-services.social/oauth/client-metadata.json',
userAgent:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36',
authorizedDaysAgo: 3,
activeDaysAgo: 0.3,
},
];
// Written the way the token endpoint stores a refresh token: the token id is
// the row key, the did its own column, and the rest a JSON blob the Apps page
// reads back for the badge and the per-session list. The table is already
// there — the running PDS created it on startup.
const db = new Database(DB_PATH);
db.pragma('busy_timeout = 5000');
const insertToken = db.prepare(
'INSERT OR REPLACE INTO oauth_tokens (token_id, did, data) VALUES (?, ?, ?)',
);
for (const s of oauthSessions) {
insertToken.run(
randomBytes(32).toString('base64url'),
identity.did,
JSON.stringify({
did: identity.did,
clientId: s.clientId,
scope: s.scope || 'atproto transition:generic',
dpopJkt: randomBytes(32).toString('base64url'),
createdAt: now - s.authorizedDaysAgo * DAY_MS,
updatedAt: now - s.activeDaysAgo * DAY_MS,
userAgent: s.userAgent,
}),
);
}
db.close();
console.log(`Seeded ${oauthSessions.length} mock OAuth sessions.`);
console.log(`\nSigned-in credentials for ${BASE}/account`);
console.log(` identifier: ${identity.did}`);
console.log(` password: ${PASSWORD}`);