@manta-eu/horns-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/dist/action-plan-routes.d.ts +22 -0
  3. package/dist/action-plan-routes.js +85 -0
  4. package/dist/agents-proxy.d.ts +35 -0
  5. package/dist/agents-proxy.js +129 -0
  6. package/dist/auth-params.d.ts +2 -0
  7. package/dist/auth-params.js +2 -0
  8. package/dist/auth-routes.d.ts +12 -0
  9. package/dist/auth-routes.js +75 -0
  10. package/dist/authorize.d.ts +24 -0
  11. package/dist/authorize.js +43 -0
  12. package/dist/datastore-access.d.ts +23 -0
  13. package/dist/datastore-access.js +74 -0
  14. package/dist/dev-session.d.ts +5 -0
  15. package/dist/dev-session.js +15 -0
  16. package/dist/entity-links.d.ts +10 -0
  17. package/dist/entity-links.js +51 -0
  18. package/dist/env.d.ts +11 -0
  19. package/dist/env.js +1 -0
  20. package/dist/horn-routes.d.ts +22 -0
  21. package/dist/horn-routes.js +344 -0
  22. package/dist/index.d.ts +19 -0
  23. package/dist/index.js +20 -0
  24. package/dist/llm.d.ts +45 -0
  25. package/dist/llm.js +126 -0
  26. package/dist/manta/agents-url.d.ts +2 -0
  27. package/dist/manta/agents-url.js +2 -0
  28. package/dist/manta-proxy-policy.d.ts +47 -0
  29. package/dist/manta-proxy-policy.js +129 -0
  30. package/dist/manta-rules.d.ts +15 -0
  31. package/dist/manta-rules.js +86 -0
  32. package/dist/manta-upload.d.ts +18 -0
  33. package/dist/manta-upload.js +80 -0
  34. package/dist/ndjson-reader.d.ts +5 -0
  35. package/dist/ndjson-reader.js +36 -0
  36. package/dist/ndjson.d.ts +9 -0
  37. package/dist/ndjson.js +48 -0
  38. package/dist/pii.d.ts +4 -0
  39. package/dist/pii.js +22 -0
  40. package/dist/redact.d.ts +2 -0
  41. package/dist/redact.js +7 -0
  42. package/dist/render-document.d.ts +8 -0
  43. package/dist/render-document.js +18 -0
  44. package/dist/sentry.d.ts +23 -0
  45. package/dist/sentry.js +18 -0
  46. package/dist/session.d.ts +23 -0
  47. package/dist/session.js +166 -0
  48. package/dist/shared.d.ts +4 -0
  49. package/dist/shared.js +9 -0
  50. package/dist/sparql-select.d.ts +8 -0
  51. package/dist/sparql-select.js +23 -0
  52. package/dist/sparql-update.d.ts +13 -0
  53. package/dist/sparql-update.js +47 -0
  54. package/dist/studio-routes.d.ts +43 -0
  55. package/dist/studio-routes.js +382 -0
  56. package/dist/worker.d.ts +14 -0
  57. package/dist/worker.js +169 -0
  58. package/package.json +62 -0
@@ -0,0 +1,382 @@
1
+ import { Hono } from "hono";
2
+ import { z } from "zod";
3
+ import { isPresent } from "@manta-eu/lib/present";
4
+ import { hornSchema, isRealm } from "@manta-eu/horns";
5
+ import { acceptedSpecs, assembleStudioMessages, createDryRun, evaluateProposal, formatFailures, hornPatchOpSchema, buildUsefulContext, builtInSkills, datastoreFacts, evaluateShapeViewOnZoo, selectSkills, runShapeCoverage, runShapeViewTurn, adviseOnHorn, runStudioTurn, sampleShapeFillRates, studioReadHandlers, studioSystemPrompt, studioWriteHandlers, } from "@manta-eu/horns/studio";
6
+ import { shapeLinks, shapeViewSpecSchema, termNotes } from "@manta-eu/horns/shapes";
7
+ import { mantaReactCatalog } from "@manta-eu/horns-kit/catalog";
8
+ import { shapeViewSpecs } from "@manta-eu/horns-kit/shape-views";
9
+ import { sameOriginDeniedResponse, signedOutMessage } from "./agents-proxy.js";
10
+ import { datastoreAccessFor, datastoreAccessUnavailableMessage, datastoreNotGrantedMessage, forgetDatastoreAccess, grantsDatastore, } from "./datastore-access.js";
11
+ import { parseBody } from "./authorize.js";
12
+ import { stageRule } from "./manta-rules.js";
13
+ import { criticModelFromEnv, studioModelFromEnv } from "./llm.js";
14
+ import { requestSession } from "./dev-session.js";
15
+ import { ndjsonResponse } from "./ndjson.js";
16
+ import { isSignedIn } from "./session.js";
17
+ import { agentsSparqlSelect } from "./sparql-select.js";
18
+ const noStore = { "cache-control": "no-store" };
19
+ /**
20
+ * How many model rounds one request may take.
21
+ *
22
+ * Twenty-four ran out on real work — fifteen queries and three repairs and the turn is over, which costs
23
+ * the person a whole round trip to say "continue". dyad allows a hundred tool-call steps and keeps only the
24
+ * last few chat turns in context; this is the same trade, sized for a worker request rather than a desktop.
25
+ */
26
+ const turnStepLimit = 48;
27
+ /**
28
+ * How much of the conversation goes back to the model.
29
+ *
30
+ * The draft itself is in `<useful-context>`, so the history is there for intent, not for state — and every
31
+ * message of it is context the turn cannot spend on work. dyad keeps three turns by default; this keeps six,
32
+ * because our messages are short and the earlier ones carry what someone wants the Horn to look like.
33
+ */
34
+ export const maxHistoryTurns = 6;
35
+ // A picture of a Horn at the capture width is tens of kilobytes; a data URL of more than this is not one.
36
+ export const maxReviewImageChars = 6 * 1024 * 1024;
37
+ const reviewRequestSchema = z.object({
38
+ /** The Horn as the browser drew it, as a PNG data URL. */
39
+ image: z
40
+ .string()
41
+ .regex(/^data:image\/png;base64,/)
42
+ .max(maxReviewImageChars),
43
+ /** What the person asked for, so the critic can say the answer does not answer it. */
44
+ asked: z.string().trim().min(1).max(2000).optional(),
45
+ });
46
+ const messagesSchema = z
47
+ .array(z.object({ role: z.enum(["user", "assistant"]), content: z.string().min(1).max(4000) }))
48
+ .min(1)
49
+ .max(40);
50
+ // A shape view is a card and the fields it reads; the shape and the class are not the model's to change.
51
+ const viewTurnRequestSchema = z.object({
52
+ spec: shapeViewSpecSchema,
53
+ messages: messagesSchema,
54
+ });
55
+ const turnRequestSchema = z.object({
56
+ horn: hornSchema,
57
+ messages: messagesSchema,
58
+ });
59
+ // One shape is a minute or two of watching. Three is the most anyone waits at a page before it stops being
60
+ // live and becomes a job, and a job is what the script is for.
61
+ const coverageRequestSchema = z.object({ limit: z.number().int().min(1).max(3).optional() });
62
+ // An edit a person made themselves — renaming a view, taking one out — as the same ops a turn proposes.
63
+ const editRequestSchema = z.object({
64
+ horn: hornSchema,
65
+ ops: z.array(hornPatchOpSchema).min(1).max(20),
66
+ });
67
+ /**
68
+ * One turn of talking to a Horn. The draft arrives in the body and comes back changed — nothing is stored
69
+ * here: saving is the save route's job, with its own lock check and its own authorization.
70
+ *
71
+ * Prior turns travel as their text alone. Their tool calls described a draft the user has since changed, and
72
+ * re-sending them would ask the model to reason from a Horn that no longer exists; useful-context carries the
73
+ * one that does.
74
+ */
75
+ export function studioRoutes(deps) {
76
+ const routes = new Hono();
77
+ /**
78
+ * Same origin, a session, and a datastore this session was granted. Both lanes ask the same question, and a
79
+ * second copy of it is a second place to get it wrong.
80
+ */
81
+ const context = async (c) => {
82
+ const denied = sameOriginDeniedResponse(c.req.raw);
83
+ if (denied)
84
+ return { ok: false, response: denied };
85
+ const session = requestSession(c.req.raw, c.env);
86
+ if (!isPresent(session) || !isSignedIn(session)) {
87
+ return { ok: false, response: Response.json({ error: signedOutMessage }, { status: 401 }) };
88
+ }
89
+ const configured = c.env.VITE_KEYCLOAK_REALM;
90
+ if (!isPresent(configured) || !isRealm(configured)) {
91
+ return {
92
+ ok: false,
93
+ response: Response.json({ error: "This deployment has no realm configured." }, { status: 500 }),
94
+ };
95
+ }
96
+ const datastoreId = c.req.param("datastoreId");
97
+ const resolved = await datastoreAccessFor(c.env, session);
98
+ if (!resolved.ok) {
99
+ return {
100
+ ok: false,
101
+ response: Response.json({ error: resolved.reason === "unauthenticated" ? signedOutMessage : datastoreAccessUnavailableMessage }, { status: resolved.reason === "unauthenticated" ? 401 : 502 }),
102
+ };
103
+ }
104
+ if (!grantsDatastore(resolved.access, datastoreId)) {
105
+ forgetDatastoreAccess(session);
106
+ return { ok: false, response: Response.json({ error: datastoreNotGrantedMessage }, { status: 403 }) };
107
+ }
108
+ return { ok: true, session, realm: configured, datastoreId };
109
+ };
110
+ routes.post("/api/d/:datastoreId/studio", async (c) => {
111
+ const allowed = await context(c);
112
+ if (!allowed.ok)
113
+ return allowed.response;
114
+ const { session, realm, datastoreId } = allowed;
115
+ const parsed = await parseBody(c.req.raw, turnRequestSchema);
116
+ if (!parsed.ok)
117
+ return parsed.response;
118
+ const { horn, messages } = parsed.data;
119
+ if (horn.datastoreId !== datastoreId) {
120
+ return c.json({ error: `This Horn belongs to the "${horn.datastoreId}" datastore.` }, 400);
121
+ }
122
+ const model = (deps.model ?? studioModelFromEnv)(c.env, { feature: "horns-studio", datastoreId });
123
+ if (!model.ok)
124
+ return c.json({ error: model.reason }, 503);
125
+ const select = agentsSparqlSelect({
126
+ requestUrl: c.req.raw.url,
127
+ agentsUrl: c.env.MANTA_AGENTS_URL,
128
+ session,
129
+ datastoreId,
130
+ });
131
+ const typed = realm;
132
+ const shapes = deps.shapes(typed);
133
+ const [latest, ...earlier] = [...messages].reverse();
134
+ // Newest first here, so this keeps the most recent exchanges and drops the oldest.
135
+ const carried = earlier.slice(0, maxHistoryTurns * 2);
136
+ const asked = latest?.content ?? "";
137
+ // Streamed like a shape view's turn: each event as the loop records it, then the result. A turn that
138
+ // looks at the data before it changes anything is a minute of work, and that minute should be visible.
139
+ return ndjsonResponse(async (emit) => {
140
+ emit({ type: "progress", event: { type: "reading" } });
141
+ // A class's shape is what a platform sends; what Manta's rules work out about it is on nothing's property
142
+ // list, so the turn is given it up front rather than left to discover that half of the graph exists.
143
+ const useful = buildUsefulContext({
144
+ realm: typed,
145
+ prompt: asked,
146
+ horn,
147
+ ...(await datastoreFacts({ realm: typed, select, shapes, prompt: asked, signal: c.req.raw.signal })),
148
+ library: [],
149
+ catalog: mantaReactCatalog,
150
+ skills: selectSkills(asked, builtInSkills),
151
+ });
152
+ const turn = await runStudioTurn({
153
+ model: model.model,
154
+ mode: "build",
155
+ draft: horn,
156
+ messages: assembleStudioMessages({
157
+ system: studioSystemPrompt({ mode: "build", realm: typed }),
158
+ history: [...carried].reverse(),
159
+ usefulContext: useful.text,
160
+ userMessage: asked,
161
+ }),
162
+ handlers: {
163
+ ...studioReadHandlers({
164
+ realm: typed,
165
+ select,
166
+ shapes,
167
+ views: shapeViewSpecs,
168
+ model: model.model,
169
+ // The investigation is a loop of its own; without this it is one line that takes a minute.
170
+ onEvent: (event) => emit({ type: "progress", event }),
171
+ }),
172
+ ...studioWriteHandlers({
173
+ realm: typed,
174
+ select,
175
+ catalog: mantaReactCatalog,
176
+ lookup: () => undefined,
177
+ // As the viewer: staging a rule is attributed to a person, and agents refuses it otherwise.
178
+ stageRule: (rule) => stageRule({ agentsUrl: c.env.MANTA_AGENTS_URL, session, datastoreId }, rule),
179
+ }),
180
+ },
181
+ maxSteps: turnStepLimit,
182
+ signal: c.req.raw.signal,
183
+ onEvent: (event) => emit({ type: "progress", event }),
184
+ });
185
+ emit({ type: "result", horn: turn.draft, reply: turn.reply, status: turn.status, steps: turn.steps });
186
+ }, noStore);
187
+ });
188
+ /**
189
+ * One turn of talking to a shape's view. The same lane as a Horn's, over a smaller thing: a view is a card
190
+ * and the fields it reads, and its gate is the zoo — the gate the coverage runs already judge by, so a view
191
+ * changed here is held to what a view generated here is held to.
192
+ */
193
+ /**
194
+ * A change the person made with their own hands. It goes through the gate a model's patch goes through —
195
+ * applied by id with a fingerprint, validated, locked and dry-run — because a Horn that cannot be drawn is
196
+ * no better for having been broken by hand. Nothing is stored: the draft goes back to the Studio, which
197
+ * saves when the person says so.
198
+ */
199
+ /**
200
+ * A second model looks at what the Studio drew and says what is wrong with it.
201
+ *
202
+ * The picture comes from the browser, because the browser is the only thing that can draw a Horn: every
203
+ * view is written source composing the design system. Advice, never a verdict — it is shown to the person
204
+ * and offered back to the Studio, and it never blocks a save.
205
+ */
206
+ routes.post("/api/d/:datastoreId/horns/review", async (c) => {
207
+ const allowed = await context(c);
208
+ if (!allowed.ok)
209
+ return allowed.response;
210
+ const { datastoreId } = allowed;
211
+ const parsed = await parseBody(c.req.raw, reviewRequestSchema);
212
+ if (!parsed.ok)
213
+ return parsed.response;
214
+ // Never the Studio's own model as a fallback, unlike the shape critic: that one can read a card as text,
215
+ // and this one has only a picture. Qwen would be shown an image it cannot see and would answer about
216
+ // nothing. Unconfigured means no reviewer, and the caller is told why.
217
+ const critic = (deps.criticModel ?? criticModelFromEnv)(c.env, { feature: "horn-critic", datastoreId });
218
+ if (!critic.ok)
219
+ return c.json({ error: critic.reason }, 503);
220
+ const advice = await adviseOnHorn({
221
+ model: critic.model,
222
+ image: parsed.data.image,
223
+ ...(parsed.data.asked === undefined ? {} : { asked: parsed.data.asked }),
224
+ signal: c.req.raw.signal,
225
+ });
226
+ return c.json({ advice }, 200, noStore);
227
+ });
228
+ routes.post("/api/d/:datastoreId/horns/edit", async (c) => {
229
+ const allowed = await context(c);
230
+ if (!allowed.ok)
231
+ return allowed.response;
232
+ const { session, realm, datastoreId } = allowed;
233
+ const parsed = await parseBody(c.req.raw, editRequestSchema);
234
+ if (!parsed.ok)
235
+ return parsed.response;
236
+ const { horn, ops } = parsed.data;
237
+ if (horn.datastoreId !== datastoreId) {
238
+ return c.json({ error: `This Horn belongs to the "${horn.datastoreId}" datastore.` }, 400);
239
+ }
240
+ const select = agentsSparqlSelect({
241
+ requestUrl: c.req.raw.url,
242
+ agentsUrl: c.env.MANTA_AGENTS_URL,
243
+ session,
244
+ datastoreId,
245
+ });
246
+ const result = await evaluateProposal(horn, ops, {
247
+ realm,
248
+ catalog: mantaReactCatalog,
249
+ lookup: () => undefined,
250
+ dryRun: createDryRun({ realm, select }),
251
+ });
252
+ if (!result.accepted) {
253
+ return c.json({ error: `That change did not hold up:\n${formatFailures(result.failures)}` }, 422);
254
+ }
255
+ return c.json({ horn: result.horn }, 200, noStore);
256
+ });
257
+ /**
258
+ * The coverage run, watched while it happens. The script is the durable one — it resumes, keeps transcripts
259
+ * and writes the kit; this stores nothing, because a run nobody is watching has no reason to go through a
260
+ * browser. What it designs comes back in the result, and saving stays where reviewing is: the zoo.
261
+ */
262
+ routes.post("/api/d/:datastoreId/shapes/cover", async (c) => {
263
+ const allowed = await context(c);
264
+ if (!allowed.ok)
265
+ return allowed.response;
266
+ const { session, realm, datastoreId } = allowed;
267
+ const body = await c.req.json().catch(() => undefined);
268
+ const parsed = coverageRequestSchema.safeParse(body ?? {});
269
+ if (!parsed.success)
270
+ return c.json({ error: "The request is invalid — limit must be between 1 and 3." }, 400);
271
+ const model = (deps.model ?? studioModelFromEnv)(c.env, { feature: "shape-coverage", datastoreId });
272
+ if (!model.ok)
273
+ return c.json({ error: model.reason }, 503);
274
+ // Optional: with no critic route it is the Studio's own model. Either way it reads the card as text —
275
+ // a shape's card is drawn by a browser now, and a coverage run has none.
276
+ const critic = deps.criticModel?.(c.env, { feature: "shape-critic", datastoreId });
277
+ const select = agentsSparqlSelect({
278
+ requestUrl: c.req.raw.url,
279
+ agentsUrl: c.env.MANTA_AGENTS_URL,
280
+ session,
281
+ datastoreId,
282
+ });
283
+ return ndjsonResponse(async (emit) => {
284
+ const state = await runShapeCoverage({
285
+ model: model.model,
286
+ select,
287
+ realm,
288
+ datastoreId,
289
+ shapes: deps.shapes(realm),
290
+ views: shapeViewSpecs,
291
+ // Watched, not resumed: the run starts from the kit's views every time and remembers nothing after it.
292
+ store: { load: async () => undefined, save: async () => undefined, saveTranscript: async () => undefined },
293
+ limit: parsed.data.limit ?? 1,
294
+ signal: c.req.raw.signal,
295
+ // Somebody is watching this one, so the critic's second opinion is worth the extra call.
296
+ advise: true,
297
+ ...(critic?.ok ? { criticModel: critic.model } : {}),
298
+ onTurnProgress: (progress) => emit({ type: "progress", ...progress }),
299
+ onProgress: ({ schema, result, index, total }) => emit({
300
+ type: "shape",
301
+ schema,
302
+ index,
303
+ total,
304
+ status: result.status,
305
+ ...(result.status === "accepted"
306
+ ? { spec: result.spec, rounds: result.rounds, advice: result.advice ?? [] }
307
+ : {}),
308
+ ...(result.status === "gave-up" ? { problems: result.problems } : {}),
309
+ ...(result.status === "failed" ? { error: result.error } : {}),
310
+ }),
311
+ });
312
+ const results = Object.values(state.shapes);
313
+ emit({
314
+ type: "result",
315
+ accepted: acceptedSpecs(state).map((spec) => spec.schema),
316
+ gaveUp: results.filter((result) => result.status === "gave-up").length,
317
+ noData: results.filter((result) => result.status === "no-data").length,
318
+ failed: results.filter((result) => result.status === "failed").length,
319
+ });
320
+ }, noStore);
321
+ });
322
+ routes.post("/api/d/:datastoreId/shape-views/:schema/studio", async (c) => {
323
+ const allowed = await context(c);
324
+ if (!allowed.ok)
325
+ return allowed.response;
326
+ const { session, realm, datastoreId } = allowed;
327
+ const parsed = await parseBody(c.req.raw, viewTurnRequestSchema);
328
+ if (!parsed.ok)
329
+ return parsed.response;
330
+ const schema = c.req.param("schema");
331
+ const shapes = deps.shapes(realm);
332
+ const shape = shapes.find((candidate) => candidate.schema === schema);
333
+ if (!shape)
334
+ return c.json({ error: `There is no shape called "${schema}" in this realm.` }, 404);
335
+ if (parsed.data.spec.schema !== schema) {
336
+ return c.json({ error: `That view is for ${parsed.data.spec.schema}, not ${schema}.` }, 400);
337
+ }
338
+ const model = (deps.model ?? studioModelFromEnv)(c.env, { feature: "horns-shape-view", datastoreId });
339
+ if (!model.ok)
340
+ return c.json({ error: model.reason }, 503);
341
+ const select = agentsSparqlSelect({
342
+ requestUrl: c.req.raw.url,
343
+ agentsUrl: c.env.MANTA_AGENTS_URL,
344
+ session,
345
+ datastoreId,
346
+ });
347
+ // A turn is up to three model rounds; see `ndjsonResponse` for why it streams.
348
+ return ndjsonResponse(async (emit) => {
349
+ const { sampled, fillRates } = await sampleShapeFillRates(shape, select, undefined, c.req.raw.signal);
350
+ const [latest, ...earlier] = [...parsed.data.messages].reverse();
351
+ const turn = await runShapeViewTurn({
352
+ model: model.model,
353
+ shape,
354
+ realm,
355
+ draft: parsed.data.spec,
356
+ request: latest?.content ?? "",
357
+ history: [...earlier].reverse(),
358
+ fillRates,
359
+ sampled,
360
+ links: await shapeLinks({ shape, shapes: [...shapes], realm, select, signal: c.req.raw.signal }),
361
+ notes: await termNotes({
362
+ realm,
363
+ terms: [shape.classIri, ...shape.properties.map((property) => property.path)],
364
+ select,
365
+ signal: c.req.raw.signal,
366
+ }),
367
+ evaluate: evaluateShapeViewOnZoo({ realm, datastoreId, select }),
368
+ signal: c.req.raw.signal,
369
+ onProgress: (progress) => emit({ type: "progress", ...progress }),
370
+ });
371
+ const reply = turn.messages.filter((message) => message.role === "assistant").at(-1)?.content ?? "";
372
+ emit({
373
+ type: "result",
374
+ reply,
375
+ ...(turn.status === "accepted"
376
+ ? { spec: turn.spec, status: turn.status, rounds: turn.rounds, repairs: turn.repairs, problems: [] }
377
+ : { spec: parsed.data.spec, status: turn.status, rounds: turn.rounds, repairs: [], problems: turn.problems }),
378
+ });
379
+ }, noStore);
380
+ });
381
+ return routes;
382
+ }
@@ -0,0 +1,14 @@
1
+ import { Hono } from "hono";
2
+ import type { Env } from "./env.ts";
3
+ import { type StudioRoutesDeps } from "./studio-routes.ts";
4
+ export type { Env };
5
+ export type HornsWorkerOptions = Pick<StudioRoutesDeps<Env>, "shapes"> & Partial<Pick<StudioRoutesDeps<Env>, "criticModel">>;
6
+ /**
7
+ * The Horns worker as a Hono app. `shapes` is the host's: the catalog describes a realm's data model, so
8
+ * Horns takes it rather than shipping one.
9
+ */
10
+ export declare function createHornsApp({ shapes, criticModel }: HornsWorkerOptions): Hono<{
11
+ Bindings: Env;
12
+ }, import("hono/types").BlankSchema, "/">;
13
+ /** The same app behind Sentry, ready to be a Worker's default export. */
14
+ export declare function createHornsWorker(options: HornsWorkerOptions): ExportedHandler<Env, QueueHandlerMessage, CfHostMetadata>;
package/dist/worker.js ADDED
@@ -0,0 +1,169 @@
1
+ import { Hono } from "hono";
2
+ import { captureException, withSentry } from "@sentry/cloudflare";
3
+ import { isPresent } from "@manta-eu/lib/present";
4
+ import { proxyToMantaAgents, sameOriginDeniedResponse, signedOutMessage } from "./agents-proxy.js";
5
+ import { authUnavailableResponse, callbackPath, localSignOutResponse, loginPath, loginRequestWithMarker, logoutPath, logoutResponseReturningTo, } from "./auth-routes.js";
6
+ import { datastoreAccessFor, datastoreAccessUnavailableMessage, datastoreNotGrantedMessage, forgetDatastoreAccess, grantsDatastore, } from "./datastore-access.js";
7
+ import { hornRoutes } from "./horn-routes.js";
8
+ import { actionPlanRoutes } from "./action-plan-routes.js";
9
+ import { studioRoutes } from "./studio-routes.js";
10
+ import { criticModelFromEnv } from "./llm.js";
11
+ import { requestSession } from "./dev-session.js";
12
+ import { asJsonRequest, followedLink, noPageToOpen, signInFirst } from "./entity-links.js";
13
+ import { isSignedIn } from "./session.js";
14
+ import { sentryOptionsForDeploy } from "./sentry.js";
15
+ /**
16
+ * The Horns worker as a Hono app. `shapes` is the host's: the catalog describes a realm's data model, so
17
+ * Horns takes it rather than shipping one.
18
+ */
19
+ export function createHornsApp({ shapes, criticModel = criticModelFromEnv }) {
20
+ const app = new Hono();
21
+ app.use("*", async (c, next) => {
22
+ if (import.meta.env.DEV)
23
+ return next();
24
+ const expected = c.env.SITE_PASSWORD;
25
+ const header = c.req.header("authorization");
26
+ if (expected && header?.startsWith("Basic ")) {
27
+ try {
28
+ const decoded = atob(header.slice(6));
29
+ const password = decoded.slice(decoded.indexOf(":") + 1);
30
+ if (password === expected)
31
+ return next();
32
+ }
33
+ catch {
34
+ /* a malformed header is just a failed attempt */
35
+ }
36
+ }
37
+ return c.text("Authentication required.", 401, {
38
+ "WWW-Authenticate": 'Basic realm="Manta Horns", charset="UTF-8"',
39
+ });
40
+ });
41
+ function userSession(c) {
42
+ return requestSession(c.req.raw, c.env);
43
+ }
44
+ function proxyRequest(c, request) {
45
+ const session = userSession(c);
46
+ return proxyToMantaAgents({
47
+ request,
48
+ agentsUrl: c.env.MANTA_AGENTS_URL,
49
+ ...(isPresent(session) ? { session } : {}),
50
+ });
51
+ }
52
+ function agentsProxy(c) {
53
+ return proxyRequest(c, c.req.raw);
54
+ }
55
+ async function recovering(run, request, recover) {
56
+ try {
57
+ return await run();
58
+ }
59
+ catch (caught) {
60
+ captureException(caught, { tags: { path: new URL(request.url).pathname, method: request.method } });
61
+ return recover(request);
62
+ }
63
+ }
64
+ app.get(loginPath, (c) => recovering(() => proxyRequest(c, loginRequestWithMarker(c.req.raw)), c.req.raw, authUnavailableResponse));
65
+ app.get(callbackPath, (c) => recovering(() => agentsProxy(c), c.req.raw, authUnavailableResponse));
66
+ app.on(["GET", "POST"], logoutPath, (c) => recovering(async () => {
67
+ const response = await agentsProxy(c);
68
+ // 5xx: agents never dropped our cookies — sign out locally instead.
69
+ if (response.status >= 500)
70
+ return localSignOutResponse(c.req.raw);
71
+ return logoutResponseReturningTo(response, c.req.raw);
72
+ }, c.req.raw, localSignOutResponse));
73
+ const signedOutLoginStatus = { valid: false, canSwitchDatastore: false, allowedModels: [] };
74
+ app.get("/api/login", async (c) => {
75
+ const denied = sameOriginDeniedResponse(c.req.raw);
76
+ if (denied)
77
+ return denied;
78
+ if (!isSignedIn(userSession(c)))
79
+ return c.json(signedOutLoginStatus);
80
+ const response = await agentsProxy(c);
81
+ return response.status === 401 ? c.json(signedOutLoginStatus) : response;
82
+ });
83
+ app.get("/api/data/context", async (c) => {
84
+ const denied = sameOriginDeniedResponse(c.req.raw);
85
+ if (denied)
86
+ return denied;
87
+ if (!isSignedIn(userSession(c)))
88
+ return c.json({ error: signedOutMessage }, 401);
89
+ const response = await agentsProxy(c);
90
+ return response.status === 401 ? c.json({ error: signedOutMessage }, 401) : response;
91
+ });
92
+ // The memoised grants name the datastore the caller just left, so they go when they leave it.
93
+ app.post("/api/switch-datastore", async (c) => {
94
+ const response = await agentsProxy(c);
95
+ const session = userSession(c);
96
+ if (response.ok && isPresent(session))
97
+ forgetDatastoreAccess(session);
98
+ return response;
99
+ });
100
+ // The worker's own trust header is never the caller's to set, on any path.
101
+ function withoutTrustHeaders(request) {
102
+ const headers = new Headers(request.headers);
103
+ headers.delete("x-sparql-update-key");
104
+ const init = { method: request.method, headers, body: request.body };
105
+ Object.assign(init, { duplex: "half" });
106
+ return new Request(request.url, init);
107
+ }
108
+ // Agents answers a read for a datastore the caller lacks from the caller's own datastore instead of
109
+ // refusing, so the path means what it says only if the session's grants are what decided it.
110
+ app.post("/d/:datastoreId/sparql", async (c) => {
111
+ const denied = sameOriginDeniedResponse(c.req.raw);
112
+ if (denied)
113
+ return denied;
114
+ const session = userSession(c);
115
+ if (!isPresent(session) || !isSignedIn(session))
116
+ return c.json({ error: signedOutMessage }, 401);
117
+ const resolved = await datastoreAccessFor(c.env, session);
118
+ if (!resolved.ok) {
119
+ if (resolved.reason === "unauthenticated")
120
+ return c.json({ error: signedOutMessage }, 401);
121
+ return c.json({ error: datastoreAccessUnavailableMessage }, 502);
122
+ }
123
+ if (!grantsDatastore(resolved.access, c.req.param("datastoreId"))) {
124
+ forgetDatastoreAccess(session);
125
+ return c.json({ error: datastoreNotGrantedMessage }, 403);
126
+ }
127
+ return proxyRequest(c, withoutTrustHeaders(c.req.raw));
128
+ });
129
+ // A link from a Horn's row to the thing it shows, opened as the viewer in a new tab.
130
+ app.get("/d/:datastoreId/redirect", async (c) => {
131
+ const datastoreId = c.req.param("datastoreId");
132
+ const session = userSession(c);
133
+ if (!isPresent(session) || !isSignedIn(session))
134
+ return signInFirst(c.req.raw);
135
+ const resolved = await datastoreAccessFor(c.env, session);
136
+ if (!resolved.ok) {
137
+ return resolved.reason === "unauthenticated" ? signInFirst(c.req.raw) : noPageToOpen(datastoreId, 502);
138
+ }
139
+ // Refused the way agents refuses a record: as not found, so the link cannot probe another datastore.
140
+ if (!grantsDatastore(resolved.access, datastoreId)) {
141
+ forgetDatastoreAccess(session);
142
+ return noPageToOpen(datastoreId, 404);
143
+ }
144
+ const response = await proxyRequest(c, asJsonRequest(c.req.raw));
145
+ if (response.status === 401)
146
+ return signInFirst(c.req.raw);
147
+ return (followedLink(response, c.env.MANTA_AGENTS_URL ?? "") ??
148
+ noPageToOpen(datastoreId, response.status === 400 || response.status === 404 ? 404 : 502));
149
+ });
150
+ app.route("/", hornRoutes());
151
+ app.route("/", actionPlanRoutes());
152
+ app.route("/", studioRoutes({ shapes, criticModel }));
153
+ app.all("*", (c) => c.env.ASSETS.fetch(c.req.raw));
154
+ app.onError((err, c) => {
155
+ captureException(err, { tags: { path: c.req.routePath, method: c.req.method } });
156
+ if (import.meta.env.DEV) {
157
+ console.error(`[horns] ${c.req.method} ${new URL(c.req.raw.url).pathname} failed:`, err);
158
+ }
159
+ return c.json({ error: "Internal Server Error" }, 500);
160
+ });
161
+ return app;
162
+ }
163
+ /** The same app behind Sentry, ready to be a Worker's default export. */
164
+ export function createHornsWorker(options) {
165
+ const app = createHornsApp(options);
166
+ return withSentry(sentryOptionsForDeploy, {
167
+ fetch: (request, env, ctx) => app.fetch(request, env, ctx),
168
+ });
169
+ }
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@manta-eu/horns-server",
3
+ "version": "0.1.0",
4
+ "description": "The Manta Horns worker as a library: Hono routes a Cloudflare host mounts on its own app.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/manta-eu/manta-horns.git",
9
+ "directory": "packages/horns-server"
10
+ },
11
+ "type": "module",
12
+ "files": [
13
+ "dist",
14
+ "LICENSE"
15
+ ],
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "default": "./dist/index.js"
20
+ },
21
+ "./worker": {
22
+ "types": "./dist/worker.d.ts",
23
+ "default": "./dist/worker.js"
24
+ },
25
+ "./shared": {
26
+ "types": "./dist/shared.d.ts",
27
+ "default": "./dist/shared.js"
28
+ },
29
+ "./llm": {
30
+ "types": "./dist/llm.d.ts",
31
+ "default": "./dist/llm.js"
32
+ }
33
+ },
34
+ "publishConfig": {
35
+ "registry": "https://registry.npmjs.org",
36
+ "access": "public"
37
+ },
38
+ "dependencies": {
39
+ "@manta-eu/lib": "0.1.4",
40
+ "@sentry/cloudflare": "^10.70.0",
41
+ "@sentry/core": "^10.70.0",
42
+ "hono": "^4.9.8",
43
+ "ldkit": "2.8.0",
44
+ "sparqljs": "^3.7.4",
45
+ "zod": "^4.5.4",
46
+ "@manta-eu/horns": "0.1.0",
47
+ "@manta-eu/horns-kit": "0.1.0"
48
+ },
49
+ "devDependencies": {
50
+ "@cloudflare/vite-plugin": "1.47.0",
51
+ "@types/sparqljs": "^3.1.12",
52
+ "rimraf": "^6.1.3",
53
+ "typescript": "^5.9.0",
54
+ "vite": "^7.3.6",
55
+ "vitest": "^3.2.0"
56
+ },
57
+ "scripts": {
58
+ "types": "tsc --noEmit && tsc -p tsconfig.build.json --noEmit",
59
+ "test": "vitest run",
60
+ "build": "rimraf dist && tsc -p tsconfig.build.json"
61
+ }
62
+ }