@waterlock/gateway 0.1.1 → 0.2.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/README.md CHANGED
@@ -1,16 +1,26 @@
1
1
  # waterlock
2
- Be water, my friend
3
2
 
4
- Policy gateway for agent tool calls. Harness holds a session token; gateway holds the real credential, checks policy, forwards.
3
+ Open source solution to [workos airlock](https://workos.com/blog/workos-airlock).
5
4
 
6
- ```sh
7
- pnpm add @waterlock/gateway
8
- ```
5
+ ** This repo is a work in progress. Not ready for production usage. If you would like to contribute,
6
+ please raise an issue and we can chat **
9
7
 
10
- ```sh
11
- pnpm gen:gmail # spec -> src/providers/gmail/generated.ts
12
- pnpm test
13
- pnpm build # -> dist/
14
- ```
8
+ ## Why use this?
15
9
 
16
- Release: bump `version` in package.json and merge. CI publishes to npm and creates the `v<version>` release.
10
+ You don't want to inject real creds into your agents since most of the applications have
11
+ really broad permissions. What waterlock gives you
12
+ - A common language to work with permissions regardless of the application you choose
13
+ - Full visibility into what the accesses were like / audit logs (upcoming)
14
+ - A gateway to proxy forward your external resource calls, preventing rogue agents from
15
+ leaking your sensitive keys.
16
+ - Static typescript functions that can work for validation.
17
+ - Natural language for limiting what your agents can do for a more restrictive agents. For example,
18
+ if you are using gmail, you can simply say 'No billing info allowed in emails' and waterlock takes
19
+ care of working with you.
20
+ - You own the integration logic. With workos, you are really coupled to their infra. This provides a
21
+ more variable approach to integration.
22
+ - You own the ai layer, use ai-sdk, direct Anthropic's ai is fine as well. Waterlock just works.
23
+
24
+ ## Guide
25
+
26
+ Install and usage: [wiki](https://github.com/waterlockcc/waterlock/wiki).
@@ -12,6 +12,7 @@ import { dirname, resolve } from "node:path";
12
12
  import { pathToFileURL } from "node:url";
13
13
  import { fromDiscovery } from "./discovery.js";
14
14
  import { generate } from "./generate.js";
15
+ import { generateGraphql } from "./graphql.js";
15
16
  import { fromOpenAPI } from "./openapi.js";
16
17
  async function main() {
17
18
  const args = process.argv.slice(2);
@@ -28,14 +29,28 @@ async function main() {
28
29
  const res = await fetch(config.source.url);
29
30
  if (!res.ok)
30
31
  throw new Error(`fetch ${config.source.url}: ${res.status}`);
31
- await writeFile(specFile, JSON.stringify(await res.json(), null, 2) + "\n");
32
+ const text = config.source.kind === "graphql" ? await res.text() : JSON.stringify(await res.json(), null, 2) + "\n";
33
+ await writeFile(specFile, text);
32
34
  console.log(`pinned spec updated: ${specFile}`);
33
35
  }
34
- const doc = JSON.parse(await readFile(specFile, "utf8"));
35
- const spec = config.source.kind === "discovery"
36
- ? fromDiscovery(doc)
37
- : fromOpenAPI(doc, { provider: config.provider });
38
- const { code, report, table } = generate(spec, config);
36
+ const source = await readFile(specFile, "utf8");
37
+ let code;
38
+ let report;
39
+ let summary;
40
+ if (config.source.kind === "graphql") {
41
+ const result = generateGraphql(source, config);
42
+ ({ code, report } = result);
43
+ summary = `${result.actions.length} actions`;
44
+ }
45
+ else {
46
+ const doc = JSON.parse(source);
47
+ const spec = config.source.kind === "discovery"
48
+ ? fromDiscovery(doc)
49
+ : fromOpenAPI(doc, { provider: config.provider });
50
+ const result = generate(spec, config);
51
+ ({ code, report } = result);
52
+ summary = `${spec.operations.length} actions, ${result.table.operations.length} routes`;
53
+ }
39
54
  const outFile = resolve(dir, config.out);
40
55
  const reportFile = config.report ? resolve(dir, config.report) : undefined;
41
56
  if (args.includes("--check")) {
@@ -50,7 +65,7 @@ async function main() {
50
65
  await writeFile(outFile, code);
51
66
  if (reportFile)
52
67
  await writeFile(reportFile, report);
53
- console.log(`${config.provider}: ${spec.operations.length} actions, ${table.operations.length} routes -> ${outFile}`);
68
+ console.log(`${config.provider}: ${summary} -> ${outFile}`);
54
69
  if (reportFile)
55
70
  console.log(` review: ${reportFile}`);
56
71
  }
@@ -3,17 +3,23 @@ export interface CodegenConfig {
3
3
  provider: string;
4
4
  /** Prefix for emitted TS types: `Gmail` -> `GmailAction`, `GmailParams`, ... */
5
5
  typePrefix: string;
6
- /** Paths are relative to the config file. `file` is the pinned spec; `url` is used by `--fetch`. */
6
+ /**
7
+ * Paths are relative to the config file. `file` is the pinned spec (JSON, or SDL text for
8
+ * `graphql`); `url` is used by `--fetch`. `title` names a GraphQL schema in generated comments.
9
+ */
7
10
  source: {
8
- kind: "discovery" | "openapi";
11
+ kind: "discovery" | "openapi" | "graphql";
9
12
  file: string;
10
13
  url?: string;
14
+ title?: string;
11
15
  };
12
16
  /** Output file for generated TS, relative to the config file. */
13
17
  out: string;
14
18
  /** Markdown report for reviewers, relative to the config file. */
15
19
  report?: string;
16
- /** Import specifier for `OperationTable` in the generated file. */
20
+ /** Import specifier for `OperationTable` / `GraphqlTable` in the generated file. */
17
21
  typesImport?: string;
22
+ /** GraphQL only: TS types for custom scalars, e.g. `{ DateTime: "string" }`. Unlisted ones are `unknown`. */
23
+ scalars?: Record<string, string>;
18
24
  }
19
25
  export declare const defineCodegenConfig: (c: CodegenConfig) => CodegenConfig;
@@ -0,0 +1,14 @@
1
+ import type { CodegenConfig } from "./config.js";
2
+ export interface GenerateGraphqlResult {
3
+ code: string;
4
+ report: string;
5
+ /** Every action, e.g. `linear.mutation.issueCreate`. */
6
+ actions: string[];
7
+ }
8
+ /**
9
+ * GraphQL SDL -> action union, typed params and the schema the matcher validates against.
10
+ * Each top-level `query` and `mutation` field is one action (`<provider>.<operation>.<field>`);
11
+ * its arguments are the params. Subscriptions are skipped. The emitted SDL has descriptions
12
+ * stripped (they don't affect validation or coercion).
13
+ */
14
+ export declare function generateGraphql(sdl: string, config: CodegenConfig): GenerateGraphqlResult;
@@ -0,0 +1,116 @@
1
+ import * as g from "graphql";
2
+ const BUILTIN_TS = { String: "string", ID: "string", Int: "number", Float: "number", Boolean: "boolean" };
3
+ /**
4
+ * GraphQL SDL -> action union, typed params and the schema the matcher validates against.
5
+ * Each top-level `query` and `mutation` field is one action (`<provider>.<operation>.<field>`);
6
+ * its arguments are the params. Subscriptions are skipped. The emitted SDL has descriptions
7
+ * stripped (they don't affect validation or coercion).
8
+ */
9
+ export function generateGraphql(sdl, config) {
10
+ const schema = g.buildSchema(sdl);
11
+ const P = config.typePrefix;
12
+ const scalars = { ...BUILTIN_TS, ...config.scalars };
13
+ // Input and enum types reachable from top-level arguments: these get TS types.
14
+ const inputs = new Map();
15
+ const enums = new Map();
16
+ const custom = new Set();
17
+ const inputValue = (v) => ({ type: String(v.type), hasDefault: v.astNode?.defaultValue !== undefined });
18
+ const visit = (type) => {
19
+ const named = g.getNamedType(type);
20
+ if (inputs.has(named.name) || enums.has(named.name) || named.name in BUILTIN_TS)
21
+ return;
22
+ if (g.isEnumType(named)) {
23
+ enums.set(named.name, named.getValues().map((v) => v.name));
24
+ }
25
+ else if (g.isInputObjectType(named)) {
26
+ const fields = new Map();
27
+ inputs.set(named.name, fields);
28
+ for (const f of Object.values(named.getFields())) {
29
+ fields.set(f.name, inputValue(f));
30
+ visit(f.type);
31
+ }
32
+ }
33
+ else
34
+ custom.add(named.name);
35
+ };
36
+ const fields = [];
37
+ for (const operation of ["query", "mutation"]) {
38
+ const root = operation === "query" ? schema.getQueryType() : schema.getMutationType();
39
+ for (const f of Object.values(root?.getFields() ?? {})) {
40
+ const args = f.args.map((a) => {
41
+ visit(a.type);
42
+ return [a.name, inputValue(a)];
43
+ });
44
+ const description = f.description?.split("\n")[0].trim();
45
+ fields.push({ action: `${config.provider}.${operation}.${f.name}`, operation, args, ...(description ? { description } : {}) });
46
+ }
47
+ }
48
+ fields.sort((a, b) => byCodeUnit(a.action, b.action));
49
+ // TS for a type ref: lists and nullability from the SDL notation.
50
+ const ts = (ref) => {
51
+ const t = ref.endsWith("!") ? ref.slice(0, -1) : ref;
52
+ const base = t.startsWith("[") ? `${wrap(ts(t.slice(1, -1)))}[]` : named(t);
53
+ return ref.endsWith("!") || base === "unknown" ? base : `${base} | null`;
54
+ };
55
+ const wrap = (s) => (s.includes(" ") ? `(${s})` : s);
56
+ const named = (name) => (inputs.has(name) || enums.has(name) ? `${P}${name}` : (scalars[name] ?? "unknown"));
57
+ // Absent only if nullable without a default: graphql-js fills defaults in.
58
+ const member = (name, v) => `${name}${v.type.endsWith("!") || v.hasDefault ? "" : "?"}: ${ts(v.type)};`;
59
+ // Schema the gateway validates against, without descriptions.
60
+ const stripped = g.print(g.visit(g.parse(sdl, { noLocation: true }), {
61
+ enter: (node) => ("description" in node && node.description ? { ...node, description: undefined } : undefined),
62
+ }));
63
+ const out = [
64
+ `// GENERATED by waterlock codegen. Do not edit by hand.`,
65
+ `// Source: ${config.source.title ?? config.provider} GraphQL schema`,
66
+ `// Regenerate with the codegen CLI after changing ${config.provider}.config.ts or the pinned schema.`,
67
+ ``,
68
+ `export type ${P}Action =`,
69
+ ...fields.map((f, i) => ` | ${JSON.stringify(f.action)}${i === fields.length - 1 ? ";" : ""}`),
70
+ ``,
71
+ `/** Arguments of each top-level field, as seen by policy rules (defaults filled in). */`,
72
+ `export interface ${P}Params {`,
73
+ ...fields.map((f) => f.args.length === 0
74
+ ? ` ${JSON.stringify(f.action)}: Record<string, never>;`
75
+ : ` ${JSON.stringify(f.action)}: { ${f.args.map(([n, v]) => member(n, v)).join(" ")} };`),
76
+ `}`,
77
+ ``,
78
+ ...sorted(enums).map(([name, values]) => `export type ${P}${name} = ${values.map((v) => JSON.stringify(v)).join(" | ")};`),
79
+ ``,
80
+ ...sorted(inputs).map(([name, f]) => `export interface ${P}${name} { ${[...f].map(([n, v]) => member(n, v)).join(" ")} }`),
81
+ ``,
82
+ `/** The upstream schema (descriptions stripped). The matcher validates every request against it. */`,
83
+ `export const ${config.provider}Sdl = ${JSON.stringify(stripped)};`,
84
+ ``,
85
+ ];
86
+ const mutations = fields.filter((f) => f.operation === "mutation");
87
+ const queries = fields.filter((f) => f.operation === "query");
88
+ const unmapped = [...custom].filter((s) => !(s in scalars)).sort(byCodeUnit);
89
+ const list = (fs) => fs.map((f) => `- \`${f.action}\`${f.description ? ` ${f.description}` : ""}`);
90
+ const report = [
91
+ `# ${config.provider} codegen report`,
92
+ ``,
93
+ `Source: ${config.source.title ?? config.provider} GraphQL schema`,
94
+ ``,
95
+ `- ${fields.length} actions: ${mutations.length} mutations, ${queries.length} queries`,
96
+ `- Nested selections are not governed: an allowed query can read anything reachable from its field.`,
97
+ ...(unmapped.length ? [`- Custom scalars typed \`unknown\` (add to \`scalars\` in the config): ${unmapped.join(", ")}`] : []),
98
+ ``,
99
+ `## Mutations`,
100
+ ``,
101
+ ...list(mutations),
102
+ ``,
103
+ `## Queries`,
104
+ ``,
105
+ ...list(queries),
106
+ ``,
107
+ ];
108
+ return { code: out.join("\n"), report: report.join("\n"), actions: fields.map((f) => f.action) };
109
+ }
110
+ function sorted(m) {
111
+ return [...m].sort(([a], [b]) => byCodeUnit(a, b));
112
+ }
113
+ /** Locale-independent, so generated output is identical on every machine. */
114
+ function byCodeUnit(a, b) {
115
+ return a < b ? -1 : a > b ? 1 : 0;
116
+ }
@@ -3,3 +3,4 @@ export { fromDiscovery, type DiscoveryDoc } from "./discovery.js";
3
3
  export { fromOpenAPI, type OpenAPIDoc } from "./openapi.js";
4
4
  export { generate, type GenerateResult } from "./generate.js";
5
5
  export type { NormalizedSpec, SpecOperation } from "./model.js";
6
+ export { generateGraphql, type GenerateGraphqlResult } from "./graphql.js";
@@ -2,3 +2,4 @@ export { defineCodegenConfig } from "./config.js";
2
2
  export { fromDiscovery } from "./discovery.js";
3
3
  export { fromOpenAPI } from "./openapi.js";
4
4
  export { generate } from "./generate.js";
5
+ export { generateGraphql } from "./graphql.js";
@@ -13,59 +13,88 @@ export function createGateway(opts) {
13
13
  const dup = ids.find((id, i) => ids.indexOf(id) !== i);
14
14
  if (dup)
15
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
- ]));
16
+ const providers = new Map(opts.providers.map((p) => {
17
+ if (!p.table && !p.graphql)
18
+ throw new Error(`provider "${p.id}" has neither a REST table nor a GraphQL endpoint`);
19
+ const matcher = p.table && createMatcher(p.table, { allowedGlobalQueryParams: p.allowedGlobalQueryParams ?? [] });
20
+ return [p.id, { def: p, matcher }];
21
+ }));
23
22
  async function handle(req) {
24
23
  const url = new URL(req.url);
25
24
  const method = req.method.toUpperCase();
26
25
  // 1. Authenticate. The signed token names the provider; the agent can't pick another.
27
26
  const auth = await authenticate(req, opts.sessions);
28
27
  if ("error" in auth) {
29
- return fail(401, "unauthenticated", "missing or invalid session token");
28
+ return failRest(401, "unauthenticated", "missing or invalid session token");
30
29
  }
31
30
  const session = auth.claims;
32
31
  const entry = providers.get(session.provider);
33
32
  if (!entry)
34
- return fail(403, "unknown_provider", `this gateway has no provider "${session.provider}"`);
33
+ return failRest(403, "unknown_provider", `this gateway has no provider "${session.provider}"`);
35
34
  // The upstream host is fixed by config; the path is forwarded as-is.
36
35
  const upstreamPath = url.pathname;
37
36
  const override = METHOD_OVERRIDE_HEADERS.find((h) => req.headers.has(h));
38
37
  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");
38
+ return failRest(400, "bad_request", `header ${override} not allowed`);
39
+ // 2. Match: the GraphQL endpoint on the body, everything else against the REST operation table.
40
+ const gql = entry.def.graphql && upstreamPath === entry.def.graphql.endpoint ? entry.def.graphql : undefined;
41
+ const fail = gql ? failGraphql : failRest;
42
+ const deny = (action, rules, reason) => fail(403, "policy_denied", reason, action, rules);
43
+ let action;
44
+ let params;
49
45
  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);
46
+ let forwardBody;
47
+ const contentType = req.headers.get("content-type");
48
+ if (gql) {
49
+ if (method !== "POST")
50
+ return fail(405, "policy_denied", "GraphQL requests must be POST");
51
+ if (url.search)
52
+ return fail(400, "policy_denied", "query string not allowed on the GraphQL endpoint");
53
+ if (!/^application\/json\s*(;|$)/i.test(contentType ?? ""))
54
+ return fail(400, "bad_request", "content-type must be application/json");
55
+ const body = await readBody(req, maxBody);
56
+ if (body === "too_large")
57
+ return fail(413, "bad_request", "body too large");
58
+ const matched = gql.matcher.match(body);
59
+ if (!matched.ok)
60
+ return fail(matched.status, "policy_denied", matched.reason);
61
+ ({ action, params } = matched);
62
+ forwardBody = matched.body;
63
+ }
64
+ else {
65
+ if (!entry.matcher)
66
+ return fail(404, "policy_denied", `no ${entry.def.id} operation matches path`);
67
+ const matched = entry.matcher.match(method, upstreamPath, url.searchParams);
68
+ if (!matched.ok)
69
+ return fail(matched.status, "policy_denied", matched.reason);
70
+ const { op } = matched;
71
+ action = op.action;
72
+ params = matched.params;
73
+ // 3. Read body once: decode it for policy, forward the same bytes.
74
+ const body = await readBody(req, maxBody);
75
+ if (body === "too_large")
76
+ return fail(413, "bad_request", "body too large");
77
+ forwardBody = body;
78
+ const decoder = entry.def.decoders?.[op.action];
79
+ if (decoder) {
80
+ if (op.variant !== "standard") {
81
+ const reason = `${op.variant} route for ${op.action} not supported: body can't be inspected`;
82
+ return deny(op.action, [], reason);
83
+ }
84
+ try {
85
+ context = await decoder({ body, contentType });
86
+ }
87
+ catch (err) {
88
+ const reason = `could not decode request body: ${err instanceof Error ? err.message : String(err)}`;
89
+ return deny(op.action, [], reason);
90
+ }
62
91
  }
63
92
  }
64
93
  // Built from the provider's own table and decoder, so it matches that provider's request type.
65
94
  const policyReq = {
66
95
  provider: entry.def.id,
67
- action: op.action,
68
- params: matched.params,
96
+ action,
97
+ params,
69
98
  context,
70
99
  session,
71
100
  };
@@ -77,7 +106,7 @@ export function createGateway(opts) {
77
106
  ...(opts.checkTimeoutMs ? { checkTimeoutMs: opts.checkTimeoutMs } : {}),
78
107
  });
79
108
  if (decision.effect === "deny") {
80
- return deny(op.action, decision.rules, decision.reason);
109
+ return deny(action, decision.rules, decision.reason);
81
110
  }
82
111
  // 5. Forward with the real credential.
83
112
  const headers = new Headers();
@@ -86,12 +115,14 @@ export function createGateway(opts) {
86
115
  if (v !== null)
87
116
  headers.set(h, v);
88
117
  }
118
+ if (gql)
119
+ headers.set("content-type", "application/json");
89
120
  let credential;
90
121
  try {
91
122
  credential = await entry.def.credentials({ session });
92
123
  }
93
124
  catch {
94
- return fail(502, "upstream_auth", "could not obtain upstream credential", op.action);
125
+ return fail(502, "upstream_auth", "could not obtain upstream credential", action);
95
126
  }
96
127
  (entry.def.inject ?? bearer)({ headers, credential });
97
128
  const upstreamUrl = entry.def.upstream.replace(/\/$/, "") + upstreamPath + url.search;
@@ -100,12 +131,12 @@ export function createGateway(opts) {
100
131
  upstream = await fetch(upstreamUrl, {
101
132
  method,
102
133
  headers,
103
- body: body.length > 0 && method !== "GET" ? body : undefined,
134
+ body: forwardBody.length > 0 && method !== "GET" ? forwardBody : undefined,
104
135
  redirect: "manual",
105
136
  });
106
137
  }
107
138
  catch {
108
- return fail(502, "upstream_unreachable", "upstream request failed", op.action);
139
+ return fail(502, "upstream_unreachable", "upstream request failed", action);
109
140
  }
110
141
  const resHeaders = new Headers(upstream.headers);
111
142
  for (const h of DROP_RESPONSE_HEADERS)
@@ -144,9 +175,14 @@ async function readBody(req, max) {
144
175
  function bearer({ headers, credential }) {
145
176
  headers.set("authorization", `Bearer ${credential.token}`);
146
177
  }
147
- function deny(action, rules, reason) {
148
- return fail(403, "policy_denied", reason, action, rules);
178
+ function failRest(status, code, message, action, rules) {
179
+ return Response.json({ error: errorFields(code, message, action, rules) }, { status });
180
+ }
181
+ /** GraphQL-style body, so GraphQL clients surface the message: `{ errors: [{ message, extensions: { code, ... } }] }`. */
182
+ function failGraphql(status, code, message, action, rules) {
183
+ const { message: _, ...extensions } = errorFields(code, message, action, rules);
184
+ return Response.json({ errors: [{ message, extensions }] }, { status });
149
185
  }
150
- function fail(status, code, message, action, rules) {
151
- return Response.json({ error: { code, message, ...(action ? { action } : {}), ...(rules?.length ? { rules } : {}) } }, { status });
186
+ function errorFields(code, message, action, rules) {
187
+ return { code, message, ...(action ? { action } : {}), ...(rules?.length ? { rules } : {}) };
152
188
  }
@@ -1,6 +1,6 @@
1
1
  import type { Credential, CredentialSource } from "./credentials.js";
2
2
  import type { AnyRequest } from "./policy.js";
3
- import type { OperationTable } from "./types.js";
3
+ import type { GraphqlMatcher, OperationTable } from "./types.js";
4
4
  /**
5
5
  * Extracts policy-relevant fields from a request body. Throwing denies the
6
6
  * request: if the gateway can't read what a call does, it doesn't forward it.
@@ -14,10 +14,19 @@ export interface ProviderDefinition<R extends AnyRequest = AnyRequest> {
14
14
  id: string;
15
15
  /** Fixed upstream origin. The harness can never choose the host. */
16
16
  upstream: string;
17
- table: OperationTable;
17
+ /** REST operations, matched on method + path. Omit for GraphQL-only providers. */
18
+ table?: OperationTable;
18
19
  /** 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. */
20
+ allowedGlobalQueryParams?: readonly string[];
21
+ /**
22
+ * GraphQL endpoint (e.g. `/graphql`). Requests to it are matched on the body: the top-level
23
+ * field is the action. Build `matcher` with `createGraphqlMatcher` from `@waterlock/gateway/graphql`.
24
+ */
25
+ graphql?: {
26
+ endpoint: string;
27
+ matcher: GraphqlMatcher;
28
+ };
29
+ /** Per-action body decoders (REST only). Only run on the standard route; upload variants of decoded actions are denied. */
21
30
  decoders?: Partial<Record<string, Decoder>>;
22
31
  credentials: CredentialSource;
23
32
  /** Default: `Authorization: Bearer <token>`. */
@@ -38,3 +38,18 @@ export interface OperationTable {
38
38
  globalQueryParams: readonly ParamSpec[];
39
39
  operations: readonly Operation[];
40
40
  }
41
+ export type GraphqlMatchResult = {
42
+ ok: true;
43
+ action: string;
44
+ /** Arguments of the top-level field: variables filled in, coerced to the schema, defaults applied. */
45
+ params: Record<string, unknown>;
46
+ /** JSON body to forward: the parsed document re-printed, so upstream runs exactly what was checked. */
47
+ body: string;
48
+ } | {
49
+ ok: false;
50
+ status: 400 | 404;
51
+ reason: string;
52
+ };
53
+ export interface GraphqlMatcher {
54
+ match(body: Uint8Array): GraphqlMatchResult;
55
+ }
@@ -0,0 +1,22 @@
1
+ import { type GraphQLSchema } from "graphql";
2
+ import type { GraphqlMatcher } from "../core/types.js";
3
+ export type { GraphqlMatcher, GraphqlMatchResult } from "../core/types.js";
4
+ export interface GraphqlMatcherOptions {
5
+ /** Provider id: actions are named `<provider>.<query|mutation>.<field>`. */
6
+ provider: string;
7
+ /** The upstream schema, or its SDL. */
8
+ schema: GraphQLSchema | string;
9
+ /** Parser token cap. Default 50k. */
10
+ maxTokens?: number;
11
+ }
12
+ /**
13
+ * Matches a GraphQL request body the way the REST matcher matches a URL: one request is one
14
+ * action, named after its top-level field (`linear.mutation.issueCreate`). Arguments, coerced
15
+ * by graphql-js exactly as the upstream server does, become `params`. Nested selections are not
16
+ * policy: they only shape the response, so reads reachable from an allowed field are limited
17
+ * by the upstream token's scopes, not by rules.
18
+ *
19
+ * Rejected rather than guessed at: anything that fails schema validation, batches, several
20
+ * operations or top-level fields in one request, subscriptions, persisted queries (no query text).
21
+ */
22
+ export declare function createGraphqlMatcher(opts: GraphqlMatcherOptions): GraphqlMatcher;
@@ -0,0 +1,130 @@
1
+ import { buildSchema, getArgumentValues, getVariableValues, Kind, parse, print, validate, } from "graphql";
2
+ /**
3
+ * Matches a GraphQL request body the way the REST matcher matches a URL: one request is one
4
+ * action, named after its top-level field (`linear.mutation.issueCreate`). Arguments, coerced
5
+ * by graphql-js exactly as the upstream server does, become `params`. Nested selections are not
6
+ * policy: they only shape the response, so reads reachable from an allowed field are limited
7
+ * by the upstream token's scopes, not by rules.
8
+ *
9
+ * Rejected rather than guessed at: anything that fails schema validation, batches, several
10
+ * operations or top-level fields in one request, subscriptions, persisted queries (no query text).
11
+ */
12
+ export function createGraphqlMatcher(opts) {
13
+ const schema = typeof opts.schema === "string" ? buildSchema(opts.schema) : opts.schema;
14
+ const maxTokens = opts.maxTokens ?? 50_000;
15
+ const bad = (reason) => ({ ok: false, status: 400, reason });
16
+ const message = (err) => (err instanceof Error ? err.message : String(err));
17
+ return {
18
+ match(raw) {
19
+ try {
20
+ return match(raw);
21
+ }
22
+ catch (err) {
23
+ // Fail closed on anything unexpected (e.g. stack overflow on pathological documents).
24
+ return bad(`could not process GraphQL request: ${message(err)}`);
25
+ }
26
+ },
27
+ };
28
+ function match(raw) {
29
+ let json;
30
+ try {
31
+ json = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(raw));
32
+ }
33
+ catch {
34
+ return bad("body must be JSON");
35
+ }
36
+ if (Array.isArray(json))
37
+ return bad("batched requests not supported: send one operation per request");
38
+ if (!isObject(json))
39
+ return bad("body must be a JSON object");
40
+ const { query, variables, operationName } = json;
41
+ if (typeof query !== "string")
42
+ return bad("body.query must be the GraphQL document (persisted queries not supported)");
43
+ if (variables != null && !isObject(variables))
44
+ return bad("body.variables must be an object");
45
+ if (operationName != null && typeof operationName !== "string")
46
+ return bad("body.operationName must be a string");
47
+ let doc;
48
+ try {
49
+ doc = parse(query, { maxTokens, noLocation: true });
50
+ }
51
+ catch (err) {
52
+ return bad(`invalid GraphQL: ${message(err)}`);
53
+ }
54
+ let invalid;
55
+ try {
56
+ invalid = validate(schema, doc, undefined, { maxErrors: 1 });
57
+ }
58
+ catch (err) {
59
+ // graphql 17.0.2 overflows the stack on some fragment cycles instead of reporting them.
60
+ return bad(`invalid GraphQL: ${err instanceof RangeError ? "document too deeply nested or cyclic" : message(err)}`);
61
+ }
62
+ if (invalid.length)
63
+ return bad(`invalid GraphQL: ${invalid[0].message}`);
64
+ const ops = doc.definitions.filter((d) => d.kind === Kind.OPERATION_DEFINITION);
65
+ if (ops.length !== 1)
66
+ return bad(`send exactly one operation per request (got ${ops.length})`);
67
+ const op = ops[0];
68
+ if (operationName && op.name?.value !== operationName)
69
+ return bad(`operationName "${operationName}" not in document`);
70
+ if (op.operation === "subscription")
71
+ return bad("subscriptions not supported");
72
+ const root = schema.getRootType(op.operation);
73
+ if (!root)
74
+ return { ok: false, status: 404, reason: `${opts.provider} has no ${op.operation} type` };
75
+ const fragments = new Map(doc.definitions.filter((d) => d.kind === Kind.FRAGMENT_DEFINITION).map((d) => [d.name.value, d]));
76
+ const roots = [];
77
+ const err = collectRootFields(op.selectionSet, root.name, fragments, roots);
78
+ if (err)
79
+ return bad(err);
80
+ const real = roots.filter((f) => f.name.value !== "__typename");
81
+ if (real.length !== 1) {
82
+ const names = real.map((f) => f.name.value).join(", ");
83
+ return bad(`send one top-level field per request (got ${real.length}${names ? `: ${names}` : ""})`);
84
+ }
85
+ const node = real[0];
86
+ const field = root.getFields()[node.name.value]; // introspection fields (__schema, __type) aren't here: denied
87
+ if (!field)
88
+ return { ok: false, status: 404, reason: `no ${opts.provider} ${op.operation} field "${node.name.value}"` };
89
+ const vars = getVariableValues(schema, op.variableDefinitions ?? [], variables ?? {}, { maxErrors: 1 });
90
+ if (vars.errors)
91
+ return bad(vars.errors[0].message);
92
+ let params;
93
+ try {
94
+ params = getArgumentValues(field, node, vars.variableValues);
95
+ }
96
+ catch (err) {
97
+ return bad(message(err));
98
+ }
99
+ // Forward what was parsed, not the original text: no room for the two parsers to disagree.
100
+ const body = JSON.stringify({
101
+ query: print(doc),
102
+ ...(variables != null ? { variables } : {}),
103
+ ...(operationName ? { operationName } : {}),
104
+ });
105
+ return { ok: true, action: `${opts.provider}.${op.operation}.${field.name}`, params, body };
106
+ }
107
+ }
108
+ /**
109
+ * Top-level fields of an operation, including those inside fragments on the root type. Fields
110
+ * under `@skip`/`@include` count as present. Validation already rejected unknown and cyclic fragments.
111
+ */
112
+ function collectRootFields(set, rootType, fragments, out) {
113
+ for (const sel of set.selections) {
114
+ if (sel.kind === Kind.FIELD) {
115
+ out.push(sel);
116
+ continue;
117
+ }
118
+ const frag = sel.kind === Kind.INLINE_FRAGMENT ? sel : fragments.get(sel.name.value);
119
+ const on = frag.typeCondition?.name.value;
120
+ if (on !== undefined && on !== rootType)
121
+ return `top-level fragment on ${on}, expected ${rootType}`;
122
+ const err = collectRootFields(frag.selectionSet, rootType, fragments, out);
123
+ if (err)
124
+ return err;
125
+ }
126
+ return undefined;
127
+ }
128
+ function isObject(v) {
129
+ return typeof v === "object" && v !== null && !Array.isArray(v);
130
+ }