This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

stigmergic / src / rdf-link-cards.ts
12 kB 364 lines
1/** 2 * rdf:link-cards 3 * 4 * Create network.cosmik.collectionLink records linking all cards 5 * to their parent collection on the PDS. 6 * 7 * Uses Rookery DPoP auth (Welcome Mat v1.0) with the researcher's 8 * existing RSA key at /home/nandi/vault/.credentials/researcher-pds-latha-org.pem 9 * 10 * Usage: 11 * bun rdf:link-cards # dry run (prints plan) 12 * bun rdf:link-cards --write # write to PDS 13 */ 14 15import crypto from 'node:crypto'; 16import { readFile } from 'node:fs/promises'; 17import { loadDotEnv } from './cli-utils.js'; 18 19const __dirname = new URL('.', import.meta.url).pathname; 20await loadDotEnv(new URL('../.env', import.meta.url).pathname); 21await loadDotEnv('/home/nandi/code/swarm/.env'); 22 23const PDS_ORIGIN = 'https://pds.latha.org'; 24const PRIVATE_KEY_PATH = '/home/nandi/vault/.credentials/researcher-pds-latha-org.pem'; 25 26// --- DPoP / Welcome Mat helpers --- 27 28function base64url(input: Buffer | Uint8Array): string { 29 return Buffer.from(input).toString('base64url'); 30} 31 32function pemToJwk(pem: string): { kty: string; n: string; e: string } { 33 const key = crypto.createPublicKey(pem); 34 const jwk = key.export({ format: 'jwk' }); 35 return { kty: jwk.kty as string, n: jwk.n as string, e: jwk.e as string }; 36} 37 38function computeThumbprint(jwk: { kty: string; n: string; e: string }): string { 39 return base64url( 40 crypto.createHash('sha256') 41 .update(JSON.stringify({ e: jwk.e, kty: 'RSA', n: jwk.n })) 42 .digest(), 43 ); 44} 45 46function createJwt(header: object, payload: object, privateKeyPem: string): string { 47 const encode = (obj: object) => base64url(Buffer.from(JSON.stringify(obj))); 48 const signingInput = `${encode(header)}.${encode(payload)}`; 49 const sig = crypto.createSign('SHA256'); 50 sig.update(signingInput); 51 return `${signingInput}.${base64url(sig.sign(privateKeyPem))}`; 52} 53 54function createDpopProof( 55 method: string, 56 url: string, 57 accessToken: string, 58 publicJwk: { kty: string; n: string; e: string }, 59 privateKeyPem: string, 60): string { 61 const ath = base64url(crypto.createHash('sha256').update(accessToken).digest()); 62 return createJwt( 63 { typ: 'dpop+jwt', alg: 'RS256', jwk: publicJwk }, 64 { 65 jti: crypto.randomUUID(), 66 htm: method, 67 htu: url, 68 iat: Math.floor(Date.now() / 1000), 69 ath, 70 }, 71 privateKeyPem, 72 ); 73} 74 75async function createRookerySession(privateKeyPem: string, publicJwk: { kty: string; n: string; e: string }, thumbprint: string): Promise<{ did: string; handle: string; wmJwt: string }> { 76 // Fetch ToS and build wm+jwt access token — this IS the API token for Rookery 77 const tosRes = await fetch(`${PDS_ORIGIN}/tos`); 78 if (!tosRes.ok) throw new Error(`Failed to fetch ToS: ${tosRes.status}`); 79 const tosText = await tosRes.text(); 80 const tosHash = base64url(crypto.createHash('sha256').update(tosText).digest()); 81 82 const wmJwt = createJwt( 83 { typ: 'wm+jwt', alg: 'RS256' }, 84 { 85 tos_hash: tosHash, 86 aud: PDS_ORIGIN, 87 cnf: { jkt: thumbprint }, 88 iat: Math.floor(Date.now() / 1000), 89 }, 90 privateKeyPem, 91 ); 92 93 // Create session to get DID/handle 94 const sessionUrl = `${PDS_ORIGIN}/xrpc/com.atproto.server.createSession`; 95 const dpop = createDpopProof('POST', sessionUrl, wmJwt, publicJwk, privateKeyPem); 96 97 const res = await fetch(sessionUrl, { 98 method: 'POST', 99 headers: { 100 'Content-Type': 'application/json', 101 'Authorization': `DPoP ${wmJwt}`, 102 'DPoP': dpop, 103 }, 104 body: JSON.stringify({}), 105 }); 106 107 if (!res.ok) { 108 const body = await res.text(); 109 throw new Error(`createSession failed (${res.status}): ${body}`); 110 } 111 112 const data = await res.json() as { did: string; handle: string; accessJwt: string }; 113 if (!data.did) throw new Error('createSession response missing did'); 114 115 return { did: data.did, handle: data.handle, wmJwt }; 116} 117 118// --- Authenticated fetch wrapper for DPoP --- 119 120async function dpopFetch( 121 url: string, 122 options: RequestInit, 123 accessToken: string, 124 publicJwk: { kty: string; n: string; e: string }, 125 privateKeyPem: string, 126): Promise<Response> { 127 const method = options.method ?? 'GET'; 128 const dpop = createDpopProof(method, url, accessToken, publicJwk, privateKeyPem); 129 const headers = new Headers(options.headers as Record<string, string>); 130 headers.set('Authorization', `DPoP ${accessToken}`); 131 headers.set('DPoP', dpop); 132 return fetch(url, { ...options, headers }); 133} 134 135// --- Types --- 136 137interface CardRecord { 138 uri: string; 139 cid: string; 140 value: { 141 type: string; 142 content?: { url?: string }; 143 }; 144} 145 146interface CollectionRecord { 147 uri: string; 148 cid: string; 149 value: { 150 name: string; 151 accessType: string; 152 }; 153} 154 155interface LinkToWrite { 156 collection: { uri: string; cid: string }; 157 card: { uri: string; cid: string }; 158 addedBy: string; 159} 160 161// --- Fetch records via raw DPoP-authenticated fetch --- 162 163async function fetchRecords( 164 did: string, 165 collection: string, 166 accessToken: string, 167 publicJwk: { kty: string; n: string; e: string }, 168 privateKeyPem: string, 169): Promise<{ uri: string; cid: string; value: Record<string, unknown> }[]> { 170 const records: { uri: string; cid: string; value: Record<string, unknown> }[] = []; 171 let cursor: string | undefined; 172 173 do { 174 const url = `${PDS_ORIGIN}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${collection}&limit=100${cursor ? `&cursor=${cursor}` : ''}`; 175 const res = await dpopFetch(url, { method: 'GET' }, accessToken, publicJwk, privateKeyPem); 176 if (!res.ok) { 177 const body = await res.text(); 178 throw new Error(`listRecords(${collection}) failed (${res.status}): ${body}`); 179 } 180 const data = await res.json() as { records: { uri: string; cid: string; value: Record<string, unknown> }[]; cursor?: string }; 181 records.push(...data.records); 182 cursor = data.cursor; 183 } while (cursor); 184 185 return records; 186} 187 188// --- Write links via raw fetch (DPoP-authenticated) --- 189 190async function writeLinks( 191 links: LinkToWrite[], 192 did: string, 193 accessToken: string, 194 publicJwk: { kty: string; n: string; e: string }, 195 privateKeyPem: string, 196): Promise<{ wrote: number; uris: string[] }> { 197 let wrote = 0; 198 const uris: string[] = []; 199 200 for (let i = 0; i < links.length; i += 10) { 201 const batch = links.slice(i, i + 10); 202 const results = await Promise.allSettled( 203 batch.map(async (link) => { 204 const record = { 205 $type: 'network.cosmik.collectionLink', 206 collection: { 207 uri: link.collection.uri, 208 cid: link.collection.cid, 209 }, 210 card: { 211 uri: link.card.uri, 212 cid: link.card.cid, 213 }, 214 addedBy: link.addedBy, 215 addedAt: new Date().toISOString(), 216 createdAt: new Date().toISOString(), 217 }; 218 219 const url = `${PDS_ORIGIN}/xrpc/com.atproto.repo.createRecord`; 220 const res = await dpopFetch(url, { 221 method: 'POST', 222 headers: { 'Content-Type': 'application/json' }, 223 body: JSON.stringify({ repo: did, collection: 'network.cosmik.collectionLink', record }), 224 }, accessToken, publicJwk, privateKeyPem); 225 226 if (!res.ok) { 227 const body = await res.text(); 228 throw new Error(`createRecord failed (${res.status}): ${body}`); 229 } 230 231 const data = await res.json() as { uri: string }; 232 return data.uri; 233 }), 234 ); 235 236 for (const result of results) { 237 if (result.status === 'fulfilled') { 238 wrote++; 239 uris.push(result.value); 240 } else { 241 console.error(` Failed: ${result.reason}`); 242 } 243 } 244 245 if (i + 10 < links.length) { 246 await new Promise(r => setTimeout(r, 500)); 247 } 248 } 249 250 return { wrote, uris }; 251} 252 253// --- Main --- 254 255async function main(): Promise<void> { 256 const args = process.argv.slice(2); 257 const shouldWrite = args.includes('--write'); 258 259 console.log('rdf:link-cards'); 260 console.log(`PDS: ${PDS_ORIGIN}`); 261 console.log(`Mode: ${shouldWrite ? 'WRITE' : 'DRY RUN (use --write to write)'}`); 262 console.log(); 263 264 // Load private key 265 console.log('Loading researcher private key...'); 266 const privateKeyPem = await readFile(PRIVATE_KEY_PATH, 'utf-8'); 267 const publicJwk = pemToJwk(privateKeyPem); 268 const thumbprint = computeThumbprint(publicJwk); 269 console.log(` Thumbprint: ${thumbprint}`); 270 271 // Create session 272 console.log('Creating Rookery session...'); 273 const session = await createRookerySession(privateKeyPem, publicJwk, thumbprint); 274 console.log(` DID: ${session.did}`); 275 console.log(` Handle: ${session.handle}`); 276 console.log(); 277 278 const did = session.did; 279 280 // Use wm+jwt as the API token for all Rookery calls (not the session accessJwt) 281 const apiToken = session.wmJwt; 282 283 // Fetch existing records 284 console.log('Fetching cards...'); 285 const cardRecords = await fetchRecords(did, 'network.cosmik.card', apiToken, publicJwk, privateKeyPem); 286 console.log(` Found ${cardRecords.length} cards`); 287 288 console.log('Fetching collections...'); 289 const collectionRecords = await fetchRecords(did, 'network.cosmik.collection', apiToken, publicJwk, privateKeyPem); 290 console.log(` Found ${collectionRecords.length} collections`); 291 292 console.log('Fetching existing collectionLinks...'); 293 const existingLinks = await fetchRecords(did, 'network.cosmik.collectionLink', apiToken, publicJwk, privateKeyPem); 294 const existingCardUris = new Set(existingLinks.map(r => (r.value as { card?: { uri: string } }).card?.uri).filter(Boolean)); 295 console.log(` Found ${existingLinks.length} existing links`); 296 console.log(); 297 298 if (collectionRecords.length === 0) { 299 console.log('No collections found. Nothing to link.'); 300 return; 301 } 302 303 if (collectionRecords.length > 1) { 304 console.log(`Warning: ${collectionRecords.length} collections found. Linking all cards to the first collection only.`); 305 } 306 307 // Build links: each card -> first collection 308 const collection = collectionRecords[0]; 309 const collName = (collection.value as { name: string }).name; 310 const links: LinkToWrite[] = []; 311 312 for (const card of cardRecords) { 313 if (existingCardUris.has(card.uri)) { 314 continue; 315 } 316 317 links.push({ 318 collection: { uri: collection.uri, cid: collection.cid }, 319 card: { uri: card.uri, cid: card.cid }, 320 addedBy: did, 321 }); 322 } 323 324 console.log(`Links to create: ${links.length} (${cardRecords.length} cards - ${existingCardUris.size} already linked)`); 325 console.log(` Collection: ${collName}`); 326 console.log(); 327 328 if (links.length === 0) { 329 console.log('All cards already linked. Nothing to do.'); 330 return; 331 } 332 333 if (!shouldWrite) { 334 console.log('Dry run — would create these collectionLink records:'); 335 for (const link of links.slice(0, 5)) { 336 const cardVal = cardRecords.find(c => c.uri === link.card.uri)?.value as { content?: { url?: string } } | undefined; 337 const cardUrl = cardVal?.content?.url ?? '(no URL)'; 338 console.log(` ${cardUrl}${collName}`); 339 } 340 if (links.length > 5) { 341 console.log(` ... and ${links.length - 5} more`); 342 } 343 console.log(); 344 console.log('Run with --write to create them.'); 345 return; 346 } 347 348 console.log('Writing collectionLink records (DPoP-authenticated)...'); 349 const { wrote, uris } = await writeLinks(links, did, apiToken, publicJwk, privateKeyPem); 350 console.log(` Created: ${wrote}/${links.length}`); 351 console.log(); 352 353 if (wrote > 0) { 354 console.log('Sample URIs:'); 355 for (const uri of uris.slice(0, 3)) { 356 console.log(` ${uri}`); 357 } 358 } 359} 360 361main().catch((err) => { 362 console.error('Error:', err.message ?? err); 363 process.exit(1); 364});