This repository has no description
32 kB
923 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 // Just load summaries for the landing page — client-side will fetch full data
383 let islandsJson = "[]";
384 try {
385 await ensureContrailReady(db);
386 const rows = await db
387 .prepare("SELECT uri, record FROM records_strata ORDER BY time_us DESC LIMIT 10")
388 .all<{ uri: string; record: string }>();
389
390 const islands: any[] = [];
391 for (const row of rows.results || []) {
392 try {
393 const rec = JSON.parse(row.record);
394 const lexmin = rec.source?.uri;
395 if (!lexmin) continue;
396 const lexminHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(lexmin));
397 const id = Array.from(new Uint8Array(lexminHash)).map(b => b.toString(16).padStart(2, "0")).join("").slice(0, 16);
398 islands.push({
399 id,
400 lexmin,
401 title: rec.analysis?.title || null,
402 recordUri: row.uri,
403 });
404 } catch {}
405 }
406 islandsJson = JSON.stringify(islands);
407 } catch (e) {
408 console.error("Failed to load islands:", e);
409 }
410
411 const page = LANDING_PAGE.replace(
412 "</head>",
413 `<script>window.__ISLANDS__=${islandsJson};</script></head>`,
414 );
415 return c.html(page);
416 });
417
418 // ── Islands API ──────────────────────────────────────────────────
419
420 app.get("/xrpc/org.latha.strata.getIslands", async (c) => {
421 const limit = Math.min(parseInt(c.req.query("limit") || "50"), 100);
422 await ensureContrailReady(db);
423
424 const rows = await db
425 .prepare("SELECT uri, record FROM records_strata ORDER BY time_us DESC LIMIT ?")
426 .bind(limit)
427 .all<{ uri: string; record: string }>();
428
429 const islands: any[] = [];
430 for (const row of rows.results || []) {
431 try {
432 const rec = JSON.parse(row.record);
433 const lexmin = rec.source?.uri;
434 if (!lexmin) continue;
435
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 // Reconstruct island from stored connections (chunked for D1)
440 const connectionUris: string[] = rec.connections || [];
441 let vertices: string[] = [];
442 let edges: any[] = [];
443 let vertexMeta: Record<string, any> = {};
444
445 if (connectionUris.length > 0) {
446 const CHUNK_SIZE = 50;
447 const vertexSet = new Set<string>();
448
449 for (let i = 0; i < connectionUris.length; i += CHUNK_SIZE) {
450 const chunk = connectionUris.slice(i, i + CHUNK_SIZE);
451 const placeholders = chunk.map(() => "?").join(",");
452 const connRows = await db
453 .prepare(`SELECT r.record, r.did, r.rkey, i.handle FROM records_connection r LEFT JOIN identities i ON r.did = i.did WHERE r.uri IN (${placeholders})`)
454 .bind(...chunk)
455 .all<{ record: string; did: string; rkey: string; handle: string | null }>();
456
457 for (const cr of connRows.results || []) {
458 try {
459 const val = JSON.parse(cr.record);
460 vertexSet.add(val.source);
461 vertexSet.add(val.target);
462 edges.push({
463 uri: `at://${cr.did}/network.cosmik.connection/${cr.rkey}`,
464 did: cr.did, rkey: cr.rkey,
465 source: val.source, target: val.target,
466 connectionType: val.connectionType || "relates",
467 note: val.note || "", handle: cr.handle,
468 });
469 } catch {}
470 }
471 }
472 vertices = [...vertexSet].sort();
473 vertexMeta = Object.fromEntries(await resolveVertexMeta(db, vertices));
474 }
475
476 const analysis = rec.analysis || {};
477 islands.push({
478 id, lexmin, vertices, edges, vertexMeta,
479 title: analysis.title || null,
480 strata: analysis.synthesis ? {
481 prose: analysis.synthesis || "", title: analysis.title || "",
482 themes: analysis.themes || [],
483 relationships: (analysis.connections || []).map((conn: any) => conn.description || ""),
484 tensions: analysis.tensions || [],
485 open_questions: analysis.openQuestions || [],
486 synthesis: analysis.synthesis || "",
487 } : null,
488 recordUri: row.uri,
489 });
490 } catch {}
491 }
492
493 return c.json({ islands });
494 });
495
496 // ── Get single island by ID or AT URI ────────────────────────────
497
498 app.get("/xrpc/org.latha.strata.getIsland", async (c) => {
499 const id = c.req.query("id");
500 if (!id) {
501 return c.json({ error: "MissingRequiredParameter", message: "id is required" }, 400);
502 }
503
504 await ensureContrailReady(db);
505
506 // If it's an AT URI, look up the strata record and derive from its lexmin
507 if (id.startsWith("at://")) {
508 const row = await db
509 .prepare("SELECT uri, record FROM records_strata WHERE uri = ?")
510 .bind(id)
511 .first<{ uri: string; record: string }>();
512
513 if (!row) {
514 return c.json({ error: "RecordNotFound" }, 404);
515 }
516
517 const rec = JSON.parse(row.record);
518 const lexmin = rec.source?.uri;
519 if (!lexmin) {
520 return c.json({ error: "InvalidRecord" }, 400);
521 }
522
523 const island = await deriveIsland(db, lexmin);
524 if (!island) {
525 return c.json({ error: "IslandNotFound" }, 404);
526 }
527
528 const meta = await resolveVertexMeta(db, island.vertices);
529 const analysis = rec.analysis || {};
530
531 return c.json({
532 island: {
533 ...island,
534 lexmin: island.vertices.slice().sort()[0],
535 vertexMeta: Object.fromEntries(meta),
536 summary: analysis.title || null,
537 strata: {
538 prose: analysis.synthesis || "",
539 title: analysis.title || "",
540 themes: analysis.themes || [],
541 relationships: (analysis.connections || []).map((conn: any) => conn.description || ""),
542 tensions: analysis.tensions || [],
543 open_questions: analysis.openQuestions || [],
544 synthesis: analysis.synthesis || "",
545 },
546 },
547 recordUri: row.uri,
548 });
549 }
550
551 // Stable ID — derive from lexmin by reverse-lookup
552 // Find the strata record whose lexmin hashes to this ID
553 const strataRows = await db
554 .prepare("SELECT uri, record FROM records_strata")
555 .all<{ uri: string; record: string }>();
556
557 for (const row of strataRows.results || []) {
558 try {
559 const rec = JSON.parse(row.record);
560 const lexmin = rec.source?.uri;
561 if (!lexmin) continue;
562
563 const lexminHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(lexmin));
564 const candidateId = Array.from(new Uint8Array(lexminHash)).map(b => b.toString(16).padStart(2, "0")).join("").slice(0, 16);
565
566 if (candidateId === id) {
567 const island = await deriveIsland(db, lexmin);
568 if (!island) continue;
569
570 const meta = await resolveVertexMeta(db, island.vertices);
571 const analysis = rec.analysis || {};
572
573 return c.json({
574 island: {
575 ...island,
576 lexmin: island.vertices.slice().sort()[0],
577 vertexMeta: Object.fromEntries(meta),
578 summary: analysis.title || null,
579 strata: {
580 prose: analysis.synthesis || "",
581 title: analysis.title || "",
582 themes: analysis.themes || [],
583 relationships: (analysis.connections || []).map((conn: any) => conn.description || ""),
584 tensions: analysis.tensions || [],
585 open_questions: analysis.openQuestions || [],
586 synthesis: analysis.synthesis || "",
587 },
588 },
589 recordUri: row.uri,
590 });
591 }
592 } catch {}
593 }
594
595 // No strata record — try deriving island by treating the ID as a lexmin seed
596 // (for islands that haven't been analyzed yet)
597 const island = await deriveIsland(db, id);
598 if (!island) {
599 return c.json({ error: "IslandNotFound" }, 404);
600 }
601
602 const meta = await resolveVertexMeta(db, island.vertices);
603 return c.json({
604 island: {
605 ...island,
606 lexmin: island.vertices.slice().sort()[0],
607 vertexMeta: Object.fromEntries(meta),
608 summary: null,
609 strata: null,
610 },
611 recordUri: null,
612 });
613 });
614
615 // ── List strata records (AT URI-based) ──────────────────────────
616
617 app.get("/xrpc/org.latha.strata.listRecords", async (c) => {
618 const rows = await db
619 .prepare("SELECT uri, record FROM records_strata ORDER BY time_us DESC LIMIT 50")
620 .all<{ uri: string; record: string }>();
621
622 const records = (rows.results || []).map(r => {
623 try {
624 const rec = JSON.parse(r.record);
625 return {
626 uri: r.uri,
627 source: rec.source?.uri || "",
628 title: rec.analysis?.title || "",
629 themes: rec.analysis?.themes || [],
630 createdAt: rec.createdAt || "",
631 };
632 } catch {
633 return null;
634 }
635 }).filter(Boolean);
636
637 return c.json({ records });
638 });
639
640 // ── Strata record endpoint (AT URI-based) ──────────────────────
641
642 app.get("/xrpc/org.latha.strata.getRecord", async (c) => {
643 const uri = c.req.query("uri");
644 if (!uri) {
645 return c.json({ error: "MissingRequiredParameter", message: "uri is required" }, 400);
646 }
647
648 // Fetch the strata record from D1 (indexed by Contrail)
649 const row = await db
650 .prepare("SELECT record FROM records_strata WHERE uri = ?")
651 .bind(uri)
652 .first<{ record: string }>();
653
654 if (!row) {
655 return c.json({ error: "NotFound", message: "Strata record not found" }, 404);
656 }
657
658 let strataRecord: any;
659 try {
660 strataRecord = JSON.parse(row.record);
661 } catch {
662 return c.json({ error: "InvalidRecord" }, 500);
663 }
664
665 // Derive island from lexmin (source.uri)
666 const lexmin = strataRecord.source?.uri;
667 if (!lexmin) {
668 return c.json({ error: "MissingLexmin", message: "Strata record has no source.uri" }, 400);
669 }
670
671 const island = await deriveIsland(db, lexmin);
672 if (!island) {
673 return c.json({ error: "IslandNotFound", message: "Could not derive island from lexmin" }, 404);
674 }
675
676 // Resolve vertex metadata
677 const meta = await resolveVertexMeta(db, island.vertices);
678
679 // Build the response in the same format as the island cache
680 const analysis = strataRecord.analysis || {};
681 const strataData = {
682 prose: analysis.synthesis || "",
683 title: analysis.title || "",
684 themes: analysis.themes || [],
685 relationships: (analysis.connections || []).map((c: any) => c.description || ""),
686 tensions: analysis.tensions || [],
687 open_questions: analysis.openQuestions || [],
688 synthesis: analysis.synthesis || "",
689 };
690
691 return c.json({
692 island: {
693 ...island,
694 vertexMeta: Object.fromEntries(meta),
695 summary: analysis.title || null,
696 strata: strataData,
697 },
698 record: strataRecord,
699 recordUri: uri,
700 });
701 });
702
703 // ── Container-backed xrpc methods ────────────────────────────────
704
705 // Helper: call the strata container
706 async function callContainer(path: string, method: string, body?: any): Promise<any> {
707 const id = env.STRATA_CONTAINER.idFromName("strata");
708 const stub = env.STRATA_CONTAINER.get(id);
709 const url = new URL(path, "http://container");
710 const init: RequestInit = { method };
711 if (body) {
712 init.body = JSON.stringify(body);
713 init.headers = { "Content-Type": "application/json" };
714 }
715 const resp = await stub.fetch(new Request(url.toString(), init));
716 return resp.json();
717 }
718
719 // Derive islands — POST, delegates to container
720 // With seed: single island. Without: all islands.
721 app.post("/xrpc/org.latha.strata.deriveIsland", async (c) => {
722 const body = await c.req.json().catch(() => ({})) as { seed?: string };
723 const seed = body.seed;
724
725 // Load all connections from D1 for the container to traverse
726 await ensureContrailReady(db);
727 const rows = await db
728 .prepare(
729 `SELECT r.record, r.did, r.rkey, i.handle
730 FROM records_connection r
731 LEFT JOIN identities i ON r.did = i.did`,
732 )
733 .all<{ record: string; did: string; rkey: string; handle: string | null }>();
734
735 const connections = (rows.results || []).map(r => {
736 try {
737 const value = JSON.parse(r.record);
738 return {
739 source: value.source,
740 target: value.target,
741 connectionType: value.connectionType || "relates",
742 note: value.note || "",
743 did: r.did,
744 rkey: r.rkey,
745 };
746 } catch {
747 return null;
748 }
749 }).filter(Boolean);
750
751 try {
752 const result = await callContainer("/deriveIsland", "POST", { connections, seed: seed || undefined });
753 return c.json(result);
754 } catch (e: any) {
755 return c.json({ error: "ContainerError", message: e.message }, 500);
756 }
757 });
758
759 // Run strata analysis — delegates to container
760 app.post("/xrpc/org.latha.strata.analyze", async (c) => {
761 const body = await c.req.json().catch(() => ({}));
762 if (!body.island) {
763 return c.json({ error: "MissingRequiredField", message: "island is required" }, 400);
764 }
765
766 try {
767 const result = await callContainer("/analyze", "POST", body);
768 return c.json(result);
769 } catch (e: any) {
770 return c.json({ error: "ContainerError", message: e.message }, 500);
771 }
772 });
773
774 // ── R2 sync endpoints ───────────────────────────────────────────
775
776 app.put("/api/sync/vault/:did/*", async (c) => {
777 const did = c.req.param("did");
778 const path = c.req.param("path");
779 if (!did || !path) {
780 return c.json({ error: "Missing path" }, 400);
781 }
782
783 const key = `${did}/${path}`;
784 const body = await c.req.raw.arrayBuffer();
785 await env.VAULT_BUCKET.put(key, body, {
786 httpMetadata: { contentType: c.req.header("content-type") || "text/markdown" },
787 });
788
789 return c.json({ ok: true, key });
790 });
791
792 app.put("/api/sync/carry/:did/*", async (c) => {
793 const did = c.req.param("did");
794 const path = c.req.param("path");
795 if (!did || !path) {
796 return c.json({ error: "Missing path" }, 400);
797 }
798
799 const key = `${did}/${path}`;
800 const body = await c.req.raw.arrayBuffer();
801 await env.CARRY_BUCKET.put(key, body, {
802 httpMetadata: { contentType: c.req.header("content-type") || "application/json" },
803 });
804
805 return c.json({ ok: true, key });
806 });
807
808 // ── Graph neighborhood query ────────────────────────────────────
809
810 app.get("/xrpc/org.latha.strata.connection.getGraph", async (c) => {
811 const uri = c.req.query("uri");
812 if (!uri) {
813 return c.json({ error: "MissingRequiredParameter", message: "uri is required" }, 400);
814 }
815
816 const depth = Math.min(parseInt(c.req.query("depth") || "2"), 3);
817 const limit = Math.min(parseInt(c.req.query("limit") || "50"), 100);
818 const types = c.req.query("types")?.split(",").map((t) => t.trim());
819
820 // Reuse island detection for the subgraph
821 const islands = await detectIslands(db);
822 const island = islands.find(i => i.vertices.includes(uri));
823
824 if (!island) {
825 return c.json({ connections: [], resources: [], depth, uri });
826 }
827
828 // Convert to graph format
829 const resources = island.vertices.map(v => ({
830 uri: v,
831 title: v,
832 type: "unknown",
833 }));
834
835 return c.json({
836 connections: island.edges,
837 resources,
838 depth,
839 uri,
840 });
841 });
842
843 // ── OAuth client metadata ──────────────────────────────────────
844
845 app.get("/oauth-client-metadata.json", (c) => {
846 const host = new URL(c.req.url).host;
847 return c.json({
848 client_id: `https://${host}/oauth-client-metadata.json`,
849 client_name: "Stigmergic",
850 client_uri: `https://${host}`,
851 redirect_uris: [`https://${host}/`],
852 scope: "atproto transition:generic",
853 grant_types: ["authorization_code", "refresh_token"],
854 response_types: ["code"],
855 token_endpoint_auth_method: "none",
856 application_type: "web",
857 dpop_bound_access_tokens: true,
858 });
859 });
860
861 // ── SPA fallback: serve landing page for client-side routes ──────
862 for (const path of ["/island", "/strata", "/connect"]) {
863 app.get(path, async (c) => {
864 let islandsJson = "[]";
865 try {
866 await ensureContrailReady(db);
867 const rows = await db
868 .prepare("SELECT uri, record FROM records_strata ORDER BY time_us DESC LIMIT 50")
869 .all<{ uri: string; record: string }>();
870 const islands: any[] = [];
871 for (const row of rows.results || []) {
872 try {
873 const rec = JSON.parse(row.record);
874 const lexmin = rec.source?.uri;
875 if (!lexmin) continue;
876 const lexminHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(lexmin));
877 const id = Array.from(new Uint8Array(lexminHash)).map(b => b.toString(16).padStart(2, "0")).join("").slice(0, 16);
878 islands.push({ id, lexmin, title: rec.analysis?.title || null, recordUri: row.uri });
879 } catch {}
880 }
881 islandsJson = JSON.stringify(islands);
882 } catch {}
883
884 const page = LANDING_PAGE.replace(
885 "</head>",
886 `<script>window.__ISLANDS__=${islandsJson};</script></head>`,
887 );
888 return c.html(page);
889 });
890 }
891
892 // ── All other routes pass through to contrail ──────────────────
893
894 app.all("*", async (c) => {
895 const response = await contrailWorker.fetch(
896 c.req.raw,
897 c.env as unknown as Record<string, unknown>,
898 );
899 return response;
900 });
901
902 return app;
903}
904
905// ─── Export ─────────────────────────────────────────────────────────
906
907export { StrataContainer };
908
909export default {
910 fetch(request: Request, env: Env): Response | Promise<Response> {
911 app ??= buildApp(env);
912 return app.fetch(request, env);
913 },
914 async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext) {
915 // Run Contrail indexing
916 await contrailWorker.scheduled(
917 event,
918 env as unknown as Record<string, unknown>,
919 ctx,
920 );
921 },
922};
923