Something went wrong. Try again.
An AT Protocol Personal Data Server written in JavaScript pdsjs.dev
pds atproto
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331#!/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}`);