/** * rdf:link-cards * * Create network.cosmik.collectionLink records linking all cards * to their parent collection on the PDS. * * Uses Rookery DPoP auth (Welcome Mat v1.0) with the researcher's * existing RSA key at /home/nandi/vault/.credentials/researcher-pds-latha-org.pem * * Usage: * bun rdf:link-cards # dry run (prints plan) * bun rdf:link-cards --write # write to PDS */ import crypto from 'node:crypto'; import { readFile } from 'node:fs/promises'; import { loadDotEnv } from './cli-utils.js'; const __dirname = new URL('.', import.meta.url).pathname; await loadDotEnv(new URL('../.env', import.meta.url).pathname); await loadDotEnv('/home/nandi/code/swarm/.env'); const PDS_ORIGIN = 'https://pds.latha.org'; const PRIVATE_KEY_PATH = '/home/nandi/vault/.credentials/researcher-pds-latha-org.pem'; // --- DPoP / Welcome Mat helpers --- function base64url(input: Buffer | Uint8Array): string { return Buffer.from(input).toString('base64url'); } function pemToJwk(pem: string): { kty: string; n: string; e: string } { const key = crypto.createPublicKey(pem); const jwk = key.export({ format: 'jwk' }); return { kty: jwk.kty as string, n: jwk.n as string, e: jwk.e as string }; } function computeThumbprint(jwk: { kty: string; n: string; e: string }): string { return base64url( crypto.createHash('sha256') .update(JSON.stringify({ e: jwk.e, kty: 'RSA', n: jwk.n })) .digest(), ); } function createJwt(header: object, payload: object, privateKeyPem: string): string { const encode = (obj: object) => base64url(Buffer.from(JSON.stringify(obj))); const signingInput = `${encode(header)}.${encode(payload)}`; const sig = crypto.createSign('SHA256'); sig.update(signingInput); return `${signingInput}.${base64url(sig.sign(privateKeyPem))}`; } function createDpopProof( method: string, url: string, accessToken: string, publicJwk: { kty: string; n: string; e: string }, privateKeyPem: string, ): string { const ath = base64url(crypto.createHash('sha256').update(accessToken).digest()); return createJwt( { typ: 'dpop+jwt', alg: 'RS256', jwk: publicJwk }, { jti: crypto.randomUUID(), htm: method, htu: url, iat: Math.floor(Date.now() / 1000), ath, }, privateKeyPem, ); } async function createRookerySession(privateKeyPem: string, publicJwk: { kty: string; n: string; e: string }, thumbprint: string): Promise<{ did: string; handle: string; wmJwt: string }> { // Fetch ToS and build wm+jwt access token — this IS the API token for Rookery const tosRes = await fetch(`${PDS_ORIGIN}/tos`); if (!tosRes.ok) throw new Error(`Failed to fetch ToS: ${tosRes.status}`); const tosText = await tosRes.text(); const tosHash = base64url(crypto.createHash('sha256').update(tosText).digest()); const wmJwt = createJwt( { typ: 'wm+jwt', alg: 'RS256' }, { tos_hash: tosHash, aud: PDS_ORIGIN, cnf: { jkt: thumbprint }, iat: Math.floor(Date.now() / 1000), }, privateKeyPem, ); // Create session to get DID/handle const sessionUrl = `${PDS_ORIGIN}/xrpc/com.atproto.server.createSession`; const dpop = createDpopProof('POST', sessionUrl, wmJwt, publicJwk, privateKeyPem); const res = await fetch(sessionUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `DPoP ${wmJwt}`, 'DPoP': dpop, }, body: JSON.stringify({}), }); if (!res.ok) { const body = await res.text(); throw new Error(`createSession failed (${res.status}): ${body}`); } const data = await res.json() as { did: string; handle: string; accessJwt: string }; if (!data.did) throw new Error('createSession response missing did'); return { did: data.did, handle: data.handle, wmJwt }; } // --- Authenticated fetch wrapper for DPoP --- async function dpopFetch( url: string, options: RequestInit, accessToken: string, publicJwk: { kty: string; n: string; e: string }, privateKeyPem: string, ): Promise { const method = options.method ?? 'GET'; const dpop = createDpopProof(method, url, accessToken, publicJwk, privateKeyPem); const headers = new Headers(options.headers as Record); headers.set('Authorization', `DPoP ${accessToken}`); headers.set('DPoP', dpop); return fetch(url, { ...options, headers }); } // --- Types --- interface CardRecord { uri: string; cid: string; value: { type: string; content?: { url?: string }; }; } interface CollectionRecord { uri: string; cid: string; value: { name: string; accessType: string; }; } interface LinkToWrite { collection: { uri: string; cid: string }; card: { uri: string; cid: string }; addedBy: string; } // --- Fetch records via raw DPoP-authenticated fetch --- async function fetchRecords( did: string, collection: string, accessToken: string, publicJwk: { kty: string; n: string; e: string }, privateKeyPem: string, ): Promise<{ uri: string; cid: string; value: Record }[]> { const records: { uri: string; cid: string; value: Record }[] = []; let cursor: string | undefined; do { const url = `${PDS_ORIGIN}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${collection}&limit=100${cursor ? `&cursor=${cursor}` : ''}`; const res = await dpopFetch(url, { method: 'GET' }, accessToken, publicJwk, privateKeyPem); if (!res.ok) { const body = await res.text(); throw new Error(`listRecords(${collection}) failed (${res.status}): ${body}`); } const data = await res.json() as { records: { uri: string; cid: string; value: Record }[]; cursor?: string }; records.push(...data.records); cursor = data.cursor; } while (cursor); return records; } // --- Write links via raw fetch (DPoP-authenticated) --- async function writeLinks( links: LinkToWrite[], did: string, accessToken: string, publicJwk: { kty: string; n: string; e: string }, privateKeyPem: string, ): Promise<{ wrote: number; uris: string[] }> { let wrote = 0; const uris: string[] = []; for (let i = 0; i < links.length; i += 10) { const batch = links.slice(i, i + 10); const results = await Promise.allSettled( batch.map(async (link) => { const record = { $type: 'network.cosmik.collectionLink', collection: { uri: link.collection.uri, cid: link.collection.cid, }, card: { uri: link.card.uri, cid: link.card.cid, }, addedBy: link.addedBy, addedAt: new Date().toISOString(), createdAt: new Date().toISOString(), }; const url = `${PDS_ORIGIN}/xrpc/com.atproto.repo.createRecord`; const res = await dpopFetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ repo: did, collection: 'network.cosmik.collectionLink', record }), }, accessToken, publicJwk, privateKeyPem); if (!res.ok) { const body = await res.text(); throw new Error(`createRecord failed (${res.status}): ${body}`); } const data = await res.json() as { uri: string }; return data.uri; }), ); for (const result of results) { if (result.status === 'fulfilled') { wrote++; uris.push(result.value); } else { console.error(` Failed: ${result.reason}`); } } if (i + 10 < links.length) { await new Promise(r => setTimeout(r, 500)); } } return { wrote, uris }; } // --- Main --- async function main(): Promise { const args = process.argv.slice(2); const shouldWrite = args.includes('--write'); console.log('rdf:link-cards'); console.log(`PDS: ${PDS_ORIGIN}`); console.log(`Mode: ${shouldWrite ? 'WRITE' : 'DRY RUN (use --write to write)'}`); console.log(); // Load private key console.log('Loading researcher private key...'); const privateKeyPem = await readFile(PRIVATE_KEY_PATH, 'utf-8'); const publicJwk = pemToJwk(privateKeyPem); const thumbprint = computeThumbprint(publicJwk); console.log(` Thumbprint: ${thumbprint}`); // Create session console.log('Creating Rookery session...'); const session = await createRookerySession(privateKeyPem, publicJwk, thumbprint); console.log(` DID: ${session.did}`); console.log(` Handle: ${session.handle}`); console.log(); const did = session.did; // Use wm+jwt as the API token for all Rookery calls (not the session accessJwt) const apiToken = session.wmJwt; // Fetch existing records console.log('Fetching cards...'); const cardRecords = await fetchRecords(did, 'network.cosmik.card', apiToken, publicJwk, privateKeyPem); console.log(` Found ${cardRecords.length} cards`); console.log('Fetching collections...'); const collectionRecords = await fetchRecords(did, 'network.cosmik.collection', apiToken, publicJwk, privateKeyPem); console.log(` Found ${collectionRecords.length} collections`); console.log('Fetching existing collectionLinks...'); const existingLinks = await fetchRecords(did, 'network.cosmik.collectionLink', apiToken, publicJwk, privateKeyPem); const existingCardUris = new Set(existingLinks.map(r => (r.value as { card?: { uri: string } }).card?.uri).filter(Boolean)); console.log(` Found ${existingLinks.length} existing links`); console.log(); if (collectionRecords.length === 0) { console.log('No collections found. Nothing to link.'); return; } if (collectionRecords.length > 1) { console.log(`Warning: ${collectionRecords.length} collections found. Linking all cards to the first collection only.`); } // Build links: each card -> first collection const collection = collectionRecords[0]; const collName = (collection.value as { name: string }).name; const links: LinkToWrite[] = []; for (const card of cardRecords) { if (existingCardUris.has(card.uri)) { continue; } links.push({ collection: { uri: collection.uri, cid: collection.cid }, card: { uri: card.uri, cid: card.cid }, addedBy: did, }); } console.log(`Links to create: ${links.length} (${cardRecords.length} cards - ${existingCardUris.size} already linked)`); console.log(` Collection: ${collName}`); console.log(); if (links.length === 0) { console.log('All cards already linked. Nothing to do.'); return; } if (!shouldWrite) { console.log('Dry run — would create these collectionLink records:'); for (const link of links.slice(0, 5)) { const cardVal = cardRecords.find(c => c.uri === link.card.uri)?.value as { content?: { url?: string } } | undefined; const cardUrl = cardVal?.content?.url ?? '(no URL)'; console.log(` ${cardUrl} → ${collName}`); } if (links.length > 5) { console.log(` ... and ${links.length - 5} more`); } console.log(); console.log('Run with --write to create them.'); return; } console.log('Writing collectionLink records (DPoP-authenticated)...'); const { wrote, uris } = await writeLinks(links, did, apiToken, publicJwk, privateKeyPem); console.log(` Created: ${wrote}/${links.length}`); console.log(); if (wrote > 0) { console.log('Sample URIs:'); for (const uri of uris.slice(0, 3)) { console.log(` ${uri}`); } } } main().catch((err) => { console.error('Error:', err.message ?? err); process.exit(1); });