@foldrun/core 2.10.1 → 2.11.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.
Files changed (55) hide show
  1. package/dist/index.d.ts +3 -0
  2. package/dist/index.js +3 -0
  3. package/dist/src/catalog.d.ts +3 -0
  4. package/dist/src/catalog.js +4 -1
  5. package/dist/src/deploy.js +16 -0
  6. package/dist/src/eval-compare.d.ts +74 -0
  7. package/dist/src/eval-compare.js +187 -0
  8. package/dist/src/evals.d.ts +62 -3
  9. package/dist/src/evals.js +161 -36
  10. package/dist/src/flow-lint.js +4 -0
  11. package/dist/src/frontmatter-fields.js +4 -1
  12. package/dist/src/grades.d.ts +118 -0
  13. package/dist/src/grades.js +481 -0
  14. package/dist/src/judge-verdict.d.ts +1 -0
  15. package/dist/src/judge-verdict.js +3 -1
  16. package/dist/src/online-checks.d.ts +4 -0
  17. package/dist/src/online-checks.js +5 -1
  18. package/dist/src/openapi.d.ts +4 -0
  19. package/dist/src/openapi.js +5 -1
  20. package/dist/src/platform.d.ts +12 -3
  21. package/dist/src/platform.js +9 -1
  22. package/dist/src/starter.js +5 -0
  23. package/dist/src/store.d.ts +8 -0
  24. package/dist/src/store.js +5 -1
  25. package/dist/src/tool-test.js +19 -7
  26. package/dist/src/translator.d.ts +4 -0
  27. package/dist/src/translator.js +22 -3
  28. package/dist/src/trigger-filter.d.ts +28 -0
  29. package/dist/src/trigger-filter.js +272 -0
  30. package/dist/src/trigger-log.d.ts +1 -1
  31. package/dist/src/triggers.d.ts +2 -0
  32. package/dist/src/triggers.js +13 -2
  33. package/dist/src/untrusted-fetch.d.ts +16 -0
  34. package/dist/src/untrusted-fetch.js +60 -2
  35. package/index.ts +3 -0
  36. package/package.json +3 -3
  37. package/src/catalog.ts +5 -1
  38. package/src/deploy.ts +10 -0
  39. package/src/eval-compare.ts +243 -0
  40. package/src/evals.ts +195 -38
  41. package/src/flow-lint.ts +4 -0
  42. package/src/frontmatter-fields.ts +4 -1
  43. package/src/grades.ts +532 -0
  44. package/src/judge-verdict.ts +4 -2
  45. package/src/online-checks.ts +5 -1
  46. package/src/openapi.ts +6 -1
  47. package/src/platform.ts +16 -4
  48. package/src/starter.ts +5 -0
  49. package/src/store.ts +11 -1
  50. package/src/tool-test.ts +21 -7
  51. package/src/translator.ts +24 -3
  52. package/src/trigger-filter.ts +264 -0
  53. package/src/trigger-log.ts +1 -0
  54. package/src/triggers.ts +14 -2
  55. package/src/untrusted-fetch.ts +65 -2
package/src/store.ts CHANGED
@@ -48,6 +48,7 @@ export function readFrontmatter(file: string): Record<string, unknown> {
48
48
  }
49
49
  import { ownToolNames, legacyUseNames, retiredToolNames } from "./tool-names.ts";
50
50
  import { refNames } from "./refs.ts";
51
+ import { checkFilter } from "./trigger-filter.ts";
51
52
  import { parseBudget, budgetProblem } from "./budget.ts";
52
53
  import { timezoneProblem } from "./clock.ts";
53
54
  import { parseStepLimits, readLimits } from "./limits.ts";
@@ -1410,6 +1411,13 @@ export interface FlowInfo {
1410
1411
  /** `trigger: storage` — a path prefix under storage/; a file landing
1411
1412
  * there starts the flow with the path as its task. */
1412
1413
  path: string | null;
1414
+ /** `filter:` — the fields a delivery must have before it becomes a run
1415
+ * (trigger-filter.ts): matched against a webhook's JSON body, an email,
1416
+ * a chained run or a storage event. Null: no `filter:` line, every
1417
+ * delivery. Kept exactly as written — a list, `false`, a word — so one
1418
+ * that cannot be read matches nothing (fails closed) rather than reading
1419
+ * as no filter at all. Test it with `!= null`, never for truthiness. */
1420
+ filter: unknown;
1413
1421
 
1414
1422
  // ---- how often a trigger is allowed to become a run ----
1415
1423
  //
@@ -2055,6 +2063,7 @@ export function parseFlow(file: string, raw: string): FlowInfo {
2055
2063
  badEnum("catchup", data.catchup, ["none", "last"]);
2056
2064
  badEnum("on", data.on, ["completed", "failed", "blocked", "any"]);
2057
2065
  badEnum("signature", data.signature, ["github", "stripe", "slack", "hmac"]);
2066
+ for (const p of checkFilter(data.filter)) frontProblems.push(p);
2058
2067
 
2059
2068
  steps.sort((a, b) => a.group - b.group);
2060
2069
  return {
@@ -2086,6 +2095,7 @@ export function parseFlow(file: string, raw: string): FlowInfo {
2086
2095
  ? data.signing_secret.trim().replace(/^\$\{|\}$/g, "")
2087
2096
  : null,
2088
2097
  path: typeof data.path === "string" ? data.path.trim().replace(/^\/+/, "") : null,
2098
+ filter: data.filter === undefined || data.filter === null ? null : data.filter,
2089
2099
  // A header name is case-insensitive on the wire; a JSON field is not.
2090
2100
  // `body:eventId` lower-cased to `body:eventid` matched nothing, and a
2091
2101
  // dedupe that matches nothing admits everything.
@@ -2607,7 +2617,7 @@ export function isEditablePath(rel: string): boolean {
2607
2617
  }
2608
2618
  }
2609
2619
 
2610
- function assertEditablePath(rel: string) {
2620
+ export function assertEditablePath(rel: string) {
2611
2621
  const norm = path.normalize(rel);
2612
2622
  if (norm.startsWith("..") || path.isAbsolute(norm)) throw new Error(`illegal path: ${rel}`);
2613
2623
  assertCanonicalCase(norm);
package/src/tool-test.ts CHANGED
@@ -28,6 +28,7 @@ import { commandFor, resolveRunPath } from "./script-tools.ts";
28
28
  import { parseRuntime, prepareRuntime } from "./runtime.ts";
29
29
  import { hostSafeEnv } from "./host-env.ts";
30
30
  import { linkWorkspace, workspaceLinkEnv } from "./confine.ts";
31
+ import { fetchUntrusted, ResponseTooLarge, MAX_BYTES } from "./untrusted-fetch.ts";
31
32
 
32
33
  export interface ToolTestResult {
33
34
  ok: boolean;
@@ -174,12 +175,23 @@ export async function testTool(
174
175
  const headers: Record<string, string> = {};
175
176
  for (const [k, v] of Object.entries(api.headers)) headers[k] = substitute(v, env);
176
177
 
177
- const res = await fetch(url, {
178
- method,
179
- headers,
180
- signal: AbortSignal.timeout(TIMEOUT_MS),
181
- });
182
- const body = await res.text();
178
+ // A URL the tenant chose, fetched from the web tier: through the
179
+ // platform's guard (no private or cluster address, each redirect hop
180
+ // checked), and the secrets in the headers go only to the origin
181
+ // they were written for.
182
+ const res = await fetchUntrusted(
183
+ url.href,
184
+ { method, headers, signal: AbortSignal.timeout(TIMEOUT_MS) },
185
+ { sameOrigin: true },
186
+ );
187
+ // The status is the test; a body past the limit is said, not shown.
188
+ let body: string;
189
+ try {
190
+ body = await res.text();
191
+ } catch (err) {
192
+ if (!(err instanceof ResponseTooLarge)) throw err;
193
+ body = `(the answer was larger than ${MAX_BYTES / (1024 * 1024)} MiB — not shown)`;
194
+ }
183
195
 
184
196
  // An unset secret means the tool cannot authenticate, so it is not
185
197
  // working — whatever a permissive endpoint chose to return. Reporting
@@ -366,7 +378,9 @@ export async function testTool(
366
378
  // A remote server: the honest check we can make without a full client is
367
379
  // that the endpoint answers.
368
380
  try {
369
- const res = await fetch(spec.url, { signal: AbortSignal.timeout(TIMEOUT_MS) });
381
+ // Through the platform's guard, as the http probe is; only the status is read.
382
+ const res = await fetchUntrusted(spec.url, { signal: AbortSignal.timeout(TIMEOUT_MS) }, { sameOrigin: true });
383
+ await res.body?.cancel().catch(() => {});
370
384
  return done({
371
385
  ok: res.ok,
372
386
  transport: "mcp",
package/src/translator.ts CHANGED
@@ -32,6 +32,7 @@ import http from "node:http";
32
32
  import crypto from "node:crypto";
33
33
  import { PROTECTED_PARAMS } from "./providers.ts";
34
34
  import { viaEgress } from "./egress.ts";
35
+ import { fetchUntrusted } from "./untrusted-fetch.ts";
35
36
  import { trimSlashes } from "./paths.ts";
36
37
  import { captureRefusalHeaders, isRefusalStatus, refusalLine } from "./refusal.ts";
37
38
 
@@ -907,6 +908,11 @@ function upstreamMessage(text: string, status: number): string {
907
908
 
908
909
  // ---------------------------------------------------------------- server
909
910
 
911
+ /** The most one upstream answer may be, read, on the host: a long streamed
912
+ * reply is many small events, well past fetchUntrusted's 1 MiB default,
913
+ * and still far under this. */
914
+ export const UPSTREAM_MAX_BYTES = 64 * 1024 * 1024;
915
+
910
916
  /**
911
917
  * Start the translator for one step. Binds an ephemeral port on loopback,
912
918
  * mints the key the SDK will present, and returns the env that points the
@@ -963,7 +969,8 @@ export async function startTranslator(spec: TranslatorSpec): Promise<RunningTran
963
969
 
964
970
  let upstream: Response;
965
971
  try {
966
- upstream = await fetch(viaEgress(spec.egress, `${base}${responses ? "/responses" : "/chat/completions"}`), {
972
+ const target = `${base}${responses ? "/responses" : "/chat/completions"}`;
973
+ const request: RequestInit = {
967
974
  method: "POST",
968
975
  headers: {
969
976
  "content-type": "application/json",
@@ -971,7 +978,16 @@ export async function startTranslator(spec: TranslatorSpec): Promise<RunningTran
971
978
  ...(spec.headers ?? {}),
972
979
  },
973
980
  body: JSON.stringify(outbound),
974
- });
981
+ };
982
+ // In a step the lease's proxy is the way out: it guards the address
983
+ // and fills the key itself. Without one this runs on the host — an
984
+ // eval's judge, a dashboard draft — at a base URL the tenant wrote,
985
+ // so through the platform's guard: no private or cluster address,
986
+ // the key never follows a redirect off the provider's origin, and
987
+ // the answer read to UPSTREAM_MAX_BYTES.
988
+ upstream = spec.egress
989
+ ? await fetch(viaEgress(spec.egress, target), request)
990
+ : await fetchUntrusted(target, request, { sameOrigin: true, maxBytes: UPSTREAM_MAX_BYTES });
975
991
  } catch (err) {
976
992
  const message = err instanceof Error ? err.message : String(err);
977
993
  log.push(`POST ${label} → unreachable: ${message}`);
@@ -1050,7 +1066,12 @@ export async function startTranslator(spec: TranslatorSpec): Promise<RunningTran
1050
1066
  const message = err instanceof Error ? err.message : String(err);
1051
1067
  log.push(`translator error: ${message}`);
1052
1068
  if (!res.headersSent) send(500, errorBody(500, `translator: ${message}`));
1053
- else res.end();
1069
+ else {
1070
+ // Mid-stream: say so as an Anthropic error event, so the SDK reads a
1071
+ // failed reply, not a short one that merely stopped.
1072
+ res.write(`event: error\ndata: ${errorBody(500, `translator: ${message}`)}\n\n`);
1073
+ res.end();
1074
+ }
1054
1075
  }
1055
1076
  });
1056
1077
 
@@ -0,0 +1,264 @@
1
+ // `filter:` — which deliveries of an event trigger become runs.
2
+ //
3
+ // A webhook flow used to start for every POST that carried the right token:
4
+ // a GitHub hook for "pull requests" started a run for drafts, bot pushes and
5
+ // label changes alike, and the first step's only job was to say "not this
6
+ // one" — a sandbox, a model call and a run on the record, per non-event.
7
+ // A filter says it in the flow's own file, and nothing starts:
8
+ //
9
+ // filter:
10
+ // action: [opened, reopened] # any of these values
11
+ // pull_request:
12
+ // draft: false # exactly this value
13
+ // base:
14
+ // ref: { starts_with: release/ } # text, never a regular expression
15
+ // sender:
16
+ // type: { not_in: [Bot] } # none of these
17
+ //
18
+ // What the pattern is matched against depends on the trigger: a webhook's
19
+ // JSON body; an email's { from, to, subject, text }; a chained flow's
20
+ // { run, flow, status, verdict, summary }; a storage event's { by, paths }.
21
+ //
22
+ // The rules, all of them:
23
+ //
24
+ // · every key must match (AND); a nested object matches the nested field;
25
+ // · a list is the values allowed (any of them); a scalar is the one value;
26
+ // · an operator object — only the keys in, not_in, exists, starts_with,
27
+ // ends_with, contains — says it explicitly; the last three compare text
28
+ // (case-sensitive) and never run a regular expression, so a body a
29
+ // sender crafts cannot make one backtrack for minutes;
30
+ // · strings compare exactly and case-sensitively, numbers numerically
31
+ // (1 equals 1.0), booleans and null as themselves;
32
+ // · a field that is missing fails `in`, a value and the text operators, and passes
33
+ // `not_in`; `exists: false` passes only when it is missing;
34
+ // · a list in the payload: a nested pattern matches when any element
35
+ // does; `in` matches when any element is allowed, `not_in` when none is;
36
+ // · it fails closed — a body that is not JSON, or a pattern that cannot be
37
+ // evaluated, never matches.
38
+ //
39
+ // Pure and browser-safe: the gate, the check, the editor and the dashboard's
40
+ // Test box all call these two functions.
41
+
42
+ export const FILTER_LIMITS = { depth: 10, leaves: 64, values: 32, textChars: 500 } as const;
43
+
44
+ const TEXT_OPERATORS = ["starts_with", "ends_with", "contains"] as const;
45
+ const TEXT_VERB = { starts_with: "start with", ends_with: "end with", contains: "contain" } as const;
46
+ const OPERATORS = new Set<string>(["in", "not_in", "exists", ...TEXT_OPERATORS]);
47
+
48
+ type Scalar = string | number | boolean | null;
49
+
50
+ const isScalar = (v: unknown): v is Scalar =>
51
+ v === null || typeof v === "string" || typeof v === "number" || typeof v === "boolean";
52
+
53
+ const isPlainObject = (v: unknown): v is Record<string, unknown> =>
54
+ !!v && typeof v === "object" && !Array.isArray(v) && !(v instanceof Date);
55
+
56
+ /** An object whose every key is an operator — `{ not_in: [Bot] }`. A pattern
57
+ * object mixing operators with field names is refused by the check, since
58
+ * it cannot be read one way. */
59
+ function isOperatorObject(v: unknown): v is Record<string, unknown> {
60
+ if (!isPlainObject(v)) return false;
61
+ const keys = Object.keys(v);
62
+ return keys.length > 0 && keys.every((k) => OPERATORS.has(k));
63
+ }
64
+
65
+ /** YAML reads `2026-10-01` as a Date; a filter means the text. */
66
+ const asScalar = (v: unknown): Scalar | undefined =>
67
+ v instanceof Date ? v.toISOString().slice(0, 10) : isScalar(v) ? v : undefined;
68
+
69
+ /** Strings exactly, numbers numerically, the rest by identity. */
70
+ function sameValue(a: Scalar, b: unknown): boolean {
71
+ if (typeof a === "number" && typeof b === "number") return a === b;
72
+ return a === b;
73
+ }
74
+
75
+ const show = (v: unknown) => (typeof v === "string" ? JSON.stringify(v) : JSON.stringify(v ?? null));
76
+
77
+ /**
78
+ * What is wrong with a `filter:` as written, one sentence each — empty when
79
+ * it can be read. `foldrun check`, the deploy and the editor report these as
80
+ * `config/invalid-value`.
81
+ */
82
+ export function checkFilter(filter: unknown): string[] {
83
+ const problems: string[] = [];
84
+ if (filter === undefined || filter === null) return problems;
85
+ if (!isPlainObject(filter) || Object.keys(filter).length === 0) {
86
+ return ["filter: is a map of field names to the values they must have — e.g. `action: [opened]`"];
87
+ }
88
+ let leaves = 0;
89
+ const walk = (node: Record<string, unknown>, where: string, depth: number) => {
90
+ if (depth > FILTER_LIMITS.depth) {
91
+ problems.push(`filter: nests deeper than ${FILTER_LIMITS.depth} levels at ${where}`);
92
+ return;
93
+ }
94
+ for (const [key, value] of Object.entries(node)) {
95
+ const at = where ? `${where}.${key}` : key;
96
+ if (OPERATORS.has(key)) {
97
+ problems.push(`filter: ${at} — ${key} is an operator; write it under a field name, e.g. \`sender: { ${key}: … }\``);
98
+ continue;
99
+ }
100
+ if (isPlainObject(value) && Object.keys(value).some((k) => OPERATORS.has(k))) {
101
+ if (!isOperatorObject(value)) {
102
+ problems.push(`filter: ${at} mixes operators (${[...OPERATORS].join(", ")}) with field names — split them`);
103
+ continue;
104
+ }
105
+ leaves++;
106
+ problems.push(...checkOperators(value, at));
107
+ continue;
108
+ }
109
+ if (isPlainObject(value)) {
110
+ if (Object.keys(value).length === 0) problems.push(`filter: ${at} is an empty map, which says nothing`);
111
+ else walk(value, at, depth + 1);
112
+ continue;
113
+ }
114
+ leaves++;
115
+ if (Array.isArray(value)) problems.push(...checkValues(value, at, "the list"));
116
+ else if (asScalar(value) === undefined) problems.push(`filter: ${at} — a value, a list of values, a map, or { in, not_in, exists, starts_with, ends_with, contains }`);
117
+ }
118
+ };
119
+ walk(filter, "", 1);
120
+ if (leaves > FILTER_LIMITS.leaves) problems.push(`filter: has ${leaves} conditions — at most ${FILTER_LIMITS.leaves}`);
121
+ return problems;
122
+ }
123
+
124
+ function checkValues(list: unknown[], at: string, what: string): string[] {
125
+ const out: string[] = [];
126
+ if (list.length === 0) out.push(`filter: ${at} — ${what} is empty, so nothing could ever match`);
127
+ if (list.length > FILTER_LIMITS.values) out.push(`filter: ${at} — ${what} has ${list.length} values, at most ${FILTER_LIMITS.values}`);
128
+ if (list.some((v) => asScalar(v) === undefined)) out.push(`filter: ${at} — ${what} holds only plain values (text, numbers, true/false, null)`);
129
+ return out;
130
+ }
131
+
132
+ function checkOperators(op: Record<string, unknown>, at: string): string[] {
133
+ const out: string[] = [];
134
+ const list = (k: "in" | "not_in") => {
135
+ const v = op[k];
136
+ if (v === undefined) return null;
137
+ const arr = Array.isArray(v) ? v : [v];
138
+ out.push(...checkValues(arr, at, k));
139
+ return arr.map(asScalar).filter((x): x is Scalar => x !== undefined);
140
+ };
141
+ const inList = list("in");
142
+ const notIn = list("not_in");
143
+ if (inList && notIn) {
144
+ const both = inList.filter((v) => notIn.some((w) => sameValue(v, w)));
145
+ if (both.length) out.push(`filter: ${at} lists ${both.map(show).join(", ")} in both in and not_in`);
146
+ }
147
+ if (op.exists !== undefined && typeof op.exists !== "boolean") out.push(`filter: ${at} — exists: is true or false`);
148
+ const texts = TEXT_OPERATORS.filter((k) => op[k] !== undefined);
149
+ if (op.exists === false && (inList?.length || texts.length)) {
150
+ out.push(`filter: ${at} — exists: false cannot also require a value`);
151
+ }
152
+ for (const k of texts) {
153
+ const v = op[k];
154
+ if (typeof v !== "string" || !v) out.push(`filter: ${at} — ${k}: is text, e.g. \`${k}: "refs/heads/"\``);
155
+ else if (v.length > FILTER_LIMITS.textChars) out.push(`filter: ${at} — ${k}: is longer than ${FILTER_LIMITS.textChars} characters`);
156
+ }
157
+ return out;
158
+ }
159
+
160
+ export interface FilterVerdict {
161
+ match: boolean;
162
+ /** Why, in one sentence: the first condition that failed, or "matched". */
163
+ reason: string;
164
+ }
165
+
166
+ const MISSING = Symbol("missing");
167
+
168
+ /** One field's value against one leaf of the pattern. */
169
+ function leafMatches(value: unknown, cond: unknown, at: string): FilterVerdict {
170
+ // An operator object.
171
+ if (isOperatorObject(cond)) {
172
+ if (cond.exists !== undefined) {
173
+ const present = value !== MISSING;
174
+ if (cond.exists === true && !present) return { match: false, reason: `${at} is missing (exists: true)` };
175
+ if (cond.exists === false && present) return { match: false, reason: `${at} is present (exists: false)` };
176
+ }
177
+ const values = Array.isArray(value) ? value : value === MISSING ? [] : [value];
178
+ if (cond.in !== undefined) {
179
+ const allowed = (Array.isArray(cond.in) ? cond.in : [cond.in]).map(asScalar).filter((x): x is Scalar => x !== undefined);
180
+ if (value === MISSING) return { match: false, reason: `${at} is missing` };
181
+ if (!values.some((v) => allowed.some((a) => sameValue(a, v)))) return { match: false, reason: `${at} is ${show(value)}, not one of ${allowed.map(show).join(", ")}` };
182
+ }
183
+ if (cond.not_in !== undefined) {
184
+ const refused = (Array.isArray(cond.not_in) ? cond.not_in : [cond.not_in]).map(asScalar).filter((x): x is Scalar => x !== undefined);
185
+ const hit = values.find((v) => refused.some((a) => sameValue(a, v)));
186
+ if (hit !== undefined) return { match: false, reason: `${at} is ${show(hit)}, which not_in refuses` };
187
+ }
188
+ for (const k of TEXT_OPERATORS) {
189
+ const want = cond[k];
190
+ if (want === undefined) continue;
191
+ if (value === MISSING) return { match: false, reason: `${at} is missing` };
192
+ const w = String(want);
193
+ const texts = values.filter((v) => typeof v === "string" || typeof v === "number").map(String);
194
+ const hit = texts.some((t) => (k === "starts_with" ? t.startsWith(w) : k === "ends_with" ? t.endsWith(w) : t.includes(w)));
195
+ if (!hit) return { match: false, reason: `${at} is ${show(value)}, which does not ${TEXT_VERB[k]} ${show(w)}` };
196
+ }
197
+ return { match: true, reason: "matched" };
198
+ }
199
+
200
+ // A list of allowed values, or one value.
201
+ const allowed = (Array.isArray(cond) ? cond : [cond]).map(asScalar);
202
+ if (allowed.some((a) => a === undefined)) return { match: false, reason: `${at}: the filter's value cannot be compared` };
203
+ if (value === MISSING) return { match: false, reason: `${at} is missing` };
204
+ const values = Array.isArray(value) ? value : [value];
205
+ if (values.some((v) => allowed.some((a) => sameValue(a as Scalar, v)))) return { match: true, reason: "matched" };
206
+ return {
207
+ match: false,
208
+ reason: allowed.length === 1 ? `${at} is ${show(value)}, not ${show(allowed[0])}` : `${at} is ${show(value)}, not one of ${allowed.map(show).join(", ")}`,
209
+ };
210
+ }
211
+
212
+ function nodeMatches(payload: unknown, pattern: Record<string, unknown>, where: string, depth: number): FilterVerdict {
213
+ if (depth > FILTER_LIMITS.depth) return { match: false, reason: `the filter nests deeper than ${FILTER_LIMITS.depth} levels` };
214
+ for (const [key, cond] of Object.entries(pattern)) {
215
+ const at = where ? `${where}.${key}` : key;
216
+ const value = isPlainObject(payload) && Object.prototype.hasOwnProperty.call(payload, key) ? payload[key] : MISSING;
217
+ if (isPlainObject(cond) && !isOperatorObject(cond)) {
218
+ // A nested pattern: the field must be an object that matches it, or a
219
+ // list holding one that does.
220
+ if (value === MISSING) return { match: false, reason: `${at} is missing` };
221
+ // A nested pattern needs an object (or a list of them) to look inside:
222
+ // a scalar or null there is not "every field missing", which would let
223
+ // not_in and exists: false pass on a malformed delivery.
224
+ if (!Array.isArray(value) && !isPlainObject(value)) return { match: false, reason: `${at} is ${show(value)}, not an object` };
225
+ if (Array.isArray(value)) {
226
+ const objects = value.filter(isPlainObject);
227
+ if (objects.some((o) => nodeMatches(o, cond, at, depth + 1).match)) continue;
228
+ return { match: false, reason: `no item of ${at} matches` };
229
+ }
230
+ const inner = nodeMatches(value, cond, at, depth + 1);
231
+ if (!inner.match) return inner;
232
+ continue;
233
+ }
234
+ const leaf = leafMatches(value, cond, at);
235
+ if (!leaf.match) return leaf;
236
+ }
237
+ return { match: true, reason: "matched" };
238
+ }
239
+
240
+ /**
241
+ * Does this payload pass this filter? No filter passes everything. A filter
242
+ * the check would refuse never matches — it fails closed, so a typo holds
243
+ * deliveries back instead of letting all of them through.
244
+ */
245
+ export function matchFilter(filter: unknown, payload: unknown): FilterVerdict {
246
+ if (filter === undefined || filter === null) return { match: true, reason: "no filter" };
247
+ const problems = checkFilter(filter);
248
+ if (problems.length) return { match: false, reason: `the filter cannot be read: ${problems[0].replace(/^filter: /, "")}` };
249
+ if (!isPlainObject(payload)) return { match: false, reason: "the delivery is not a JSON object, so no field can match" };
250
+ return nodeMatches(payload, filter as Record<string, unknown>, "", 1);
251
+ }
252
+
253
+ /** A webhook body as the filter reads it: its JSON, or null when it is not
254
+ * JSON (which no filter matches). */
255
+ export function webhookFilterPayload(raw: string): unknown {
256
+ try {
257
+ return JSON.parse(raw);
258
+ } catch {
259
+ return null;
260
+ }
261
+ }
262
+
263
+ /** The triggers a filter means something for. */
264
+ export const FILTERABLE_TRIGGERS = ["webhook", "email", "flow", "storage"] as const;
@@ -42,6 +42,7 @@ export type TriggerOutcome =
42
42
  | "halted"
43
43
  | "overlap-skipped"
44
44
  | "missed"
45
+ | "filtered"
45
46
  | "failed";
46
47
 
47
48
  export interface TriggerEvent {
package/src/triggers.ts CHANGED
@@ -59,6 +59,18 @@ export function chainedFlows(tenant: string, workspace: string, finished: RunRec
59
59
  );
60
60
  }
61
61
 
62
+ /** What a chained flow's `filter:` reads: the run that just settled. */
63
+ export function chainFields(finished: RunRecord): Record<string, unknown> {
64
+ return {
65
+ run: finished.id,
66
+ flow: finished.flow,
67
+ status: finished.status,
68
+ ending: endingOf(finished),
69
+ verdict: runVerdict(finished) ?? null,
70
+ summary: finished.summary ?? null,
71
+ };
72
+ }
73
+
62
74
  /**
63
75
  * Start every flow chained on a run that just settled. The finished run's
64
76
  * one-line summary and its final result are the task; the run id rides
@@ -78,7 +90,7 @@ export async function fireChainedFlows(tenant: string, workspace: string, finish
78
90
  (result ? `\n${result}` : "");
79
91
  for (const flow of flows) {
80
92
  try {
81
- const gate = await platform.admitTrigger(tenant, workspace, flow, { body, tag: "previous_run" });
93
+ const gate = await platform.admitTrigger(tenant, workspace, flow, { body, tag: "previous_run", fields: chainFields(finished) });
82
94
  if (!gate.admit) {
83
95
  console.log(`[foldrun] trigger: flow — ${tenant}/${workspace}/${flow.name} not started: ${gate.detail}`);
84
96
  continue;
@@ -143,7 +155,7 @@ export async function fireStorageTriggers(
143
155
  const body = `by: ${by}\n${hits.map((h) => `- storage/${h}`).join("\n")}`;
144
156
  // A deploy that writes forty files under one prefix is one event, not
145
157
  // forty runs — which is exactly what `debounce:` is for.
146
- const gate = await platform.admitTrigger(tenant, workspace, flow, { body, tag: "storage_event" });
158
+ const gate = await platform.admitTrigger(tenant, workspace, flow, { body, tag: "storage_event", fields: { by, paths: hits } });
147
159
  if (!gate.admit) {
148
160
  console.log(`[foldrun] trigger: storage — ${tenant}/${workspace}/${flow.name} not started: ${gate.detail}`);
149
161
  continue;
@@ -14,6 +14,12 @@
14
14
  // the first one was. A hop to another origin keeps only the headers that
15
15
  // cannot carry a credential, and a caller whose request IS a credential
16
16
  // (a token exchange, a provider key in a header) follows none at all.
17
+ //
18
+ // And every answer is read to a limit (maxBytes, 1 MiB unless the caller
19
+ // says otherwise): a tenant's URL can answer forever, and the worker or web
20
+ // tier holding it all in memory is the platform's problem, not the tenant's.
21
+ // Past the limit the body errors — a caller never gets the first megabyte
22
+ // passed off as the whole answer.
17
23
 
18
24
  import { platform } from "./platform.ts";
19
25
 
@@ -28,6 +34,60 @@ const REDIRECT = new Set([301, 302, 303, 307, 308]);
28
34
  * webhook's signature — stays with the origin it was meant for. */
29
35
  const CROSS_ORIGIN_SAFE = new Set(["accept", "accept-language", "content-type", "user-agent"]);
30
36
 
37
+ /** What one answer may be, read, unless the caller says otherwise. */
38
+ export const MAX_BYTES = 1024 * 1024;
39
+
40
+ export class ResponseTooLarge extends Error {
41
+ constructor(url: string, maxBytes: number) {
42
+ super(`${url} answered more than ${sizeLabel(maxBytes)} — not read`);
43
+ this.name = "ResponseTooLarge";
44
+ }
45
+ }
46
+
47
+ const sizeLabel = (n: number) => (n % (1024 * 1024) === 0 ? `${n / (1024 * 1024)} MiB` : `${n} bytes`);
48
+
49
+ /** Statuses whose Response may carry no body (the constructor throws on one). */
50
+ const NULL_BODY = new Set([101, 204, 205, 304]);
51
+
52
+ /**
53
+ * `res`, its body read to at most `maxBytes`. The status and headers stand;
54
+ * the body errors with ResponseTooLarge at the limit — `text()`, `json()`
55
+ * and a reader all reject, never resolve short. A declared length past the
56
+ * limit errors at the first read, before a byte is fetched; an undeclared
57
+ * one is counted as it streams (after decompression, so a small gzip that
58
+ * unpacks huge is caught too). Infinity: no limit.
59
+ */
60
+ export function capBody(res: Response, maxBytes: number, url: string): Response {
61
+ if (!Number.isFinite(maxBytes) || !res.body || NULL_BODY.has(res.status)) return res;
62
+ const declared = Number(res.headers.get("content-length") ?? NaN);
63
+ const tooLong = Number.isFinite(declared) && declared > maxBytes;
64
+ const reader = res.body.getReader();
65
+ let seen = 0;
66
+ const body = new ReadableStream<Uint8Array>({
67
+ async pull(controller) {
68
+ if (tooLong) {
69
+ reader.cancel().catch(() => {});
70
+ return controller.error(new ResponseTooLarge(url, maxBytes));
71
+ }
72
+ const { done, value } = await reader.read();
73
+ if (done) return controller.close();
74
+ seen += value.byteLength;
75
+ if (seen > maxBytes) {
76
+ reader.cancel().catch(() => {});
77
+ return controller.error(new ResponseTooLarge(url, maxBytes));
78
+ }
79
+ controller.enqueue(value);
80
+ },
81
+ cancel(reason) {
82
+ return reader.cancel(reason);
83
+ },
84
+ });
85
+ const out = new Response(body, { status: res.status, statusText: res.statusText, headers: res.headers });
86
+ // Where the answer came from, as the original said.
87
+ Object.defineProperty(out, "url", { value: res.url });
88
+ return out;
89
+ }
90
+
31
91
  export class RedirectRefused extends Error {
32
92
  constructor(message: string) {
33
93
  super(message);
@@ -42,14 +102,17 @@ export async function fetchUntrusted(
42
102
  /** The request carries a credential (in its body or its headers):
43
103
  * follow a redirect only within the origin it was sent to. */
44
104
  sameOrigin?: boolean;
105
+ /** The most the answer may be, read; MAX_BYTES unless said. */
106
+ maxBytes?: number;
45
107
  } = {},
46
108
  ): Promise<Response> {
109
+ const maxBytes = opts.maxBytes ?? MAX_BYTES;
47
110
  let current = url;
48
111
  let req: RequestInit = { ...init };
49
112
  for (let hop = 0; ; hop++) {
50
- const res = await platform.fetchUntrusted(current, { ...req, redirect: "manual" });
113
+ const res = await platform.fetchUntrusted(current, { ...req, redirect: "manual" }, { maxBytes });
51
114
  const location = REDIRECT.has(res.status) ? res.headers.get("location") : null;
52
- if (!location) return res;
115
+ if (!location) return capBody(res, maxBytes, current);
53
116
  // The body of a redirect is never read; free the socket.
54
117
  await res.body?.cancel().catch(() => {});
55
118
  if (hop >= MAX_REDIRECTS) throw new RedirectRefused(`${url}: more than ${MAX_REDIRECTS} redirects — not followed`);