#!/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}`);