This repository has no description
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});