@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Manta AI AS
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { HornRoutesEnv } from "./horn-routes.ts";
|
|
3
|
+
import type { MantaSessionState } from "./session.ts";
|
|
4
|
+
export declare const planNotFoundMessage = "There is no plan like that in this datastore.";
|
|
5
|
+
export type PlanRunResult = {
|
|
6
|
+
ok: true;
|
|
7
|
+
workflowId?: string;
|
|
8
|
+
} | {
|
|
9
|
+
ok: false;
|
|
10
|
+
status: number;
|
|
11
|
+
error: string;
|
|
12
|
+
};
|
|
13
|
+
export declare function executeActionPlan(args: {
|
|
14
|
+
agentsUrl: string | undefined;
|
|
15
|
+
session: MantaSessionState;
|
|
16
|
+
datastoreId: string;
|
|
17
|
+
plan: string;
|
|
18
|
+
preview: boolean;
|
|
19
|
+
}): Promise<PlanRunResult>;
|
|
20
|
+
export declare function actionPlanRoutes<Env extends HornRoutesEnv>(): Hono<{
|
|
21
|
+
Bindings: Env;
|
|
22
|
+
}, import("hono/types").BlankSchema, "/">;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { isPresent } from "@manta-eu/lib/present";
|
|
4
|
+
import { absoluteIri, isRealm } from "@manta-eu/horns";
|
|
5
|
+
import { isPlanQuery } from "@manta-eu/horns/render";
|
|
6
|
+
import { agentsGraphql } from "./agents-proxy.js";
|
|
7
|
+
import { authorize, parseBody, refuse } from "./authorize.js";
|
|
8
|
+
import { agentsSparqlSelect } from "./sparql-select.js";
|
|
9
|
+
// Running an action plan is agents' `executeActionPlan`, called by this worker as the viewer: agents authorizes
|
|
10
|
+
// it with the viewer's own token and hands it to Manta's workflow, which validates every change against its
|
|
11
|
+
// shape before anything reaches a platform. The browser never gets a GraphQL route; it asks for exactly this one
|
|
12
|
+
// mutation, for a plan this datastore holds.
|
|
13
|
+
export const planNotFoundMessage = "There is no plan like that in this datastore.";
|
|
14
|
+
const runRequestSchema = z.object({
|
|
15
|
+
// Written into a query as a term, so nothing that could close it gets that far.
|
|
16
|
+
plan: z.string().regex(absoluteIri),
|
|
17
|
+
/** A preview checks every change and makes none. A real run is only ever asked for explicitly. */
|
|
18
|
+
preview: z.boolean(),
|
|
19
|
+
});
|
|
20
|
+
const executeActionPlanMutation = "mutation ExecuteActionPlan($input: ExecuteActionPlanInput!) { executeActionPlan(input: $input) { success workflowId error } }";
|
|
21
|
+
const executeAnswerSchema = z.object({
|
|
22
|
+
success: z.boolean(),
|
|
23
|
+
workflowId: z.string().nullish(),
|
|
24
|
+
error: z.string().nullish(),
|
|
25
|
+
});
|
|
26
|
+
export async function executeActionPlan(args) {
|
|
27
|
+
const sent = await agentsGraphql({ agentsUrl: args.agentsUrl, session: args.session, datastoreId: args.datastoreId }, executeActionPlanMutation, { input: { namedGraphIri: args.plan, dryRun: args.preview } }, "executeActionPlan");
|
|
28
|
+
// Being signed out and having no address are about the caller, so they are said plainly. What agents
|
|
29
|
+
// answered is about the plan, so it is said as such.
|
|
30
|
+
if (!sent.ok) {
|
|
31
|
+
return sent.status === 502 ? { ...sent, error: `The plan could not be started: ${sent.error}` } : sent;
|
|
32
|
+
}
|
|
33
|
+
const parsed = executeAnswerSchema.safeParse(sent.value);
|
|
34
|
+
if (!parsed.success) {
|
|
35
|
+
return { ok: false, status: 502, error: "The plan could not be started: Manta said nothing about it." };
|
|
36
|
+
}
|
|
37
|
+
if (!parsed.data.success) {
|
|
38
|
+
const said = parsed.data.error ?? "Manta refused it.";
|
|
39
|
+
return { ok: false, status: 502, error: `The plan could not be started: ${said}` };
|
|
40
|
+
}
|
|
41
|
+
const answer = parsed.data;
|
|
42
|
+
return isPresent(answer.workflowId) ? { ok: true, workflowId: answer.workflowId } : { ok: true };
|
|
43
|
+
}
|
|
44
|
+
export function actionPlanRoutes() {
|
|
45
|
+
const routes = new Hono();
|
|
46
|
+
routes.post("/api/d/:datastoreId/action-plans/run", async (c) => {
|
|
47
|
+
const datastoreId = c.req.param("datastoreId");
|
|
48
|
+
const authorized = await authorize(c.req.raw, c.env, datastoreId);
|
|
49
|
+
if (!authorized.ok)
|
|
50
|
+
return authorized.response;
|
|
51
|
+
const realm = c.env.VITE_KEYCLOAK_REALM;
|
|
52
|
+
if (!isPresent(realm) || !isRealm(realm))
|
|
53
|
+
return refuse(500, "This deployment has no realm configured.").response;
|
|
54
|
+
const body = await parseBody(c.req.raw, runRequestSchema);
|
|
55
|
+
if (!body.ok)
|
|
56
|
+
return body.response;
|
|
57
|
+
// Read as the viewer, so a plan they cannot see is a plan that is not there.
|
|
58
|
+
try {
|
|
59
|
+
const found = await agentsSparqlSelect({
|
|
60
|
+
requestUrl: c.req.raw.url,
|
|
61
|
+
agentsUrl: c.env.MANTA_AGENTS_URL,
|
|
62
|
+
session: authorized.session,
|
|
63
|
+
datastoreId,
|
|
64
|
+
})(isPlanQuery(realm, body.data.plan), undefined);
|
|
65
|
+
if (!isPresent(found.results.bindings))
|
|
66
|
+
return refuse(404, planNotFoundMessage).response;
|
|
67
|
+
}
|
|
68
|
+
catch (caught) {
|
|
69
|
+
return refuse(502, caught instanceof Error ? caught.message : String(caught)).response;
|
|
70
|
+
}
|
|
71
|
+
const started = await executeActionPlan({
|
|
72
|
+
agentsUrl: c.env.MANTA_AGENTS_URL,
|
|
73
|
+
session: authorized.session,
|
|
74
|
+
datastoreId,
|
|
75
|
+
plan: body.data.plan,
|
|
76
|
+
preview: body.data.preview,
|
|
77
|
+
});
|
|
78
|
+
if (!started.ok)
|
|
79
|
+
return refuse(started.status, started.error).response;
|
|
80
|
+
return c.json({ started: true, ...(started.workflowId ? { workflowId: started.workflowId } : {}) }, 202, {
|
|
81
|
+
"cache-control": "no-store",
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
return routes;
|
|
85
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type MantaSessionState } from "./session.ts";
|
|
2
|
+
export { checkReadOnlySparql, crossOriginBlockedMessage, evaluateProxyPolicy, isAllowedProxyPath, isAuthProxyPath, isReadOnlySparql, originAllowsRequest, proxyMethodBlockedMessage, proxyPathBlockedMessage, sameOriginDeniedResponse, sparqlEmptyMessage, sparqlFederationBlockedMessage, sparqlParseFailedMessage, sparqlWriteBlockedMessage, } from "./manta-proxy-policy.ts";
|
|
3
|
+
export declare const missingAgentsUrlMessage = "MANTA_AGENTS_URL is not set \u2014 the live data source has no address.";
|
|
4
|
+
export declare const signedOutMessage = "Not signed in \u2014 sign in to Manta to reach the live data source.";
|
|
5
|
+
export type AgentsCaller = {
|
|
6
|
+
agentsUrl: string | undefined;
|
|
7
|
+
session: MantaSessionState;
|
|
8
|
+
datastoreId: string;
|
|
9
|
+
};
|
|
10
|
+
export type AgentsGraphqlResult = {
|
|
11
|
+
ok: true;
|
|
12
|
+
value: unknown;
|
|
13
|
+
} | {
|
|
14
|
+
ok: false;
|
|
15
|
+
status: number;
|
|
16
|
+
error: string;
|
|
17
|
+
};
|
|
18
|
+
export declare function agentsGraphql(caller: AgentsCaller, query: string, variables: Record<string, unknown>, field: string): Promise<AgentsGraphqlResult>;
|
|
19
|
+
export type ProxyToMantaAgentsArgs = {
|
|
20
|
+
request: Request;
|
|
21
|
+
agentsUrl?: string;
|
|
22
|
+
session?: MantaSessionState;
|
|
23
|
+
};
|
|
24
|
+
export declare function agentsPathUrl(agentsUrl: string, pathAndQuery: string): URL;
|
|
25
|
+
export declare function agentsUpstreamUrl(agentsUrl: string, requestUrl: string): URL;
|
|
26
|
+
type BuildAgentsProxyRequestArgs = {
|
|
27
|
+
request: Request;
|
|
28
|
+
agentsUrl: string;
|
|
29
|
+
session?: MantaSessionState;
|
|
30
|
+
};
|
|
31
|
+
export declare function buildAgentsProxyRequest(args: BuildAgentsProxyRequestArgs): {
|
|
32
|
+
url: URL;
|
|
33
|
+
init: RequestInit;
|
|
34
|
+
};
|
|
35
|
+
export declare function proxyToMantaAgents(args: ProxyToMantaAgentsArgs): Promise<Response>;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { isPresent } from "@manta-eu/lib/present";
|
|
3
|
+
import { evaluateProxyPolicy, isAuthProxyPath } from "./manta-proxy-policy.js";
|
|
4
|
+
import { forwardedOriginHeader, logoutPath } from "./auth-routes.js";
|
|
5
|
+
import { captureUpstreamCookies, clearSessionSetCookies, isSignedIn, requestIsSecure, sessionSetCookies, sessionsDiffer, setCookieHeadersOf, upstreamCookieHeader, } from "./session.js";
|
|
6
|
+
export { checkReadOnlySparql, crossOriginBlockedMessage, evaluateProxyPolicy, isAllowedProxyPath, isAuthProxyPath, isReadOnlySparql, originAllowsRequest, proxyMethodBlockedMessage, proxyPathBlockedMessage, sameOriginDeniedResponse, sparqlEmptyMessage, sparqlFederationBlockedMessage, sparqlParseFailedMessage, sparqlWriteBlockedMessage, } from "./manta-proxy-policy.js";
|
|
7
|
+
export const missingAgentsUrlMessage = "MANTA_AGENTS_URL is not set — the live data source has no address.";
|
|
8
|
+
export const signedOutMessage = "Not signed in — sign in to Manta to reach the live data source.";
|
|
9
|
+
// The one viewer-attributed GraphQL call. Both writers the worker has — staging a rule, uploading a file —
|
|
10
|
+
// go through it, so a timeout or a request id is added once. `status` is carried for the caller that answers
|
|
11
|
+
// with one; the caller that answers a model drops it.
|
|
12
|
+
export async function agentsGraphql(caller, query, variables, field) {
|
|
13
|
+
if (!isPresent(caller.agentsUrl))
|
|
14
|
+
return { ok: false, status: 500, error: missingAgentsUrlMessage };
|
|
15
|
+
const url = agentsPathUrl(caller.agentsUrl, "/graphql");
|
|
16
|
+
const response = await fetch(url, {
|
|
17
|
+
method: "POST",
|
|
18
|
+
headers: {
|
|
19
|
+
cookie: upstreamCookieHeader(caller.session),
|
|
20
|
+
origin: url.origin,
|
|
21
|
+
"content-type": "application/json",
|
|
22
|
+
accept: "application/json",
|
|
23
|
+
"x-manta-datastore": caller.datastoreId,
|
|
24
|
+
},
|
|
25
|
+
body: JSON.stringify({ query, variables }),
|
|
26
|
+
});
|
|
27
|
+
if (response.status === 401)
|
|
28
|
+
return { ok: false, status: 401, error: signedOutMessage };
|
|
29
|
+
const body = await response.json().catch(() => undefined);
|
|
30
|
+
const parsed = z
|
|
31
|
+
.object({
|
|
32
|
+
data: z.record(z.string(), z.unknown()).nullish(),
|
|
33
|
+
errors: z.array(z.object({ message: z.string() })).optional(),
|
|
34
|
+
})
|
|
35
|
+
.safeParse(body);
|
|
36
|
+
const said = parsed.success ? parsed.data.errors?.[0]?.message : undefined;
|
|
37
|
+
const value = parsed.success ? parsed.data.data?.[field] : undefined;
|
|
38
|
+
if (!response.ok || !isPresent(value) || isPresent(said)) {
|
|
39
|
+
return { ok: false, status: 502, error: said ?? `Manta answered ${response.status}` };
|
|
40
|
+
}
|
|
41
|
+
return { ok: true, value };
|
|
42
|
+
}
|
|
43
|
+
// Not `new URL(path, base)`: that discards the base's own pathname, breaking a prefixed MANTA_AGENTS_URL.
|
|
44
|
+
export function agentsPathUrl(agentsUrl, pathAndQuery) {
|
|
45
|
+
return new URL(`${agentsUrl.replace(/\/+$/, "")}${pathAndQuery}`);
|
|
46
|
+
}
|
|
47
|
+
// Horns routes datastore-scoped calls under agents' own `/d/:datastoreId` prefix, so paths pass through.
|
|
48
|
+
export function agentsUpstreamUrl(agentsUrl, requestUrl) {
|
|
49
|
+
const incoming = new URL(requestUrl);
|
|
50
|
+
return agentsPathUrl(agentsUrl, `${incoming.pathname}${incoming.search}`);
|
|
51
|
+
}
|
|
52
|
+
export function buildAgentsProxyRequest(args) {
|
|
53
|
+
const { request, agentsUrl, session } = args;
|
|
54
|
+
const url = agentsUpstreamUrl(agentsUrl, request.url);
|
|
55
|
+
const headers = new Headers(request.headers);
|
|
56
|
+
headers.delete("host");
|
|
57
|
+
headers.delete("cookie");
|
|
58
|
+
headers.delete("authorization");
|
|
59
|
+
headers.set("origin", url.origin);
|
|
60
|
+
headers.delete("referer");
|
|
61
|
+
headers.delete("sec-fetch-site");
|
|
62
|
+
headers.set(forwardedOriginHeader, new URL(request.url).origin);
|
|
63
|
+
if (isPresent(session))
|
|
64
|
+
headers.set("cookie", upstreamCookieHeader(session));
|
|
65
|
+
const init = { method: request.method, headers, redirect: "manual" };
|
|
66
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
67
|
+
init.body = request.body;
|
|
68
|
+
Object.assign(init, { duplex: "half" });
|
|
69
|
+
}
|
|
70
|
+
return { url, init };
|
|
71
|
+
}
|
|
72
|
+
function sessionCookiesToWrite(args) {
|
|
73
|
+
const { request, session, clearSession } = args;
|
|
74
|
+
const secure = requestIsSecure(request.url);
|
|
75
|
+
if (clearSession)
|
|
76
|
+
return clearSessionSetCookies({ secure });
|
|
77
|
+
if (!sessionsDiffer(session.before, session.after))
|
|
78
|
+
return [];
|
|
79
|
+
return isPresent(Object.keys(session.after.upstreamCookies))
|
|
80
|
+
? sessionSetCookies(session.after, { secure })
|
|
81
|
+
: clearSessionSetCookies({ secure });
|
|
82
|
+
}
|
|
83
|
+
// Agents' cookies never reach the browser: they are folded into this worker's own session cookies.
|
|
84
|
+
function withSessionCookies(args) {
|
|
85
|
+
const { response } = args;
|
|
86
|
+
const written = sessionCookiesToWrite(args);
|
|
87
|
+
if (!isPresent(written) && !isPresent(setCookieHeadersOf(response)))
|
|
88
|
+
return response;
|
|
89
|
+
const headers = new Headers(response.headers);
|
|
90
|
+
headers.delete("set-cookie");
|
|
91
|
+
headers.delete("content-encoding");
|
|
92
|
+
headers.delete("content-length");
|
|
93
|
+
for (const cookie of written)
|
|
94
|
+
headers.append("set-cookie", cookie);
|
|
95
|
+
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
|
|
96
|
+
}
|
|
97
|
+
export async function proxyToMantaAgents(args) {
|
|
98
|
+
const { request, agentsUrl, session } = args;
|
|
99
|
+
const decision = await evaluateProxyPolicy(request);
|
|
100
|
+
if (!decision.allow) {
|
|
101
|
+
return Response.json({ error: decision.error }, { status: decision.status });
|
|
102
|
+
}
|
|
103
|
+
if (!isPresent(agentsUrl)) {
|
|
104
|
+
return Response.json({ error: missingAgentsUrlMessage }, { status: 500 });
|
|
105
|
+
}
|
|
106
|
+
if (!isSignedIn(session) && !isAuthProxyPath(new URL(request.url).pathname)) {
|
|
107
|
+
return Response.json({ error: signedOutMessage }, { status: 401 });
|
|
108
|
+
}
|
|
109
|
+
const outbound = decision.bodyText === undefined
|
|
110
|
+
? request
|
|
111
|
+
: new Request(request.url, {
|
|
112
|
+
method: request.method,
|
|
113
|
+
headers: request.headers,
|
|
114
|
+
body: decision.bodyText,
|
|
115
|
+
});
|
|
116
|
+
const { url, init } = buildAgentsProxyRequest({
|
|
117
|
+
request: outbound,
|
|
118
|
+
agentsUrl,
|
|
119
|
+
...(isPresent(session) ? { session } : {}),
|
|
120
|
+
});
|
|
121
|
+
const response = await fetch(url, init);
|
|
122
|
+
const before = session ?? { upstreamCookies: {} };
|
|
123
|
+
return withSessionCookies({
|
|
124
|
+
response,
|
|
125
|
+
request,
|
|
126
|
+
session: { before, after: captureUpstreamCookies(before, response) },
|
|
127
|
+
clearSession: new URL(request.url).pathname === logoutPath,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { signedInParam } from "./auth-params.ts";
|
|
2
|
+
export { signedInParam };
|
|
3
|
+
export declare const loginPath = "/login";
|
|
4
|
+
export declare const callbackPath = "/auth/callback";
|
|
5
|
+
export declare const logoutPath = "/logout";
|
|
6
|
+
export declare const forwardedOriginHeader = "x-manta-forwarded-origin";
|
|
7
|
+
export declare function safeRedirect(raw: string | null | undefined): string;
|
|
8
|
+
export declare function logoutResponseReturningTo(response: Response, request: Request): Response;
|
|
9
|
+
export declare function localSignOutResponse(request: Request): Response;
|
|
10
|
+
export declare const authUnavailableMessage = "Manta could not be reached, so signing in is not possible right now.";
|
|
11
|
+
export declare function authUnavailableResponse(request: Request): Response;
|
|
12
|
+
export declare function loginRequestWithMarker(request: Request): Request;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { isPresent } from "@manta-eu/lib/present";
|
|
2
|
+
import { signedInParam } from "./auth-params.js";
|
|
3
|
+
import { clearSessionSetCookies, requestIsSecure } from "./session.js";
|
|
4
|
+
export { signedInParam };
|
|
5
|
+
export const loginPath = "/login";
|
|
6
|
+
export const callbackPath = "/auth/callback";
|
|
7
|
+
export const logoutPath = "/logout";
|
|
8
|
+
export const forwardedOriginHeader = "x-manta-forwarded-origin";
|
|
9
|
+
const redirectBase = "https://horns.invalid";
|
|
10
|
+
export function safeRedirect(raw) {
|
|
11
|
+
if (!isPresent(raw))
|
|
12
|
+
return "/";
|
|
13
|
+
if (!raw.startsWith("/"))
|
|
14
|
+
return "/";
|
|
15
|
+
let resolved;
|
|
16
|
+
try {
|
|
17
|
+
resolved = new URL(raw, redirectBase);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return "/";
|
|
21
|
+
}
|
|
22
|
+
if (resolved.origin !== redirectBase)
|
|
23
|
+
return "/";
|
|
24
|
+
if (resolved.pathname.startsWith("//"))
|
|
25
|
+
return "/";
|
|
26
|
+
return `${resolved.pathname}${resolved.search}${resolved.hash}`;
|
|
27
|
+
}
|
|
28
|
+
export function logoutResponseReturningTo(response, request) {
|
|
29
|
+
const target = safeRedirect(new URL(request.url).searchParams.get("redirect"));
|
|
30
|
+
const location = response.headers.get("location");
|
|
31
|
+
if (target === "/" || !isPresent(location))
|
|
32
|
+
return response;
|
|
33
|
+
const rewritten = rewrittenLogoutLocation(location, new URL(request.url).origin + target);
|
|
34
|
+
if (!isPresent(rewritten))
|
|
35
|
+
return response;
|
|
36
|
+
const headers = new Headers(response.headers);
|
|
37
|
+
headers.set("location", rewritten);
|
|
38
|
+
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
|
|
39
|
+
}
|
|
40
|
+
function rewrittenLogoutLocation(location, returnTo) {
|
|
41
|
+
if (location.startsWith("/"))
|
|
42
|
+
return returnTo;
|
|
43
|
+
let keycloak;
|
|
44
|
+
try {
|
|
45
|
+
keycloak = new URL(location);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
if (!keycloak.searchParams.has("post_logout_redirect_uri"))
|
|
51
|
+
return undefined;
|
|
52
|
+
keycloak.searchParams.set("post_logout_redirect_uri", returnTo);
|
|
53
|
+
return keycloak.toString();
|
|
54
|
+
}
|
|
55
|
+
export function localSignOutResponse(request) {
|
|
56
|
+
const url = new URL(request.url);
|
|
57
|
+
const headers = new Headers({ location: url.origin + safeRedirect(url.searchParams.get("redirect")) });
|
|
58
|
+
for (const cookie of clearSessionSetCookies({ secure: requestIsSecure(request.url) })) {
|
|
59
|
+
headers.append("set-cookie", cookie);
|
|
60
|
+
}
|
|
61
|
+
return new Response(null, { status: 302, headers });
|
|
62
|
+
}
|
|
63
|
+
export const authUnavailableMessage = "Manta could not be reached, so signing in is not possible right now.";
|
|
64
|
+
export function authUnavailableResponse(request) {
|
|
65
|
+
const target = new URL("/", new URL(request.url).origin);
|
|
66
|
+
target.searchParams.set("auth_error", authUnavailableMessage);
|
|
67
|
+
return new Response(null, { status: 302, headers: { location: target.toString() } });
|
|
68
|
+
}
|
|
69
|
+
export function loginRequestWithMarker(request) {
|
|
70
|
+
const url = new URL(request.url);
|
|
71
|
+
const target = new URL(safeRedirect(url.searchParams.get("redirect")), url);
|
|
72
|
+
target.searchParams.set(signedInParam, "1");
|
|
73
|
+
url.searchParams.set("redirect", `${target.pathname}${target.search}`);
|
|
74
|
+
return new Request(url, request);
|
|
75
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import { type DatastoreAccessEnv } from "./datastore-access.ts";
|
|
3
|
+
import { type DevSessionEnv } from "./dev-session.ts";
|
|
4
|
+
import { type MantaSessionState } from "./session.ts";
|
|
5
|
+
/** Everything the two questions read from the environment, and nothing a route happens to need besides. */
|
|
6
|
+
export type AuthorizedEnv = DevSessionEnv & DatastoreAccessEnv;
|
|
7
|
+
export type Refusal = {
|
|
8
|
+
ok: false;
|
|
9
|
+
response: Response;
|
|
10
|
+
};
|
|
11
|
+
export declare function refuse(status: number, error: string): Refusal;
|
|
12
|
+
/**
|
|
13
|
+
* Same origin, a signed-in session, and a datastore that session was granted. Agents takes the datastore from
|
|
14
|
+
* the path and authorizes on the PSK alone, so the grant check here is the boundary rather than a convenience;
|
|
15
|
+
* a refusal drops the memoised grants, because they name a datastore the caller has just been refused.
|
|
16
|
+
*/
|
|
17
|
+
export declare function authorize(request: Request, env: AuthorizedEnv, datastoreId: string): Promise<{
|
|
18
|
+
ok: true;
|
|
19
|
+
session: MantaSessionState;
|
|
20
|
+
} | Refusal>;
|
|
21
|
+
export declare function parseBody<Schema extends z.ZodType>(request: Request, schema: Schema): Promise<{
|
|
22
|
+
ok: true;
|
|
23
|
+
data: z.output<Schema>;
|
|
24
|
+
} | Refusal>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { isPresent } from "@manta-eu/lib/present";
|
|
2
|
+
import { sameOriginDeniedResponse, signedOutMessage } from "./agents-proxy.js";
|
|
3
|
+
import { datastoreAccessFor, datastoreAccessUnavailableMessage, datastoreNotGrantedMessage, forgetDatastoreAccess, grantsDatastore, } from "./datastore-access.js";
|
|
4
|
+
import { requestSession } from "./dev-session.js";
|
|
5
|
+
import { isSignedIn } from "./session.js";
|
|
6
|
+
export function refuse(status, error) {
|
|
7
|
+
return { ok: false, response: Response.json({ error }, { status }) };
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Same origin, a signed-in session, and a datastore that session was granted. Agents takes the datastore from
|
|
11
|
+
* the path and authorizes on the PSK alone, so the grant check here is the boundary rather than a convenience;
|
|
12
|
+
* a refusal drops the memoised grants, because they name a datastore the caller has just been refused.
|
|
13
|
+
*/
|
|
14
|
+
export async function authorize(request, env, datastoreId) {
|
|
15
|
+
const denied = sameOriginDeniedResponse(request);
|
|
16
|
+
if (denied)
|
|
17
|
+
return { ok: false, response: denied };
|
|
18
|
+
const session = requestSession(request, env);
|
|
19
|
+
if (!isPresent(session) || !isSignedIn(session))
|
|
20
|
+
return refuse(401, signedOutMessage);
|
|
21
|
+
const resolved = await datastoreAccessFor(env, session);
|
|
22
|
+
if (!resolved.ok) {
|
|
23
|
+
return resolved.reason === "unauthenticated"
|
|
24
|
+
? refuse(401, signedOutMessage)
|
|
25
|
+
: refuse(502, datastoreAccessUnavailableMessage);
|
|
26
|
+
}
|
|
27
|
+
if (!grantsDatastore(resolved.access, datastoreId)) {
|
|
28
|
+
forgetDatastoreAccess(session);
|
|
29
|
+
return refuse(403, datastoreNotGrantedMessage);
|
|
30
|
+
}
|
|
31
|
+
return { ok: true, session };
|
|
32
|
+
}
|
|
33
|
+
export async function parseBody(request, schema) {
|
|
34
|
+
const body = await request.json().catch(() => undefined);
|
|
35
|
+
const parsed = schema.safeParse(body);
|
|
36
|
+
if (parsed.success)
|
|
37
|
+
return { ok: true, data: parsed.data };
|
|
38
|
+
const issues = parsed.error.issues
|
|
39
|
+
.slice(0, 5)
|
|
40
|
+
.map((issue) => `${issue.path.join(".") || "body"}: ${issue.message}`)
|
|
41
|
+
.join("; ");
|
|
42
|
+
return refuse(400, `The request is invalid — ${issues}`);
|
|
43
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type MantaSessionState } from "./session.ts";
|
|
2
|
+
export type DatastoreAccessEnv = {
|
|
3
|
+
MANTA_AGENTS_URL?: string;
|
|
4
|
+
};
|
|
5
|
+
export type DatastoreAccess = {
|
|
6
|
+
active?: string;
|
|
7
|
+
granted: readonly string[];
|
|
8
|
+
};
|
|
9
|
+
export type DatastoreAccessDenial = "unauthenticated" | "unavailable";
|
|
10
|
+
export type DatastoreAccessResult = {
|
|
11
|
+
ok: true;
|
|
12
|
+
access: DatastoreAccess;
|
|
13
|
+
} | {
|
|
14
|
+
ok: false;
|
|
15
|
+
reason: DatastoreAccessDenial;
|
|
16
|
+
};
|
|
17
|
+
export declare const datastoreNotGrantedMessage = "That datastore is not one this session can open.";
|
|
18
|
+
export declare const noDatastoreMessage = "This session has no datastore to open.";
|
|
19
|
+
export declare const datastoreAccessUnavailableMessage = "Could not check this session's datastore access.";
|
|
20
|
+
export declare function resetDatastoreAccessCache(): void;
|
|
21
|
+
export declare function forgetDatastoreAccess(session: MantaSessionState): void;
|
|
22
|
+
export declare function datastoreAccessFor(env: DatastoreAccessEnv, session: MantaSessionState): Promise<DatastoreAccessResult>;
|
|
23
|
+
export declare function grantsDatastore(access: DatastoreAccess, datastoreId: string): boolean;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { isPresent } from "@manta-eu/lib/present";
|
|
2
|
+
import { isRecord } from "@manta-eu/horns";
|
|
3
|
+
import { agentsPathUrl } from "./agents-proxy.js";
|
|
4
|
+
import { upstreamCookieHeader } from "./session.js";
|
|
5
|
+
export const datastoreNotGrantedMessage = "That datastore is not one this session can open.";
|
|
6
|
+
export const noDatastoreMessage = "This session has no datastore to open.";
|
|
7
|
+
export const datastoreAccessUnavailableMessage = "Could not check this session's datastore access.";
|
|
8
|
+
const accessMemoTtlMs = 30_000;
|
|
9
|
+
const accessMemoLimit = 500;
|
|
10
|
+
const accessMemo = new Map();
|
|
11
|
+
function memoizedAccess(cookie) {
|
|
12
|
+
const hit = accessMemo.get(cookie);
|
|
13
|
+
if (!isPresent(hit))
|
|
14
|
+
return undefined;
|
|
15
|
+
if (Date.now() - hit.at > accessMemoTtlMs) {
|
|
16
|
+
accessMemo.delete(cookie);
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
return hit.access;
|
|
20
|
+
}
|
|
21
|
+
function rememberAccess(cookie, access) {
|
|
22
|
+
if (accessMemo.size >= accessMemoLimit) {
|
|
23
|
+
const oldest = accessMemo.keys().next();
|
|
24
|
+
if (!oldest.done)
|
|
25
|
+
accessMemo.delete(oldest.value);
|
|
26
|
+
}
|
|
27
|
+
accessMemo.set(cookie, { access, at: Date.now() });
|
|
28
|
+
}
|
|
29
|
+
export function resetDatastoreAccessCache() {
|
|
30
|
+
accessMemo.clear();
|
|
31
|
+
}
|
|
32
|
+
// Grants ride in the token, and agents re-issues it only after provisioning finishes, so a datastore
|
|
33
|
+
// minted seconds ago is legitimately absent from one answer and present from the next. Dropping the
|
|
34
|
+
// memo on a refusal is what keeps that a retry rather than a 30s wall.
|
|
35
|
+
export function forgetDatastoreAccess(session) {
|
|
36
|
+
accessMemo.delete(upstreamCookieHeader(session));
|
|
37
|
+
}
|
|
38
|
+
function readableAccess(body) {
|
|
39
|
+
const user = isRecord(body) && isRecord(body.user) ? body.user : undefined;
|
|
40
|
+
const active = isRecord(user?.datastore) ? user.datastore.id : undefined;
|
|
41
|
+
const datastores = isRecord(body) && Array.isArray(body.datastores) ? body.datastores : [];
|
|
42
|
+
const listed = datastores.flatMap((datastore) => isRecord(datastore) && typeof datastore.id === "string" ? [datastore.id] : []);
|
|
43
|
+
const granted = [...new Set([...(typeof active === "string" ? [active] : []), ...listed])];
|
|
44
|
+
return { ...(typeof active === "string" ? { active } : {}), granted };
|
|
45
|
+
}
|
|
46
|
+
// Agents authenticates SPARQL updates with the shared PSK and takes the datastore from the path, so
|
|
47
|
+
// nothing upstream checks that the caller was granted it. This lookup is that check.
|
|
48
|
+
export async function datastoreAccessFor(env, session) {
|
|
49
|
+
if (!isPresent(env.MANTA_AGENTS_URL))
|
|
50
|
+
return { ok: false, reason: "unavailable" };
|
|
51
|
+
const cookie = upstreamCookieHeader(session);
|
|
52
|
+
const memoized = memoizedAccess(cookie);
|
|
53
|
+
if (isPresent(memoized))
|
|
54
|
+
return { ok: true, access: memoized };
|
|
55
|
+
const url = agentsPathUrl(env.MANTA_AGENTS_URL, "/api/data/context");
|
|
56
|
+
let response;
|
|
57
|
+
try {
|
|
58
|
+
response = await fetch(url, { headers: { cookie, accept: "application/json" } });
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return { ok: false, reason: "unavailable" };
|
|
62
|
+
}
|
|
63
|
+
if (!response.ok) {
|
|
64
|
+
const denied = response.status === 401 || response.status === 403;
|
|
65
|
+
return { ok: false, reason: denied ? "unauthenticated" : "unavailable" };
|
|
66
|
+
}
|
|
67
|
+
const body = await response.json().catch(() => undefined);
|
|
68
|
+
const access = readableAccess(body);
|
|
69
|
+
rememberAccess(cookie, access);
|
|
70
|
+
return { ok: true, access };
|
|
71
|
+
}
|
|
72
|
+
export function grantsDatastore(access, datastoreId) {
|
|
73
|
+
return access.granted.includes(datastoreId);
|
|
74
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { isPresent } from "@manta-eu/lib/present";
|
|
2
|
+
import { agentsJwtCookieName, parseCookieHeader, readSessionState } from "./session.js";
|
|
3
|
+
const loopbackHosts = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
4
|
+
// HireManta staging will not send a sign-in back to localhost, so `pnpm dev` can borrow a staging agents session
|
|
5
|
+
// captured by `dev:borrow-session`. It stands in only for requests to this machine that carry no Horns session
|
|
6
|
+
// yet (agents' token refreshes then land in a real one), and production builds drop it with import.meta.env.DEV.
|
|
7
|
+
export function requestSession(request, env) {
|
|
8
|
+
const session = readSessionState(request.headers.get("cookie"));
|
|
9
|
+
if (isPresent(session) || !import.meta.env.DEV || !isPresent(env.DEV_AGENTS_COOKIE))
|
|
10
|
+
return session;
|
|
11
|
+
if (!loopbackHosts.has(new URL(request.url).hostname))
|
|
12
|
+
return undefined;
|
|
13
|
+
const upstreamCookies = parseCookieHeader(env.DEV_AGENTS_COOKIE);
|
|
14
|
+
return isPresent(upstreamCookies[agentsJwtCookieName]) ? { upstreamCookies } : undefined;
|
|
15
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** The request agents gets: the caller's own, but asking for JSON so a failure comes back as one. */
|
|
2
|
+
export declare function asJsonRequest(request: Request): Request;
|
|
3
|
+
export declare function signInFirst(request: Request): Response;
|
|
4
|
+
/**
|
|
5
|
+
* Where agents sent the caller, made to work from this site. A platform's own URL is left alone; a path on
|
|
6
|
+
* agents (an operator's record page) is made absolute there, because this site has no such page.
|
|
7
|
+
*/
|
|
8
|
+
export declare function followedLink(response: Response, agentsUrl: string): Response | undefined;
|
|
9
|
+
/** A page a person can read when there is nowhere to go, with the way back to their Horns. */
|
|
10
|
+
export declare function noPageToOpen(datastoreId: string, status: number): Response;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { isPresent } from "@manta-eu/lib/present";
|
|
2
|
+
import { agentsPathUrl } from "./agents-proxy.js";
|
|
3
|
+
import { loginPath } from "./auth-routes.js";
|
|
4
|
+
// A row in a Horn links to the thing it shows — the campaign in Google Ads, the ad set in Meta Ads Manager —
|
|
5
|
+
// through agents' `/redirect` (`entityLinkPath` in `@manta-eu/horns/render`), which reads the entity's type and `schema:identifier` from the graph and knows
|
|
6
|
+
// each platform's URL. The link is `/d/:datastoreId/redirect?iri=…` on this site, the same path agents serves
|
|
7
|
+
// on its own, so a Horn embedded in agents links the same way.
|
|
8
|
+
//
|
|
9
|
+
// Agents answers a browser that asks for HTML with redirects to its own error pages, which are not pages here;
|
|
10
|
+
// this asks for JSON and turns a refusal into a sentence instead.
|
|
11
|
+
/** The request agents gets: the caller's own, but asking for JSON so a failure comes back as one. */
|
|
12
|
+
export function asJsonRequest(request) {
|
|
13
|
+
const headers = new Headers(request.headers);
|
|
14
|
+
headers.set("accept", "application/json");
|
|
15
|
+
return new Request(request.url, { method: "GET", headers });
|
|
16
|
+
}
|
|
17
|
+
export function signInFirst(request) {
|
|
18
|
+
const url = new URL(request.url);
|
|
19
|
+
const back = `${url.pathname}${url.search}`;
|
|
20
|
+
return new Response(null, {
|
|
21
|
+
status: 302,
|
|
22
|
+
headers: { location: `${loginPath}?redirect=${encodeURIComponent(back)}` },
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Where agents sent the caller, made to work from this site. A platform's own URL is left alone; a path on
|
|
27
|
+
* agents (an operator's record page) is made absolute there, because this site has no such page.
|
|
28
|
+
*/
|
|
29
|
+
export function followedLink(response, agentsUrl) {
|
|
30
|
+
const location = response.headers.get("location");
|
|
31
|
+
if (response.status < 300 || response.status >= 400 || !isPresent(location))
|
|
32
|
+
return undefined;
|
|
33
|
+
const target = /^https?:\/\//i.test(location) ? location : agentsPathUrl(agentsUrl, location).toString();
|
|
34
|
+
const headers = new Headers(response.headers);
|
|
35
|
+
headers.set("location", target);
|
|
36
|
+
return new Response(null, { status: 302, headers });
|
|
37
|
+
}
|
|
38
|
+
const escapeHtml = (text) => text.replace(/[&<>"']/g, (character) => `&#${character.charCodeAt(0)};`);
|
|
39
|
+
/** A page a person can read when there is nowhere to go, with the way back to their Horns. */
|
|
40
|
+
export function noPageToOpen(datastoreId, status) {
|
|
41
|
+
const title = status === 404 ? "There is no page to open for this" : "That page could not be opened";
|
|
42
|
+
const hint = status === 404
|
|
43
|
+
? "It has no page in the platform it came from, or it has been removed there."
|
|
44
|
+
: "Something went wrong while looking it up. Try again in a moment.";
|
|
45
|
+
const back = `/d/${encodeURIComponent(datastoreId)}/horns`;
|
|
46
|
+
const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escapeHtml(title)}</title></head><body style="margin:0;min-height:100vh;display:grid;place-items:center;background:#0b0b0c;color:#f3f4f6;font-family:system-ui,sans-serif"><main style="max-width:28rem;padding:24px;text-align:center"><h1 style="font-size:20px;margin:0 0 8px">${escapeHtml(title)}</h1><p style="color:#9ca3af;margin:0 0 20px;line-height:1.5">${escapeHtml(hint)}</p><a href="${escapeHtml(back)}" style="color:#a5b4fc">Back to your Horns</a></main></body></html>`;
|
|
47
|
+
return new Response(html, {
|
|
48
|
+
status,
|
|
49
|
+
headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" },
|
|
50
|
+
});
|
|
51
|
+
}
|