@waterlock/gateway 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.md +15 -2
- package/dist/checks/index.d.ts +35 -0
- package/dist/checks/index.js +47 -0
- package/dist/codegen/cli.d.ts +2 -0
- package/dist/codegen/cli.js +60 -0
- package/dist/codegen/config.d.ts +19 -0
- package/dist/codegen/config.js +1 -0
- package/dist/codegen/discovery.d.ts +42 -0
- package/dist/codegen/discovery.js +78 -0
- package/dist/codegen/generate.d.ts +9 -0
- package/dist/codegen/generate.js +77 -0
- package/dist/codegen/index.d.ts +5 -0
- package/dist/codegen/index.js +4 -0
- package/dist/codegen/model.d.ts +21 -0
- package/dist/codegen/model.js +1 -0
- package/dist/codegen/openapi.d.ts +33 -0
- package/dist/codegen/openapi.js +73 -0
- package/dist/core/credentials.d.ts +24 -0
- package/dist/core/credentials.js +41 -0
- package/dist/core/evaluate.d.ts +41 -0
- package/dist/core/evaluate.js +99 -0
- package/dist/core/gateway.d.ts +21 -0
- package/dist/core/gateway.js +152 -0
- package/dist/core/match.d.ts +16 -0
- package/dist/core/match.js +97 -0
- package/dist/core/policy.d.ts +42 -0
- package/dist/core/policy.js +10 -0
- package/dist/core/provider.d.ts +50 -0
- package/dist/core/provider.js +20 -0
- package/dist/core/session.d.ts +59 -0
- package/dist/core/session.js +66 -0
- package/dist/core/store.d.ts +44 -0
- package/dist/core/store.js +18 -0
- package/dist/core/types.d.ts +40 -0
- package/dist/core/types.js +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +8 -0
- package/dist/providers/gmail/decode.d.ts +38 -0
- package/dist/providers/gmail/decode.js +85 -0
- package/dist/providers/gmail/generated.d.ts +323 -0
- package/dist/providers/gmail/generated.js +113 -0
- package/dist/providers/gmail/gmail.config.d.ts +5 -0
- package/dist/providers/gmail/gmail.config.js +16 -0
- package/dist/providers/gmail/index.d.ts +23 -0
- package/dist/providers/gmail/index.js +21 -0
- package/package.json +70 -4
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { AnyRequest, Policy } from "./policy.js";
|
|
2
|
+
import type { SessionStore } from "./store.js";
|
|
3
|
+
/**
|
|
4
|
+
* Answers one natural-language `when` condition. You write it: call the AI SDK (or anything)
|
|
5
|
+
* however you like. Helpers for the prompt and output schema live in `@waterlock/gateway/checks`.
|
|
6
|
+
*/
|
|
7
|
+
export type Check<R extends AnyRequest = AnyRequest> = (input: {
|
|
8
|
+
condition: string;
|
|
9
|
+
request: R;
|
|
10
|
+
/**
|
|
11
|
+
* The gateway's session store (if configured). Read context from it, e.g. user messages
|
|
12
|
+
* for `request.session`. `checkPrompt` does this for you.
|
|
13
|
+
*/
|
|
14
|
+
store?: SessionStore;
|
|
15
|
+
/** Aborted once the decision is made elsewhere. Pass it on, e.g. `generateText({ abortSignal: signal })`. */
|
|
16
|
+
signal: AbortSignal;
|
|
17
|
+
}) => Promise<{
|
|
18
|
+
result: boolean;
|
|
19
|
+
explanation?: string;
|
|
20
|
+
}>;
|
|
21
|
+
export interface CheckOptions<R extends AnyRequest = AnyRequest> {
|
|
22
|
+
check?: Check<R>;
|
|
23
|
+
/** Per-check timeout. Default 60s. */
|
|
24
|
+
checkTimeoutMs?: number;
|
|
25
|
+
}
|
|
26
|
+
export interface Decision {
|
|
27
|
+
/** May gain members later (e.g. "approve"): treat unknown values as deny. */
|
|
28
|
+
effect: "allow" | "deny";
|
|
29
|
+
/** Rules that decided the outcome, as `#<index>`. */
|
|
30
|
+
rules: string[];
|
|
31
|
+
reason: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* 1. Function rules run (sync). Any error or non-boolean denies.
|
|
35
|
+
* 2. A function deny rule matched -> deny (its reason). No checks run.
|
|
36
|
+
* 3. No allow rule matched -> deny (allow-rule reasons as hints). No checks run.
|
|
37
|
+
* 4. Policy has no natural-language rows -> allow (nothing left to ask a model).
|
|
38
|
+
* 5. Natural-language deny rules run in parallel. The first `true` denies and aborts the rest;
|
|
39
|
+
* all `false` allows. Errors, timeouts and malformed answers deny.
|
|
40
|
+
*/
|
|
41
|
+
export declare function evaluatePolicy<R extends AnyRequest>(policy: Policy<R>, req: R, opts?: CheckOptions<R>): Promise<Decision>;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 1. Function rules run (sync). Any error or non-boolean denies.
|
|
3
|
+
* 2. A function deny rule matched -> deny (its reason). No checks run.
|
|
4
|
+
* 3. No allow rule matched -> deny (allow-rule reasons as hints). No checks run.
|
|
5
|
+
* 4. Policy has no natural-language rows -> allow (nothing left to ask a model).
|
|
6
|
+
* 5. Natural-language deny rules run in parallel. The first `true` denies and aborts the rest;
|
|
7
|
+
* all `false` allows. Errors, timeouts and malformed answers deny.
|
|
8
|
+
*/
|
|
9
|
+
export async function evaluatePolicy(policy, req, opts = {}) {
|
|
10
|
+
const rules = policy.rules.map((rule, i) => ({ id: `#${i}`, rule, action: rule.action ?? "allow" }));
|
|
11
|
+
const deny = (ids, reason) => ({ effect: "deny", rules: ids, reason });
|
|
12
|
+
const input = Object.freeze({ ...req }); // rules can't mutate what later rules see
|
|
13
|
+
const matched = [];
|
|
14
|
+
for (const r of rules) {
|
|
15
|
+
if (typeof r.rule.when !== "function")
|
|
16
|
+
continue;
|
|
17
|
+
let value;
|
|
18
|
+
try {
|
|
19
|
+
value = r.rule.when(input);
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
return deny([r.id], `rule ${r.id} threw: ${err instanceof Error ? err.message : String(err)}`);
|
|
23
|
+
}
|
|
24
|
+
if (typeof value !== "boolean")
|
|
25
|
+
return deny([r.id], `rule ${r.id} returned ${describe(value)}, expected boolean`);
|
|
26
|
+
if (value)
|
|
27
|
+
matched.push(r);
|
|
28
|
+
}
|
|
29
|
+
const staticDeny = matched.find((r) => r.action === "deny");
|
|
30
|
+
if (staticDeny)
|
|
31
|
+
return deny([staticDeny.id], staticDeny.rule.reason ?? `denied by ${staticDeny.id}`);
|
|
32
|
+
const allowed = matched.filter((r) => r.action === "allow");
|
|
33
|
+
if (!allowed.length) {
|
|
34
|
+
const hints = rules.filter((r) => r.action === "allow" && r.rule.reason).map((r) => r.rule.reason);
|
|
35
|
+
return deny([], hints.length ? hints.join(" ") : "no allow rule matched");
|
|
36
|
+
}
|
|
37
|
+
const dynamic = rules.filter((r) => typeof r.rule.when === "string");
|
|
38
|
+
const allow = {
|
|
39
|
+
effect: "allow",
|
|
40
|
+
rules: allowed.map((r) => r.id),
|
|
41
|
+
reason: `allowed by ${allowed.map((r) => r.id).join(", ")}`,
|
|
42
|
+
};
|
|
43
|
+
if (!dynamic.length)
|
|
44
|
+
return allow;
|
|
45
|
+
return runChecks(dynamic, req, opts, allow);
|
|
46
|
+
}
|
|
47
|
+
async function runChecks(dynamic, request, opts, allow) {
|
|
48
|
+
const check = opts.check;
|
|
49
|
+
if (!check)
|
|
50
|
+
return { effect: "deny", rules: [], reason: "policy has natural-language rules but the gateway has no check function" };
|
|
51
|
+
const timeoutMs = opts.checkTimeoutMs ?? 60_000;
|
|
52
|
+
const controller = new AbortController();
|
|
53
|
+
const asked = new Map(); // same condition in two rows runs once
|
|
54
|
+
const ask = (condition) => {
|
|
55
|
+
let p = asked.get(condition);
|
|
56
|
+
if (!p) {
|
|
57
|
+
p = withTimeout(check({ condition, request, signal: controller.signal }), timeoutMs).then((out) => {
|
|
58
|
+
const o = out;
|
|
59
|
+
if (typeof o?.result !== "boolean")
|
|
60
|
+
throw new Error("returned a malformed result");
|
|
61
|
+
return { result: o.result, ...(typeof o.explanation === "string" ? { explanation: o.explanation } : {}) };
|
|
62
|
+
});
|
|
63
|
+
asked.set(condition, p);
|
|
64
|
+
}
|
|
65
|
+
return p;
|
|
66
|
+
};
|
|
67
|
+
return new Promise((resolve) => {
|
|
68
|
+
let remaining = dynamic.length;
|
|
69
|
+
let settled = false;
|
|
70
|
+
const settle = (d) => {
|
|
71
|
+
if (settled)
|
|
72
|
+
return;
|
|
73
|
+
settled = true;
|
|
74
|
+
controller.abort();
|
|
75
|
+
resolve(d);
|
|
76
|
+
};
|
|
77
|
+
for (const { id, rule } of dynamic) {
|
|
78
|
+
const condition = rule.when;
|
|
79
|
+
ask(condition).then((record) => {
|
|
80
|
+
if (record.result)
|
|
81
|
+
settle({ ...allow, effect: "deny", rules: [id], reason: rule.reason ?? record.explanation ?? `denied by ${id}` });
|
|
82
|
+
else if (--remaining === 0)
|
|
83
|
+
settle(allow);
|
|
84
|
+
}, (err) => settle({ ...allow, effect: "deny", rules: [id], reason: `check "${condition}" failed: ${err instanceof Error ? err.message : String(err)}` }));
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
function withTimeout(p, ms) {
|
|
89
|
+
let timer;
|
|
90
|
+
const timeout = new Promise((_, reject) => {
|
|
91
|
+
timer = setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms);
|
|
92
|
+
});
|
|
93
|
+
return Promise.race([p, timeout]).finally(() => clearTimeout(timer));
|
|
94
|
+
}
|
|
95
|
+
function describe(v) {
|
|
96
|
+
if (v instanceof Promise || (typeof v === "object" && v !== null && "then" in v))
|
|
97
|
+
return "a Promise (rules must be sync)";
|
|
98
|
+
return v === null ? "null" : typeof v;
|
|
99
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type Check } from "./evaluate.js";
|
|
2
|
+
import { type Policy, type PolicyRule } from "./policy.js";
|
|
3
|
+
import type { ProviderDefinition, RequestOf } from "./provider.js";
|
|
4
|
+
import type { Sessions } from "./session.js";
|
|
5
|
+
export interface GatewayOptions<P extends readonly ProviderDefinition[]> {
|
|
6
|
+
providers: P;
|
|
7
|
+
/**
|
|
8
|
+
* `definePolicy(...)` result, or the rules array inline (typed from `providers`, no type argument).
|
|
9
|
+
* Must cover every provider's requests: a Gmail-only policy on a Gmail + GitHub gateway won't compile.
|
|
10
|
+
*/
|
|
11
|
+
policy: Policy<RequestOf<P>> | PolicyRule<RequestOf<P>>[];
|
|
12
|
+
sessions: Sessions;
|
|
13
|
+
/** Answers plain-language `when` rows, e.g. with your own AI SDK call. Absent = those rows deny. */
|
|
14
|
+
check?: Check<RequestOf<P>>;
|
|
15
|
+
/** Per-check timeout. Default 60s. */
|
|
16
|
+
checkTimeoutMs?: number;
|
|
17
|
+
maxBodyBytes?: number;
|
|
18
|
+
}
|
|
19
|
+
export declare function createGateway<const P extends readonly ProviderDefinition[]>(opts: GatewayOptions<P>): {
|
|
20
|
+
fetch: (req: Request) => Promise<Response>;
|
|
21
|
+
};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { createMatcher } from "./match.js";
|
|
2
|
+
import { evaluatePolicy } from "./evaluate.js";
|
|
3
|
+
import { definePolicy } from "./policy.js";
|
|
4
|
+
/** Only these request headers reach upstream. Everything else (cookies, method overrides, the session token) is dropped. */
|
|
5
|
+
const FORWARD_REQUEST_HEADERS = ["accept", "accept-language", "content-type", "if-match", "if-none-match", "user-agent", "x-goog-api-client"];
|
|
6
|
+
/** Some APIs honor these and would let a POST act as a DELETE. Reject outright. */
|
|
7
|
+
const METHOD_OVERRIDE_HEADERS = ["x-http-method-override", "x-http-method", "x-method-override"];
|
|
8
|
+
const DROP_RESPONSE_HEADERS = ["set-cookie", "content-encoding", "content-length", "transfer-encoding", "connection", "keep-alive"];
|
|
9
|
+
export function createGateway(opts) {
|
|
10
|
+
const policy = Array.isArray(opts.policy) ? definePolicy(opts.policy) : opts.policy;
|
|
11
|
+
const maxBody = opts.maxBodyBytes ?? 10 * 1024 * 1024;
|
|
12
|
+
const ids = opts.providers.map((p) => p.id);
|
|
13
|
+
const dup = ids.find((id, i) => ids.indexOf(id) !== i);
|
|
14
|
+
if (dup)
|
|
15
|
+
throw new Error(`provider "${dup}" is configured twice`);
|
|
16
|
+
const providers = new Map(opts.providers.map((p) => [
|
|
17
|
+
p.id,
|
|
18
|
+
{
|
|
19
|
+
def: p,
|
|
20
|
+
matcher: createMatcher(p.table, { allowedGlobalQueryParams: p.allowedGlobalQueryParams }),
|
|
21
|
+
},
|
|
22
|
+
]));
|
|
23
|
+
async function handle(req) {
|
|
24
|
+
const url = new URL(req.url);
|
|
25
|
+
const method = req.method.toUpperCase();
|
|
26
|
+
// 1. Authenticate. The signed token names the provider; the agent can't pick another.
|
|
27
|
+
const auth = await authenticate(req, opts.sessions);
|
|
28
|
+
if ("error" in auth) {
|
|
29
|
+
return fail(401, "unauthenticated", "missing or invalid session token");
|
|
30
|
+
}
|
|
31
|
+
const session = auth.claims;
|
|
32
|
+
const entry = providers.get(session.provider);
|
|
33
|
+
if (!entry)
|
|
34
|
+
return fail(403, "unknown_provider", `this gateway has no provider "${session.provider}"`);
|
|
35
|
+
// The upstream host is fixed by config; the path is forwarded as-is.
|
|
36
|
+
const upstreamPath = url.pathname;
|
|
37
|
+
const override = METHOD_OVERRIDE_HEADERS.find((h) => req.headers.has(h));
|
|
38
|
+
if (override)
|
|
39
|
+
return fail(400, "bad_request", `header ${override} not allowed`);
|
|
40
|
+
// 2. Match against the generated operation table.
|
|
41
|
+
const matched = entry.matcher.match(method, upstreamPath, url.searchParams);
|
|
42
|
+
if (!matched.ok)
|
|
43
|
+
return fail(matched.status, "policy_denied", matched.reason);
|
|
44
|
+
const { op } = matched;
|
|
45
|
+
// 3. Read body once: decode it for policy, forward the same bytes.
|
|
46
|
+
const body = await readBody(req, maxBody);
|
|
47
|
+
if (body === "too_large")
|
|
48
|
+
return fail(413, "bad_request", "body too large");
|
|
49
|
+
let context = {};
|
|
50
|
+
const decoder = entry.def.decoders?.[op.action];
|
|
51
|
+
if (decoder) {
|
|
52
|
+
if (op.variant !== "standard") {
|
|
53
|
+
const reason = `${op.variant} route for ${op.action} not supported: body can't be inspected`;
|
|
54
|
+
return deny(op.action, [], reason);
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
context = await decoder({ body, contentType: req.headers.get("content-type") });
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
const reason = `could not decode request body: ${err instanceof Error ? err.message : String(err)}`;
|
|
61
|
+
return deny(op.action, [], reason);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// Built from the provider's own table and decoder, so it matches that provider's request type.
|
|
65
|
+
const policyReq = {
|
|
66
|
+
provider: entry.def.id,
|
|
67
|
+
action: op.action,
|
|
68
|
+
params: matched.params,
|
|
69
|
+
context,
|
|
70
|
+
session,
|
|
71
|
+
};
|
|
72
|
+
// 4. Policy.
|
|
73
|
+
const decision = await evaluatePolicy(policy, policyReq, {
|
|
74
|
+
...(opts.check
|
|
75
|
+
? { check: (input) => opts.check({ ...input, ...(opts.sessions.store ? { store: opts.sessions.store } : {}) }) }
|
|
76
|
+
: {}),
|
|
77
|
+
...(opts.checkTimeoutMs ? { checkTimeoutMs: opts.checkTimeoutMs } : {}),
|
|
78
|
+
});
|
|
79
|
+
if (decision.effect === "deny") {
|
|
80
|
+
return deny(op.action, decision.rules, decision.reason);
|
|
81
|
+
}
|
|
82
|
+
// 5. Forward with the real credential.
|
|
83
|
+
const headers = new Headers();
|
|
84
|
+
for (const h of FORWARD_REQUEST_HEADERS) {
|
|
85
|
+
const v = req.headers.get(h);
|
|
86
|
+
if (v !== null)
|
|
87
|
+
headers.set(h, v);
|
|
88
|
+
}
|
|
89
|
+
let credential;
|
|
90
|
+
try {
|
|
91
|
+
credential = await entry.def.credentials({ session });
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return fail(502, "upstream_auth", "could not obtain upstream credential", op.action);
|
|
95
|
+
}
|
|
96
|
+
(entry.def.inject ?? bearer)({ headers, credential });
|
|
97
|
+
const upstreamUrl = entry.def.upstream.replace(/\/$/, "") + upstreamPath + url.search;
|
|
98
|
+
let upstream;
|
|
99
|
+
try {
|
|
100
|
+
upstream = await fetch(upstreamUrl, {
|
|
101
|
+
method,
|
|
102
|
+
headers,
|
|
103
|
+
body: body.length > 0 && method !== "GET" ? body : undefined,
|
|
104
|
+
redirect: "manual",
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return fail(502, "upstream_unreachable", "upstream request failed", op.action);
|
|
109
|
+
}
|
|
110
|
+
const resHeaders = new Headers(upstream.headers);
|
|
111
|
+
for (const h of DROP_RESPONSE_HEADERS)
|
|
112
|
+
resHeaders.delete(h);
|
|
113
|
+
return new Response(upstream.body, { status: upstream.status, headers: resHeaders });
|
|
114
|
+
}
|
|
115
|
+
return { fetch: handle };
|
|
116
|
+
}
|
|
117
|
+
async function authenticate(req, sessions) {
|
|
118
|
+
const header = req.headers.get("authorization");
|
|
119
|
+
const token = header?.match(/^Bearer (\S+)$/i)?.[1];
|
|
120
|
+
return token ? sessions.verify({ token }) : { error: "missing" };
|
|
121
|
+
}
|
|
122
|
+
async function readBody(req, max) {
|
|
123
|
+
const declared = Number(req.headers.get("content-length") ?? 0);
|
|
124
|
+
if (declared > max)
|
|
125
|
+
return "too_large";
|
|
126
|
+
if (!req.body)
|
|
127
|
+
return new Uint8Array();
|
|
128
|
+
const chunks = [];
|
|
129
|
+
let size = 0;
|
|
130
|
+
for await (const chunk of req.body) {
|
|
131
|
+
size += chunk.length;
|
|
132
|
+
if (size > max)
|
|
133
|
+
return "too_large";
|
|
134
|
+
chunks.push(chunk);
|
|
135
|
+
}
|
|
136
|
+
const out = new Uint8Array(size);
|
|
137
|
+
let off = 0;
|
|
138
|
+
for (const c of chunks) {
|
|
139
|
+
out.set(c, off);
|
|
140
|
+
off += c.length;
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
function bearer({ headers, credential }) {
|
|
145
|
+
headers.set("authorization", `Bearer ${credential.token}`);
|
|
146
|
+
}
|
|
147
|
+
function deny(action, rules, reason) {
|
|
148
|
+
return fail(403, "policy_denied", reason, action, rules);
|
|
149
|
+
}
|
|
150
|
+
function fail(status, code, message, action, rules) {
|
|
151
|
+
return Response.json({ error: { code, message, ...(action ? { action } : {}), ...(rules?.length ? { rules } : {}) } }, { status });
|
|
152
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Operation, OperationTable } from "./types.js";
|
|
2
|
+
export type MatchResult = {
|
|
3
|
+
ok: true;
|
|
4
|
+
op: Operation;
|
|
5
|
+
params: Record<string, unknown>;
|
|
6
|
+
} | {
|
|
7
|
+
ok: false;
|
|
8
|
+
status: 400 | 404 | 405;
|
|
9
|
+
reason: string;
|
|
10
|
+
};
|
|
11
|
+
export interface Matcher {
|
|
12
|
+
match(method: string, path: string, query: URLSearchParams): MatchResult;
|
|
13
|
+
}
|
|
14
|
+
export declare function createMatcher(table: OperationTable, opts: {
|
|
15
|
+
allowedGlobalQueryParams: readonly string[];
|
|
16
|
+
}): Matcher;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Paths the upstream might interpret differently than we do. Rejected rather
|
|
3
|
+
* than normalized: whatever we match must be exactly what we forward.
|
|
4
|
+
* (WHATWG URL parsing already resolves `.`/`..` segments before we see the path.)
|
|
5
|
+
*/
|
|
6
|
+
const AMBIGUOUS_PATH = /\/\/|%2f|%5c|\\|%00|%2e/i;
|
|
7
|
+
export function createMatcher(table, opts) {
|
|
8
|
+
const compiled = table.operations.map(compileOp);
|
|
9
|
+
const allowedGlobals = new Set(opts.allowedGlobalQueryParams);
|
|
10
|
+
return { match };
|
|
11
|
+
function match(method, path, query) {
|
|
12
|
+
if (AMBIGUOUS_PATH.test(path))
|
|
13
|
+
return { ok: false, status: 400, reason: "ambiguous path encoding" };
|
|
14
|
+
const byPath = compiled.filter((c) => c.re.test(path));
|
|
15
|
+
if (byPath.length === 0)
|
|
16
|
+
return { ok: false, status: 404, reason: `no ${table.provider} operation matches path` };
|
|
17
|
+
const candidates = byPath.filter((c) => c.op.method === method);
|
|
18
|
+
if (candidates.length === 0)
|
|
19
|
+
return { ok: false, status: 405, reason: `method ${method} not allowed on path` };
|
|
20
|
+
// Prefer the most specific template: `/messages/send` beats `/messages/{id}`.
|
|
21
|
+
candidates.sort((a, b) => b.literalChars - a.literalChars);
|
|
22
|
+
const best = candidates[0];
|
|
23
|
+
const tie = candidates.find((c) => c !== best && c.literalChars === best.literalChars && c.op.action !== best.op.action);
|
|
24
|
+
if (tie)
|
|
25
|
+
return { ok: false, status: 400, reason: `ambiguous route: ${best.op.action} vs ${tie.op.action}` };
|
|
26
|
+
const params = {};
|
|
27
|
+
const m = best.re.exec(path);
|
|
28
|
+
for (let i = 0; i < best.names.length; i++) {
|
|
29
|
+
const spec = best.op.params.find((p) => p.in === "path" && p.name === best.names[i]);
|
|
30
|
+
let raw;
|
|
31
|
+
try {
|
|
32
|
+
raw = decodeURIComponent(m[i + 1]);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return { ok: false, status: 400, reason: `bad encoding in path param ${spec.name}` };
|
|
36
|
+
}
|
|
37
|
+
const v = coerce(spec, raw);
|
|
38
|
+
if (!v.ok)
|
|
39
|
+
return { ok: false, status: 400, reason: v.reason };
|
|
40
|
+
params[spec.name] = v.value;
|
|
41
|
+
}
|
|
42
|
+
for (const key of new Set(query.keys())) {
|
|
43
|
+
const spec = best.op.params.find((p) => p.in === "query" && p.name === key);
|
|
44
|
+
if (!spec) {
|
|
45
|
+
if (allowedGlobals.has(key))
|
|
46
|
+
continue;
|
|
47
|
+
return { ok: false, status: 400, reason: `query param "${key}" not allowed for ${best.op.action}` };
|
|
48
|
+
}
|
|
49
|
+
const values = query.getAll(key);
|
|
50
|
+
if (!spec.repeated && values.length > 1) {
|
|
51
|
+
return { ok: false, status: 400, reason: `query param "${key}" given ${values.length} times` };
|
|
52
|
+
}
|
|
53
|
+
const parsed = [];
|
|
54
|
+
for (const raw of values) {
|
|
55
|
+
const v = coerce(spec, raw);
|
|
56
|
+
if (!v.ok)
|
|
57
|
+
return { ok: false, status: 400, reason: v.reason };
|
|
58
|
+
parsed.push(v.value);
|
|
59
|
+
}
|
|
60
|
+
params[key] = spec.repeated ? parsed : parsed[0];
|
|
61
|
+
}
|
|
62
|
+
const missing = best.op.params.find((p) => p.in === "query" && p.required && !(p.name in params));
|
|
63
|
+
if (missing)
|
|
64
|
+
return { ok: false, status: 400, reason: `missing required query param "${missing.name}"` };
|
|
65
|
+
return { ok: true, op: best.op, params };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function compileOp(op) {
|
|
69
|
+
const names = [];
|
|
70
|
+
let literalChars = 0;
|
|
71
|
+
const pattern = op.path.replace(/\{(\+?)([^}]+)\}|[^{]+/g, (whole, plus, name) => {
|
|
72
|
+
if (name === undefined) {
|
|
73
|
+
literalChars += whole.length;
|
|
74
|
+
return whole.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
75
|
+
}
|
|
76
|
+
names.push(name);
|
|
77
|
+
return plus ? "(.+)" : "([^/]+)";
|
|
78
|
+
});
|
|
79
|
+
return { op, re: new RegExp(`^${pattern}$`), names, literalChars };
|
|
80
|
+
}
|
|
81
|
+
function coerce(spec, raw) {
|
|
82
|
+
const bad = (what) => ({ ok: false, reason: `param "${spec.name}" ${what}` });
|
|
83
|
+
if (spec.enum && !spec.enum.includes(raw))
|
|
84
|
+
return bad(`must be one of ${spec.enum.join(", ")}`);
|
|
85
|
+
switch (spec.type) {
|
|
86
|
+
case "integer":
|
|
87
|
+
return /^-?\d+$/.test(raw) ? { ok: true, value: Number(raw) } : bad("must be an integer");
|
|
88
|
+
case "number": {
|
|
89
|
+
const n = Number(raw);
|
|
90
|
+
return raw.trim() !== "" && Number.isFinite(n) ? { ok: true, value: n } : bad("must be a number");
|
|
91
|
+
}
|
|
92
|
+
case "boolean":
|
|
93
|
+
return raw === "true" || raw === "false" ? { ok: true, value: raw === "true" } : bad("must be true or false");
|
|
94
|
+
case "string":
|
|
95
|
+
return { ok: true, value: raw };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { SessionClaims } from "./session.js";
|
|
2
|
+
/** What a rule sees. Providers narrow this with literal action/param types (autocomplete only). */
|
|
3
|
+
export interface ProviderRequest<P extends string = string, A extends string = string, Params extends object = object, Ctx extends object = object> {
|
|
4
|
+
provider: P;
|
|
5
|
+
action: A;
|
|
6
|
+
/** Typed path + query params, parsed by the matcher. */
|
|
7
|
+
params: Params;
|
|
8
|
+
/** Fields extracted from the body by a provider decoder (e.g. Gmail recipients). */
|
|
9
|
+
context: Ctx;
|
|
10
|
+
session: SessionClaims;
|
|
11
|
+
}
|
|
12
|
+
export type AnyRequest = ProviderRequest;
|
|
13
|
+
/**
|
|
14
|
+
* One policy row. `when` is either:
|
|
15
|
+
* - a sync function over the request (static), allow or deny, or
|
|
16
|
+
* - a plain-language condition answered by the gateway's `check` (e.g. an LLM). These
|
|
17
|
+
* rows can only deny: a model can narrow what's allowed, never grant it.
|
|
18
|
+
*/
|
|
19
|
+
export type PolicyRule<R extends AnyRequest = AnyRequest> = {
|
|
20
|
+
when: (r: R) => boolean;
|
|
21
|
+
/** Effect when the rule matches. Default "allow". May gain members later (e.g. "approve"). */
|
|
22
|
+
action?: "allow" | "deny";
|
|
23
|
+
/** Context for the calling agent when this rule decides the outcome. */
|
|
24
|
+
reason?: string;
|
|
25
|
+
} | {
|
|
26
|
+
/** e.g. "The email contains billing information" */
|
|
27
|
+
when: string;
|
|
28
|
+
action: "deny";
|
|
29
|
+
/** Shown to the agent on deny. Default: the check's explanation. */
|
|
30
|
+
reason?: string;
|
|
31
|
+
};
|
|
32
|
+
export interface Policy<R extends AnyRequest = AnyRequest> {
|
|
33
|
+
readonly rules: readonly PolicyRule<R>[];
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* A policy is a list of rows. The gateway evaluates it:
|
|
37
|
+
* - any matching "deny" row -> deny
|
|
38
|
+
* - no matching "allow" row -> deny (default deny)
|
|
39
|
+
* - otherwise -> allow
|
|
40
|
+
* Plain-language rows only run once the function rows would allow.
|
|
41
|
+
*/
|
|
42
|
+
export declare function definePolicy<R extends AnyRequest = AnyRequest>(rules: PolicyRule<R>[]): Policy<R>;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A policy is a list of rows. The gateway evaluates it:
|
|
3
|
+
* - any matching "deny" row -> deny
|
|
4
|
+
* - no matching "allow" row -> deny (default deny)
|
|
5
|
+
* - otherwise -> allow
|
|
6
|
+
* Plain-language rows only run once the function rows would allow.
|
|
7
|
+
*/
|
|
8
|
+
export function definePolicy(rules) {
|
|
9
|
+
return { rules: Object.freeze([...rules]) };
|
|
10
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Credential, CredentialSource } from "./credentials.js";
|
|
2
|
+
import type { AnyRequest } from "./policy.js";
|
|
3
|
+
import type { OperationTable } from "./types.js";
|
|
4
|
+
/**
|
|
5
|
+
* Extracts policy-relevant fields from a request body. Throwing denies the
|
|
6
|
+
* request: if the gateway can't read what a call does, it doesn't forward it.
|
|
7
|
+
*/
|
|
8
|
+
export type Decoder = (input: {
|
|
9
|
+
body: Uint8Array;
|
|
10
|
+
contentType: string | null;
|
|
11
|
+
}) => object | Promise<object>;
|
|
12
|
+
/** `R` is the request type rules see for this provider. Type-only: drives policy autocomplete and checks. */
|
|
13
|
+
export interface ProviderDefinition<R extends AnyRequest = AnyRequest> {
|
|
14
|
+
id: string;
|
|
15
|
+
/** Fixed upstream origin. The harness can never choose the host. */
|
|
16
|
+
upstream: string;
|
|
17
|
+
table: OperationTable;
|
|
18
|
+
/** Spec-level query params (e.g. Google's `fields`, `alt`) allowed on every call. Anything else unlisted is denied. */
|
|
19
|
+
allowedGlobalQueryParams: readonly string[];
|
|
20
|
+
/** Per-action body decoders. Only run on the standard route; upload variants of decoded actions are denied. */
|
|
21
|
+
decoders?: Partial<Record<string, Decoder>>;
|
|
22
|
+
credentials: CredentialSource;
|
|
23
|
+
/** Default: `Authorization: Bearer <token>`. */
|
|
24
|
+
inject?: (input: {
|
|
25
|
+
headers: Headers;
|
|
26
|
+
credential: Credential;
|
|
27
|
+
}) => void;
|
|
28
|
+
/** Type-only marker, never set at runtime. */
|
|
29
|
+
readonly __request?: R;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Typed helper for provider definitions. Use it for custom providers too: methods added
|
|
33
|
+
* later (e.g. `withGroups`) arrive here without changing your provider code.
|
|
34
|
+
*
|
|
35
|
+
* TODO(groups): named sets of actions, usable in rules as `r.in("gmail:send")`.
|
|
36
|
+
*
|
|
37
|
+
* withGroups<const M extends Record<string, readonly (R["action"] | `${string}*${string}`)[]>>(
|
|
38
|
+
* groups: M,
|
|
39
|
+
* ): Provider<R & { groups: readonly `${R["provider"]}:${keyof M & string}`[] }>;
|
|
40
|
+
*
|
|
41
|
+
* - returns a new provider; names are prefixed with the provider id (`gmail:risky`)
|
|
42
|
+
* - values are action names or globs (`*` one dotted segment, `**` any number)
|
|
43
|
+
* - throw on a pattern matching no action and on invalid names; adding to an existing group extends it
|
|
44
|
+
* - gateway: build action -> groups once per provider, set `request.groups`
|
|
45
|
+
* - rules: `in(group)` on the rule input, typed from `groups` so typos fail typecheck
|
|
46
|
+
* - built-in providers ship curated defaults (gmail: read, compose, send, delete, forwarding...)
|
|
47
|
+
*/
|
|
48
|
+
export declare function createProvider<R extends AnyRequest>(def: ProviderDefinition<R>): ProviderDefinition<R>;
|
|
49
|
+
/** Union of the request types of a list of providers. */
|
|
50
|
+
export type RequestOf<P extends readonly ProviderDefinition[]> = P[number] extends infer D ? D extends ProviderDefinition<infer R> ? R : never : never;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed helper for provider definitions. Use it for custom providers too: methods added
|
|
3
|
+
* later (e.g. `withGroups`) arrive here without changing your provider code.
|
|
4
|
+
*
|
|
5
|
+
* TODO(groups): named sets of actions, usable in rules as `r.in("gmail:send")`.
|
|
6
|
+
*
|
|
7
|
+
* withGroups<const M extends Record<string, readonly (R["action"] | `${string}*${string}`)[]>>(
|
|
8
|
+
* groups: M,
|
|
9
|
+
* ): Provider<R & { groups: readonly `${R["provider"]}:${keyof M & string}`[] }>;
|
|
10
|
+
*
|
|
11
|
+
* - returns a new provider; names are prefixed with the provider id (`gmail:risky`)
|
|
12
|
+
* - values are action names or globs (`*` one dotted segment, `**` any number)
|
|
13
|
+
* - throw on a pattern matching no action and on invalid names; adding to an existing group extends it
|
|
14
|
+
* - gateway: build action -> groups once per provider, set `request.groups`
|
|
15
|
+
* - rules: `in(group)` on the rule input, typed from `groups` so typos fail typecheck
|
|
16
|
+
* - built-in providers ship curated defaults (gmail: read, compose, send, delete, forwarding...)
|
|
17
|
+
*/
|
|
18
|
+
export function createProvider(def) {
|
|
19
|
+
return def;
|
|
20
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { SessionRef, SessionStore, UserMessage } from "./store.js";
|
|
2
|
+
/**
|
|
3
|
+
* Session tokens: the only credential the harness ever holds.
|
|
4
|
+
* Format: `as1.<base64url(claims)>.<base64url(hmac-sha256)>`. HMAC via WebCrypto
|
|
5
|
+
* (constant-time verify). Base64 via Buffer: Node/Bun/Deno, Workers need nodejs_compat.
|
|
6
|
+
*/
|
|
7
|
+
export interface SessionClaims {
|
|
8
|
+
sid: string;
|
|
9
|
+
/** Provider id this token is for. The gateway routes by it, so paths may collide across providers. */
|
|
10
|
+
provider: string;
|
|
11
|
+
agent: string;
|
|
12
|
+
onBehalfOf: string;
|
|
13
|
+
/** Task the session was minted for. Set by the trusted launcher, not the agent. */
|
|
14
|
+
intent?: string;
|
|
15
|
+
iat: number;
|
|
16
|
+
exp: number;
|
|
17
|
+
}
|
|
18
|
+
export interface MintInput {
|
|
19
|
+
/** Provider id this token is for, e.g. `gmail`. One token per provider. */
|
|
20
|
+
provider: string;
|
|
21
|
+
/**
|
|
22
|
+
* Reuse an existing session id, e.g. to issue a second provider's token for the same session
|
|
23
|
+
* so both share one store entry (user messages). Default: a new id.
|
|
24
|
+
*/
|
|
25
|
+
sid?: string;
|
|
26
|
+
agent: string;
|
|
27
|
+
onBehalfOf: string;
|
|
28
|
+
intent?: string;
|
|
29
|
+
ttlSeconds?: number;
|
|
30
|
+
}
|
|
31
|
+
/** Why a token was rejected. May gain members later (e.g. "revoked"). */
|
|
32
|
+
export type VerifyError = "malformed" | "bad_signature" | "expired";
|
|
33
|
+
export interface Sessions {
|
|
34
|
+
mint(input: MintInput): Promise<{
|
|
35
|
+
token: string;
|
|
36
|
+
claims: SessionClaims;
|
|
37
|
+
}>;
|
|
38
|
+
verify(input: {
|
|
39
|
+
token: string;
|
|
40
|
+
}): Promise<{
|
|
41
|
+
claims: SessionClaims;
|
|
42
|
+
} | {
|
|
43
|
+
error: VerifyError;
|
|
44
|
+
}>;
|
|
45
|
+
/** The configured store, if any. */
|
|
46
|
+
readonly store: SessionStore | undefined;
|
|
47
|
+
/** Trusted code only: record what the human said. Throws if the store can't hold messages. */
|
|
48
|
+
addUserMessage(input: {
|
|
49
|
+
session: SessionRef;
|
|
50
|
+
message: UserMessage;
|
|
51
|
+
}): Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
export declare function createSessions(opts: {
|
|
54
|
+
signingKey: string | Uint8Array;
|
|
55
|
+
defaultTtlSeconds?: number;
|
|
56
|
+
now?: () => number;
|
|
57
|
+
/** Optional storage: `memoryStore()`, your own adapter, or omit for stateless tokens. */
|
|
58
|
+
store?: SessionStore;
|
|
59
|
+
}): Sessions;
|