~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
330 lines · 12.0 KB Raw History
#!/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}`);