@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
package/dist/env.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ import type { HornRoutesEnv } from "./horn-routes.ts";
2
+ import type { StudioRoutesEnv } from "./studio-routes.ts";
3
+ import type { StudioModelEnv } from "./llm.ts";
4
+ /** Everything the assembled worker reads. A host mounting only some routes needs only those routes' env. */
5
+ export type Env = HornRoutesEnv & StudioRoutesEnv & StudioModelEnv & {
6
+ SITE_PASSWORD?: string;
7
+ SENTRY_DSN?: string;
8
+ CF_VERSION_METADATA?: {
9
+ id: string;
10
+ };
11
+ };
package/dist/env.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,22 @@
1
+ import { Hono } from "hono";
2
+ import { type RevisionLookup } from "@manta-eu/horns";
3
+ import type { DevSessionEnv } from "./dev-session.ts";
4
+ export declare const maxScreenshotBytes: number;
5
+ type AssetFetcher = {
6
+ fetch: (request: Request) => Response | Promise<Response>;
7
+ };
8
+ export type HornRoutesEnv = DevSessionEnv & {
9
+ ASSETS: AssetFetcher;
10
+ MANTA_AGENTS_URL?: string;
11
+ VITE_KEYCLOAK_REALM?: string;
12
+ SPARQL_UPDATE_PSK?: string;
13
+ };
14
+ export type HornRoutesDeps = {
15
+ /** The realm library's revisions, for checking a Horn's pins. Absent until the library is readable. */
16
+ libraryLookup?: RevisionLookup;
17
+ now?: () => Date;
18
+ };
19
+ export declare function hornRoutes<Env extends HornRoutesEnv>(deps?: HornRoutesDeps): Hono<{
20
+ Bindings: Env;
21
+ }, import("hono/types").BlankSchema, "/">;
22
+ export {};
@@ -0,0 +1,344 @@
1
+ import { Hono } from "hono";
2
+ import { z } from "zod";
3
+ import { isPresent } from "@manta-eu/lib/present";
4
+ import { contentHash, deskolemize, hornBySlugQuery, hornsInDatastoreQuery, itemLookup, libraryItemsQuery, readItemRevisions, hornFromTriples, hornSchema, isRealm, hornRevisionIri, hornToTriples, legacyRevisionTriples, iri, mintHornIri, revisionReadQuery, revisionSaveUpdate, screenshotSaveUpdate, skolemize, verifyLock, sourceOf, triplesOfResults, } from "@manta-eu/horns";
5
+ import { buildHornSnapshot, HornSnapshotError, unreviewedMessage, unreviewedWrittenViews, } from "@manta-eu/horns/render";
6
+ import { describeLockProblem } from "@manta-eu/horns/studio";
7
+ import { agentsSparqlUpdate, WriteRefusedError } from "./sparql-update.js";
8
+ import { uploadPng } from "./manta-upload.js";
9
+ import { hornTemplatePath, renderHornDocument } from "./render-document.js";
10
+ import { agentsSparqlSelect } from "./sparql-select.js";
11
+ import { authorize, parseBody, refuse } from "./authorize.js";
12
+ // A picture of a Horn at the capture width is tens of kilobytes; past this it is not a Horn's picture.
13
+ export const maxScreenshotBytes = 4 * 1024 * 1024;
14
+ const hornRequestSchema = z.object({
15
+ horn: hornSchema,
16
+ variables: z.record(z.string(), z.json()).optional(),
17
+ viewIds: z.array(z.string()).min(1).max(24).optional(),
18
+ });
19
+ const saveRequestSchema = z.object({
20
+ horn: hornSchema,
21
+ /** The revision the editor was looking at. Absent only for a Horn's first save. */
22
+ baseRevision: z.url().optional(),
23
+ });
24
+ /**
25
+ * The realm library, read from the realm's root datastore — the one whose id is the realm name. A datastore
26
+ * installs from it, so a pin resolves there and not here; a library that cannot be read answers no revisions,
27
+ * which refuses a Horn that pins and leaves one that pins nothing alone.
28
+ */
29
+ async function realmLibraryLookup(args) {
30
+ try {
31
+ const results = await agentsSparqlSelect({
32
+ requestUrl: args.requestUrl,
33
+ agentsUrl: args.env.MANTA_AGENTS_URL,
34
+ session: args.session,
35
+ datastoreId: args.realm,
36
+ })(libraryItemsQuery(args.realm), undefined);
37
+ return itemLookup(readItemRevisions(results));
38
+ }
39
+ catch {
40
+ return () => undefined;
41
+ }
42
+ void args.datastoreId;
43
+ }
44
+ function hornMismatch(horn, datastoreId, env) {
45
+ if (horn.datastoreId !== datastoreId)
46
+ return refuse(400, `This Horn belongs to the "${horn.datastoreId}" datastore.`);
47
+ const realm = env.VITE_KEYCLOAK_REALM;
48
+ if (isPresent(realm) && horn.realm !== realm) {
49
+ return refuse(400, `This Horn is for the ${horn.realm} realm; this deployment serves ${realm}.`);
50
+ }
51
+ return undefined;
52
+ }
53
+ async function hornTemplate(env, requestUrl) {
54
+ const response = await env.ASSETS.fetch(new Request(new URL(hornTemplatePath, requestUrl)));
55
+ if (!response.ok)
56
+ throw new Error(`${hornTemplatePath} could not be loaded (${response.status}).`);
57
+ return response.text();
58
+ }
59
+ const noStore = { "cache-control": "no-store" };
60
+ // Horn snapshots, rendered on the server: as a page that hydrates, as JSON for hosts that render the kit
61
+ // themselves (manta/agents), and as PNGs. The Horn arrives in the body until Horns are stored in the
62
+ // datastore, and its bindings only ever run as the caller.
63
+ export function hornRoutes(deps = {}) {
64
+ const routes = new Hono();
65
+ const accept = async (request, env, datastoreId, schema) => {
66
+ const authorized = await authorize(request, env, datastoreId);
67
+ if (!authorized.ok)
68
+ return authorized;
69
+ const body = await parseBody(request, schema);
70
+ if (!body.ok)
71
+ return body;
72
+ const mismatch = hornMismatch(body.data.horn, datastoreId, env);
73
+ if (mismatch)
74
+ return mismatch;
75
+ return { ok: true, session: authorized.session, body: body.data };
76
+ };
77
+ const snapshotFor = async (args) => {
78
+ try {
79
+ const snapshot = await buildHornSnapshot({
80
+ horn: args.body.horn,
81
+ select: agentsSparqlSelect({
82
+ requestUrl: args.request.url,
83
+ agentsUrl: args.env.MANTA_AGENTS_URL,
84
+ session: args.session,
85
+ datastoreId: args.datastoreId,
86
+ }),
87
+ ...(args.body.variables ? { variables: args.body.variables } : {}),
88
+ ...(args.body.viewIds ? { viewIds: args.body.viewIds } : {}),
89
+ ...(deps.now ? { now: deps.now } : {}),
90
+ });
91
+ return { ok: true, snapshot };
92
+ }
93
+ catch (caught) {
94
+ if (caught instanceof HornSnapshotError)
95
+ return refuse(400, caught.message);
96
+ throw caught;
97
+ }
98
+ };
99
+ // What this datastore holds. Without it nothing can show a Horn without already knowing its slug.
100
+ routes.get("/api/d/:datastoreId/horns", async (c) => {
101
+ const datastoreId = c.req.param("datastoreId");
102
+ const authorized = await authorize(c.req.raw, c.env, datastoreId);
103
+ if (!authorized.ok)
104
+ return authorized.response;
105
+ const realm = c.env.VITE_KEYCLOAK_REALM;
106
+ if (!isPresent(realm) || !isRealm(realm))
107
+ return refuse(500, "This deployment has no realm configured.").response;
108
+ try {
109
+ const found = await agentsSparqlSelect({
110
+ requestUrl: c.req.raw.url,
111
+ agentsUrl: c.env.MANTA_AGENTS_URL,
112
+ session: authorized.session,
113
+ datastoreId,
114
+ })(hornsInDatastoreQuery(realm), undefined);
115
+ const horns = found.results.bindings.flatMap((row) => {
116
+ const slug = row["slug"]?.value;
117
+ const iri = row["horn"]?.value;
118
+ if (!isPresent(slug) || !isPresent(iri))
119
+ return [];
120
+ // A Horn nobody has pictured yet simply has no picture, rather than an empty one.
121
+ const screenshot = row["screenshot"]?.value;
122
+ const capturedAt = row["capturedAt"]?.value;
123
+ return [
124
+ {
125
+ slug,
126
+ iri,
127
+ title: row["title"]?.value ?? slug,
128
+ revision: row["revision"]?.value ?? "",
129
+ number: Number(row["number"]?.value) || 1,
130
+ ...(isPresent(screenshot) ? { screenshot } : {}),
131
+ ...(isPresent(capturedAt) ? { capturedAt } : {}),
132
+ },
133
+ ];
134
+ });
135
+ return Response.json({ horns }, { headers: noStore });
136
+ }
137
+ catch (caught) {
138
+ return refuse(502, caught instanceof Error ? caught.message : String(caught)).response;
139
+ }
140
+ });
141
+ // A saved Horn, by the slug a URL can carry: the IRI is opaque.
142
+ routes.get("/api/d/:datastoreId/horns/:slug", async (c) => {
143
+ const datastoreId = c.req.param("datastoreId");
144
+ const authorized = await authorize(c.req.raw, c.env, datastoreId);
145
+ if (!authorized.ok)
146
+ return authorized.response;
147
+ const realm = c.env.VITE_KEYCLOAK_REALM;
148
+ if (!isPresent(realm) || !isRealm(realm))
149
+ return refuse(500, "This deployment has no realm configured.").response;
150
+ const select = agentsSparqlSelect({
151
+ requestUrl: c.req.raw.url,
152
+ agentsUrl: c.env.MANTA_AGENTS_URL,
153
+ session: authorized.session,
154
+ datastoreId,
155
+ });
156
+ const slug = c.req.param("slug");
157
+ try {
158
+ const found = await select(hornBySlugQuery(realm, slug), undefined);
159
+ const row = found.results.bindings[0];
160
+ const revision = row?.["revision"]?.value;
161
+ const hornIri = row?.["horn"]?.value;
162
+ if (!isPresent(revision) || !isPresent(hornIri))
163
+ return refuse(404, `No Horn here is called "${slug}".`).response;
164
+ const answer = await select(revisionReadQuery(realm, revision), undefined);
165
+ const stored = triplesOfResults(answer.results.bindings);
166
+ // A revision is rooted at itself. One saved before that was rooted at the Horn, which its other revisions
167
+ // share, so what that root says about other revisions is set aside before it is read.
168
+ const number = Number(row?.["number"]?.value);
169
+ const horn = hornFromTriples(sourceOf(deskolemize(stored, revision)), iri(revision), hornIri) ??
170
+ hornFromTriples(sourceOf(deskolemize(legacyRevisionTriples(stored, { root: hornIri, revision, number }), revision)), iri(hornIri));
171
+ // A revision that holds no Horn is a bug in whatever wrote it, not a missing Horn.
172
+ if (!isPresent(horn))
173
+ return refuse(500, `Revision ${revision} of "${slug}" is not a Horn.`).response;
174
+ return Response.json({ horn, revision, contentHash: await contentHash(stored) }, { headers: noStore });
175
+ }
176
+ catch (caught) {
177
+ return refuse(502, caught instanceof Error ? caught.message : String(caught)).response;
178
+ }
179
+ });
180
+ // Save a Horn under a slug. The route owns the IRI and the revision number: the store's bookkeeping is not
181
+ // something a client gets to choose, and a revision that already exists must not be written into twice.
182
+ routes.put("/api/d/:datastoreId/horns/:slug", async (c) => {
183
+ const datastoreId = c.req.param("datastoreId");
184
+ const authorized = await authorize(c.req.raw, c.env, datastoreId);
185
+ if (!authorized.ok)
186
+ return authorized.response;
187
+ const realm = c.env.VITE_KEYCLOAK_REALM;
188
+ if (!isPresent(realm) || !isRealm(realm))
189
+ return refuse(500, "This deployment has no realm configured.").response;
190
+ const body = await parseBody(c.req.raw, saveRequestSchema);
191
+ if (!body.ok)
192
+ return body.response;
193
+ const slug = c.req.param("slug");
194
+ const select = agentsSparqlSelect({
195
+ requestUrl: c.req.raw.url,
196
+ agentsUrl: c.env.MANTA_AGENTS_URL,
197
+ session: authorized.session,
198
+ datastoreId,
199
+ });
200
+ const update = agentsSparqlUpdate({
201
+ agentsUrl: c.env.MANTA_AGENTS_URL,
202
+ datastoreId,
203
+ updateKey: c.env.SPARQL_UPDATE_PSK,
204
+ purpose: { kind: "horn-save", realm },
205
+ });
206
+ try {
207
+ const found = await select(hornBySlugQuery(realm, slug), undefined);
208
+ const current = found.results.bindings[0];
209
+ const currentRevision = current?.["revision"]?.value;
210
+ const currentNumber = Number(current?.["number"]?.value);
211
+ if (currentRevision !== undefined && body.data.baseRevision !== currentRevision) {
212
+ return refuse(409, `"${slug}" has moved on since you opened it; reload before saving.`).response;
213
+ }
214
+ if (currentRevision === undefined && body.data.baseRevision !== undefined) {
215
+ return refuse(409, `There is no Horn called "${slug}" to have revised.`).response;
216
+ }
217
+ // A Horn that exists but whose revision carries no number is a stored revision something else wrote; the
218
+ // next number cannot be guessed from it without risking a revision IRI that already exists.
219
+ if (currentRevision !== undefined && !Number.isFinite(currentNumber)) {
220
+ return refuse(500, `Revision ${currentRevision} of "${slug}" has no number to count on.`).response;
221
+ }
222
+ const hornIri = current?.["horn"]?.value ?? mintHornIri();
223
+ // Every pin is an exact revision and hash. A Horn whose lock does not match what its dependencies
224
+ // resolve to must not reach a store: nothing downstream can tell afterwards that it was never true. The
225
+ // resolved IRI is the root, not whatever the body claimed, because the route owns the IRI.
226
+ const locked = verifyLock(hornIri, body.data.horn.dependencies, body.data.horn.lock, deps.libraryLookup ??
227
+ (await realmLibraryLookup({
228
+ requestUrl: c.req.raw.url,
229
+ env: c.env,
230
+ session: authorized.session,
231
+ datastoreId,
232
+ realm,
233
+ })));
234
+ if (!locked.ok) {
235
+ return refuse(400, `The lock does not hold:\n${locked.problems.map((problem) => `- ${describeLockProblem(problem)}`).join("\n")}`).response;
236
+ }
237
+ // Model-written code reaches a store only after a person has read it. The gate refuses source that
238
+ // reaches off the surface, but only a reader can say whether the code does what someone wanted — so
239
+ // this is the boundary, and it is checked here rather than at the proposal gate because a draft is
240
+ // allowed to hold unread code: previewing it is how a person reads it.
241
+ const unreviewed = await unreviewedWrittenViews(body.data.horn);
242
+ if (isPresent(unreviewed))
243
+ return refuse(400, unreviewedMessage(unreviewed)).response;
244
+ const number = Number.isFinite(currentNumber) ? currentNumber + 1 : 1;
245
+ const revision = hornRevisionIri(hornIri, number);
246
+ // The route owns both, so a document that disagrees is replaced rather than refused: a client editing a
247
+ // Horn it did not create has no business renaming it.
248
+ const document = { ...body.data.horn, iri: hornIri, slug, revision: number };
249
+ // The hash is of the document as written about the Horn, so the same document hashes the same in every
250
+ // revision. What is stored is rooted at the revision, so no two revisions share a node.
251
+ const triples = [];
252
+ hornToTriples(document, iri(hornIri), (triple) => triples.push(triple));
253
+ const rooted = [];
254
+ hornToTriples(document, iri(revision), (triple) => rooted.push(triple));
255
+ const stored = skolemize(rooted, revision);
256
+ await update(revisionSaveUpdate(realm, { hornIri, slug, revision, number, baseRevision: currentRevision, triples: stored }));
257
+ return Response.json({ iri: hornIri, slug, revision, contentHash: await contentHash(triples) }, { headers: noStore });
258
+ }
259
+ catch (caught) {
260
+ if (caught instanceof WriteRefusedError)
261
+ return refuse(403, caught.message).response;
262
+ return refuse(502, caught instanceof Error ? caught.message : String(caught)).response;
263
+ }
264
+ });
265
+ routes.post("/d/:datastoreId/horns/render", async (c) => {
266
+ const datastoreId = c.req.param("datastoreId");
267
+ const accepted = await accept(c.req.raw, c.env, datastoreId, hornRequestSchema);
268
+ if (!accepted.ok)
269
+ return accepted.response;
270
+ const built = await snapshotFor({ request: c.req.raw, env: c.env, datastoreId, ...accepted });
271
+ if (!built.ok)
272
+ return built.response;
273
+ const mode = c.req.query("mode");
274
+ const html = renderHornDocument({
275
+ template: await hornTemplate(c.env, c.req.url),
276
+ snapshot: built.snapshot,
277
+ mode: mode === "embed" ? "embed" : "page",
278
+ });
279
+ return c.html(html, 200, { ...noStore, "referrer-policy": "no-referrer", "x-content-type-options": "nosniff" });
280
+ });
281
+ routes.post("/api/d/:datastoreId/horns/snapshot", async (c) => {
282
+ const datastoreId = c.req.param("datastoreId");
283
+ const accepted = await accept(c.req.raw, c.env, datastoreId, hornRequestSchema);
284
+ if (!accepted.ok)
285
+ return accepted.response;
286
+ const built = await snapshotFor({ request: c.req.raw, env: c.env, datastoreId, ...accepted });
287
+ if (!built.ok)
288
+ return built.response;
289
+ return c.json({ snapshot: built.snapshot }, 200, noStore);
290
+ });
291
+ /**
292
+ * The picture a browser took of a Horn. The bytes come through the worker because the browser has no GraphQL
293
+ * route, and the upload runs as the viewer because a stored file is attributed to a person — the write key
294
+ * cannot stand in for one. Nothing is captured here: the page that already drew the Horn drew this too.
295
+ */
296
+ routes.post("/api/d/:datastoreId/horns/:slug/screenshot", async (c) => {
297
+ const datastoreId = c.req.param("datastoreId");
298
+ const authorized = await authorize(c.req.raw, c.env, datastoreId);
299
+ if (!authorized.ok)
300
+ return authorized.response;
301
+ const realm = c.env.VITE_KEYCLOAK_REALM;
302
+ if (!isPresent(realm) || !isRealm(realm))
303
+ return refuse(500, "This deployment has no realm configured.").response;
304
+ const bytes = new Uint8Array(await c.req.arrayBuffer());
305
+ if (bytes.byteLength === 0)
306
+ return refuse(400, "That request carried no picture.").response;
307
+ if (bytes.byteLength > maxScreenshotBytes) {
308
+ return refuse(413, `A Horn's picture may be at most ${maxScreenshotBytes / (1024 * 1024)} MB.`).response;
309
+ }
310
+ const slug = c.req.param("slug");
311
+ const select = agentsSparqlSelect({
312
+ requestUrl: c.req.raw.url,
313
+ agentsUrl: c.env.MANTA_AGENTS_URL,
314
+ session: authorized.session,
315
+ datastoreId,
316
+ });
317
+ try {
318
+ // The revision is the store's to say, not the caller's: a picture labels what the datastore currently shows.
319
+ const found = await select(hornBySlugQuery(realm, slug), undefined);
320
+ const current = found.results.bindings[0];
321
+ const revision = current?.["revision"]?.value;
322
+ if (!isPresent(revision))
323
+ return refuse(404, `There is no Horn called "${slug}" here.`).response;
324
+ const number = Number(current?.["number"]?.value) || 1;
325
+ const uploaded = await uploadPng({ agentsUrl: c.env.MANTA_AGENTS_URL, session: authorized.session, datastoreId }, { fileName: `horn-${slug}-r${number}.png`, bytes });
326
+ if (!uploaded.ok)
327
+ return refuse(uploaded.status, uploaded.error).response;
328
+ const capturedAt = (deps.now?.() ?? new Date()).toISOString();
329
+ await agentsSparqlUpdate({
330
+ agentsUrl: c.env.MANTA_AGENTS_URL,
331
+ datastoreId,
332
+ updateKey: c.env.SPARQL_UPDATE_PSK,
333
+ purpose: { kind: "horn-save", realm },
334
+ })(screenshotSaveUpdate(realm, { revision, fileIri: uploaded.fileIri, capturedAt }));
335
+ return Response.json({ screenshot: uploaded.fileIri, capturedAt }, { headers: noStore });
336
+ }
337
+ catch (caught) {
338
+ if (caught instanceof WriteRefusedError)
339
+ return refuse(403, caught.message).response;
340
+ return refuse(502, caught instanceof Error ? caught.message : String(caught)).response;
341
+ }
342
+ });
343
+ return routes;
344
+ }
@@ -0,0 +1,19 @@
1
+ export type { Env } from "./env.ts";
2
+ export { ndjsonResponse } from "./ndjson.ts";
3
+ export { readNdjson } from "./ndjson-reader.ts";
4
+ export { hornRoutes, maxScreenshotBytes, type HornRoutesDeps, type HornRoutesEnv } from "./horn-routes.ts";
5
+ export { studioRoutes, maxHistoryTurns, type StudioRoutesDeps, type StudioRoutesEnv } from "./studio-routes.ts";
6
+ export { actionPlanRoutes } from "./action-plan-routes.ts";
7
+ export { authUnavailableResponse, callbackPath, localSignOutResponse, loginPath, loginRequestWithMarker, logoutPath, logoutResponseReturningTo, } from "./auth-routes.ts";
8
+ export { signedInParam } from "./auth-params.ts";
9
+ export { proxyToMantaAgents, sameOriginDeniedResponse, signedOutMessage } from "./agents-proxy.ts";
10
+ export { datastoreAccessFor, datastoreAccessUnavailableMessage, datastoreNotGrantedMessage, forgetDatastoreAccess, grantsDatastore, resetDatastoreAccessCache, } from "./datastore-access.ts";
11
+ export { agentsSparqlSelect } from "./sparql-select.ts";
12
+ export { agentsSparqlUpdate, WriteRefusedError } from "./sparql-update.ts";
13
+ export { criticModelFromEnv, studioModelFromEnv, type StudioModelEnv, type StudioModelResult } from "./llm.ts";
14
+ export { isSignedIn, sessionSetCookies, type MantaSessionState } from "./session.ts";
15
+ export { requestSession, type DevSessionEnv } from "./dev-session.ts";
16
+ export { hornTemplatePath, renderHornDocument } from "./render-document.ts";
17
+ export { sentryOptionsForDeploy } from "./sentry.ts";
18
+ export { withoutPiiErrorEvent } from "./pii.ts";
19
+ export { asJsonRequest, followedLink, noPageToOpen, signInFirst } from "./entity-links.ts";
package/dist/index.js ADDED
@@ -0,0 +1,20 @@
1
+ export { ndjsonResponse } from "./ndjson.js";
2
+ export { readNdjson } from "./ndjson-reader.js";
3
+ export { hornRoutes, maxScreenshotBytes } from "./horn-routes.js";
4
+ export { studioRoutes, maxHistoryTurns } from "./studio-routes.js";
5
+ export { actionPlanRoutes } from "./action-plan-routes.js";
6
+ export { authUnavailableResponse, callbackPath, localSignOutResponse, loginPath, loginRequestWithMarker, logoutPath, logoutResponseReturningTo, } from "./auth-routes.js";
7
+ export { signedInParam } from "./auth-params.js";
8
+ export { proxyToMantaAgents, sameOriginDeniedResponse, signedOutMessage } from "./agents-proxy.js";
9
+ export { datastoreAccessFor, datastoreAccessUnavailableMessage, datastoreNotGrantedMessage, forgetDatastoreAccess, grantsDatastore, resetDatastoreAccessCache, } from "./datastore-access.js";
10
+ export { agentsSparqlSelect } from "./sparql-select.js";
11
+ export { agentsSparqlUpdate, WriteRefusedError } from "./sparql-update.js";
12
+ // Also its own entry, `@manta-eu/horns-server/llm`: building a model client must not drag in the render
13
+ // path, which is JSX and which a Node script cannot load.
14
+ export { criticModelFromEnv, studioModelFromEnv } from "./llm.js";
15
+ export { isSignedIn, sessionSetCookies } from "./session.js";
16
+ export { requestSession } from "./dev-session.js";
17
+ export { hornTemplatePath, renderHornDocument } from "./render-document.js";
18
+ export { sentryOptionsForDeploy } from "./sentry.js";
19
+ export { withoutPiiErrorEvent } from "./pii.js";
20
+ export { asJsonRequest, followedLink, noPageToOpen, signInFirst } from "./entity-links.js";
package/dist/llm.d.ts ADDED
@@ -0,0 +1,45 @@
1
+ import { type StudioModel } from "@manta-eu/horns/studio";
2
+ export type StudioModelEnv = {
3
+ HORNS_LLM_BASE_URL?: string;
4
+ HORNS_LLM_MODEL?: string;
5
+ /**
6
+ * `vllm` (the default) for the self-hosted Qwen, `openai` for anything else behind the gateway's
7
+ * OpenAI-compatible route — Anthropic, OpenAI. It decides whether `chat_template_kwargs` is sent and
8
+ * whether we carry a key of our own, because on any route but vLLM's the gateway holds the provider key.
9
+ */
10
+ HORNS_LLM_DIALECT?: string;
11
+ HORNS_LLM_MAX_TOKENS?: string;
12
+ VLLM_PRIMARY_AUTH_TOKEN?: string;
13
+ CLOUDFLARE_AI_GATEWAY_TOKEN?: string;
14
+ CLOUDFLARE_ENV?: string;
15
+ /** The critic, on its own gateway route: a model that can look at a picture of what the Studio drew. */
16
+ HORNS_CRITIC_BASE_URL?: string;
17
+ HORNS_CRITIC_MODEL?: string;
18
+ HORNS_CRITIC_DIALECT?: string;
19
+ };
20
+ export declare const aiGatewayHost = "gateway.ai.cloudflare.com";
21
+ export declare const defaultMaxTokens = 6000;
22
+ export type StudioModelResult = {
23
+ ok: true;
24
+ model: StudioModel;
25
+ monitored: boolean;
26
+ } | {
27
+ ok: false;
28
+ reason: string;
29
+ };
30
+ export declare function studioModelFromEnv(env: StudioModelEnv, context: {
31
+ feature: string;
32
+ datastoreId?: string;
33
+ }, fetcher?: typeof fetch): StudioModelResult;
34
+ /**
35
+ * The critic's model, which is not the Studio's: it reads a picture of what the Studio drew, so it has to be a
36
+ * model with image input. It goes through the same gateway for the same reason — every call is logged — but it
37
+ * carries no key of ours. The gateway holds the provider key and injects it; sending one of ours would
38
+ * override the stored key with something the provider never issued.
39
+ *
40
+ * Unconfigured is not an error: the caller falls back to the Studio's own model, which reads the card as text.
41
+ */
42
+ export declare function criticModelFromEnv(env: StudioModelEnv, context: {
43
+ feature: string;
44
+ datastoreId?: string;
45
+ }, fetcher?: typeof fetch): StudioModelResult;
package/dist/llm.js ADDED
@@ -0,0 +1,126 @@
1
+ import { isPresent } from "@manta-eu/lib/present";
2
+ import { createOpenAICompatibleModel } from "@manta-eu/horns/studio";
3
+ /** Anything but an explicit `openai` is vLLM, so a deployment that says nothing keeps the Qwen it had. */
4
+ function dialectOf(configured) {
5
+ return configured?.trim().toLowerCase() === "openai" ? "openai" : "vllm";
6
+ }
7
+ export const aiGatewayHost = "gateway.ai.cloudflare.com";
8
+ // A round that runs past the gateway's ~100 s limit comes back as a timeout instead of an answer. Studio
9
+ // rounds need around 3000 tokens, so this bounds a runaway thinking chain without cutting a real answer.
10
+ export const defaultMaxTokens = 6000;
11
+ const loopbackHosts = new Set(["localhost", "127.0.0.1", "[::1]"]);
12
+ function maxTokensOf(configured) {
13
+ const parsed = Number(configured);
14
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : defaultMaxTokens;
15
+ }
16
+ // Every Qwen call goes through agents' AI Gateway route (`custom-vllm-manta`), so it is logged and metered
17
+ // beside agents' own traffic and tagged as Horns'. The one exception is a loopback LLM stub in tests.
18
+ export function studioModelFromEnv(env, context, fetcher) {
19
+ const baseURL = env.HORNS_LLM_BASE_URL;
20
+ const model = env.HORNS_LLM_MODEL;
21
+ if (!isPresent(baseURL) || !isPresent(model)) {
22
+ return { ok: false, reason: "HORNS_LLM_BASE_URL and HORNS_LLM_MODEL must both be set." };
23
+ }
24
+ if (!URL.canParse(baseURL))
25
+ return { ok: false, reason: "HORNS_LLM_BASE_URL is not a URL." };
26
+ const { hostname, protocol } = new URL(baseURL);
27
+ const dialect = dialectOf(env.HORNS_LLM_DIALECT);
28
+ const withFetch = fetcher ? { fetch: fetcher } : {};
29
+ if (loopbackHosts.has(hostname)) {
30
+ return {
31
+ ok: true,
32
+ monitored: false,
33
+ model: createOpenAICompatibleModel({ baseURL, model, dialect, ...withFetch }),
34
+ };
35
+ }
36
+ if (protocol !== "https:" || hostname !== aiGatewayHost) {
37
+ return {
38
+ ok: false,
39
+ reason: `HORNS_LLM_BASE_URL must be the AI Gateway (${aiGatewayHost}) so model calls are logged, not ${hostname}.`,
40
+ };
41
+ }
42
+ if (!isPresent(env.CLOUDFLARE_AI_GATEWAY_TOKEN)) {
43
+ return { ok: false, reason: "CLOUDFLARE_AI_GATEWAY_TOKEN must be set to call the AI Gateway." };
44
+ }
45
+ // Only vLLM's route takes a key of ours. On any other the gateway holds the provider key, and sending one
46
+ // would override the stored key with something the provider never issued.
47
+ if (dialect === "vllm" && !isPresent(env.VLLM_PRIMARY_AUTH_TOKEN)) {
48
+ return { ok: false, reason: "VLLM_PRIMARY_AUTH_TOKEN must be set for the gateway's vLLM route." };
49
+ }
50
+ return {
51
+ ok: true,
52
+ monitored: true,
53
+ model: createOpenAICompatibleModel({
54
+ baseURL,
55
+ model,
56
+ dialect,
57
+ ...(dialect === "vllm" && isPresent(env.VLLM_PRIMARY_AUTH_TOKEN)
58
+ ? { apiKey: env.VLLM_PRIMARY_AUTH_TOKEN }
59
+ : {}),
60
+ gatewayToken: env.CLOUDFLARE_AI_GATEWAY_TOKEN,
61
+ maxTokens: maxTokensOf(env.HORNS_LLM_MAX_TOKENS),
62
+ // No user identifiers: the gateway log is shared with agents.
63
+ metadata: {
64
+ app: "manta-horns",
65
+ environment: env.CLOUDFLARE_ENV ?? "local",
66
+ feature: context.feature,
67
+ ...(isPresent(context.datastoreId) ? { datastore: context.datastoreId } : {}),
68
+ },
69
+ ...withFetch,
70
+ }),
71
+ };
72
+ }
73
+ /**
74
+ * The critic's model, which is not the Studio's: it reads a picture of what the Studio drew, so it has to be a
75
+ * model with image input. It goes through the same gateway for the same reason — every call is logged — but it
76
+ * carries no key of ours. The gateway holds the provider key and injects it; sending one of ours would
77
+ * override the stored key with something the provider never issued.
78
+ *
79
+ * Unconfigured is not an error: the caller falls back to the Studio's own model, which reads the card as text.
80
+ */
81
+ export function criticModelFromEnv(env, context, fetcher) {
82
+ const baseURL = env.HORNS_CRITIC_BASE_URL;
83
+ const model = env.HORNS_CRITIC_MODEL;
84
+ if (!isPresent(baseURL) || !isPresent(model)) {
85
+ return { ok: false, reason: "HORNS_CRITIC_BASE_URL and HORNS_CRITIC_MODEL must both be set." };
86
+ }
87
+ if (!URL.canParse(baseURL))
88
+ return { ok: false, reason: "HORNS_CRITIC_BASE_URL is not a URL." };
89
+ const { hostname, protocol } = new URL(baseURL);
90
+ const dialect = dialectOf(env.HORNS_CRITIC_DIALECT);
91
+ const withFetch = fetcher ? { fetch: fetcher } : {};
92
+ if (loopbackHosts.has(hostname)) {
93
+ return {
94
+ ok: true,
95
+ monitored: false,
96
+ model: createOpenAICompatibleModel({ baseURL, model, dialect, ...withFetch }),
97
+ };
98
+ }
99
+ if (protocol !== "https:" || hostname !== aiGatewayHost) {
100
+ return {
101
+ ok: false,
102
+ reason: `HORNS_CRITIC_BASE_URL must be the AI Gateway (${aiGatewayHost}) so critic calls are logged, not ${hostname}.`,
103
+ };
104
+ }
105
+ if (!isPresent(env.CLOUDFLARE_AI_GATEWAY_TOKEN)) {
106
+ return { ok: false, reason: "CLOUDFLARE_AI_GATEWAY_TOKEN must be set to call the AI Gateway." };
107
+ }
108
+ return {
109
+ ok: true,
110
+ monitored: true,
111
+ model: createOpenAICompatibleModel({
112
+ baseURL,
113
+ model,
114
+ dialect,
115
+ gatewayToken: env.CLOUDFLARE_AI_GATEWAY_TOKEN,
116
+ maxTokens: maxTokensOf(env.HORNS_LLM_MAX_TOKENS),
117
+ metadata: {
118
+ app: "manta-horns",
119
+ environment: env.CLOUDFLARE_ENV ?? "local",
120
+ feature: context.feature,
121
+ ...(isPresent(context.datastoreId) ? { datastore: context.datastoreId } : {}),
122
+ },
123
+ ...withFetch,
124
+ }),
125
+ };
126
+ }
@@ -0,0 +1,2 @@
1
+ export declare const HIREMANTA_AGENTS_STAGING_URL = "https://staging-app.hiremanta.com";
2
+ export declare const JOURNEY_AGENTS_STAGING_URL = "https://staging-chat.manta.journey.travel";
@@ -0,0 +1,2 @@
1
+ export const HIREMANTA_AGENTS_STAGING_URL = "https://staging-app.hiremanta.com";
2
+ export const JOURNEY_AGENTS_STAGING_URL = "https://staging-chat.manta.journey.travel";
@@ -0,0 +1,47 @@
1
+ export declare const crossOriginBlockedMessage = "Cross-origin requests to the Horns proxy are blocked.";
2
+ export declare const proxyPathBlockedMessage = "This proxy only forwards the Manta paths Horns uses.";
3
+ export declare const proxyMethodBlockedMessage = "This proxy does not allow that method on this path.";
4
+ export declare const sparqlEmptyMessage = "SPARQL query is empty.";
5
+ export declare const sparqlParseFailedMessage = "SPARQL query could not be parsed as a single query.";
6
+ export declare const sparqlWriteBlockedMessage = "This proxy only forwards read-only SPARQL.";
7
+ export declare const sparqlFederationBlockedMessage = "This proxy does not forward SERVICE, FROM or FROM NAMED.";
8
+ export type ProxyPolicyDecision = {
9
+ allow: true;
10
+ bodyText?: string;
11
+ } | {
12
+ allow: false;
13
+ status: number;
14
+ error: string;
15
+ };
16
+ export declare function isAuthProxyPath(pathname: string): boolean;
17
+ export declare function originAllowsRequest({ request }: {
18
+ request: Request;
19
+ }): boolean;
20
+ export declare function sameOriginDeniedResponse(request: Request): Response | undefined;
21
+ type ProxyRoute = {
22
+ path: string;
23
+ methods: readonly string[];
24
+ params?: Record<string, (value: string) => boolean>;
25
+ body?: "sparql";
26
+ };
27
+ export declare const isDatastoreId: (value: string) => boolean;
28
+ export declare function matchProxyRoute(pathname: string): ProxyRoute | undefined;
29
+ export declare function isAllowedProxyPath(args: {
30
+ method: string;
31
+ pathname: string;
32
+ isWebSocket: boolean;
33
+ }): {
34
+ allowed: boolean;
35
+ methodAllowed: boolean;
36
+ };
37
+ export type SparqlVerdict = {
38
+ ok: true;
39
+ } | {
40
+ ok: false;
41
+ status: 400 | 403;
42
+ error: string;
43
+ };
44
+ export declare function checkReadOnlySparql(text: string): SparqlVerdict;
45
+ export declare function isReadOnlySparql(text: string): boolean;
46
+ export declare function evaluateProxyPolicy(request: Request): Promise<ProxyPolicyDecision>;
47
+ export {};