@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.
- package/LICENSE +21 -0
- package/dist/action-plan-routes.d.ts +22 -0
- package/dist/action-plan-routes.js +85 -0
- package/dist/agents-proxy.d.ts +35 -0
- package/dist/agents-proxy.js +129 -0
- package/dist/auth-params.d.ts +2 -0
- package/dist/auth-params.js +2 -0
- package/dist/auth-routes.d.ts +12 -0
- package/dist/auth-routes.js +75 -0
- package/dist/authorize.d.ts +24 -0
- package/dist/authorize.js +43 -0
- package/dist/datastore-access.d.ts +23 -0
- package/dist/datastore-access.js +74 -0
- package/dist/dev-session.d.ts +5 -0
- package/dist/dev-session.js +15 -0
- package/dist/entity-links.d.ts +10 -0
- package/dist/entity-links.js +51 -0
- package/dist/env.d.ts +11 -0
- package/dist/env.js +1 -0
- package/dist/horn-routes.d.ts +22 -0
- package/dist/horn-routes.js +344 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +20 -0
- package/dist/llm.d.ts +45 -0
- package/dist/llm.js +126 -0
- package/dist/manta/agents-url.d.ts +2 -0
- package/dist/manta/agents-url.js +2 -0
- package/dist/manta-proxy-policy.d.ts +47 -0
- package/dist/manta-proxy-policy.js +129 -0
- package/dist/manta-rules.d.ts +15 -0
- package/dist/manta-rules.js +86 -0
- package/dist/manta-upload.d.ts +18 -0
- package/dist/manta-upload.js +80 -0
- package/dist/ndjson-reader.d.ts +5 -0
- package/dist/ndjson-reader.js +36 -0
- package/dist/ndjson.d.ts +9 -0
- package/dist/ndjson.js +48 -0
- package/dist/pii.d.ts +4 -0
- package/dist/pii.js +22 -0
- package/dist/redact.d.ts +2 -0
- package/dist/redact.js +7 -0
- package/dist/render-document.d.ts +8 -0
- package/dist/render-document.js +18 -0
- package/dist/sentry.d.ts +23 -0
- package/dist/sentry.js +18 -0
- package/dist/session.d.ts +23 -0
- package/dist/session.js +166 -0
- package/dist/shared.d.ts +4 -0
- package/dist/shared.js +9 -0
- package/dist/sparql-select.d.ts +8 -0
- package/dist/sparql-select.js +23 -0
- package/dist/sparql-update.d.ts +13 -0
- package/dist/sparql-update.js +47 -0
- package/dist/studio-routes.d.ts +43 -0
- package/dist/studio-routes.js +382 -0
- package/dist/worker.d.ts +14 -0
- package/dist/worker.js +169 -0
- package/package.json +62 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import sparqljs from "sparqljs";
|
|
2
|
+
import { isPresent } from "@manta-eu/lib/present";
|
|
3
|
+
import { isRecord } from "@manta-eu/horns";
|
|
4
|
+
export const crossOriginBlockedMessage = "Cross-origin requests to the Horns proxy are blocked.";
|
|
5
|
+
export const proxyPathBlockedMessage = "This proxy only forwards the Manta paths Horns uses.";
|
|
6
|
+
export const proxyMethodBlockedMessage = "This proxy does not allow that method on this path.";
|
|
7
|
+
export const sparqlEmptyMessage = "SPARQL query is empty.";
|
|
8
|
+
export const sparqlParseFailedMessage = "SPARQL query could not be parsed as a single query.";
|
|
9
|
+
export const sparqlWriteBlockedMessage = "This proxy only forwards read-only SPARQL.";
|
|
10
|
+
export const sparqlFederationBlockedMessage = "This proxy does not forward SERVICE, FROM or FROM NAMED.";
|
|
11
|
+
function denied(status, error) {
|
|
12
|
+
return { allow: false, status, error };
|
|
13
|
+
}
|
|
14
|
+
const authProxyPaths = new Set(["/login", "/auth/callback", "/logout"]);
|
|
15
|
+
// Keycloak hands the browser back from its own origin, so these two cannot demand same-origin.
|
|
16
|
+
const crossOriginAuthPaths = new Set(["/login", "/auth/callback"]);
|
|
17
|
+
export function isAuthProxyPath(pathname) {
|
|
18
|
+
return authProxyPaths.has(pathname);
|
|
19
|
+
}
|
|
20
|
+
export function originAllowsRequest({ request }) {
|
|
21
|
+
const origin = request.headers.get("origin");
|
|
22
|
+
const requestOrigin = new URL(request.url).origin;
|
|
23
|
+
if (isPresent(origin))
|
|
24
|
+
return origin === requestOrigin;
|
|
25
|
+
const fetchSite = request.headers.get("sec-fetch-site");
|
|
26
|
+
if (fetchSite === "same-origin")
|
|
27
|
+
return true;
|
|
28
|
+
if (fetchSite === "cross-site" || fetchSite === "same-site")
|
|
29
|
+
return false;
|
|
30
|
+
const isWebSocket = request.headers.get("upgrade")?.toLowerCase() === "websocket";
|
|
31
|
+
if (isWebSocket)
|
|
32
|
+
return false;
|
|
33
|
+
return request.method === "GET" || request.method === "HEAD";
|
|
34
|
+
}
|
|
35
|
+
export function sameOriginDeniedResponse(request) {
|
|
36
|
+
if (originAllowsRequest({ request }))
|
|
37
|
+
return undefined;
|
|
38
|
+
return Response.json({ error: crossOriginBlockedMessage }, { status: 403 });
|
|
39
|
+
}
|
|
40
|
+
export const isDatastoreId = (value) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
|
|
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.
|
|
44
|
+
const proxyRoutes = [
|
|
45
|
+
{ path: "/d/:datastoreId/sparql", methods: ["POST"], params: { datastoreId: isDatastoreId }, body: "sparql" },
|
|
46
|
+
// Where a Horn's row links: agents resolves an entity to its platform's page. A read, and a navigation.
|
|
47
|
+
{ path: "/d/:datastoreId/redirect", methods: ["GET"], params: { datastoreId: isDatastoreId } },
|
|
48
|
+
{ path: "/login", methods: ["GET"] },
|
|
49
|
+
{ path: "/auth/callback", methods: ["GET"] },
|
|
50
|
+
{ path: "/logout", methods: ["GET", "POST"] },
|
|
51
|
+
{ path: "/api/login", methods: ["GET"] },
|
|
52
|
+
{ path: "/api/data/context", methods: ["GET"] },
|
|
53
|
+
{ path: "/api/switch-datastore", methods: ["POST"] },
|
|
54
|
+
];
|
|
55
|
+
function routeMatches(route, pathname) {
|
|
56
|
+
const wanted = route.path.split("/");
|
|
57
|
+
const given = pathname.split("/");
|
|
58
|
+
if (wanted.length !== given.length)
|
|
59
|
+
return false;
|
|
60
|
+
return wanted.every((segment, at) => {
|
|
61
|
+
const value = given[at] ?? "";
|
|
62
|
+
if (!segment.startsWith(":"))
|
|
63
|
+
return segment === value;
|
|
64
|
+
if (!isPresent(value))
|
|
65
|
+
return false;
|
|
66
|
+
const guard = route.params?.[segment.slice(1)];
|
|
67
|
+
return guard ? guard(value) : true;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
export function matchProxyRoute(pathname) {
|
|
71
|
+
return proxyRoutes.find((route) => routeMatches(route, pathname));
|
|
72
|
+
}
|
|
73
|
+
export function isAllowedProxyPath(args) {
|
|
74
|
+
const route = matchProxyRoute(args.pathname);
|
|
75
|
+
if (!route)
|
|
76
|
+
return { allowed: false, methodAllowed: false };
|
|
77
|
+
return { allowed: true, methodAllowed: !args.isWebSocket && route.methods.includes(args.method) };
|
|
78
|
+
}
|
|
79
|
+
function mentionsService(node) {
|
|
80
|
+
if (Array.isArray(node))
|
|
81
|
+
return node.some(mentionsService);
|
|
82
|
+
if (!isRecord(node))
|
|
83
|
+
return false;
|
|
84
|
+
return node["type"] === "service" || Object.values(node).some(mentionsService);
|
|
85
|
+
}
|
|
86
|
+
// Parsed, never pattern-matched: a lexer that skips text it thinks is an IRI or a string can be made to skip
|
|
87
|
+
// an update. The body must be exactly one query — no update, no second operation — and may neither federate
|
|
88
|
+
// (SERVICE) nor pick its own graphs (FROM, FROM NAMED), the same rules bindings compile under.
|
|
89
|
+
export function checkReadOnlySparql(text) {
|
|
90
|
+
let parsed;
|
|
91
|
+
try {
|
|
92
|
+
parsed = new sparqljs.Parser().parse(text);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return { ok: false, status: 400, error: sparqlParseFailedMessage };
|
|
96
|
+
}
|
|
97
|
+
if (parsed.type !== "query")
|
|
98
|
+
return { ok: false, status: 403, error: sparqlWriteBlockedMessage };
|
|
99
|
+
const pickedGraphs = (parsed.from?.default.length ?? 0) + (parsed.from?.named.length ?? 0);
|
|
100
|
+
if (pickedGraphs > 0 || mentionsService(parsed)) {
|
|
101
|
+
return { ok: false, status: 403, error: sparqlFederationBlockedMessage };
|
|
102
|
+
}
|
|
103
|
+
return { ok: true };
|
|
104
|
+
}
|
|
105
|
+
export function isReadOnlySparql(text) {
|
|
106
|
+
return checkReadOnlySparql(text).ok;
|
|
107
|
+
}
|
|
108
|
+
function evaluateSparqlBody(bodyText) {
|
|
109
|
+
if (!isPresent(bodyText.trim()))
|
|
110
|
+
return denied(400, sparqlEmptyMessage);
|
|
111
|
+
const verdict = checkReadOnlySparql(bodyText);
|
|
112
|
+
return verdict.ok ? { allow: true, bodyText } : denied(verdict.status, verdict.error);
|
|
113
|
+
}
|
|
114
|
+
export async function evaluateProxyPolicy(request) {
|
|
115
|
+
const url = new URL(request.url);
|
|
116
|
+
if (!crossOriginAuthPaths.has(url.pathname) && !originAllowsRequest({ request })) {
|
|
117
|
+
return denied(403, crossOriginBlockedMessage);
|
|
118
|
+
}
|
|
119
|
+
const route = matchProxyRoute(url.pathname);
|
|
120
|
+
if (!route)
|
|
121
|
+
return denied(403, proxyPathBlockedMessage);
|
|
122
|
+
const isWebSocket = request.headers.get("upgrade")?.toLowerCase() === "websocket";
|
|
123
|
+
if (isWebSocket || !route.methods.includes(request.method))
|
|
124
|
+
return denied(405, proxyMethodBlockedMessage);
|
|
125
|
+
if (request.method === "GET" || request.method === "HEAD")
|
|
126
|
+
return { allow: true };
|
|
127
|
+
const bodyText = await request.text();
|
|
128
|
+
return route.body === "sparql" ? evaluateSparqlBody(bodyText) : { allow: true, bodyText };
|
|
129
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type RuleStager } from "@manta-eu/horns/studio";
|
|
2
|
+
import { type AgentsCaller } from "./agents-proxy.ts";
|
|
3
|
+
export type RuleWriter = AgentsCaller;
|
|
4
|
+
/** What the SDK's `ask_manta_for_rule` hands back, so the two cannot drift apart. */
|
|
5
|
+
export type StagedRule = Awaited<ReturnType<RuleStager>>;
|
|
6
|
+
/**
|
|
7
|
+
* A rule Manta has checked and staged for a person to read. The syntax check runs first because a malformed
|
|
8
|
+
* rule staged is a rule somebody has to unstage; the stratifiability check runs after, because it is the whole
|
|
9
|
+
* staged set that either stratifies or does not, and that answer is worth handing back even when it is bad
|
|
10
|
+
* news — a rule that stages but breaks the set is exactly what a reviewer needs told.
|
|
11
|
+
*/
|
|
12
|
+
export declare function stageRule(writer: RuleWriter, rule: {
|
|
13
|
+
text: string;
|
|
14
|
+
humanIntent: string;
|
|
15
|
+
}): Promise<StagedRule>;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { isPresent } from "@manta-eu/lib/present";
|
|
3
|
+
import { maxHumanIntent } from "@manta-eu/horns/studio";
|
|
4
|
+
import { agentsGraphql } from "./agents-proxy.js";
|
|
5
|
+
// Manta writes the rules. The Studio knows what a Horn needs to draw; Manta's own graph knows what a rule may
|
|
6
|
+
// say and whether the set of them still stratifies — so a derived value the chart vocabulary cannot reach is
|
|
7
|
+
// asked for here rather than invented in a binding.
|
|
8
|
+
//
|
|
9
|
+
// Staged, never committed. `stageRuleOperation` needs a real person (its resolver calls `requireUserId`), so
|
|
10
|
+
// this runs as the viewer on their replayed session, the way uploads and plan runs do. `commitStagedRules`
|
|
11
|
+
// asks only for a datastore — agents will not stop an unconfirmed commit, so the commit is not offered here at
|
|
12
|
+
// all: a staged rule waits on the rules page for somebody to read it.
|
|
13
|
+
const validateMutation = "mutation ValidateRDFoxRule($input: ValidateRDFoxRuleInput!) { validateRDFoxRule(input: $input) " +
|
|
14
|
+
"{ valid error } }";
|
|
15
|
+
const stageMutation = "mutation StageRuleOperation($input: StageRuleOperationInput!) { stageRuleOperation(input: $input) " +
|
|
16
|
+
"{ success operation ruleIri error } }";
|
|
17
|
+
const validateStagedMutation = "mutation ValidateStagedRules { validateStagedRules { isValid isStratifiable nonStratifiableComponents syntaxErrors } }";
|
|
18
|
+
const validateAnswer = z.object({ valid: z.boolean(), error: z.string().nullish() });
|
|
19
|
+
// Only what is read. `operation` was required and never used, so agents answering without it turned a rule
|
|
20
|
+
// that did stage into a refusal — and a model told the ask failed asks again, leaving two to unstage.
|
|
21
|
+
const stageAnswer = z.object({
|
|
22
|
+
success: z.boolean(),
|
|
23
|
+
ruleIri: z.string().nullish(),
|
|
24
|
+
error: z.string().nullish(),
|
|
25
|
+
});
|
|
26
|
+
const stagedAnswer = z.object({
|
|
27
|
+
isValid: z.boolean(),
|
|
28
|
+
isStratifiable: z.boolean(),
|
|
29
|
+
nonStratifiableComponents: z.array(z.string()),
|
|
30
|
+
syntaxErrors: z.array(z.string()),
|
|
31
|
+
});
|
|
32
|
+
/**
|
|
33
|
+
* What the staged set looks like now that this rule is in it.
|
|
34
|
+
*
|
|
35
|
+
* Not getting an answer is itself worth reporting: the rule is staged either way, and a reviewer told
|
|
36
|
+
* nothing would read that as a clean bill of health. So every way of not knowing says so.
|
|
37
|
+
*/
|
|
38
|
+
async function setHealth(writer) {
|
|
39
|
+
const set = await agentsGraphql(writer, validateStagedMutation, {}, "validateStagedRules");
|
|
40
|
+
if (!set.ok)
|
|
41
|
+
return [`Manta could not check the staged set: ${set.error}`];
|
|
42
|
+
const health = stagedAnswer.safeParse(set.value);
|
|
43
|
+
if (!health.success)
|
|
44
|
+
return ["Manta did not say whether the staged rules still hold together."];
|
|
45
|
+
return [
|
|
46
|
+
...health.data.syntaxErrors,
|
|
47
|
+
...(health.data.isStratifiable
|
|
48
|
+
? []
|
|
49
|
+
: [`The staged rules no longer stratify: ${health.data.nonStratifiableComponents.join(", ")}`]),
|
|
50
|
+
// `isValid` is agents' own summary of the two above. It can be false with neither populated, and that is
|
|
51
|
+
// still something a reviewer needs told.
|
|
52
|
+
...(health.data.isValid || isPresent(health.data.syntaxErrors) || !health.data.isStratifiable
|
|
53
|
+
? []
|
|
54
|
+
: ["Manta says the staged rules are not valid, without saying why."]),
|
|
55
|
+
];
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* A rule Manta has checked and staged for a person to read. The syntax check runs first because a malformed
|
|
59
|
+
* rule staged is a rule somebody has to unstage; the stratifiability check runs after, because it is the whole
|
|
60
|
+
* staged set that either stratifies or does not, and that answer is worth handing back even when it is bad
|
|
61
|
+
* news — a rule that stages but breaks the set is exactly what a reviewer needs told.
|
|
62
|
+
*/
|
|
63
|
+
export async function stageRule(writer, rule) {
|
|
64
|
+
const text = rule.text.trim();
|
|
65
|
+
// Checked here rather than sent, so the message says who refused it: `write-tools.ts` prefixes a refusal
|
|
66
|
+
// with "Manta would not take that rule", which would be a lie about a rule Manta never saw.
|
|
67
|
+
if (!text.endsWith("."))
|
|
68
|
+
return { ok: false, error: "Not sent: a Datalog rule has to end with a period." };
|
|
69
|
+
const checked = await agentsGraphql(writer, validateMutation, { input: { rule: text } }, "validateRDFoxRule");
|
|
70
|
+
if (!checked.ok)
|
|
71
|
+
return { ok: false, error: checked.error };
|
|
72
|
+
const verdict = validateAnswer.safeParse(checked.value);
|
|
73
|
+
if (!verdict.success)
|
|
74
|
+
return { ok: false, error: "Manta did not say whether the rule is valid." };
|
|
75
|
+
if (!verdict.data.valid)
|
|
76
|
+
return { ok: false, error: verdict.data.error ?? "Manta will not accept that rule." };
|
|
77
|
+
const staged = await agentsGraphql(writer, stageMutation, { input: { operation: "ADD", newRule: text, humanIntent: rule.humanIntent.slice(0, maxHumanIntent) } }, "stageRuleOperation");
|
|
78
|
+
if (!staged.ok)
|
|
79
|
+
return { ok: false, error: staged.error };
|
|
80
|
+
const answer = stageAnswer.safeParse(staged.value);
|
|
81
|
+
if (!answer.success)
|
|
82
|
+
return { ok: false, error: "Manta did not say what it staged." };
|
|
83
|
+
if (!answer.data.success)
|
|
84
|
+
return { ok: false, error: answer.data.error ?? "Manta would not stage that rule." };
|
|
85
|
+
return { ok: true, ruleIri: answer.data.ruleIri ?? undefined, warnings: await setHealth(writer) };
|
|
86
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type AgentsCaller } from "./agents-proxy.ts";
|
|
2
|
+
export type Uploader = AgentsCaller;
|
|
3
|
+
export type UploadResult = {
|
|
4
|
+
ok: true;
|
|
5
|
+
fileIri: string;
|
|
6
|
+
} | {
|
|
7
|
+
ok: false;
|
|
8
|
+
status: number;
|
|
9
|
+
error: string;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* A PNG stored in the datastore the viewer is looking at, returned as the IRI it now has. The name decides the
|
|
13
|
+
* type agents records — it reads the extension and ignores anything a caller claims — so it ends in `.png`.
|
|
14
|
+
*/
|
|
15
|
+
export declare function uploadPng(uploader: Uploader, file: {
|
|
16
|
+
fileName: string;
|
|
17
|
+
bytes: Uint8Array;
|
|
18
|
+
}): Promise<UploadResult>;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { isPresent } from "@manta-eu/lib/present";
|
|
3
|
+
import { pngSize } from "@manta-eu/horns/render";
|
|
4
|
+
import { agentsGraphql } from "./agents-proxy.js";
|
|
5
|
+
// Putting a file in Manta: agents hands out presigned URLs, the bytes go straight to storage, and agents is
|
|
6
|
+
// told when they landed — it never carries them itself. The worker does this rather than the browser because
|
|
7
|
+
// the browser gets no GraphQL route; and it does it as the viewer, because a completed upload is attributed to
|
|
8
|
+
// a person and the write key cannot stand in for one.
|
|
9
|
+
const initiateMutation = "mutation InitiateUpload($input: InitiateUploadInput!) { initiateUpload(input: $input) " +
|
|
10
|
+
"{ uploadId storageKey partUrls { partNumber url } } }";
|
|
11
|
+
const completeMutation = "mutation CompleteUpload($input: CompleteUploadInput!) { completeUpload(input: $input) { fileIri mediaType } }";
|
|
12
|
+
const abortMutation = "mutation AbortUpload($input: AbortUploadInput!) { abortUpload(input: $input) { ok } }";
|
|
13
|
+
// Agents' own part size. A Horn's picture is one part; the loop is here so a bigger file is not a new bug.
|
|
14
|
+
const partSize = 8 * 1024 * 1024;
|
|
15
|
+
const initiateAnswerSchema = z.object({
|
|
16
|
+
uploadId: z.string(),
|
|
17
|
+
storageKey: z.string(),
|
|
18
|
+
partUrls: z.array(z.object({ partNumber: z.number(), url: z.url() })),
|
|
19
|
+
});
|
|
20
|
+
const completeAnswerSchema = z.object({ fileIri: z.string(), mediaType: z.string() });
|
|
21
|
+
function hex(digest) {
|
|
22
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
23
|
+
}
|
|
24
|
+
/** WebCrypto wants a buffer of its own; a view over one it cannot name is not one. */
|
|
25
|
+
async function sha256(bytes) {
|
|
26
|
+
const buffer = new ArrayBuffer(bytes.byteLength);
|
|
27
|
+
new Uint8Array(buffer).set(bytes);
|
|
28
|
+
return hex(await crypto.subtle.digest("SHA-256", buffer));
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* A PNG stored in the datastore the viewer is looking at, returned as the IRI it now has. The name decides the
|
|
32
|
+
* type agents records — it reads the extension and ignores anything a caller claims — so it ends in `.png`.
|
|
33
|
+
*/
|
|
34
|
+
export async function uploadPng(uploader, file) {
|
|
35
|
+
const started = await agentsGraphql(uploader, initiateMutation, { input: { fileName: file.fileName, byteSize: file.bytes.byteLength } }, "initiateUpload");
|
|
36
|
+
if (!started.ok)
|
|
37
|
+
return started;
|
|
38
|
+
const begun = initiateAnswerSchema.safeParse(started.value);
|
|
39
|
+
if (!begun.success)
|
|
40
|
+
return { ok: false, status: 502, error: "Manta did not say where to put the file." };
|
|
41
|
+
const { uploadId, storageKey, partUrls } = begun.data;
|
|
42
|
+
const giveUp = async () => {
|
|
43
|
+
await agentsGraphql(uploader, abortMutation, { input: { uploadId, storageKey } }, "abortUpload").catch(() => undefined);
|
|
44
|
+
};
|
|
45
|
+
try {
|
|
46
|
+
const parts = [];
|
|
47
|
+
for (const [index, part] of partUrls.entries()) {
|
|
48
|
+
const chunk = file.bytes.slice(index * partSize, (index + 1) * partSize);
|
|
49
|
+
const sent = await fetch(part.url, { method: "PUT", body: chunk });
|
|
50
|
+
const etag = sent.headers.get("etag");
|
|
51
|
+
if (!sent.ok || !isPresent(etag)) {
|
|
52
|
+
await giveUp();
|
|
53
|
+
return { ok: false, status: 502, error: `Part ${part.partNumber} of the file did not store.` };
|
|
54
|
+
}
|
|
55
|
+
parts.push({ partNumber: part.partNumber, etag });
|
|
56
|
+
}
|
|
57
|
+
const { width, height } = pngSize(file.bytes);
|
|
58
|
+
const landed = await agentsGraphql(uploader, completeMutation, {
|
|
59
|
+
input: {
|
|
60
|
+
uploadId,
|
|
61
|
+
storageKey,
|
|
62
|
+
fileName: file.fileName,
|
|
63
|
+
parts,
|
|
64
|
+
facts: { byteSize: file.bytes.byteLength, sha256: await sha256(file.bytes), width, height },
|
|
65
|
+
},
|
|
66
|
+
}, "completeUpload");
|
|
67
|
+
if (!landed.ok) {
|
|
68
|
+
await giveUp();
|
|
69
|
+
return landed;
|
|
70
|
+
}
|
|
71
|
+
const done = completeAnswerSchema.safeParse(landed.value);
|
|
72
|
+
if (!done.success)
|
|
73
|
+
return { ok: false, status: 502, error: "Manta stored the file but did not name it." };
|
|
74
|
+
return { ok: true, fileIri: done.data.fileIri };
|
|
75
|
+
}
|
|
76
|
+
catch (caught) {
|
|
77
|
+
await giveUp();
|
|
78
|
+
return { ok: false, status: 502, error: caught instanceof Error ? caught.message : String(caught) };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads what `ndjsonResponse` wrote: one JSON object per line, the last of which is the result. `onLine` sees
|
|
3
|
+
* each as it arrives, which is what makes a minute-long turn show its work rather than a spinner.
|
|
4
|
+
*/
|
|
5
|
+
export declare function readNdjson(response: Response, onLine?: (line: Record<string, unknown>) => void): Promise<Record<string, unknown> | undefined>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { isPresent } from "@manta-eu/lib/present";
|
|
2
|
+
import { isRecord } from "@manta-eu/horns";
|
|
3
|
+
/**
|
|
4
|
+
* Reads what `ndjsonResponse` wrote: one JSON object per line, the last of which is the result. `onLine` sees
|
|
5
|
+
* each as it arrives, which is what makes a minute-long turn show its work rather than a spinner.
|
|
6
|
+
*/
|
|
7
|
+
export async function readNdjson(response, onLine = () => undefined) {
|
|
8
|
+
const reader = response.body?.getReader();
|
|
9
|
+
if (!isPresent(reader))
|
|
10
|
+
return undefined;
|
|
11
|
+
const decoder = new TextDecoder();
|
|
12
|
+
let buffer = "";
|
|
13
|
+
let last;
|
|
14
|
+
const take = (text) => {
|
|
15
|
+
const line = text.trim();
|
|
16
|
+
if (!isPresent(line))
|
|
17
|
+
return;
|
|
18
|
+
const parsed = JSON.parse(line);
|
|
19
|
+
if (!isRecord(parsed))
|
|
20
|
+
return;
|
|
21
|
+
last = parsed;
|
|
22
|
+
onLine(parsed);
|
|
23
|
+
};
|
|
24
|
+
for (;;) {
|
|
25
|
+
const { done, value } = await reader.read();
|
|
26
|
+
buffer += decoder.decode(value ?? new Uint8Array(), { stream: !done });
|
|
27
|
+
const lines = buffer.split("\n");
|
|
28
|
+
buffer = lines.pop() ?? "";
|
|
29
|
+
for (const line of lines)
|
|
30
|
+
take(line);
|
|
31
|
+
if (done)
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
take(buffer);
|
|
35
|
+
return last;
|
|
36
|
+
}
|
package/dist/ndjson.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A turn as a stream: one JSON object per line, as the turn produces them, the last of which is the result.
|
|
3
|
+
* A turn is several model rounds and takes a minute, and a minute of nothing reads as a hang.
|
|
4
|
+
*
|
|
5
|
+
* `run` starts now, not when someone reads: a client that walks away must not leave half a turn behind, so the
|
|
6
|
+
* lines queue until a reader wants them. Anything `run` throws becomes a last `{ type: "error" }` line, because
|
|
7
|
+
* by then the status is already sent.
|
|
8
|
+
*/
|
|
9
|
+
export declare function ndjsonResponse(run: (emit: (line: unknown) => void) => Promise<void>, headers?: Readonly<Record<string, string>>): Response;
|
package/dist/ndjson.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { isPresent } from "@manta-eu/lib/present";
|
|
2
|
+
/**
|
|
3
|
+
* A turn as a stream: one JSON object per line, as the turn produces them, the last of which is the result.
|
|
4
|
+
* A turn is several model rounds and takes a minute, and a minute of nothing reads as a hang.
|
|
5
|
+
*
|
|
6
|
+
* `run` starts now, not when someone reads: a client that walks away must not leave half a turn behind, so the
|
|
7
|
+
* lines queue until a reader wants them. Anything `run` throws becomes a last `{ type: "error" }` line, because
|
|
8
|
+
* by then the status is already sent.
|
|
9
|
+
*/
|
|
10
|
+
export function ndjsonResponse(run, headers = {}) {
|
|
11
|
+
const encoder = new TextEncoder();
|
|
12
|
+
const queued = [];
|
|
13
|
+
let finished = false;
|
|
14
|
+
let notify;
|
|
15
|
+
const emit = (line) => {
|
|
16
|
+
queued.push(`${JSON.stringify(line)}\n`);
|
|
17
|
+
notify?.();
|
|
18
|
+
};
|
|
19
|
+
void (async () => {
|
|
20
|
+
try {
|
|
21
|
+
await run(emit);
|
|
22
|
+
}
|
|
23
|
+
catch (caught) {
|
|
24
|
+
emit({ type: "error", error: caught instanceof Error ? caught.message : String(caught) });
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
finished = true;
|
|
28
|
+
notify?.();
|
|
29
|
+
}
|
|
30
|
+
})();
|
|
31
|
+
const stream = new ReadableStream({
|
|
32
|
+
async pull(controller) {
|
|
33
|
+
while (!isPresent(queued) && !finished) {
|
|
34
|
+
await new Promise((resolve) => {
|
|
35
|
+
notify = resolve;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
const next = queued.shift();
|
|
39
|
+
if (next === undefined)
|
|
40
|
+
controller.close();
|
|
41
|
+
else
|
|
42
|
+
controller.enqueue(encoder.encode(next));
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
return new Response(stream, {
|
|
46
|
+
headers: { ...headers, "content-type": "application/x-ndjson", "x-accel-buffering": "no" },
|
|
47
|
+
});
|
|
48
|
+
}
|
package/dist/pii.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ErrorEvent } from "@sentry/core";
|
|
2
|
+
export declare const diagnosticExtraKeys: Set<string>;
|
|
3
|
+
export declare function onlyDiagnosticExtra(extra: ErrorEvent["extra"]): ErrorEvent["extra"];
|
|
4
|
+
export declare function withoutPiiErrorEvent(event: ErrorEvent): ErrorEvent;
|
package/dist/pii.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { isPresent } from "@manta-eu/lib/present";
|
|
2
|
+
import { redactedUrl } from "./redact.js";
|
|
3
|
+
export const diagnosticExtraKeys = new Set(["method", "path", "status"]);
|
|
4
|
+
export function onlyDiagnosticExtra(extra) {
|
|
5
|
+
if (!isPresent(extra))
|
|
6
|
+
return {};
|
|
7
|
+
return Object.fromEntries(Object.entries(extra).filter(([key]) => diagnosticExtraKeys.has(key)));
|
|
8
|
+
}
|
|
9
|
+
export function withoutPiiErrorEvent(event) {
|
|
10
|
+
if (event.request) {
|
|
11
|
+
delete event.request.headers;
|
|
12
|
+
delete event.request.cookies;
|
|
13
|
+
delete event.request.query_string;
|
|
14
|
+
delete event.request.data;
|
|
15
|
+
event.request.url = isPresent(event.request.url) ? redactedUrl(event.request.url) : event.request.url;
|
|
16
|
+
}
|
|
17
|
+
delete event.user;
|
|
18
|
+
delete event.contexts?.culture;
|
|
19
|
+
delete event.breadcrumbs;
|
|
20
|
+
event.extra = onlyDiagnosticExtra(event.extra);
|
|
21
|
+
return event;
|
|
22
|
+
}
|
package/dist/redact.d.ts
ADDED
package/dist/redact.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { HornViewMode } from "@manta-eu/horns-kit";
|
|
2
|
+
import type { HornSnapshot } from "@manta-eu/horns/render";
|
|
3
|
+
export declare const hornTemplatePath = "/horn.html";
|
|
4
|
+
export declare function renderHornDocument(args: {
|
|
5
|
+
template: string;
|
|
6
|
+
snapshot: HornSnapshot;
|
|
7
|
+
mode: Exclude<HornViewMode, "capture">;
|
|
8
|
+
}): string;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { renderHornEmbed } from "@manta-eu/horns-kit/server";
|
|
2
|
+
export const hornTemplatePath = "/horn.html";
|
|
3
|
+
const titlePlaceholder = "<!--horn-title-->";
|
|
4
|
+
const embedPlaceholder = "<!--horn-embed-->";
|
|
5
|
+
function escapeHtml(text) {
|
|
6
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
7
|
+
}
|
|
8
|
+
// Fills horn.html as Vite built or serves it, so its script links stay right. Replacer functions keep a
|
|
9
|
+
// `$&` in the data literal.
|
|
10
|
+
export function renderHornDocument(args) {
|
|
11
|
+
const { template, snapshot, mode } = args;
|
|
12
|
+
if (!template.includes(titlePlaceholder) || !template.includes(embedPlaceholder)) {
|
|
13
|
+
throw new Error(`${hornTemplatePath} has lost its placeholders.`);
|
|
14
|
+
}
|
|
15
|
+
return template
|
|
16
|
+
.replace(titlePlaceholder, () => escapeHtml(snapshot.horn.title))
|
|
17
|
+
.replace(embedPlaceholder, () => renderHornEmbed({ snapshot, mode }));
|
|
18
|
+
}
|
package/dist/sentry.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { withoutPiiErrorEvent } from "./pii.ts";
|
|
2
|
+
import type { Env } from "./env.ts";
|
|
3
|
+
export declare function deployedEnvironment(env: Env): string | undefined;
|
|
4
|
+
export declare function sentryOptions(env: Env): {
|
|
5
|
+
dsn: string | undefined;
|
|
6
|
+
release: string;
|
|
7
|
+
environment: string;
|
|
8
|
+
sendDefaultPii: boolean;
|
|
9
|
+
integrations: (import("@sentry/core").Integration & {
|
|
10
|
+
name: string;
|
|
11
|
+
})[];
|
|
12
|
+
beforeSend: typeof withoutPiiErrorEvent;
|
|
13
|
+
};
|
|
14
|
+
export declare function sentryOptionsForDeploy(env: Env): {
|
|
15
|
+
dsn: string | undefined;
|
|
16
|
+
release: string;
|
|
17
|
+
environment: string;
|
|
18
|
+
sendDefaultPii: boolean;
|
|
19
|
+
integrations: (import("@sentry/core").Integration & {
|
|
20
|
+
name: string;
|
|
21
|
+
})[];
|
|
22
|
+
beforeSend: typeof withoutPiiErrorEvent;
|
|
23
|
+
} | undefined;
|
package/dist/sentry.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { httpServerIntegration } from "@sentry/cloudflare";
|
|
2
|
+
import { withoutPiiErrorEvent } from "./pii.js";
|
|
3
|
+
export function deployedEnvironment(env) {
|
|
4
|
+
return env.CLOUDFLARE_ENV ?? import.meta.env.DEPLOYED_ENVIRONMENT;
|
|
5
|
+
}
|
|
6
|
+
export function sentryOptions(env) {
|
|
7
|
+
return {
|
|
8
|
+
dsn: env.SENTRY_DSN,
|
|
9
|
+
release: env.CF_VERSION_METADATA?.id ?? "local-dev",
|
|
10
|
+
environment: deployedEnvironment(env) ?? "local",
|
|
11
|
+
sendDefaultPii: false,
|
|
12
|
+
integrations: [httpServerIntegration({ maxRequestBodySize: "none" })],
|
|
13
|
+
beforeSend: withoutPiiErrorEvent,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export function sentryOptionsForDeploy(env) {
|
|
17
|
+
return deployedEnvironment(env) === undefined ? undefined : sentryOptions(env);
|
|
18
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export declare const agentsJwtCookieName = "auth";
|
|
2
|
+
export declare const sessionCookiePrefix = "mh_session";
|
|
3
|
+
export declare const maxSessionCookieChunks = 8;
|
|
4
|
+
export type MantaSessionState = {
|
|
5
|
+
upstreamCookies: Record<string, string>;
|
|
6
|
+
};
|
|
7
|
+
type CookieAttributes = {
|
|
8
|
+
secure: boolean;
|
|
9
|
+
maxAge?: number;
|
|
10
|
+
};
|
|
11
|
+
export declare function isForwardableCookie(name: string, value: string): boolean;
|
|
12
|
+
export declare function parseCookieHeader(header: string | null | undefined): Record<string, string>;
|
|
13
|
+
export declare function serializeCookie(name: string, value: string, attributes: CookieAttributes): string;
|
|
14
|
+
export declare function requestIsSecure(requestUrl: string): boolean;
|
|
15
|
+
export declare function sessionSetCookies(state: MantaSessionState, attributes: CookieAttributes): string[];
|
|
16
|
+
export declare function clearSessionSetCookies(attributes: CookieAttributes): string[];
|
|
17
|
+
export declare function readSessionState(cookieHeader: string | null | undefined): MantaSessionState | undefined;
|
|
18
|
+
export declare function isSignedIn(state: MantaSessionState | undefined): boolean;
|
|
19
|
+
export declare function upstreamCookieHeader(state: MantaSessionState): string;
|
|
20
|
+
export declare function setCookieHeadersOf(response: Response): string[];
|
|
21
|
+
export declare function captureUpstreamCookies(state: MantaSessionState, response: Response, now?: number): MantaSessionState;
|
|
22
|
+
export declare function sessionsDiffer(before: MantaSessionState, after: MantaSessionState): boolean;
|
|
23
|
+
export {};
|