@manta-eu/horns-server 0.1.0 → 0.1.1

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.
@@ -57,7 +57,6 @@ export function actionPlanRoutes() {
57
57
  // Read as the viewer, so a plan they cannot see is a plan that is not there.
58
58
  try {
59
59
  const found = await agentsSparqlSelect({
60
- requestUrl: c.req.raw.url,
61
60
  agentsUrl: c.env.MANTA_AGENTS_URL,
62
61
  session: authorized.session,
63
62
  datastoreId,
@@ -15,7 +15,7 @@ export type AgentsGraphqlResult = {
15
15
  status: number;
16
16
  error: string;
17
17
  };
18
- export declare function agentsGraphql(caller: AgentsCaller, query: string, variables: Record<string, unknown>, field: string): Promise<AgentsGraphqlResult>;
18
+ export declare function agentsGraphql(caller: AgentsCaller, query: string, variables: Record<string, unknown>, field: string, signal?: AbortSignal): Promise<AgentsGraphqlResult>;
19
19
  export type ProxyToMantaAgentsArgs = {
20
20
  request: Request;
21
21
  agentsUrl?: string;
@@ -9,7 +9,7 @@ export const signedOutMessage = "Not signed in — sign in to Manta to reach the
9
9
  // The one viewer-attributed GraphQL call. Both writers the worker has — staging a rule, uploading a file —
10
10
  // go through it, so a timeout or a request id is added once. `status` is carried for the caller that answers
11
11
  // with one; the caller that answers a model drops it.
12
- export async function agentsGraphql(caller, query, variables, field) {
12
+ export async function agentsGraphql(caller, query, variables, field, signal) {
13
13
  if (!isPresent(caller.agentsUrl))
14
14
  return { ok: false, status: 500, error: missingAgentsUrlMessage };
15
15
  const url = agentsPathUrl(caller.agentsUrl, "/graphql");
@@ -23,6 +23,8 @@ export async function agentsGraphql(caller, query, variables, field) {
23
23
  "x-manta-datastore": caller.datastoreId,
24
24
  },
25
25
  body: JSON.stringify({ query, variables }),
26
+ // A read the caller walked away from should not go on costing agents the query.
27
+ ...(signal ? { signal } : {}),
26
28
  });
27
29
  if (response.status === 401)
28
30
  return { ok: false, status: 401, error: signedOutMessage };
package/dist/csp.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * `connect-src` is the point of this. Everything else is the cheap part of the same idea.
3
+ *
4
+ * `'unsafe-eval'` is not an oversight: the written-view runtime compiles a view with `new Function`, and
5
+ * without it no written view draws at all. That is the trade the realm-sharing design already made, and the
6
+ * reason `connect-src` carries the weight here.
7
+ */
8
+ export declare function contentSecurityPolicy(sentryDsn: string | undefined): string;
package/dist/csp.js ADDED
@@ -0,0 +1,41 @@
1
+ // What the page may do, as a header.
2
+ //
3
+ // A written view runs in the page's own realm (ARCHITECTURE.md §9), so if its source gets past the gate and
4
+ // the shadowed globals, this is what is left between it and somewhere to send the data. It is the only
5
+ // layer that does not depend on reading the source.
6
+ /**
7
+ * `connect-src` is the point of this. Everything else is the cheap part of the same idea.
8
+ *
9
+ * `'unsafe-eval'` is not an oversight: the written-view runtime compiles a view with `new Function`, and
10
+ * without it no written view draws at all. That is the trade the realm-sharing design already made, and the
11
+ * reason `connect-src` carries the weight here.
12
+ */
13
+ export function contentSecurityPolicy(sentryDsn) {
14
+ const report = ingestOrigin(sentryDsn);
15
+ return [
16
+ "default-src 'self'",
17
+ // Styles and fonts are the design system's; images include the screenshots a Horn stores as data URLs.
18
+ "style-src 'self' 'unsafe-inline'",
19
+ "font-src 'self' data:",
20
+ "img-src 'self' data: blob:",
21
+ // Neither word is doing any work, and pretending otherwise would be worse than saying so: the realm
22
+ // shim is an inline script the SDK needs before anything else runs, and the written-view runtime
23
+ // compiles with `new Function`. `connect-src` is the directive that matters here.
24
+ "script-src 'self' 'unsafe-eval' 'unsafe-inline'",
25
+ `connect-src 'self'${report === undefined ? "" : ` ${report}`}`,
26
+ "object-src 'none'",
27
+ "base-uri 'self'",
28
+ "form-action 'self'",
29
+ ].join("; ");
30
+ }
31
+ /** Where the browser's Sentry posts, which is the one place off this origin the app talks to. */
32
+ function ingestOrigin(dsn) {
33
+ if (dsn === undefined || dsn === "")
34
+ return undefined;
35
+ try {
36
+ return new URL(dsn).origin;
37
+ }
38
+ catch {
39
+ return undefined;
40
+ }
41
+ }
@@ -1,6 +1,7 @@
1
1
  import { isPresent } from "@manta-eu/lib/present";
2
2
  import { isRecord } from "@manta-eu/horns";
3
3
  import { agentsPathUrl } from "./agents-proxy.js";
4
+ import { CustomerDatastoresDocument } from "@manta-eu/client";
4
5
  import { upstreamCookieHeader } from "./session.js";
5
6
  export const datastoreNotGrantedMessage = "That datastore is not one this session can open.";
6
7
  export const noDatastoreMessage = "This session has no datastore to open.";
@@ -38,10 +39,47 @@ export function forgetDatastoreAccess(session) {
38
39
  function readableAccess(body) {
39
40
  const user = isRecord(body) && isRecord(body.user) ? body.user : undefined;
40
41
  const active = isRecord(user?.datastore) ? user.datastore.id : undefined;
42
+ const isOperator = user?.isOperator === true;
41
43
  const datastores = isRecord(body) && Array.isArray(body.datastores) ? body.datastores : [];
42
44
  const listed = datastores.flatMap((datastore) => isRecord(datastore) && typeof datastore.id === "string" ? [datastore.id] : []);
43
45
  const granted = [...new Set([...(typeof active === "string" ? [active] : []), ...listed])];
44
- return { ...(typeof active === "string" ? { active } : {}), granted };
46
+ return { isOperator, ...(typeof active === "string" ? { active } : {}), granted };
47
+ }
48
+ /**
49
+ * The datastores an operator can reach.
50
+ *
51
+ * `/api/data/context` answers the ones a member *belongs* to, which for an operator is none — so on its own
52
+ * it grants them their own datastore and refuses every other, including ones agents would happily serve.
53
+ * This is agents' own answer to what this session can address, so it widens the grant to exactly that and
54
+ * no further. A failure answers nothing rather than everything.
55
+ */
56
+ async function customerDatastores(env, cookie, active) {
57
+ if (!isPresent(env.MANTA_AGENTS_URL))
58
+ return [];
59
+ try {
60
+ const response = await fetch(agentsPathUrl(env.MANTA_AGENTS_URL, "/graphql"), {
61
+ method: "POST",
62
+ headers: {
63
+ cookie,
64
+ "content-type": "application/json",
65
+ accept: "application/json",
66
+ "x-manta-datastore": active,
67
+ },
68
+ body: JSON.stringify({ query: CustomerDatastoresDocument }),
69
+ });
70
+ if (!response.ok)
71
+ return [];
72
+ const body = await response.json();
73
+ if (!isRecord(body) || !isRecord(body.data))
74
+ return [];
75
+ const listed = body.data.customerDatastores;
76
+ if (!Array.isArray(listed))
77
+ return [];
78
+ return listed.flatMap((entry) => (isRecord(entry) && typeof entry.id === "string" ? [entry.id] : []));
79
+ }
80
+ catch {
81
+ return [];
82
+ }
45
83
  }
46
84
  // Agents authenticates SPARQL updates with the shared PSK and takes the datastore from the path, so
47
85
  // nothing upstream checks that the caller was granted it. This lookup is that check.
@@ -65,7 +103,10 @@ export async function datastoreAccessFor(env, session) {
65
103
  return { ok: false, reason: denied ? "unauthenticated" : "unavailable" };
66
104
  }
67
105
  const body = await response.json().catch(() => undefined);
68
- const access = readableAccess(body);
106
+ const { isOperator, ...read } = readableAccess(body);
107
+ // An operator's reach is the customer list, which costs a second call — so only they pay for it.
108
+ const reach = isOperator && isPresent(read.active) ? await customerDatastores(env, cookie, read.active) : [];
109
+ const access = { ...read, granted: [...new Set([...read.granted, ...reach])] };
69
110
  rememberAccess(cookie, access);
70
111
  return { ok: true, access };
71
112
  }
@@ -1,13 +1,17 @@
1
1
  import { Hono } from "hono";
2
2
  import { z } from "zod";
3
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";
4
+ import { contentHash, deskolemize, hornBySlugQuery, hornsAboutQuery, hornsInDatastoreQuery, itemLookup, libraryItemsQuery, readItemRevisions, hornFromTriples, hornSchema, isAbsoluteIri, isRealm, hornRevisionIri, maxTsxSource, hornToTriples, legacyRevisionTriples, iri, mintHornIri, revisionReadQuery, revisionSaveUpdate, screenshotSaveUpdate, skolemize, verifyLock, sourceOf, triplesOfResults, } from "@manta-eu/horns";
5
5
  import { buildHornSnapshot, HornSnapshotError, unreviewedMessage, unreviewedWrittenViews, } from "@manta-eu/horns/render";
6
6
  import { describeLockProblem } from "@manta-eu/horns/studio";
7
7
  import { agentsSparqlUpdate, WriteRefusedError } from "./sparql-update.js";
8
8
  import { uploadPng } from "./manta-upload.js";
9
9
  import { hornTemplatePath, renderHornDocument } from "./render-document.js";
10
+ import { ruleCoverage, ruleHornView, ruleTitle, ruleViewId } from "@manta-eu/horns/rules";
11
+ import { planEntitiesQuery, planNameFromIri, planStatusQuery, readPlanEntities, readPlanStatus, } from "@manta-eu/horns/render";
12
+ import { readRules, ruleQuery, standaloneHorn } from "@manta-eu/horns/shapes";
10
13
  import { agentsSparqlSelect } from "./sparql-select.js";
14
+ import { datastoreAccessFor, grantsDatastore } from "./datastore-access.js";
11
15
  import { authorize, parseBody, refuse } from "./authorize.js";
12
16
  // A picture of a Horn at the capture width is tens of kilobytes; past this it is not a Horn's picture.
13
17
  export const maxScreenshotBytes = 4 * 1024 * 1024;
@@ -25,11 +29,19 @@ const saveRequestSchema = z.object({
25
29
  * The realm library, read from the realm's root datastore — the one whose id is the realm name. A datastore
26
30
  * installs from it, so a pin resolves there and not here; a library that cannot be read answers no revisions,
27
31
  * which refuses a Horn that pins and leaves one that pins nothing alone.
32
+ *
33
+ * The grant is checked here rather than left to agents. This read used to travel through this worker's own
34
+ * `/d/:realm/sparql`, whose grant check answered 403 for a session that does not hold the realm datastore,
35
+ * and the `catch` below turned that into "no revisions". Over GraphQL the datastore is a header, and agents
36
+ * answers from the caller's own datastore instead of refusing — so without this a caller's own library items
37
+ * would stand in for the realm's, and a pinned Horn would verify its lock against the wrong library.
28
38
  */
29
39
  async function realmLibraryLookup(args) {
40
+ const resolved = await datastoreAccessFor(args.env, args.session);
41
+ if (!resolved.ok || !grantsDatastore(resolved.access, args.realm))
42
+ return () => undefined;
30
43
  try {
31
44
  const results = await agentsSparqlSelect({
32
- requestUrl: args.requestUrl,
33
45
  agentsUrl: args.env.MANTA_AGENTS_URL,
34
46
  session: args.session,
35
47
  datastoreId: args.realm,
@@ -39,7 +51,6 @@ async function realmLibraryLookup(args) {
39
51
  catch {
40
52
  return () => undefined;
41
53
  }
42
- void args.datastoreId;
43
54
  }
44
55
  function hornMismatch(horn, datastoreId, env) {
45
56
  if (horn.datastoreId !== datastoreId)
@@ -79,7 +90,6 @@ export function hornRoutes(deps = {}) {
79
90
  const snapshot = await buildHornSnapshot({
80
91
  horn: args.body.horn,
81
92
  select: agentsSparqlSelect({
82
- requestUrl: args.request.url,
83
93
  agentsUrl: args.env.MANTA_AGENTS_URL,
84
94
  session: args.session,
85
95
  datastoreId: args.datastoreId,
@@ -96,6 +106,20 @@ export function hornRoutes(deps = {}) {
96
106
  throw caught;
97
107
  }
98
108
  };
109
+ /**
110
+ * The Horn a revision row names, read whole.
111
+ *
112
+ * Shared by the slug lookup and the `about` one: the row differs, the reading does not.
113
+ */
114
+ const hornAtRevision = async (args) => {
115
+ const answer = await args.select(revisionReadQuery(args.realm, args.revision), undefined);
116
+ const stored = triplesOfResults(answer.results.bindings);
117
+ // A revision is rooted at itself. One saved before that was rooted at the Horn, which its other
118
+ // revisions share, so what that root says about other revisions is set aside before it is read.
119
+ const horn = hornFromTriples(sourceOf(deskolemize(stored, args.revision)), iri(args.revision), args.hornIri) ??
120
+ hornFromTriples(sourceOf(deskolemize(legacyRevisionTriples(stored, { root: args.hornIri, revision: args.revision, number: args.number }), args.revision)), iri(args.hornIri));
121
+ return isPresent(horn) ? { horn, stored } : undefined;
122
+ };
99
123
  // What this datastore holds. Without it nothing can show a Horn without already knowing its slug.
100
124
  routes.get("/api/d/:datastoreId/horns", async (c) => {
101
125
  const datastoreId = c.req.param("datastoreId");
@@ -107,7 +131,6 @@ export function hornRoutes(deps = {}) {
107
131
  return refuse(500, "This deployment has no realm configured.").response;
108
132
  try {
109
133
  const found = await agentsSparqlSelect({
110
- requestUrl: c.req.raw.url,
111
134
  agentsUrl: c.env.MANTA_AGENTS_URL,
112
135
  session: authorized.session,
113
136
  datastoreId,
@@ -148,7 +171,6 @@ export function hornRoutes(deps = {}) {
148
171
  if (!isPresent(realm) || !isRealm(realm))
149
172
  return refuse(500, "This deployment has no realm configured.").response;
150
173
  const select = agentsSparqlSelect({
151
- requestUrl: c.req.raw.url,
152
174
  agentsUrl: c.env.MANTA_AGENTS_URL,
153
175
  session: authorized.session,
154
176
  datastoreId,
@@ -161,17 +183,17 @@ export function hornRoutes(deps = {}) {
161
183
  const hornIri = row?.["horn"]?.value;
162
184
  if (!isPresent(revision) || !isPresent(hornIri))
163
185
  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));
186
+ const read = await hornAtRevision({
187
+ select,
188
+ realm,
189
+ hornIri,
190
+ revision,
191
+ number: Number(row?.["number"]?.value),
192
+ });
171
193
  // A revision that holds no Horn is a bug in whatever wrote it, not a missing Horn.
172
- if (!isPresent(horn))
194
+ if (read === undefined)
173
195
  return refuse(500, `Revision ${revision} of "${slug}" is not a Horn.`).response;
174
- return Response.json({ horn, revision, contentHash: await contentHash(stored) }, { headers: noStore });
196
+ return Response.json({ horn: read.horn, revision, contentHash: await contentHash(read.stored) }, { headers: noStore });
175
197
  }
176
198
  catch (caught) {
177
199
  return refuse(502, caught instanceof Error ? caught.message : String(caught)).response;
@@ -192,7 +214,6 @@ export function hornRoutes(deps = {}) {
192
214
  return body.response;
193
215
  const slug = c.req.param("slug");
194
216
  const select = agentsSparqlSelect({
195
- requestUrl: c.req.raw.url,
196
217
  agentsUrl: c.env.MANTA_AGENTS_URL,
197
218
  session: authorized.session,
198
219
  datastoreId,
@@ -225,7 +246,6 @@ export function hornRoutes(deps = {}) {
225
246
  // resolved IRI is the root, not whatever the body claimed, because the route owns the IRI.
226
247
  const locked = verifyLock(hornIri, body.data.horn.dependencies, body.data.horn.lock, deps.libraryLookup ??
227
248
  (await realmLibraryLookup({
228
- requestUrl: c.req.raw.url,
229
249
  env: c.env,
230
250
  session: authorized.session,
231
251
  datastoreId,
@@ -262,6 +282,266 @@ export function hornRoutes(deps = {}) {
262
282
  return refuse(502, caught instanceof Error ? caught.message : String(caught)).response;
263
283
  }
264
284
  });
285
+ /**
286
+ * A Horn built from a rule, for a thing nobody has curated one for.
287
+ *
288
+ * The rule already describes a view — its body is a query — so this is the same construction the rules
289
+ * page does in the browser, run here instead. Nothing is saved: it is a document to draw, and `about`
290
+ * says what it answers for so a curated one can replace it later.
291
+ */
292
+ /**
293
+ * What to call a Horn nobody named.
294
+ *
295
+ * A rule is named as a condition — "Name off convention" — which reads as a rule and not as a page. This
296
+ * says what the page is of. A first pass: worth judging against a real list of rules rather than here.
297
+ */
298
+ const nameForRule = (spec) => spec.kind === "plan" ? `${spec.title}: what it would change` : `${spec.title}: what it flags`;
299
+ /** What a refusal may take up in the view before the rule itself does. */
300
+ const maxRuleWhy = 400;
301
+ /** The longest body that still leaves `source` inside `maxTsxSource`, with an ellipsis when it was cut. */
302
+ const fitted = (body, budget) => {
303
+ if (JSON.stringify(body).length <= budget)
304
+ return body;
305
+ let text = body;
306
+ while (text.length > 1 && JSON.stringify(`${text}…`).length > budget) {
307
+ text = text.slice(0, Math.max(1, Math.ceil(text.length * 0.9) - 1));
308
+ }
309
+ return `${text}…`;
310
+ };
311
+ /**
312
+ * A short, stable key for a thing, for the one place a built Horn needs a kebab id.
313
+ *
314
+ * Derived, not read: the IRI goes in whole and comes out as a number, so nothing here depends on an IRI
315
+ * having parts (AGENTS.md §IRIs are opaque). Two things collide only if they hash alike.
316
+ */
317
+ const opaqueId = (iri) => {
318
+ let hash = 0x811c9dc5;
319
+ for (let at = 0; at < iri.length; at += 1) {
320
+ hash = Math.imul(hash ^ iri.charCodeAt(at), 0x01000193) >>> 0;
321
+ }
322
+ return hash.toString(36);
323
+ };
324
+ /** A view that shows the rule itself, for a rule there is no drawing. */
325
+ const datalogView = (rule, said) => {
326
+ const why = said.length > maxRuleWhy ? `${said.slice(0, maxRuleWhy)}…` : said;
327
+ const sourceFor = (body) => [
328
+ 'import { Separator } from "@manta-eu/react";',
329
+ "",
330
+ "const muted = { opacity: 0.7 };",
331
+ "",
332
+ "export default function View() {",
333
+ " return (",
334
+ ' <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>',
335
+ ` <p style={{ ...muted, margin: 0, fontSize: 14 }}>{${JSON.stringify(why)}}</p>`,
336
+ " <Separator />",
337
+ " <pre",
338
+ " style={{",
339
+ " ...muted,",
340
+ " margin: 0,",
341
+ ' overflow: "auto",',
342
+ ' fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",',
343
+ " fontSize: 12,",
344
+ ' whiteSpace: "pre-wrap",',
345
+ " }}",
346
+ " >",
347
+ ` {${JSON.stringify(body)}}`,
348
+ " </pre>",
349
+ " </div>",
350
+ " );",
351
+ "}",
352
+ ].join("\n");
353
+ return {
354
+ // Per rule, because `standaloneHorn` makes the Horn's IRI and slug out of this: two rules with no
355
+ // view would otherwise be the same Horn, which is what naming them apart was for.
356
+ id: ruleViewId(rule.iri),
357
+ title: "The rule itself",
358
+ // Safe to write: the route checked the IRI before anything was built for it.
359
+ binding: {
360
+ kind: "select",
361
+ sparql: `SELECT ?this WHERE { VALUES ?this { <${rule.iri}> } }`,
362
+ params: [],
363
+ limit: 1,
364
+ derivations: [],
365
+ columns: [{ name: "this", type: "iri" }],
366
+ },
367
+ // A rule is as long as somebody wrote it, and a hand-drafted plan rule is pages; the schema caps the
368
+ // source, so what is shown is cut to fit rather than answered with a Horn nothing will parse.
369
+ render: { kind: "tsx", source: sourceFor(fitted(rule.body, Math.max(2, maxTsxSource - sourceFor("").length))) },
370
+ };
371
+ };
372
+ /**
373
+ * A Horn built from a rule, for a rule nobody has curated one for.
374
+ *
375
+ * A rule whose body translates to a query describes its own view, and that is the Horn. One whose body
376
+ * does not — the parser could not read it, or it mints IRIs with `SKOLEM`, which a query cannot ask for —
377
+ * gets a Horn that says so and shows the Datalog. A card that opens on an explanation is worth more than
378
+ * one that opens on a 404, and the caller does not have to know the difference.
379
+ */
380
+ const hornForRule = async (args) => {
381
+ const rules = readRules(await args.select(ruleQuery(args.realm, args.about), undefined));
382
+ const entry = ruleCoverage({ rules, realm: args.realm }).entries.find((one) => one.rule.iri === args.about);
383
+ if (entry === undefined)
384
+ return undefined;
385
+ const drawn = entry.spec === undefined ? undefined : ruleHornView(entry.spec);
386
+ const view = drawn ??
387
+ datalogView(entry.rule, entry.issues?.[0] ??
388
+ "Its body does not translate to a query, so there is nothing to draw from it — the rule is below.");
389
+ // Named after the rule either way: a Horn's address is made from its title, so two Horns built for two
390
+ // rules must not arrive at the same words.
391
+ const title = entry.spec === undefined ? `${ruleTitle(entry.rule.iri, undefined)}: the rule itself` : nameForRule(entry.spec);
392
+ return {
393
+ ...standaloneHorn({
394
+ id: view.id,
395
+ title,
396
+ views: [view],
397
+ realm: args.realm,
398
+ datastoreId: args.datastoreId,
399
+ }),
400
+ about: [args.about],
401
+ };
402
+ };
403
+ /** What the graph calls a plan, when it calls it anything. A name is a nicety and never fails the Horn. */
404
+ const planName = async (args) => {
405
+ try {
406
+ const status = readPlanStatus(await args.select(planStatusQuery(args.realm, [args.plan]), undefined));
407
+ return status.get(args.plan)?.name;
408
+ }
409
+ catch {
410
+ return undefined;
411
+ }
412
+ };
413
+ const hornForPlan = async (args) => {
414
+ // The things the plan carries an action shape for, which is what a plan concerns — not the rows that
415
+ // survive `readPlanValues`, which drops a value that already holds and one pointing at another thing.
416
+ // The same query refuses a run's copy of a plan, so a record of a run does not get a Horn.
417
+ const entities = readPlanEntities(await args.select(planEntitiesQuery(args.realm, args.about), undefined));
418
+ if (entities.length === 0)
419
+ return undefined;
420
+ // The graph names almost no plan, so this is mostly the name agents shows — which is also what keeps
421
+ // two plans from arriving at one title, and so at one slug.
422
+ const name = (await planName({ select: args.select, realm: args.realm, plan: args.about })) ?? planNameFromIri(args.about);
423
+ const view = {
424
+ id: "suggested-changes",
425
+ title: "Suggested changes",
426
+ // `readPlanEntities` answers with IRIs that can be written into a query and nothing else.
427
+ binding: {
428
+ kind: "select",
429
+ sparql: `SELECT ?this WHERE { VALUES ?this { ${entities.map((one) => `<${one}>`).join(" ")} } }`,
430
+ params: [],
431
+ limit: entities.length,
432
+ derivations: [],
433
+ columns: [{ name: "this", type: "iri" }],
434
+ },
435
+ render: {
436
+ kind: "tsx",
437
+ source: [
438
+ 'import { SuggestedChanges, usePlansBehindView } from "@manta-eu/horns-kit/plan";',
439
+ 'import { Nothing } from "@manta-eu/horns-kit/view";',
440
+ // This Horn is about one plan. Other plans touching the same things are behind the view too, and
441
+ // showing them here would put another plan's changes under this one's name.
442
+ `const about = ${JSON.stringify(args.about)};`,
443
+ "export default function View() {",
444
+ " const plans = usePlansBehindView().filter((plan) => plan.iri === about);",
445
+ " if (plans.length === 0) {",
446
+ ' return <Nothing title="Nothing to change" hint="This plan proposes no changes right now." />;',
447
+ " }",
448
+ " return (",
449
+ ' <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>',
450
+ " {plans.map((plan) => (",
451
+ " <SuggestedChanges key={plan.iri} plan={plan.iri} />",
452
+ " ))}",
453
+ " </div>",
454
+ " );",
455
+ "}",
456
+ ].join("\n"),
457
+ },
458
+ };
459
+ return {
460
+ ...standaloneHorn({
461
+ id: `plan-${opaqueId(args.about)}`,
462
+ // Named after the plan, for the same reason a rule's Horn is: the address comes from the title.
463
+ title: isPresent(name) ? `${name}: what it would change` : "Suggested changes",
464
+ views: [view],
465
+ realm: args.realm,
466
+ datastoreId: args.datastoreId,
467
+ }),
468
+ about: [args.about],
469
+ };
470
+ };
471
+ /**
472
+ * The Horn kept for a thing, by that thing's IRI.
473
+ *
474
+ * A host embedding beside a rule or an action plan holds the IRI and never a slug. `about` is a list, so
475
+ * several Horns can answer for one thing; the first by slug is the one, and the rest are reported so a
476
+ * caller can tell "one" from "one of several" rather than guessing.
477
+ *
478
+ * Two segments because one would not be safe: `about` is a legal slug, so `/horns/about` is a Horn
479
+ * somebody may have saved, and `/horns/:slug` would answer for it either way.
480
+ */
481
+ routes.get("/api/d/:datastoreId/horns/by/about", async (c) => {
482
+ const datastoreId = c.req.param("datastoreId");
483
+ const authorized = await authorize(c.req.raw, c.env, datastoreId);
484
+ if (!authorized.ok)
485
+ return authorized.response;
486
+ const realm = c.env.VITE_KEYCLOAK_REALM;
487
+ if (!isPresent(realm) || !isRealm(realm))
488
+ return refuse(500, "This deployment has no realm configured.").response;
489
+ // A query parameter, not a path segment: an IRI is opaque and carries slashes of its own.
490
+ const about = c.req.query("iri");
491
+ if (!isPresent(about))
492
+ return refuse(400, "Ask with ?iri=<the thing's IRI>.").response;
493
+ // Checked here rather than where it is written into a query: a caller's malformed IRI is a bad request,
494
+ // and refused further down it would be reported as the store having failed.
495
+ if (!isAbsoluteIri(about))
496
+ return refuse(400, `"${about}" is not an IRI.`).response;
497
+ const select = agentsSparqlSelect({
498
+ agentsUrl: c.env.MANTA_AGENTS_URL,
499
+ session: authorized.session,
500
+ datastoreId,
501
+ });
502
+ try {
503
+ const found = await select(hornsAboutQuery(realm, about), undefined);
504
+ const rows = found.results.bindings;
505
+ const row = rows[0];
506
+ const revision = row?.["revision"]?.value;
507
+ const hornIri = row?.["horn"]?.value;
508
+ const slug = row?.["slug"]?.value;
509
+ if (!isPresent(revision) || !isPresent(hornIri)) {
510
+ // Nobody has made one. A rule describes a view on its own, so one is built rather than refused —
511
+ // a host with an IRI gets something to draw whether or not anyone has been here before.
512
+ const made = (await hornForRule({ select, realm, datastoreId, about })) ??
513
+ (await hornForPlan({ select, realm, datastoreId, about }));
514
+ return made === undefined
515
+ ? refuse(404, "No Horn here says it is about that, and nothing could be built for it.").response
516
+ : Response.json({ horn: made, kept: false }, { headers: noStore });
517
+ }
518
+ const read = await hornAtRevision({
519
+ select,
520
+ realm,
521
+ hornIri,
522
+ revision,
523
+ number: Number(row?.["number"]?.value),
524
+ });
525
+ if (read === undefined)
526
+ return refuse(500, `Revision ${revision} is not a Horn.`).response;
527
+ const others = new Set(rows.flatMap((one) => (one["horn"]?.value === undefined ? [] : [one["horn"].value]))).size - 1;
528
+ // `kept` on both answers, and the slug with it: a caller editing this Horn has to save it back where it
529
+ // is, and a caller that only learned it was built would open a second Horn about the same thing.
530
+ return Response.json({
531
+ horn: read.horn,
532
+ kept: true,
533
+ ...(isPresent(slug) ? { slug } : {}),
534
+ revision,
535
+ contentHash: await contentHash(read.stored),
536
+ // Horns, not rows: `DISTINCT` leaves one row per revision, and a Horn could still bring two if a
537
+ // revision carried two titles. What a caller is asking is how many other Horns say this.
538
+ ...(others > 0 ? { others } : {}),
539
+ }, { headers: noStore });
540
+ }
541
+ catch (caught) {
542
+ return refuse(502, caught instanceof Error ? caught.message : String(caught)).response;
543
+ }
544
+ });
265
545
  routes.post("/d/:datastoreId/horns/render", async (c) => {
266
546
  const datastoreId = c.req.param("datastoreId");
267
547
  const accepted = await accept(c.req.raw, c.env, datastoreId, hornRequestSchema);
@@ -309,7 +589,6 @@ export function hornRoutes(deps = {}) {
309
589
  }
310
590
  const slug = c.req.param("slug");
311
591
  const select = agentsSparqlSelect({
312
- requestUrl: c.req.raw.url,
313
592
  agentsUrl: c.env.MANTA_AGENTS_URL,
314
593
  session: authorized.session,
315
594
  datastoreId,
package/dist/llm.js CHANGED
@@ -54,9 +54,7 @@ export function studioModelFromEnv(env, context, fetcher) {
54
54
  baseURL,
55
55
  model,
56
56
  dialect,
57
- ...(dialect === "vllm" && isPresent(env.VLLM_PRIMARY_AUTH_TOKEN)
58
- ? { apiKey: env.VLLM_PRIMARY_AUTH_TOKEN }
59
- : {}),
57
+ ...(dialect === "vllm" && isPresent(env.VLLM_PRIMARY_AUTH_TOKEN) ? { apiKey: env.VLLM_PRIMARY_AUTH_TOKEN } : {}),
60
58
  gatewayToken: env.CLOUDFLARE_AI_GATEWAY_TOKEN,
61
59
  maxTokens: maxTokensOf(env.HORNS_LLM_MAX_TOKENS),
62
60
  // No user identifiers: the gateway log is shared with agents.
@@ -39,10 +39,21 @@ export function sameOriginDeniedResponse(request) {
39
39
  }
40
40
  export const isDatastoreId = (value) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
41
41
  // The proxy's own boundary: nothing reaches agents that is not named here. There is deliberately no
42
- // sparql-update route — every write is built and guarded by the worker itself (ARCHITECTURE.md §11) — and no
43
- // GraphQL route: the worker calls the one mutation Horns needs, running an action plan, itself.
42
+ // sparql-update route — every write is built and guarded by the worker itself (ARCHITECTURE.md §11).
43
+ //
44
+ // `/graphql` and `/sparql` are the SDK's own endpoints, which the browser now reaches as the viewer: the
45
+ // GraphQL client posts to the first and LDkit to the second, both against this worker's origin because the
46
+ // browser holds its cookies and none of agents'. Neither names a datastore in its path — agents takes it from
47
+ // `x-manta-datastore` — so the route handlers check the grant themselves rather than trusting the header, or a
48
+ // caller could name another datastore and be quietly answered from their own.
49
+ //
50
+ // `/graphql` is not filtered by operation. The browser can therefore reach every mutation the viewer could,
51
+ // `executeActionPlan` among them, so the preview-then-confirm gate on a plan run is this app's manners rather
52
+ // than a boundary the transport enforces. Do not add UI that goes around it.
44
53
  const proxyRoutes = [
45
54
  { path: "/d/:datastoreId/sparql", methods: ["POST"], params: { datastoreId: isDatastoreId }, body: "sparql" },
55
+ { path: "/sparql", methods: ["POST"], body: "sparql" },
56
+ { path: "/graphql", methods: ["POST"] },
46
57
  // Where a Horn's row links: agents resolves an entity to its platform's page. A read, and a navigation.
47
58
  { path: "/d/:datastoreId/redirect", methods: ["GET"], params: { datastoreId: isDatastoreId } },
48
59
  { path: "/login", methods: ["GET"] },
@@ -1,8 +1,10 @@
1
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;
2
+ import { type AgentsCaller } from "./agents-proxy.ts";
3
+ /**
4
+ * Server-side reads, as the viewer.
5
+ *
6
+ * The read-only check is this worker's, not agents': it is the write-authorization boundary
7
+ * (ARCHITECTURE.md §11), and a SELECT that is not one must not reach agents merely because it travelled as a
8
+ * GraphQL variable rather than a request body.
9
+ */
10
+ export declare function agentsSparqlSelect(args: AgentsCaller): SparqlSelect;
@@ -1,23 +1,22 @@
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.
1
+ import { checkReadOnlySparql } from "./manta-proxy-policy.js";
2
+ import { SparqlReadError, sparqlResultsOfGraphqlField } from "@manta-eu/horns";
3
+ import { agentsGraphql } from "./agents-proxy.js";
4
+ import { SparqlQueryDocument } from "@manta-eu/client";
5
+ /**
6
+ * Server-side reads, as the viewer.
7
+ *
8
+ * The read-only check is this worker's, not agents': it is the write-authorization boundary
9
+ * (ARCHITECTURE.md §11), and a SELECT that is not one must not reach agents merely because it travelled as a
10
+ * GraphQL variable rather than a request body.
11
+ */
5
12
  export function agentsSparqlSelect(args) {
6
- const origin = new URL(args.requestUrl).origin;
7
- const url = `${origin}/d/${encodeURIComponent(args.datastoreId)}/sparql`;
8
13
  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);
14
+ const verdict = checkReadOnlySparql(query);
15
+ if (!verdict.ok)
16
+ throw new SparqlReadError(verdict.error);
17
+ const answer = await agentsGraphql(args, SparqlQueryDocument, { query }, "sparqlQuery", signal);
18
+ if (!answer.ok)
19
+ throw new SparqlReadError(answer.error);
20
+ return sparqlResultsOfGraphqlField(answer.value);
22
21
  };
23
22
  }
@@ -1,5 +1,6 @@
1
1
  import { Hono } from "hono";
2
2
  import { type Realm } from "@manta-eu/horns";
3
+ import { type ComponentApiLookup, type TypeProblem } from "@manta-eu/horns/studio";
3
4
  import { type Shape } from "@manta-eu/horns/shapes";
4
5
  import { type StudioModelEnv, type StudioModelResult } from "./llm.ts";
5
6
  import { type DevSessionEnv } from "./dev-session.ts";
@@ -10,6 +11,10 @@ export type StudioRoutesEnv = DevSessionEnv & StudioModelEnv & {
10
11
  export type StudioRoutesDeps<Env extends StudioRoutesEnv> = {
11
12
  /** The realm's SHACL shapes, which is what a turn reads classes from. Absent shapes answer no classes. */
12
13
  shapes: (realm: Realm) => readonly Shape[];
14
+ /** The surfaced components' declarations, read at build time. Absent, `read_component_api` is not offered. */
15
+ componentApi?: ComponentApiLookup;
16
+ /** A compiler over a written view. Absent, a proposal is judged on its shape alone. */
17
+ typecheck?: (source: string) => readonly TypeProblem[];
13
18
  /** Overridden by tests with a scripted model; the deployment builds Qwen through the AI Gateway. */
14
19
  model?: (env: Env, context: {
15
20
  feature: string;
@@ -123,7 +123,6 @@ export function studioRoutes(deps) {
123
123
  if (!model.ok)
124
124
  return c.json({ error: model.reason }, 503);
125
125
  const select = agentsSparqlSelect({
126
- requestUrl: c.req.raw.url,
127
126
  agentsUrl: c.env.MANTA_AGENTS_URL,
128
127
  session,
129
128
  datastoreId,
@@ -166,6 +165,7 @@ export function studioRoutes(deps) {
166
165
  shapes,
167
166
  views: shapeViewSpecs,
168
167
  model: model.model,
168
+ ...(deps.componentApi ? { componentApi: deps.componentApi } : {}),
169
169
  // The investigation is a loop of its own; without this it is one line that takes a minute.
170
170
  onEvent: (event) => emit({ type: "progress", event }),
171
171
  }),
@@ -174,6 +174,7 @@ export function studioRoutes(deps) {
174
174
  select,
175
175
  catalog: mantaReactCatalog,
176
176
  lookup: () => undefined,
177
+ ...(deps.typecheck ? { typecheck: deps.typecheck } : {}),
177
178
  // As the viewer: staging a rule is attributed to a person, and agents refuses it otherwise.
178
179
  stageRule: (rule) => stageRule({ agentsUrl: c.env.MANTA_AGENTS_URL, session, datastoreId }, rule),
179
180
  }),
@@ -238,7 +239,6 @@ export function studioRoutes(deps) {
238
239
  return c.json({ error: `This Horn belongs to the "${horn.datastoreId}" datastore.` }, 400);
239
240
  }
240
241
  const select = agentsSparqlSelect({
241
- requestUrl: c.req.raw.url,
242
242
  agentsUrl: c.env.MANTA_AGENTS_URL,
243
243
  session,
244
244
  datastoreId,
@@ -275,7 +275,6 @@ export function studioRoutes(deps) {
275
275
  // a shape's card is drawn by a browser now, and a coverage run has none.
276
276
  const critic = deps.criticModel?.(c.env, { feature: "shape-critic", datastoreId });
277
277
  const select = agentsSparqlSelect({
278
- requestUrl: c.req.raw.url,
279
278
  agentsUrl: c.env.MANTA_AGENTS_URL,
280
279
  session,
281
280
  datastoreId,
@@ -339,7 +338,6 @@ export function studioRoutes(deps) {
339
338
  if (!model.ok)
340
339
  return c.json({ error: model.reason }, 503);
341
340
  const select = agentsSparqlSelect({
342
- requestUrl: c.req.raw.url,
343
341
  agentsUrl: c.env.MANTA_AGENTS_URL,
344
342
  session,
345
343
  datastoreId,
@@ -0,0 +1,10 @@
1
+ import type { TypeProblem } from "@manta-eu/horns/studio";
2
+ /** Every `.d.ts` the check needs, keyed by the path the compiler should find it at. */
3
+ export type TypecheckFs = Readonly<Record<string, string>>;
4
+ /**
5
+ * A check over one view's source, reusing the parsed library across calls.
6
+ *
7
+ * Every `.d.ts` is parsed once and kept: a turn checks a view several times, and re-reading four megabytes
8
+ * of declarations each time is the whole cost.
9
+ */
10
+ export declare function createTypecheck(fs: TypecheckFs): (source: string) => readonly TypeProblem[];
@@ -0,0 +1,67 @@
1
+ import ts from "typescript";
2
+ const viewPath = "/view.tsx";
3
+ const options = {
4
+ jsx: ts.JsxEmit.ReactJSX,
5
+ strict: true,
6
+ noEmit: true,
7
+ // The SDK's own typings are not ours to police, and checking them costs more than the view does.
8
+ skipLibCheck: true,
9
+ target: ts.ScriptTarget.ES2022,
10
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
11
+ // Without this every `@types/*` package in the tree is pulled in, and none of them is the view's business.
12
+ types: [],
13
+ };
14
+ /**
15
+ * A check over one view's source, reusing the parsed library across calls.
16
+ *
17
+ * Every `.d.ts` is parsed once and kept: a turn checks a view several times, and re-reading four megabytes
18
+ * of declarations each time is the whole cost.
19
+ */
20
+ export function createTypecheck(fs) {
21
+ const parsed = new Map();
22
+ const paths = new Set(Object.keys(fs));
23
+ return (source) => {
24
+ const files = { ...fs, [viewPath]: source };
25
+ const host = {
26
+ fileExists: (path) => path === viewPath || paths.has(path),
27
+ readFile: (path) => files[path],
28
+ directoryExists: (path) => {
29
+ const prefix = path.endsWith("/") ? path : `${path}/`;
30
+ return [...paths].some((one) => one.startsWith(prefix));
31
+ },
32
+ getDirectories: () => [],
33
+ realpath: (path) => path,
34
+ getSourceFile: (path, language) => {
35
+ if (path !== viewPath) {
36
+ const already = parsed.get(path);
37
+ if (already !== undefined)
38
+ return already;
39
+ }
40
+ const text = files[path];
41
+ if (text === undefined)
42
+ return undefined;
43
+ const file = ts.createSourceFile(path, text, language, true);
44
+ if (path !== viewPath)
45
+ parsed.set(path, file);
46
+ return file;
47
+ },
48
+ getDefaultLibFileName: () => "/lib.es2022.full.d.ts",
49
+ writeFile: () => { },
50
+ getCurrentDirectory: () => "/",
51
+ getCanonicalFileName: (path) => path,
52
+ useCaseSensitiveFileNames: () => true,
53
+ getNewLine: () => "\n",
54
+ };
55
+ const program = ts.createProgram([viewPath], options, host);
56
+ const view = program.getSourceFile(viewPath);
57
+ if (view === undefined)
58
+ return [];
59
+ return [...program.getSemanticDiagnostics(view), ...program.getSyntacticDiagnostics(view)].map((diagnostic) => {
60
+ const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, " ");
61
+ if (diagnostic.start === undefined)
62
+ return { message };
63
+ const { line } = view.getLineAndCharacterOfPosition(diagnostic.start);
64
+ return { message, line: line + 1 };
65
+ });
66
+ };
67
+ }
package/dist/worker.d.ts CHANGED
@@ -2,12 +2,12 @@ import { Hono } from "hono";
2
2
  import type { Env } from "./env.ts";
3
3
  import { type StudioRoutesDeps } from "./studio-routes.ts";
4
4
  export type { Env };
5
- export type HornsWorkerOptions = Pick<StudioRoutesDeps<Env>, "shapes"> & Partial<Pick<StudioRoutesDeps<Env>, "criticModel">>;
5
+ export type HornsWorkerOptions = Pick<StudioRoutesDeps<Env>, "shapes" | "componentApi" | "typecheck"> & Partial<Pick<StudioRoutesDeps<Env>, "criticModel">>;
6
6
  /**
7
7
  * The Horns worker as a Hono app. `shapes` is the host's: the catalog describes a realm's data model, so
8
8
  * Horns takes it rather than shipping one.
9
9
  */
10
- export declare function createHornsApp({ shapes, criticModel }: HornsWorkerOptions): Hono<{
10
+ export declare function createHornsApp({ shapes, componentApi, typecheck, criticModel, }: HornsWorkerOptions): Hono<{
11
11
  Bindings: Env;
12
12
  }, import("hono/types").BlankSchema, "/">;
13
13
  /** The same app behind Sentry, ready to be a Worker's default export. */
package/dist/worker.js CHANGED
@@ -7,6 +7,7 @@ import { datastoreAccessFor, datastoreAccessUnavailableMessage, datastoreNotGran
7
7
  import { hornRoutes } from "./horn-routes.js";
8
8
  import { actionPlanRoutes } from "./action-plan-routes.js";
9
9
  import { studioRoutes } from "./studio-routes.js";
10
+ import { contentSecurityPolicy } from "./csp.js";
10
11
  import { criticModelFromEnv } from "./llm.js";
11
12
  import { requestSession } from "./dev-session.js";
12
13
  import { asJsonRequest, followedLink, noPageToOpen, signInFirst } from "./entity-links.js";
@@ -16,8 +17,20 @@ import { sentryOptionsForDeploy } from "./sentry.js";
16
17
  * The Horns worker as a Hono app. `shapes` is the host's: the catalog describes a realm's data model, so
17
18
  * Horns takes it rather than shipping one.
18
19
  */
19
- export function createHornsApp({ shapes, criticModel = criticModelFromEnv }) {
20
+ export function createHornsApp({ shapes, componentApi, typecheck, criticModel = criticModelFromEnv, }) {
20
21
  const app = new Hono();
22
+ // On the document only: a JSON answer nobody renders has nothing to protect, and the header is not small.
23
+ app.use("*", async (c, next) => {
24
+ await next();
25
+ if (c.res.headers.get("content-type")?.includes("text/html") !== true)
26
+ return;
27
+ // Rebuilt rather than mutated: what `ASSETS` hands back carries immutable headers.
28
+ const headers = new Headers(c.res.headers);
29
+ headers.set("content-security-policy", contentSecurityPolicy(c.env.SENTRY_DSN));
30
+ headers.set("x-content-type-options", "nosniff");
31
+ headers.set("referrer-policy", "same-origin");
32
+ c.res = new Response(c.res.body, { status: c.res.status, statusText: c.res.statusText, headers });
33
+ });
21
34
  app.use("*", async (c, next) => {
22
35
  if (import.meta.env.DEV)
23
36
  return next();
@@ -106,8 +119,12 @@ export function createHornsApp({ shapes, criticModel = criticModelFromEnv }) {
106
119
  return new Request(request.url, init);
107
120
  }
108
121
  // 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) => {
122
+ // refusing, so a datastore the caller *names* means what it says only if the session's grants decided it.
123
+ //
124
+ // A request naming none is let through: it claims nothing to be mislabelled, and agents scopes it to the
125
+ // session's own datastore, which is the caller's to read. The session itself is asked for this way — on a
126
+ // page with no datastore chosen there is nothing to name yet.
127
+ async function proxyForGrantedDatastore(c, datastoreId) {
111
128
  const denied = sameOriginDeniedResponse(c.req.raw);
112
129
  if (denied)
113
130
  return denied;
@@ -120,12 +137,18 @@ export function createHornsApp({ shapes, criticModel = criticModelFromEnv }) {
120
137
  return c.json({ error: signedOutMessage }, 401);
121
138
  return c.json({ error: datastoreAccessUnavailableMessage }, 502);
122
139
  }
123
- if (!grantsDatastore(resolved.access, c.req.param("datastoreId"))) {
140
+ if (isPresent(datastoreId) && !grantsDatastore(resolved.access, datastoreId)) {
124
141
  forgetDatastoreAccess(session);
125
142
  return c.json({ error: datastoreNotGrantedMessage }, 403);
126
143
  }
127
144
  return proxyRequest(c, withoutTrustHeaders(c.req.raw));
128
- });
145
+ }
146
+ app.post("/d/:datastoreId/sparql", (c) => proxyForGrantedDatastore(c, c.req.param("datastoreId")));
147
+ // The SDK's own two endpoints, which the browser reaches as the viewer. Neither names a datastore in its
148
+ // path — the client sends `x-manta-datastore`, and sends none before one is chosen.
149
+ const datastoreHeader = (c) => c.req.header("x-manta-datastore");
150
+ app.post("/sparql", (c) => proxyForGrantedDatastore(c, datastoreHeader(c)));
151
+ app.post("/graphql", (c) => proxyForGrantedDatastore(c, datastoreHeader(c)));
129
152
  // A link from a Horn's row to the thing it shows, opened as the viewer in a new tab.
130
153
  app.get("/d/:datastoreId/redirect", async (c) => {
131
154
  const datastoreId = c.req.param("datastoreId");
@@ -149,7 +172,12 @@ export function createHornsApp({ shapes, criticModel = criticModelFromEnv }) {
149
172
  });
150
173
  app.route("/", hornRoutes());
151
174
  app.route("/", actionPlanRoutes());
152
- app.route("/", studioRoutes({ shapes, criticModel }));
175
+ app.route("/", studioRoutes({
176
+ shapes,
177
+ criticModel,
178
+ ...(componentApi ? { componentApi } : {}),
179
+ ...(typecheck ? { typecheck } : {}),
180
+ }));
153
181
  app.all("*", (c) => c.env.ASSETS.fetch(c.req.raw));
154
182
  app.onError((err, c) => {
155
183
  captureException(err, { tags: { path: c.req.routePath, method: c.req.method } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@manta-eu/horns-server",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "The Manta Horns worker as a library: Hono routes a Cloudflare host mounts on its own app.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -29,6 +29,10 @@
29
29
  "./llm": {
30
30
  "types": "./dist/llm.d.ts",
31
31
  "default": "./dist/llm.js"
32
+ },
33
+ "./typecheck": {
34
+ "types": "./dist/typecheck.d.ts",
35
+ "default": "./dist/typecheck.js"
32
36
  }
33
37
  },
34
38
  "publishConfig": {
@@ -36,15 +40,17 @@
36
40
  "access": "public"
37
41
  },
38
42
  "dependencies": {
39
- "@manta-eu/lib": "0.1.4",
43
+ "@manta-eu/client": "0.1.5",
44
+ "@manta-eu/lib": "0.1.5",
40
45
  "@sentry/cloudflare": "^10.70.0",
41
46
  "@sentry/core": "^10.70.0",
42
47
  "hono": "^4.9.8",
43
- "ldkit": "2.8.0",
48
+ "ldkit": "2.9.0",
44
49
  "sparqljs": "^3.7.4",
50
+ "typescript": "^5.9.0",
45
51
  "zod": "^4.5.4",
46
- "@manta-eu/horns": "0.1.0",
47
- "@manta-eu/horns-kit": "0.1.0"
52
+ "@manta-eu/horns": "0.1.1",
53
+ "@manta-eu/horns-kit": "0.1.1"
48
54
  },
49
55
  "devDependencies": {
50
56
  "@cloudflare/vite-plugin": "1.47.0",