This repository has no description
0

Configure Feed

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

stigmergic / src / worker.ts
29 kB 878 lines
1import { Hono } from "hono"; 2import { cors } from "hono/cors"; 3import { createWorker } from "@atmo-dev/contrail/worker"; 4import { Contrail } from "@atmo-dev/contrail"; 5import { config } from "./contrail.config.js"; 6import { lexicons } from "../lexicons/generated/index.js"; 7import { LANDING_PAGE } from "./landing-page.js"; 8import { StrataContainer } from "./container.js"; 9 10// ─── Env ──────────────────────────────────────────────────────────── 11 12interface Env { 13 DB: D1Database; 14 VAULT_BUCKET: R2Bucket; 15 CARRY_BUCKET: R2Bucket; 16 STRATA_CONTAINER: DurableObjectNamespace; 17} 18 19// ─── Contrail setup ───────────────────────────────────────────────── 20 21const contrailWorker = createWorker(config, { lexicons }); 22const contrail = new Contrail(config); 23let contrailReady = false; 24 25async function ensureContrailReady(db: D1Database): Promise<void> { 26 if (contrailReady) return; 27 await contrail.init(db); 28 contrailReady = true; 29} 30 31// ─── Island detection (connected components) ──────────────────────── 32// 33// Load all connections, build an undirected graph, find connected 34// components via BFS. Each component is an "island" — a cluster of 35// linked URLs that form a constellation in the knowledge graph. 36 37interface Island { 38 id: string; 39 vertices: string[]; 40 edges: Array<{ 41 uri: string; 42 did: string; 43 source: string; 44 target: string; 45 connectionType: string; 46 note: string; 47 handle?: string | null; 48 }>; 49} 50 51async function detectIslands(db: D1Database): Promise<Island[]> { 52 await ensureContrailReady(db); 53 54 // Load all connections 55 const rows = await db 56 .prepare( 57 `SELECT r.record, r.did, r.rkey, i.handle 58 FROM records_connection r 59 LEFT JOIN identities i ON r.did = i.did 60 ORDER BY r.time_us DESC`, 61 ) 62 .all<{ record: string; did: string; rkey: string; handle: string | null }>(); 63 64 // Build adjacency list 65 const adj = new Map<string, Set<string>>(); 66 const edges: Island["edges"] = []; 67 68 for (const row of rows.results || []) { 69 try { 70 const value = JSON.parse(row.record); 71 const source = value.source as string; 72 const target = value.target as string; 73 if (!source || !target) continue; 74 75 if (!adj.has(source)) adj.set(source, new Set()); 76 if (!adj.has(target)) adj.set(target, new Set()); 77 adj.get(source)!.add(target); 78 adj.get(target)!.add(source); 79 80 edges.push({ 81 uri: `at://${row.did}/network.cosmik.connection/${row.rkey}`, 82 did: row.did, 83 source, 84 target, 85 connectionType: value.connectionType || "relates", 86 note: value.note || "", 87 handle: row.handle, 88 }); 89 } catch {} 90 } 91 92 // BFS to find connected components 93 const visited = new Set<string>(); 94 const islands: Island[] = []; 95 96 for (const node of adj.keys()) { 97 if (visited.has(node)) continue; 98 99 const component: string[] = []; 100 const queue = [node]; 101 while (queue.length > 0) { 102 const current = queue.shift()!; 103 if (visited.has(current)) continue; 104 visited.add(current); 105 component.push(current); 106 107 for (const neighbor of adj.get(current) || []) { 108 if (!visited.has(neighbor)) queue.push(neighbor); 109 } 110 } 111 112 // Only include edges where both endpoints are in this component 113 const componentSet = new Set(component); 114 const componentEdges = edges.filter( 115 e => componentSet.has(e.source) && componentSet.has(e.target), 116 ); 117 118 // Stable ID: truncated SHA-256 of the lexicographic minimum vertex 119 const lexmin = component.sort()[0]; 120 const lexminHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(lexmin)); 121 const id = Array.from(new Uint8Array(lexminHash)).map(b => b.toString(16).padStart(2, "0")).join("").slice(0, 16); 122 123 islands.push({ 124 id, 125 vertices: component, 126 edges: componentEdges, 127 }); 128 } 129 130 // Sort by size descending 131 islands.sort((a, b) => b.vertices.length - a.vertices.length); 132 return islands; 133} 134 135// ─── Resolve vertex metadata (batched) ────────────────────────────── 136// 137// Bulk queries instead of N+1 per vertex. Loads all cards and citations 138// matching the given URIs in a few chunked queries. 139 140async function resolveVertexMeta( 141 db: D1Database, 142 uris: string[], 143): Promise<Map<string, { title: string; description: string; type: string }>> { 144 const meta = new Map<string, { title: string; description: string; type: string }>(); 145 for (const uri of uris) { 146 meta.set(uri, { title: uri, description: "", type: "unknown" }); 147 } 148 149 const httpUris = uris.filter(u => u.startsWith("http")); 150 const atUris = uris.filter(u => u.startsWith("at://")); 151 const CHUNK = 50; 152 153 // ── Resolve HTTP URIs (existing logic) ── 154 155 // Batch card lookup 156 for (let i = 0; i < httpUris.length; i += CHUNK) { 157 const chunk = httpUris.slice(i, i + CHUNK); 158 const placeholders = chunk.map(() => "?").join(","); 159 const rows = await db 160 .prepare( 161 `SELECT record FROM records_card 162 WHERE json_extract(record, '$.content.url') IN (${placeholders})`, 163 ) 164 .bind(...chunk) 165 .all<{ record: string }>(); 166 167 for (const row of rows.results || []) { 168 try { 169 const card = JSON.parse(row.record); 170 const url = card?.content?.url; 171 if (!url || !meta.has(url)) continue; 172 const m = card.content.metadata; 173 if (m?.title || m?.description) { 174 meta.set(url, { 175 title: m.title || url, 176 description: m.description || "", 177 type: card.type === "URL" ? "url" : "note", 178 }); 179 } 180 } catch {} 181 } 182 } 183 184 // Batch citation lookup (overrides card data if found) 185 for (let i = 0; i < httpUris.length; i += CHUNK) { 186 const chunk = httpUris.slice(i, i + CHUNK); 187 const placeholders = chunk.map(() => "?").join(","); 188 const rows = await db 189 .prepare( 190 `SELECT record FROM records_citation 191 WHERE json_extract(record, '$.url') IN (${placeholders})`, 192 ) 193 .bind(...chunk) 194 .all<{ record: string }>(); 195 196 for (const row of rows.results || []) { 197 try { 198 const citation = JSON.parse(row.record); 199 const url = citation.url; 200 if (!url || !meta.has(url)) continue; 201 if (citation.title) { 202 const existing = meta.get(url)!; 203 meta.set(url, { 204 title: citation.title, 205 description: citation.takeaway || existing.description, 206 type: "citation", 207 }); 208 } 209 } catch {} 210 } 211 } 212 213 // ── Resolve AT URIs ── 214 215 // Batch card lookup by AT URI 216 for (let i = 0; i < atUris.length; i += CHUNK) { 217 const chunk = atUris.slice(i, i + CHUNK); 218 const placeholders = chunk.map(() => "?").join(","); 219 const rows = await db 220 .prepare(`SELECT uri, record FROM records_card WHERE uri IN (${placeholders})`) 221 .bind(...chunk) 222 .all<{ uri: string; record: string }>(); 223 224 for (const row of rows.results || []) { 225 try { 226 const card = JSON.parse(row.record); 227 const m = card?.content?.metadata; 228 const url = card?.content?.url; 229 if (meta.has(row.uri)) { 230 meta.set(row.uri, { 231 title: m?.title || url || row.uri, 232 description: m?.description || "", 233 type: "card", 234 }); 235 } 236 } catch {} 237 } 238 } 239 240 // Batch collection lookup by AT URI 241 for (let i = 0; i < atUris.length; i += CHUNK) { 242 const chunk = atUris.slice(i, i + CHUNK); 243 const placeholders = chunk.map(() => "?").join(","); 244 const rows = await db 245 .prepare(`SELECT uri, record FROM records_collection WHERE uri IN (${placeholders})`) 246 .bind(...chunk) 247 .all<{ uri: string; record: string }>(); 248 249 for (const row of rows.results || []) { 250 try { 251 const coll = JSON.parse(row.record); 252 if (meta.has(row.uri)) { 253 meta.set(row.uri, { 254 title: coll.name || row.uri, 255 description: coll.description || "", 256 type: "collection", 257 }); 258 } 259 } catch {} 260 } 261 } 262 263 return meta; 264} 265 266// ─── Derive island from lexmin ──────────────────────────────────── 267// 268// Given a lexmin vertex (canonical component reference), derive the 269// connected component by traversing network.cosmik.connection records. 270 271async function deriveIsland( 272 db: D1Database, 273 seed: string, 274): Promise<Island | null> { 275 await ensureContrailReady(db); 276 277 // BFS in application code — recursive CTE OOMs in D1 due to cross join 278 const visited = new Set<string>(); 279 const queue = [seed]; 280 const allEdges: Array<{ 281 uri: string; did: string; rkey: string; 282 source: string; target: string; connectionType: string; 283 note: string; handle: string | null; 284 }> = []; 285 286 while (queue.length > 0) { 287 // Drain the current queue into a batch 288 const batch = queue.splice(0, 50); 289 const toQuery = batch.filter(v => !visited.has(v)); 290 if (toQuery.length === 0) continue; 291 292 // Mark visited 293 for (const v of toQuery) visited.add(v); 294 295 // Query connections where any of these vertices are source or target 296 const placeholders = toQuery.map(() => "?").join(","); 297 const rows = await db 298 .prepare( 299 `SELECT r.record, r.did, r.rkey, i.handle 300 FROM records_connection r 301 LEFT JOIN identities i ON r.did = i.did 302 WHERE json_extract(r.record, '$.source') IN (${placeholders}) 303 OR json_extract(r.record, '$.target') IN (${placeholders})`, 304 ) 305 .bind(...toQuery, ...toQuery) 306 .all<{ record: string; did: string; rkey: string; handle: string | null }>(); 307 308 for (const row of rows.results || []) { 309 try { 310 const value = JSON.parse(row.record); 311 const source = value.source as string; 312 const target = value.target as string; 313 if (!source || !target) continue; 314 315 // Collect edge 316 const edgeKey = `${source}|${target}|${row.rkey}`; 317 allEdges.push({ 318 uri: `at://${row.did}/network.cosmik.connection/${row.rkey}`, 319 did: row.did, 320 rkey: row.rkey, 321 source, 322 target, 323 connectionType: value.connectionType || "relates", 324 note: value.note || "", 325 handle: row.handle, 326 }); 327 328 // Enqueue unvisited neighbors 329 if (!visited.has(source)) queue.push(source); 330 if (!visited.has(target)) queue.push(target); 331 } catch {} 332 } 333 } 334 335 if (visited.size === 0) return null; 336 337 const vertices = [...visited]; 338 339 // Filter edges to only those with both endpoints in the component 340 const vertexSet = new Set(vertices); 341 const edges: Island["edges"] = []; 342 const seenEdges = new Set<string>(); 343 for (const e of allEdges) { 344 if (!vertexSet.has(e.source) || !vertexSet.has(e.target)) continue; 345 const edgeKey = `${e.source}|${e.target}|${e.rkey}`; 346 if (seenEdges.has(edgeKey)) continue; 347 seenEdges.add(edgeKey); 348 edges.push({ 349 uri: e.uri, 350 did: e.did, 351 source: e.source, 352 target: e.target, 353 connectionType: e.connectionType, 354 note: e.note, 355 handle: e.handle, 356 }); 357 } 358 359 // Compute stable ID from lexmin 360 const sortedVertices = [...vertices].sort(); 361 const actualLexmin = sortedVertices[0]; 362 const lexminHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(actualLexmin)); 363 const id = Array.from(new Uint8Array(lexminHash)).map(b => b.toString(16).padStart(2, "0")).join("").slice(0, 16); 364 365 return { id, vertices, edges }; 366} 367 368// ─── List strata islands from records_strata ────────────────────── 369 370// ─── Hono app ─────────────────────────────────────────────────────── 371 372let app: Hono<{ Bindings: Env }> | null = null; 373 374function buildApp(env: Env): Hono<{ Bindings: Env }> { 375 const app = new Hono<{ Bindings: Env }>(); 376 const db = env.DB; 377 378 app.use("*", cors()); 379 380 // Landing page — strata records feed 381 app.get("/", async (c) => { 382 let islandsJson = "[]"; 383 try { 384 await ensureContrailReady(db); 385 const rows = await db 386 .prepare("SELECT uri, record FROM records_strata ORDER BY time_us DESC LIMIT 50") 387 .all<{ uri: string; record: string }>(); 388 389 const islands: any[] = []; 390 for (const row of rows.results || []) { 391 try { 392 const rec = JSON.parse(row.record); 393 const lexmin = rec.source?.uri; 394 if (!lexmin) continue; 395 const lexminHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(lexmin)); 396 const id = Array.from(new Uint8Array(lexminHash)).map(b => b.toString(16).padStart(2, "0")).join("").slice(0, 16); 397 islands.push({ 398 id, 399 lexmin, 400 title: rec.analysis?.title || null, 401 recordUri: row.uri, 402 }); 403 } catch {} 404 } 405 islandsJson = JSON.stringify(islands); 406 } catch (e) { 407 console.error("Failed to load islands:", e); 408 } 409 410 const page = LANDING_PAGE.replace( 411 "</head>", 412 `<script>window.__ISLANDS__=${islandsJson};</script></head>`, 413 ); 414 return c.html(page); 415 }); 416 417 // ── Islands API ────────────────────────────────────────────────── 418 419 app.get("/xrpc/org.latha.strata.getIslands", async (c) => { 420 const limit = Math.min(parseInt(c.req.query("limit") || "50"), 100); 421 await ensureContrailReady(db); 422 423 const rows = await db 424 .prepare("SELECT uri, record FROM records_strata ORDER BY time_us DESC LIMIT ?") 425 .bind(limit) 426 .all<{ uri: string; record: string }>(); 427 428 const islands: any[] = []; 429 for (const row of rows.results || []) { 430 try { 431 const rec = JSON.parse(row.record); 432 const lexmin = rec.source?.uri; 433 if (!lexmin) continue; 434 435 // Compute stable ID from lexmin 436 const lexminHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(lexmin)); 437 const id = Array.from(new Uint8Array(lexminHash)).map(b => b.toString(16).padStart(2, "0")).join("").slice(0, 16); 438 439 islands.push({ 440 id, 441 lexmin, 442 title: rec.analysis?.title || null, 443 recordUri: row.uri, 444 }); 445 } catch {} 446 } 447 448 return c.json({ islands }); 449 }); 450 451 // ── Get single island by ID or AT URI ──────────────────────────── 452 453 app.get("/xrpc/org.latha.strata.getIsland", async (c) => { 454 const id = c.req.query("id"); 455 if (!id) { 456 return c.json({ error: "MissingRequiredParameter", message: "id is required" }, 400); 457 } 458 459 await ensureContrailReady(db); 460 461 // If it's an AT URI, look up the strata record and derive from its lexmin 462 if (id.startsWith("at://")) { 463 const row = await db 464 .prepare("SELECT uri, record FROM records_strata WHERE uri = ?") 465 .bind(id) 466 .first<{ uri: string; record: string }>(); 467 468 if (!row) { 469 return c.json({ error: "RecordNotFound" }, 404); 470 } 471 472 const rec = JSON.parse(row.record); 473 const lexmin = rec.source?.uri; 474 if (!lexmin) { 475 return c.json({ error: "InvalidRecord" }, 400); 476 } 477 478 const island = await deriveIsland(db, lexmin); 479 if (!island) { 480 return c.json({ error: "IslandNotFound" }, 404); 481 } 482 483 const meta = await resolveVertexMeta(db, island.vertices); 484 const analysis = rec.analysis || {}; 485 486 return c.json({ 487 island: { 488 ...island, 489 lexmin: island.vertices.slice().sort()[0], 490 vertexMeta: Object.fromEntries(meta), 491 summary: analysis.title || null, 492 strata: { 493 prose: analysis.synthesis || "", 494 title: analysis.title || "", 495 themes: analysis.themes || [], 496 relationships: (analysis.connections || []).map((conn: any) => conn.description || ""), 497 tensions: analysis.tensions || [], 498 open_questions: analysis.openQuestions || [], 499 synthesis: analysis.synthesis || "", 500 }, 501 }, 502 recordUri: row.uri, 503 }); 504 } 505 506 // Stable ID — derive from lexmin by reverse-lookup 507 // Find the strata record whose lexmin hashes to this ID 508 const strataRows = await db 509 .prepare("SELECT uri, record FROM records_strata") 510 .all<{ uri: string; record: string }>(); 511 512 for (const row of strataRows.results || []) { 513 try { 514 const rec = JSON.parse(row.record); 515 const lexmin = rec.source?.uri; 516 if (!lexmin) continue; 517 518 const lexminHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(lexmin)); 519 const candidateId = Array.from(new Uint8Array(lexminHash)).map(b => b.toString(16).padStart(2, "0")).join("").slice(0, 16); 520 521 if (candidateId === id) { 522 const island = await deriveIsland(db, lexmin); 523 if (!island) continue; 524 525 const meta = await resolveVertexMeta(db, island.vertices); 526 const analysis = rec.analysis || {}; 527 528 return c.json({ 529 island: { 530 ...island, 531 lexmin: island.vertices.slice().sort()[0], 532 vertexMeta: Object.fromEntries(meta), 533 summary: analysis.title || null, 534 strata: { 535 prose: analysis.synthesis || "", 536 title: analysis.title || "", 537 themes: analysis.themes || [], 538 relationships: (analysis.connections || []).map((conn: any) => conn.description || ""), 539 tensions: analysis.tensions || [], 540 open_questions: analysis.openQuestions || [], 541 synthesis: analysis.synthesis || "", 542 }, 543 }, 544 recordUri: row.uri, 545 }); 546 } 547 } catch {} 548 } 549 550 // No strata record — try deriving island by treating the ID as a lexmin seed 551 // (for islands that haven't been analyzed yet) 552 const island = await deriveIsland(db, id); 553 if (!island) { 554 return c.json({ error: "IslandNotFound" }, 404); 555 } 556 557 const meta = await resolveVertexMeta(db, island.vertices); 558 return c.json({ 559 island: { 560 ...island, 561 lexmin: island.vertices.slice().sort()[0], 562 vertexMeta: Object.fromEntries(meta), 563 summary: null, 564 strata: null, 565 }, 566 recordUri: null, 567 }); 568 }); 569 570 // ── List strata records (AT URI-based) ────────────────────────── 571 572 app.get("/xrpc/org.latha.strata.listRecords", async (c) => { 573 const rows = await db 574 .prepare("SELECT uri, record FROM records_strata ORDER BY time_us DESC LIMIT 50") 575 .all<{ uri: string; record: string }>(); 576 577 const records = (rows.results || []).map(r => { 578 try { 579 const rec = JSON.parse(r.record); 580 return { 581 uri: r.uri, 582 source: rec.source?.uri || "", 583 title: rec.analysis?.title || "", 584 themes: rec.analysis?.themes || [], 585 createdAt: rec.createdAt || "", 586 }; 587 } catch { 588 return null; 589 } 590 }).filter(Boolean); 591 592 return c.json({ records }); 593 }); 594 595 // ── Strata record endpoint (AT URI-based) ────────────────────── 596 597 app.get("/xrpc/org.latha.strata.getRecord", async (c) => { 598 const uri = c.req.query("uri"); 599 if (!uri) { 600 return c.json({ error: "MissingRequiredParameter", message: "uri is required" }, 400); 601 } 602 603 // Fetch the strata record from D1 (indexed by Contrail) 604 const row = await db 605 .prepare("SELECT record FROM records_strata WHERE uri = ?") 606 .bind(uri) 607 .first<{ record: string }>(); 608 609 if (!row) { 610 return c.json({ error: "NotFound", message: "Strata record not found" }, 404); 611 } 612 613 let strataRecord: any; 614 try { 615 strataRecord = JSON.parse(row.record); 616 } catch { 617 return c.json({ error: "InvalidRecord" }, 500); 618 } 619 620 // Derive island from lexmin (source.uri) 621 const lexmin = strataRecord.source?.uri; 622 if (!lexmin) { 623 return c.json({ error: "MissingLexmin", message: "Strata record has no source.uri" }, 400); 624 } 625 626 const island = await deriveIsland(db, lexmin); 627 if (!island) { 628 return c.json({ error: "IslandNotFound", message: "Could not derive island from lexmin" }, 404); 629 } 630 631 // Resolve vertex metadata 632 const meta = await resolveVertexMeta(db, island.vertices); 633 634 // Build the response in the same format as the island cache 635 const analysis = strataRecord.analysis || {}; 636 const strataData = { 637 prose: analysis.synthesis || "", 638 title: analysis.title || "", 639 themes: analysis.themes || [], 640 relationships: (analysis.connections || []).map((c: any) => c.description || ""), 641 tensions: analysis.tensions || [], 642 open_questions: analysis.openQuestions || [], 643 synthesis: analysis.synthesis || "", 644 }; 645 646 return c.json({ 647 island: { 648 ...island, 649 vertexMeta: Object.fromEntries(meta), 650 summary: analysis.title || null, 651 strata: strataData, 652 }, 653 record: strataRecord, 654 recordUri: uri, 655 }); 656 }); 657 658 // ── Container-backed xrpc methods ──────────────────────────────── 659 660 // Helper: call the strata container 661 async function callContainer(path: string, method: string, body?: any): Promise<any> { 662 const id = env.STRATA_CONTAINER.idFromName("strata"); 663 const stub = env.STRATA_CONTAINER.get(id); 664 const url = new URL(path, "http://container"); 665 const init: RequestInit = { method }; 666 if (body) { 667 init.body = JSON.stringify(body); 668 init.headers = { "Content-Type": "application/json" }; 669 } 670 const resp = await stub.fetch(new Request(url.toString(), init)); 671 return resp.json(); 672 } 673 674 // Derive islands — POST, delegates to container 675 // With seed: single island. Without: all islands. 676 app.post("/xrpc/org.latha.strata.deriveIsland", async (c) => { 677 const body = await c.req.json().catch(() => ({})) as { seed?: string }; 678 const seed = body.seed; 679 680 // Load all connections from D1 for the container to traverse 681 await ensureContrailReady(db); 682 const rows = await db 683 .prepare( 684 `SELECT r.record, r.did, r.rkey, i.handle 685 FROM records_connection r 686 LEFT JOIN identities i ON r.did = i.did`, 687 ) 688 .all<{ record: string; did: string; rkey: string; handle: string | null }>(); 689 690 const connections = (rows.results || []).map(r => { 691 try { 692 const value = JSON.parse(r.record); 693 return { 694 source: value.source, 695 target: value.target, 696 connectionType: value.connectionType || "relates", 697 note: value.note || "", 698 did: r.did, 699 rkey: r.rkey, 700 }; 701 } catch { 702 return null; 703 } 704 }).filter(Boolean); 705 706 try { 707 const result = await callContainer("/deriveIsland", "POST", { connections, seed: seed || undefined }); 708 return c.json(result); 709 } catch (e: any) { 710 return c.json({ error: "ContainerError", message: e.message }, 500); 711 } 712 }); 713 714 // Run strata analysis — delegates to container 715 app.post("/xrpc/org.latha.strata.analyze", async (c) => { 716 const body = await c.req.json().catch(() => ({})); 717 if (!body.island) { 718 return c.json({ error: "MissingRequiredField", message: "island is required" }, 400); 719 } 720 721 try { 722 const result = await callContainer("/analyze", "POST", body); 723 return c.json(result); 724 } catch (e: any) { 725 return c.json({ error: "ContainerError", message: e.message }, 500); 726 } 727 }); 728 729 // ── R2 sync endpoints ─────────────────────────────────────────── 730 731 app.put("/api/sync/vault/:did/*", async (c) => { 732 const did = c.req.param("did"); 733 const path = c.req.param("path"); 734 if (!did || !path) { 735 return c.json({ error: "Missing path" }, 400); 736 } 737 738 const key = `${did}/${path}`; 739 const body = await c.req.raw.arrayBuffer(); 740 await env.VAULT_BUCKET.put(key, body, { 741 httpMetadata: { contentType: c.req.header("content-type") || "text/markdown" }, 742 }); 743 744 return c.json({ ok: true, key }); 745 }); 746 747 app.put("/api/sync/carry/:did/*", async (c) => { 748 const did = c.req.param("did"); 749 const path = c.req.param("path"); 750 if (!did || !path) { 751 return c.json({ error: "Missing path" }, 400); 752 } 753 754 const key = `${did}/${path}`; 755 const body = await c.req.raw.arrayBuffer(); 756 await env.CARRY_BUCKET.put(key, body, { 757 httpMetadata: { contentType: c.req.header("content-type") || "application/json" }, 758 }); 759 760 return c.json({ ok: true, key }); 761 }); 762 763 // ── Graph neighborhood query ──────────────────────────────────── 764 765 app.get("/xrpc/org.latha.strata.connection.getGraph", async (c) => { 766 const uri = c.req.query("uri"); 767 if (!uri) { 768 return c.json({ error: "MissingRequiredParameter", message: "uri is required" }, 400); 769 } 770 771 const depth = Math.min(parseInt(c.req.query("depth") || "2"), 3); 772 const limit = Math.min(parseInt(c.req.query("limit") || "50"), 100); 773 const types = c.req.query("types")?.split(",").map((t) => t.trim()); 774 775 // Reuse island detection for the subgraph 776 const islands = await detectIslands(db); 777 const island = islands.find(i => i.vertices.includes(uri)); 778 779 if (!island) { 780 return c.json({ connections: [], resources: [], depth, uri }); 781 } 782 783 // Convert to graph format 784 const resources = island.vertices.map(v => ({ 785 uri: v, 786 title: v, 787 type: "unknown", 788 })); 789 790 return c.json({ 791 connections: island.edges, 792 resources, 793 depth, 794 uri, 795 }); 796 }); 797 798 // ── OAuth client metadata ────────────────────────────────────── 799 800 app.get("/oauth-client-metadata.json", (c) => { 801 const host = new URL(c.req.url).host; 802 return c.json({ 803 client_id: `https://${host}/oauth-client-metadata.json`, 804 client_name: "Stigmergic", 805 client_uri: `https://${host}`, 806 redirect_uris: [`https://${host}/`], 807 scope: "atproto transition:generic", 808 grant_types: ["authorization_code", "refresh_token"], 809 response_types: ["code"], 810 token_endpoint_auth_method: "none", 811 application_type: "web", 812 dpop_bound_access_tokens: true, 813 }); 814 }); 815 816 // ── SPA fallback: serve landing page for client-side routes ────── 817 for (const path of ["/island", "/strata", "/connect"]) { 818 app.get(path, async (c) => { 819 let islandsJson = "[]"; 820 try { 821 await ensureContrailReady(db); 822 const rows = await db 823 .prepare("SELECT uri, record FROM records_strata ORDER BY time_us DESC LIMIT 50") 824 .all<{ uri: string; record: string }>(); 825 const islands: any[] = []; 826 for (const row of rows.results || []) { 827 try { 828 const rec = JSON.parse(row.record); 829 const lexmin = rec.source?.uri; 830 if (!lexmin) continue; 831 const lexminHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(lexmin)); 832 const id = Array.from(new Uint8Array(lexminHash)).map(b => b.toString(16).padStart(2, "0")).join("").slice(0, 16); 833 islands.push({ id, lexmin, title: rec.analysis?.title || null, recordUri: row.uri }); 834 } catch {} 835 } 836 islandsJson = JSON.stringify(islands); 837 } catch {} 838 839 const page = LANDING_PAGE.replace( 840 "</head>", 841 `<script>window.__ISLANDS__=${islandsJson};</script></head>`, 842 ); 843 return c.html(page); 844 }); 845 } 846 847 // ── All other routes pass through to contrail ────────────────── 848 849 app.all("*", async (c) => { 850 const response = await contrailWorker.fetch( 851 c.req.raw, 852 c.env as unknown as Record<string, unknown>, 853 ); 854 return response; 855 }); 856 857 return app; 858} 859 860// ─── Export ───────────────────────────────────────────────────────── 861 862export { StrataContainer }; 863 864export default { 865 fetch(request: Request, env: Env): Response | Promise<Response> { 866 app ??= buildApp(env); 867 return app.fetch(request, env); 868 }, 869 async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext) { 870 // Run Contrail indexing 871 await contrailWorker.scheduled( 872 event, 873 env as unknown as Record<string, unknown>, 874 ctx, 875 ); 876 }, 877}; 878