This repository has no description
0

Configure Feed

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

stigmergic / src / island-create.ts
17 kB 285 lines
1/** 2 * island:create 3 * 4 * Create and publish an island from a centroid URL. 5 * Uses DPoP auth (Welcome Mat v1.0) with the researcher's RSA key. 6 * 7 * Usage: 8 * bun island:create <centroid-url> 9 */ 10 11import crypto from 'node:crypto'; 12import { readFile } from 'node:fs/promises'; 13import { loadDotEnv } from './cli-utils.js'; 14 15const __dirname = new URL('.', import.meta.url).pathname; 16await loadDotEnv(new URL('../.env', import.meta.url).pathname); 17 18const PDS_ORIGIN = 'https://pds.latha.org'; 19const PRIVATE_KEY_PATH = '/home/nandi/vault/.credentials/researcher-pds-latha-org.pem'; 20 21// --- DPoP / Welcome Mat helpers --- 22 23function base64url(input: Buffer | Uint8Array): string { 24 return Buffer.from(input).toString('base64url'); 25} 26 27function pemToJwk(pem: string): { kty: string; n: string; e: string } { 28 const key = crypto.createPublicKey(pem); 29 const jwk = key.export({ format: 'jwk' }); 30 return { kty: jwk.kty as string, n: jwk.n as string, e: jwk.e as string }; 31} 32 33function computeThumbprint(jwk: { kty: string; n: string; e: string }): string { 34 return base64url( 35 crypto.createHash('sha256') 36 .update(JSON.stringify({ e: jwk.e, kty: 'RSA', n: jwk.n })) 37 .digest(), 38 ); 39} 40 41function createJwt(header: object, payload: object, privateKeyPem: string): string { 42 const encode = (obj: object) => base64url(Buffer.from(JSON.stringify(obj))); 43 const signingInput = `${encode(header)}.${encode(payload)}`; 44 const sig = crypto.createSign('SHA256'); 45 sig.update(signingInput); 46 return `${signingInput}.${base64url(sig.sign(privateKeyPem))}`; 47} 48 49function createDpopProof( 50 method: string, 51 url: string, 52 accessToken: string, 53 publicJwk: { kty: string; n: string; e: string }, 54 privateKeyPem: string, 55): string { 56 const ath = base64url(crypto.createHash('sha256').update(accessToken).digest()); 57 return createJwt( 58 { typ: 'dpop+jwt', alg: 'RS256', jwk: publicJwk }, 59 { 60 jti: crypto.randomUUID(), 61 htm: method, 62 htu: url, 63 iat: Math.floor(Date.now() / 1000), 64 ath, 65 }, 66 privateKeyPem, 67 ); 68} 69 70async function createRookerySession(privateKeyPem: string, publicJwk: { kty: string; n: string; e: string }, thumbprint: string): Promise<{ did: string; handle: string; wmJwt: string }> { 71 // Fetch ToS and build wm+jwt access token — this IS the API token for Rookery 72 const tosRes = await fetch(`${PDS_ORIGIN}/tos`); 73 if (!tosRes.ok) throw new Error(`Failed to fetch ToS: ${tosRes.status}`); 74 const tosText = await tosRes.text(); 75 const tosHash = base64url(crypto.createHash('sha256').update(tosText).digest()); 76 77 const wmJwt = createJwt( 78 { typ: 'wm+jwt', alg: 'RS256' }, 79 { 80 tos_hash: tosHash, 81 aud: PDS_ORIGIN, 82 cnf: { jkt: thumbprint }, 83 iat: Math.floor(Date.now() / 1000), 84 }, 85 privateKeyPem, 86 ); 87 88 // Create session to get DID/handle 89 const sessionUrl = `${PDS_ORIGIN}/xrpc/com.atproto.server.createSession`; 90 const dpop = createDpopProof('POST', sessionUrl, wmJwt, publicJwk, privateKeyPem); 91 92 const res = await fetch(sessionUrl, { 93 method: 'POST', 94 headers: { 95 'Content-Type': 'application/json', 96 'Authorization': `DPoP ${wmJwt}`, 97 'DPoP': dpop, 98 }, 99 body: JSON.stringify({}), 100 }); 101 102 if (!res.ok) { 103 const body = await res.text(); 104 throw new Error(`createSession failed (${res.status}): ${body}`); 105 } 106 107 const data = await res.json() as { did: string; handle: string; accessJwt: string }; 108 if (!data.did) throw new Error('createSession response missing did'); 109 110 return { did: data.did, handle: data.handle, wmJwt }; 111} 112 113async function dpopFetch( 114 url: string, 115 options: RequestInit, 116 accessToken: string, 117 publicJwk: { kty: string; n: string; e: string }, 118 privateKeyPem: string, 119): Promise<Response> { 120 const method = options.method ?? 'GET'; 121 const dpop = createDpopProof(method, url, accessToken, publicJwk, privateKeyPem); 122 const headers = new Headers(options.headers as Record<string, string>); 123 headers.set('Authorization', `DPoP ${accessToken}`); 124 headers.set('DPoP', dpop); 125 return fetch(url, { ...options, headers }); 126} 127 128// --- Island hash --- 129 130async function islandHash(centroid: string): Promise<string> { 131 const data = new TextEncoder().encode(centroid); 132 const hashBuffer = await crypto.subtle.digest('SHA-256', data); 133 const hashArray = Array.from(new Uint8Array(hashBuffer)); 134 return hashArray.map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 12); 135} 136 137// --- Main --- 138 139async function main() { 140 const centroidUrl = process.argv[2]; 141 if (!centroidUrl) { 142 console.error('Usage: bun island:create <centroid-url>'); 143 process.exit(1); 144 } 145 146 console.log(`Creating island for: ${centroidUrl}`); 147 148 const privateKeyPem = await readFile(PRIVATE_KEY_PATH, 'utf-8'); 149 const publicJwk = pemToJwk(privateKeyPem); 150 const thumbprint = computeThumbprint(publicJwk); 151 152 const { did, handle, wmJwt } = await createRookerySession(privateKeyPem, publicJwk, thumbprint); 153 console.log(`Authenticated as: ${handle} (${did})`); 154 155 const rkey = await islandHash(centroidUrl); 156 console.log(`Island rkey: ${rkey}`); 157 158 // Build the island record with related research 159 const record = { 160 $type: 'org.latha.island', 161 source: { 162 uri: centroidUrl, 163 collection: 'network.cosmik.connection', 164 }, 165 connections: [ 166 // --- Spoke edges from centroid --- 167 { uri: 'at://inline:0', source: centroidUrl, target: 'https://dl.acm.org/doi/10.1145/3125571.3125586', connectionType: 'network.cosmik.connection#related', note: 'Context-based bookmarking and refinding (Hwang & Ronchetti 2016)' }, 168 { uri: 'at://inline:1', source: centroidUrl, target: 'https://dl.acm.org/doi/10.1145/985692.985766', connectionType: 'network.cosmik.connection#related', note: 'Cross-tool PIM study (Boardman & Sasse 2003)' }, 169 { uri: 'at://inline:2', source: centroidUrl, target: 'https://dl.acm.org/doi/10.1145/1180875.1180905', connectionType: 'network.cosmik.connection#related', note: 'Social bookmarking in del.icio.us' }, 170 { uri: 'at://inline:3', source: centroidUrl, target: 'https://dl.acm.org/doi/10.1145/221296.221307', connectionType: 'network.cosmik.connection#related', note: 'Finding and reminding: file organization from the desktop (Barreau & Nardi 1995)' }, 171 172 // --- Dense Bergman–Whittaker co-authorship mesh --- 173 // Navigation preference (Bergman, Whittaker et al. 2008) 174 { uri: 'at://inline:4', source: centroidUrl, target: 'https://dl.acm.org/doi/10.1145/1402256.1402259', connectionType: 'network.cosmik.connection#coauthor', note: 'Bergman, Whittaker, Beyth-Marom, Nachmias, Gradovitch 2008' }, 175 // Folder structure (Bergman, Whittaker et al. 2010) 176 { uri: 'at://inline:5', source: centroidUrl, target: 'https://dl.acm.org/doi/abs/10.5555/1890706.1890712', connectionType: 'network.cosmik.connection#coauthor', note: 'Bergman, Whittaker, Sanderson, Nachmias, Ramamoorthy 2010' }, 177 // Shared files (Bergman, Whittaker, Falk 2014) 178 { uri: 'at://inline:6', source: centroidUrl, target: 'https://www.semanticscholar.org/paper/1f76d97b58137bbf7bdd9ba17c65c5f05b49789f', connectionType: 'network.cosmik.connection#coauthor', note: 'Bergman, Whittaker, Falk 2014' }, 179 // Project fragmentation (Bergman, Beyth-Marom, Nachmias 2006) 180 { uri: 'at://inline:7', source: centroidUrl, target: 'https://dl.acm.org/doi/10.1145/1124772.1124813', connectionType: 'network.cosmik.connection#related', note: 'Bergman, Beyth-Marom, Nachmias 2006' }, 181 // User-subjective approach chapter (Bergman 2012) 182 { uri: 'at://inline:8', source: centroidUrl, target: 'https://link.springer.com/chapter/10.1007/978-3-642-25691-2_3', connectionType: 'network.cosmik.connection#related', note: 'User-subjective approach to PIM (Bergman)' }, 183 // Don't take my folders away (Jones et al. 2005) 184 { uri: 'at://inline:9', source: centroidUrl, target: 'https://dl.acm.org/doi/10.1145/1056808.1056952', connectionType: 'network.cosmik.connection#related', note: 'Folders as information in their own right (Jones, Whittaker et al. 2005)' }, 185 // Scale and structure of file collections (Dinneen, Julien 2019) 186 { uri: 'at://inline:10', source: centroidUrl, target: 'https://dl.acm.org/doi/10.1145/3290605.3300557', connectionType: 'network.cosmik.connection#related', note: 'Scale and structure of personal file collections (Dinneen, Julien, Frissen 2019)' }, 187 // Email refinding (Whittaker et al. 2011) 188 { uri: 'at://inline:11', source: centroidUrl, target: 'https://dl.acm.org/doi/10.1145/1978942.1979457', connectionType: 'network.cosmik.connection#coauthor', note: 'Am I wasting my time organizing email? (Whittaker et al. 2011)' }, 189 // Delphi PIM practices (Jones et al. 2015) 190 { uri: 'at://inline:12', source: centroidUrl, target: 'https://dl.acm.org/doi/10.1145/2702123.2702523', connectionType: 'network.cosmik.connection#related', note: '36 PIM practices via Delphi method (Jones et al. 2015)' }, 191 // Folders vs tags (Bergman et al. 2013) 192 { uri: 'at://inline:13', source: centroidUrl, target: 'https://dl.acm.org/doi/10.1145/1520340.1520565', connectionType: 'network.cosmik.connection#related', note: 'File management with folders and tags' }, 193 194 // --- Cross-edges: inter-paper connections (the mesh) --- 195 // Navigation preference cites folder structure 196 { uri: 'at://inline:14', source: 'https://dl.acm.org/doi/10.1145/1402256.1402259', target: 'https://dl.acm.org/doi/abs/10.5555/1890706.1890712', connectionType: 'network.cosmik.connection#cites', note: 'Navigation preference paper cites folder structure study' }, 197 // Navigation preference cites project fragmentation 198 { uri: 'at://inline:15', source: 'https://dl.acm.org/doi/10.1145/1402256.1402259', target: 'https://dl.acm.org/doi/10.1145/1124772.1124813', connectionType: 'network.cosmik.connection#cites', note: 'Navigation preference paper cites project fragmentation' }, 199 // Shared files cites folder structure 200 { uri: 'at://inline:16', source: 'https://www.semanticscholar.org/paper/1f76d97b58137bbf7bdd9ba17c65c5f05b49789f', target: 'https://dl.acm.org/doi/abs/10.5555/1890706.1890712', connectionType: 'network.cosmik.connection#cites', note: 'Shared files paper cites folder structure study' }, 201 // Shared files cites navigation preference 202 { uri: 'at://inline:17', source: 'https://www.semanticscholar.org/paper/1f76d97b58137bbf7bdd9ba17c65c5f05b49789f', target: 'https://dl.acm.org/doi/10.1145/1402256.1402259', connectionType: 'network.cosmik.connection#cites', note: 'Shared files paper cites navigation preference' }, 203 // Scale/structure cites folder structure 204 { uri: 'at://inline:18', source: 'https://dl.acm.org/doi/10.1145/3290605.3300557', target: 'https://dl.acm.org/doi/abs/10.5555/1890706.1890712', connectionType: 'network.cosmik.connection#cites', note: 'Scale and structure paper cites folder structure' }, 205 // Scale/structure cites shared files 206 { uri: 'at://inline:19', source: 'https://dl.acm.org/doi/10.1145/3290605.3300557', target: 'https://www.semanticscholar.org/paper/1f76d97b58137bbf7bdd9ba17c65c5f05b49789f', connectionType: 'network.cosmik.connection#cites', note: 'Scale and structure paper cites shared files' }, 207 // Scale/structure cites navigation preference 208 { uri: 'at://inline:20', source: 'https://dl.acm.org/doi/10.1145/3290605.3300557', target: 'https://dl.acm.org/doi/10.1145/1402256.1402259', connectionType: 'network.cosmik.connection#cites', note: 'Scale and structure paper cites navigation preference' }, 209 // Don't take my folders cites navigation preference 210 { uri: 'at://inline:21', source: 'https://dl.acm.org/doi/10.1145/1056808.1056952', target: 'https://dl.acm.org/doi/10.1145/1402256.1402259', connectionType: 'network.cosmik.connection#cites', note: 'Folders paper cites navigation preference' }, 211 // Email refinding cites navigation preference 212 { uri: 'at://inline:22', source: 'https://dl.acm.org/doi/10.1145/1978942.1979457', target: 'https://dl.acm.org/doi/10.1145/1402256.1402259', connectionType: 'network.cosmik.connection#cites', note: 'Email refinding paper cites navigation preference' }, 213 // Delphi PIM cites folder structure 214 { uri: 'at://inline:23', source: 'https://dl.acm.org/doi/10.1145/2702123.2702523', target: 'https://dl.acm.org/doi/abs/10.5555/1890706.1890712', connectionType: 'network.cosmik.connection#cites', note: 'Delphi PIM paper cites folder structure' }, 215 // Delphi PIM cites project fragmentation 216 { uri: 'at://inline:24', source: 'https://dl.acm.org/doi/10.1145/2702123.2702523', target: 'https://dl.acm.org/doi/10.1145/1124772.1124813', connectionType: 'network.cosmik.connection#cites', note: 'Delphi PIM paper cites project fragmentation' }, 217 // Folders vs tags cites shared files 218 { uri: 'at://inline:25', source: 'https://dl.acm.org/doi/10.1145/1520340.1520565', target: 'https://www.semanticscholar.org/paper/1f76d97b58137bbf7bdd9ba17c65c5f05b49789f', connectionType: 'network.cosmik.connection#cites', note: 'Folders vs tags paper cites shared files' }, 219 // User-subjective chapter cites navigation preference 220 { uri: 'at://inline:26', source: 'https://link.springer.com/chapter/10.1007/978-3-642-25691-2_3', target: 'https://dl.acm.org/doi/10.1145/1402256.1402259', connectionType: 'network.cosmik.connection#cites', note: 'User-subjective chapter cites navigation preference' }, 221 // User-subjective chapter cites folder structure 222 { uri: 'at://inline:27', source: 'https://link.springer.com/chapter/10.1007/978-3-642-25691-2_3', target: 'https://dl.acm.org/doi/abs/10.5555/1890706.1890712', connectionType: 'network.cosmik.connection#cites', note: 'User-subjective chapter cites folder structure' }, 223 // User-subjective chapter cites project fragmentation 224 { uri: 'at://inline:28', source: 'https://link.springer.com/chapter/10.1007/978-3-642-25691-2_3', target: 'https://dl.acm.org/doi/10.1145/1124772.1124813', connectionType: 'network.cosmik.connection#cites', note: 'User-subjective chapter cites project fragmentation' }, 225 ], 226 analysis: { 227 title: 'Bookmarking and Information Refinding in Personal Information Management', 228 themes: ['personal information management', 'bookmarking', 'information refinding', 'user behavior', 'digital organization', 'navigation vs search'], 229 highlights: [], 230 tensions: [ 231 'Users create bookmarks for future use but rarely revisit them', 232 'Folder organization is effortful but search is underutilized', 233 'Context cues help refinding but are rarely captured', 234 ], 235 openQuestions: [ 236 'How can bookmarking tools better support refinding?', 237 'What contextual cues most improve retrieval success?', 238 'Why do users prefer navigation over search in PIM?', 239 ], 240 synthesis: `This island centers on the study "Out of sight and out of mind: Bookmarks are created but not used" (Schooler, Bergman, Whittaker, 2020), which documents a fundamental tension in personal information management: users create bookmarks intending to refind information later, but the vast majority of bookmarks go unused. 241 242The research lineage here connects to broader PIM studies by Bergman, Whittaker, and colleagues showing persistent navigation preference—users prefer browsing folder hierarchies over search, even when search is available. This preference persists despite evidence that elaborate folder structures are inefficient and don't improve retrieval success. 243 244Related work on context-based refinding (Hwang & Ronchetti 2016) suggests that contextual cues—when captured—significantly improve retrieval success. Annotations and social bookmarking (Kawase et al., del.icio.us studies) point toward alternative models where social and contextual metadata help surface forgotten bookmarks. 245 246The "stuff goes into the computer and doesn't come out" pattern (Boardman & Sasse 2003) extends beyond bookmarks to files and email, suggesting the bookmark problem is one instance of a broader PIM challenge: information capture outpaces retrieval capability.`, 247 }, 248 createdAt: new Date().toISOString(), 249 }; 250 251 // Publish to PDS 252 const url = `${PDS_ORIGIN}/xrpc/com.atproto.repo.putRecord`; 253 const body = { 254 repo: did, 255 collection: 'org.latha.island', 256 rkey, 257 record, 258 }; 259 260 console.log('Publishing to PDS...'); 261 262 const res = await dpopFetch( 263 url, 264 { 265 method: 'POST', 266 headers: { 'Content-Type': 'application/json' }, 267 body: JSON.stringify(body), 268 }, 269 wmJwt, 270 publicJwk, 271 privateKeyPem 272 ); 273 274 if (!res.ok) { 275 const err = await res.text(); 276 console.error('Publish failed:', err); 277 process.exit(1); 278 } 279 280 const result = await res.json() as { uri: string }; 281 console.log('Published:', result.uri); 282 console.log(`View at: https://stigmergic.latha.org/island/${handle}/${rkey}`); 283} 284 285main().catch(console.error);