@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,166 @@
1
+ import { isPresent } from "@manta-eu/lib/present";
2
+ import { isRecord } from "@manta-eu/horns";
3
+ export const agentsJwtCookieName = "auth";
4
+ export const sessionCookiePrefix = "mh_session";
5
+ export const maxSessionCookieChunks = 8;
6
+ const chunkSize = 3500;
7
+ // RFC 6265 token and cookie-octets: a stored pair can never add a `;` and smuggle a second cookie upstream.
8
+ const cookieNamePattern = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
9
+ const cookieValuePattern = /^(?:[\x21\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]*|"[\x21\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]*")$/;
10
+ export function isForwardableCookie(name, value) {
11
+ return cookieNamePattern.test(name) && cookieValuePattern.test(value);
12
+ }
13
+ export function parseCookieHeader(header) {
14
+ const jar = {};
15
+ if (!isPresent(header))
16
+ return jar;
17
+ for (const pair of header.split(";")) {
18
+ const index = pair.indexOf("=");
19
+ if (index <= 0)
20
+ continue;
21
+ const name = pair.slice(0, index).trim();
22
+ if (!isPresent(name))
23
+ continue;
24
+ jar[name] = pair.slice(index + 1).trim();
25
+ }
26
+ return jar;
27
+ }
28
+ export function serializeCookie(name, value, attributes) {
29
+ const parts = [`${name}=${value}`, "Path=/", "HttpOnly", "SameSite=Lax"];
30
+ if (attributes.secure)
31
+ parts.push("Secure");
32
+ if (attributes.maxAge !== undefined)
33
+ parts.push(`Max-Age=${attributes.maxAge}`);
34
+ return parts.join("; ");
35
+ }
36
+ export function requestIsSecure(requestUrl) {
37
+ return new URL(requestUrl).protocol === "https:";
38
+ }
39
+ function encodeJson(value) {
40
+ const bytes = new TextEncoder().encode(JSON.stringify(value));
41
+ let binary = "";
42
+ for (const byte of bytes)
43
+ binary += String.fromCharCode(byte);
44
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
45
+ }
46
+ function decodeJson(encoded) {
47
+ const padded = encoded.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (encoded.length % 4)) % 4);
48
+ const binary = atob(padded);
49
+ const bytes = new Uint8Array(binary.length);
50
+ for (let i = 0; i < binary.length; i += 1)
51
+ bytes[i] = binary.charCodeAt(i);
52
+ return JSON.parse(new TextDecoder().decode(bytes));
53
+ }
54
+ function chunk(value) {
55
+ const chunks = [];
56
+ for (let i = 0; i < value.length; i += chunkSize)
57
+ chunks.push(value.slice(i, i + chunkSize));
58
+ return chunks;
59
+ }
60
+ export function sessionSetCookies(state, attributes) {
61
+ const chunks = chunk(encodeJson(state));
62
+ if (chunks.length > maxSessionCookieChunks) {
63
+ throw new Error(`Horns session does not fit in ${maxSessionCookieChunks} cookies.`);
64
+ }
65
+ const written = chunks.map((value, index) => serializeCookie(`${sessionCookiePrefix}${index}`, value, attributes));
66
+ const cleared = Array.from({ length: maxSessionCookieChunks - chunks.length }, (_unused, offset) => serializeCookie(`${sessionCookiePrefix}${chunks.length + offset}`, "", { ...attributes, maxAge: 0 }));
67
+ return [...written, ...cleared];
68
+ }
69
+ export function clearSessionSetCookies(attributes) {
70
+ return Array.from({ length: maxSessionCookieChunks }, (_unused, index) => serializeCookie(`${sessionCookiePrefix}${index}`, "", { ...attributes, maxAge: 0 }));
71
+ }
72
+ export function readSessionState(cookieHeader) {
73
+ const jar = parseCookieHeader(cookieHeader);
74
+ let encoded = "";
75
+ for (let index = 0; index < maxSessionCookieChunks; index += 1) {
76
+ const part = jar[`${sessionCookiePrefix}${index}`];
77
+ if (!isPresent(part))
78
+ break;
79
+ encoded += part;
80
+ }
81
+ if (!isPresent(encoded))
82
+ return undefined;
83
+ let parsed;
84
+ try {
85
+ parsed = decodeJson(encoded);
86
+ }
87
+ catch {
88
+ return undefined;
89
+ }
90
+ if (!isRecord(parsed))
91
+ return undefined;
92
+ const candidate = parsed.upstreamCookies;
93
+ if (!isRecord(candidate))
94
+ return undefined;
95
+ const upstreamCookies = Object.fromEntries(Object.entries(candidate).flatMap(([name, value]) => typeof value === "string" && isForwardableCookie(name, value) ? [[name, value]] : []));
96
+ if (!isPresent(Object.keys(upstreamCookies)))
97
+ return undefined;
98
+ return { upstreamCookies };
99
+ }
100
+ export function isSignedIn(state) {
101
+ return isPresent(state) && isPresent(state.upstreamCookies[agentsJwtCookieName]);
102
+ }
103
+ export function upstreamCookieHeader(state) {
104
+ return Object.entries(state.upstreamCookies)
105
+ .filter(([name, value]) => isForwardableCookie(name, value))
106
+ .map(([name, value]) => `${name}=${value}`)
107
+ .join("; ");
108
+ }
109
+ export function setCookieHeadersOf(response) {
110
+ const { headers } = response;
111
+ if (typeof headers.getSetCookie === "function")
112
+ return headers.getSetCookie();
113
+ const single = headers.get("set-cookie");
114
+ return isPresent(single) ? [single] : [];
115
+ }
116
+ function attributeExpiresCookie(attribute, now) {
117
+ const index = attribute.indexOf("=");
118
+ if (index <= 0)
119
+ return false;
120
+ const name = attribute.slice(0, index).trim().toLowerCase();
121
+ const value = attribute.slice(index + 1).trim();
122
+ if (name === "max-age") {
123
+ const seconds = Number(value);
124
+ return isPresent(value) && Number.isFinite(seconds) && seconds <= 0;
125
+ }
126
+ if (name === "expires") {
127
+ const at = Date.parse(value);
128
+ return Number.isFinite(at) && at <= now;
129
+ }
130
+ return false;
131
+ }
132
+ export function captureUpstreamCookies(state, response, now = Date.now()) {
133
+ const jar = { ...state.upstreamCookies };
134
+ let changed = false;
135
+ for (const header of setCookieHeadersOf(response)) {
136
+ const [pair = "", ...attributes] = header.split(";");
137
+ const index = pair.indexOf("=");
138
+ if (index <= 0)
139
+ continue;
140
+ const name = pair.slice(0, index).trim();
141
+ const value = pair.slice(index + 1).trim();
142
+ if (!isForwardableCookie(name, value))
143
+ continue;
144
+ const expired = attributes.some((attribute) => attributeExpiresCookie(attribute, now));
145
+ if (expired || !isPresent(value)) {
146
+ if (name in jar) {
147
+ delete jar[name];
148
+ changed = true;
149
+ }
150
+ continue;
151
+ }
152
+ if (jar[name] !== value) {
153
+ jar[name] = value;
154
+ changed = true;
155
+ }
156
+ }
157
+ if (!changed)
158
+ return state;
159
+ return { upstreamCookies: jar };
160
+ }
161
+ function fingerprint(state) {
162
+ return JSON.stringify(Object.entries(state.upstreamCookies).sort(([a], [b]) => a.localeCompare(b)));
163
+ }
164
+ export function sessionsDiffer(before, after) {
165
+ return fingerprint(before) !== fingerprint(after);
166
+ }
@@ -0,0 +1,4 @@
1
+ export { signedInParam } from "./auth-params.ts";
2
+ export { onlyDiagnosticExtra, withoutPiiErrorEvent } from "./pii.ts";
3
+ export { redactedUrl, redactShareTokens } from "./redact.ts";
4
+ export { readNdjson } from "./ndjson-reader.ts";
package/dist/shared.js ADDED
@@ -0,0 +1,9 @@
1
+ // The little that both halves of Horns need.
2
+ //
3
+ // Its own entry so the browser can import it without the worker coming too: everything else in this package
4
+ // pulls Hono and `@sentry/cloudflare`, which do not belong in a page bundle. Nothing here has a runtime
5
+ // dependency on either.
6
+ export { signedInParam } from "./auth-params.js";
7
+ export { onlyDiagnosticExtra, withoutPiiErrorEvent } from "./pii.js";
8
+ export { redactedUrl, redactShareTokens } from "./redact.js";
9
+ export { readNdjson } from "./ndjson-reader.js";
@@ -0,0 +1,8 @@
1
+ import { type SparqlSelect } from "@manta-eu/horns";
2
+ import type { MantaSessionState } from "./session.ts";
3
+ export declare function agentsSparqlSelect(args: {
4
+ requestUrl: string;
5
+ agentsUrl: string | undefined;
6
+ session: MantaSessionState;
7
+ datastoreId: string;
8
+ }): SparqlSelect;
@@ -0,0 +1,23 @@
1
+ import { sparqlResultsSchema } from "@manta-eu/horns";
2
+ import { proxyToMantaAgents } from "./agents-proxy.js";
3
+ // Server-side reads go through the same proxy, read-only policy and session as the browser's /d/:ds/sparql,
4
+ // so rendering on the server can reach nothing the viewer could not query themselves.
5
+ export function agentsSparqlSelect(args) {
6
+ const origin = new URL(args.requestUrl).origin;
7
+ const url = `${origin}/d/${encodeURIComponent(args.datastoreId)}/sparql`;
8
+ return async (query, signal) => {
9
+ const request = new Request(url, {
10
+ method: "POST",
11
+ headers: { origin, "content-type": "application/sparql-query", accept: "application/sparql-results+json" },
12
+ body: query,
13
+ ...(signal ? { signal } : {}),
14
+ });
15
+ const response = await proxyToMantaAgents({ request, agentsUrl: args.agentsUrl, session: args.session });
16
+ if (!response.ok) {
17
+ const detail = (await response.text().catch(() => "")).slice(0, 200);
18
+ throw new Error(`Manta answered ${response.status}${detail ? `: ${detail}` : ""}`);
19
+ }
20
+ const body = await response.json();
21
+ return sparqlResultsSchema.parse(body);
22
+ };
23
+ }
@@ -0,0 +1,13 @@
1
+ import { type WritePurpose } from "@manta-eu/horns";
2
+ export declare const updateKeyHeader = "x-sparql-update-key";
3
+ export declare const missingUpdateKeyMessage = "SPARQL_UPDATE_PSK is not set \u2014 this deployment cannot write.";
4
+ export declare class WriteRefusedError extends Error {
5
+ constructor(message: string);
6
+ }
7
+ export type SparqlUpdate = (update: string) => Promise<void>;
8
+ export declare function agentsSparqlUpdate(args: {
9
+ agentsUrl: string | undefined;
10
+ datastoreId: string;
11
+ updateKey: string | undefined;
12
+ purpose: WritePurpose;
13
+ }): SparqlUpdate;
@@ -0,0 +1,47 @@
1
+ import { isPresent } from "@manta-eu/lib/present";
2
+ import { checkWrite } from "@manta-eu/horns";
3
+ import { agentsPathUrl, missingAgentsUrlMessage } from "./agents-proxy.js";
4
+ // Agents' `/d/:ds/sparql-update` authorizes on the pre-shared key alone and does not restrict `GRAPH`, so this
5
+ // worker is the write-authorization boundary. The guard runs here rather than at each call site: there is one
6
+ // way to send an update and it cannot be the way that forgot to check.
7
+ export const updateKeyHeader = "x-sparql-update-key";
8
+ export const missingUpdateKeyMessage = "SPARQL_UPDATE_PSK is not set — this deployment cannot write.";
9
+ export class WriteRefusedError extends Error {
10
+ constructor(message) {
11
+ super(message);
12
+ this.name = "WriteRefusedError";
13
+ }
14
+ }
15
+ export function agentsSparqlUpdate(args) {
16
+ return async (update) => {
17
+ // Before the key is looked at: a refused update is not a request.
18
+ const verdict = checkWrite(update, args.purpose);
19
+ if (!verdict.ok)
20
+ throw new WriteRefusedError(verdict.reason);
21
+ if (args.agentsUrl === undefined)
22
+ throw new Error(missingAgentsUrlMessage);
23
+ if (args.updateKey === undefined)
24
+ throw new Error(missingUpdateKeyMessage);
25
+ const url = agentsPathUrl(args.agentsUrl, `/d/${encodeURIComponent(args.datastoreId)}/sparql-update`);
26
+ const response = await fetch(url, {
27
+ method: "POST",
28
+ headers: {
29
+ [updateKeyHeader]: args.updateKey,
30
+ "content-type": "application/sparql-update",
31
+ origin: url.origin,
32
+ },
33
+ body: update,
34
+ });
35
+ if (!response.ok) {
36
+ const detail = (await response.text().catch(() => "")).slice(0, 200);
37
+ // agents' PSK middleware answers 503 before it looks at the key, so this is a deployment with no
38
+ // SPARQL_UPDATE_PSK set — not a key of ours being wrong (that is a 401). Saying so here saves the next
39
+ // person from reading the raw body and wondering which side to look at.
40
+ if (response.status === 503) {
41
+ throw new Error(`Writes are off in this realm: the deployment does not serve the write endpoint` +
42
+ `${isPresent(detail) ? ` (${detail})` : ""}. An operator has to set SPARQL_UPDATE_PSK on the agents worker.`);
43
+ }
44
+ throw new Error(`Manta answered ${response.status}${isPresent(detail) ? `: ${detail}` : ""}`);
45
+ }
46
+ };
47
+ }
@@ -0,0 +1,43 @@
1
+ import { Hono } from "hono";
2
+ import { type Realm } from "@manta-eu/horns";
3
+ import { type Shape } from "@manta-eu/horns/shapes";
4
+ import { type StudioModelEnv, type StudioModelResult } from "./llm.ts";
5
+ import { type DevSessionEnv } from "./dev-session.ts";
6
+ export type StudioRoutesEnv = DevSessionEnv & StudioModelEnv & {
7
+ MANTA_AGENTS_URL?: string;
8
+ VITE_KEYCLOAK_REALM?: string;
9
+ };
10
+ export type StudioRoutesDeps<Env extends StudioRoutesEnv> = {
11
+ /** The realm's SHACL shapes, which is what a turn reads classes from. Absent shapes answer no classes. */
12
+ shapes: (realm: Realm) => readonly Shape[];
13
+ /** Overridden by tests with a scripted model; the deployment builds Qwen through the AI Gateway. */
14
+ model?: (env: Env, context: {
15
+ feature: string;
16
+ datastoreId: string;
17
+ }) => StudioModelResult;
18
+ /** The critic's model, which can look at a picture. Absent, the critic reads the card as text instead. */
19
+ criticModel?: (env: Env, context: {
20
+ feature: string;
21
+ datastoreId: string;
22
+ }) => StudioModelResult;
23
+ };
24
+ /**
25
+ * How much of the conversation goes back to the model.
26
+ *
27
+ * The draft itself is in `<useful-context>`, so the history is there for intent, not for state — and every
28
+ * message of it is context the turn cannot spend on work. dyad keeps three turns by default; this keeps six,
29
+ * because our messages are short and the earlier ones carry what someone wants the Horn to look like.
30
+ */
31
+ export declare const maxHistoryTurns = 6;
32
+ export declare const maxReviewImageChars: number;
33
+ /**
34
+ * One turn of talking to a Horn. The draft arrives in the body and comes back changed — nothing is stored
35
+ * here: saving is the save route's job, with its own lock check and its own authorization.
36
+ *
37
+ * Prior turns travel as their text alone. Their tool calls described a draft the user has since changed, and
38
+ * re-sending them would ask the model to reason from a Horn that no longer exists; useful-context carries the
39
+ * one that does.
40
+ */
41
+ export declare function studioRoutes<Env extends StudioRoutesEnv>(deps: StudioRoutesDeps<Env>): Hono<{
42
+ Bindings: Env;
43
+ }, import("hono/types").BlankSchema, "/">;