// Track Tuesdays: a run club on permissioned data. // // Boots two real PDS instances — Maya the organizer on one, Sam on the other — // creates a club space, logs runs into each member's own repo, then serves a // feed assembled the way a real application would: space credential, writer // set, per-member repo pull, client-side verification. See sync.js. // // Requires the local PLC directory: docker compose up -d // Then: npm run dev:runclub import { execFile } from 'node:child_process'; import { mkdirSync, readFileSync, rmSync } from 'node:fs'; import { createServer as createHttpServer } from 'node:http'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; import { createServer } from '@pdsjs/node'; import { renderFeedPage } from './page.js'; import { getSpaceCredential, syncSpace } from './sync.js'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '../..'); const DATA = join(dirname(fileURLToPath(import.meta.url)), '.data'); const PLC_URL = process.env.PLC_URL || 'http://localhost:2582'; const APP_PORT = 2800; const CLUB_TYPE = 'club.runclub.group'; const CLUB_SKEY = 'track-tuesdays'; const CLUB_NAME = 'Track Tuesdays'; const RUN_COLLECTION = 'club.runclub.run'; const PASSWORD = 'runclub-demo'; /** @param {string} msg */ const step = (msg) => console.log(`\n▸ ${msg}`); /** * @param {string} base * @param {string} nsid * @param {any} body * @param {string} [token] */ async function xrpcPost(base, nsid, body, token) { const res = await fetch(`${base}/xrpc/${nsid}`, { method: 'POST', headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify(body), }); if (!res.ok) { throw new Error(`${nsid} failed: ${res.status} ${await res.text()}`); } return res.json(); } /** * Boot a PDS and register an account on the local PLC, the same way the * README quick start does. * @param {string} name * @param {number} port */ async function bootPds(name, port) { const dir = join(DATA, name); mkdirSync(join(dir, 'blobs'), { recursive: true }); const { listen } = await createServer({ dbPath: join(dir, 'pds.db'), blobsDir: join(dir, 'blobs'), jwtSecret: `runclub-demo-${name}`, password: PASSWORD, port, plcUrl: PLC_URL, spaces: true, }); await listen(); const base = `http://localhost:${port}`; // Async, not execFileSync: setup.js calls back into the PDS this process is // serving, so blocking the event loop here would deadlock against ourselves. await promisify(execFile)( 'node', [ join(ROOT, 'scripts/setup.js'), '--pds', base, '--plc-url', PLC_URL, '--handle', name, ], { cwd: dir }, ); const creds = JSON.parse( readFileSync(join(dir, `credentials-${name}.json`), 'utf8'), ); const session = await xrpcPost(base, 'com.atproto.server.createSession', { identifier: creds.did, password: PASSWORD, }); console.log(` ${name}: ${creds.did} on ${base}`); return { name, base, did: creds.did, jwt: session.accessJwt }; } /** * @param {{base: string, did: string, jwt: string}} account * @param {string} space * @param {number} daysAgo * @param {number} distanceMeters * @param {number} durationSec * @param {string} note */ function logRun(account, space, daysAgo, distanceMeters, durationSec, note) { const startedAt = new Date( Date.now() - daysAgo * 24 * 60 * 60 * 1000, ).toISOString(); return xrpcPost( account.base, 'com.atproto.space.createRecord', { space, repo: account.did, collection: RUN_COLLECTION, record: { $type: RUN_COLLECTION, startedAt, // Integers only: atproto records avoid floats, so distance is meters // and duration is seconds. The page derives km and pace. distanceMeters, durationSec, note, }, }, account.jwt, ); } async function main() { rmSync(DATA, { recursive: true, force: true }); try { await fetch(PLC_URL); } catch { console.error( `Cannot reach the PLC directory at ${PLC_URL}.\nStart it with: docker compose up -d`, ); process.exit(1); } step('Booting two PDS instances'); process.on('uncaughtException', (err) => { if (/** @type {NodeJS.ErrnoException} */ (err).code === 'EADDRINUSE') { console.error( `${err.message}\nA previous demo is still running \u2014 stop it with: pkill -f runclub/index.js`, ); process.exit(1); } throw err; }); const maya = await bootPds('maya', 2801); const sam = await bootPds('sam', 2802); step('Maya creates the club and adds members'); const { uri: space } = await xrpcPost( maya.base, 'com.atproto.space.createSpace', { spaceType: CLUB_TYPE, skey: CLUB_SKEY, policy: { $type: 'com.atproto.memberspace.policy' }, }, maya.jwt, ); console.log(` ${space}`); // Maya is the club's authority, which every policy admits, so only Sam is // added here. await xrpcPost( maya.base, 'com.atproto.memberspace.putMember', { space, did: sam.did, read: true, write: true }, maya.jwt, ); step("Members log this week's runs into their own repos"); await logRun(maya, space, 5, 8000, 2472, '6×800 at the track'); await logRun(maya, space, 3, 5200, 1690, 'easy shakeout'); await logRun(maya, space, 1, 12100, 3751, 'long run, negative split'); await logRun(sam, space, 5, 8000, 2704, '6×800, hung on'); await logRun(sam, space, 2, 6400, 2022, 'tempo loop'); console.log(` 3 runs on ${maya.base}, 2 runs on ${sam.base}`); step('The app trades a session for a space credential'); const held = await getSpaceCredential({ space, memberPds: maya.base, sessionJwt: maya.jwt, plcUrl: PLC_URL, }); console.log(' credential issued by the space authority'); const handles = { [maya.did]: 'maya', [sam.did]: 'sam' }; const app = createHttpServer(async (_req, res) => { try { // Sync on every request so the page always reflects the space; a real // app would cache and lean on notifications. const repos = await syncSpace({ space, held, plcUrl: PLC_URL }); const members = repos.map((r) => ({ did: r.did, handle: handles[r.did] ?? r.did, rev: r.rev, hashHex: r.hashHex, runs: r.records .filter((rec) => rec.collection === RUN_COLLECTION) .map((rec) => rec.value), })); const html = renderFeedPage({ clubName: CLUB_NAME, space, members }); res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); res.end(html); } catch (err) { const message = err instanceof Error ? err.message : String(err); // The demo's identities live only as long as the PLC directory that // registered them. If the directory behind PLC_URL is restarted or // swapped (its port is shared with other local setups), resolution // fails here even though this process is still healthy. const hint = message.includes('could not resolve') ? '\n\nThe PLC directory no longer knows this demo\u2019s identities \u2014 ' + 'it was likely restarted or replaced after the demo booted. ' + 'Re-run `npm run dev:runclub` to register fresh ones.' : ''; res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' }); res.end(message + hint); } }); app.listen(APP_PORT, () => { step(`Feed: http://localhost:${APP_PORT}`); console.log( ' assembled per request: listRepos → getRepo per member → verify → render\n', ); }); } main().catch((err) => { console.error(err); process.exit(1); });