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