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