@foldrun/core 0.3.0 → 0.4.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.
Files changed (83) hide show
  1. package/README.md +27 -10
  2. package/dist/src/approvals.d.ts +104 -1
  3. package/dist/src/approvals.js +192 -2
  4. package/dist/src/budget.d.ts +32 -0
  5. package/dist/src/budget.js +177 -0
  6. package/dist/src/catalog.js +2 -1
  7. package/dist/src/confine.js +6 -1
  8. package/dist/src/egress.d.ts +19 -2
  9. package/dist/src/egress.js +2 -1
  10. package/dist/src/evals.d.ts +4 -0
  11. package/dist/src/evals.js +20 -11
  12. package/dist/src/flow-lint.d.ts +4 -0
  13. package/dist/src/flow-lint.js +108 -0
  14. package/dist/src/host-env.d.ts +8 -0
  15. package/dist/src/host-env.js +48 -0
  16. package/dist/src/json-schema.d.ts +12 -0
  17. package/dist/src/json-schema.js +202 -0
  18. package/dist/src/library.js +13 -1
  19. package/dist/src/notify.d.ts +79 -1
  20. package/dist/src/notify.js +243 -9
  21. package/dist/src/paths.d.ts +5 -0
  22. package/dist/src/paths.js +32 -0
  23. package/dist/src/platform.d.ts +102 -2
  24. package/dist/src/platform.js +18 -2
  25. package/dist/src/provider-health.d.ts +55 -0
  26. package/dist/src/provider-health.js +232 -0
  27. package/dist/src/run-container.d.ts +57 -4
  28. package/dist/src/run-container.js +147 -41
  29. package/dist/src/runner.d.ts +39 -3
  30. package/dist/src/runner.js +422 -73
  31. package/dist/src/script-tools.d.ts +12 -0
  32. package/dist/src/script-tools.js +10 -2
  33. package/dist/src/secret-health.d.ts +59 -0
  34. package/dist/src/secret-health.js +155 -0
  35. package/dist/src/secrets.d.ts +19 -1
  36. package/dist/src/secrets.js +44 -12
  37. package/dist/src/starter.js +3 -0
  38. package/dist/src/step-exec.d.ts +47 -1
  39. package/dist/src/step-exec.js +155 -60
  40. package/dist/src/store.d.ts +276 -3
  41. package/dist/src/store.js +552 -26
  42. package/dist/src/tar.js +2 -1
  43. package/dist/src/test-mode.d.ts +111 -0
  44. package/dist/src/test-mode.js +285 -0
  45. package/dist/src/tool-test.js +48 -21
  46. package/dist/src/translator.js +2 -1
  47. package/dist/src/trigger-log.d.ts +43 -0
  48. package/dist/src/trigger-log.js +122 -0
  49. package/dist/src/triggers.d.ts +20 -0
  50. package/dist/src/triggers.js +91 -10
  51. package/dist/src/webhook.d.ts +15 -3
  52. package/dist/src/webhook.js +11 -11
  53. package/package.json +4 -4
  54. package/src/approvals.ts +253 -2
  55. package/src/budget.ts +178 -0
  56. package/src/catalog.ts +2 -1
  57. package/src/confine.ts +6 -1
  58. package/src/egress.ts +20 -4
  59. package/src/evals.ts +25 -9
  60. package/src/flow-lint.ts +118 -0
  61. package/src/host-env.ts +51 -0
  62. package/src/json-schema.ts +184 -0
  63. package/src/library.ts +13 -1
  64. package/src/notify.ts +272 -11
  65. package/src/paths.ts +29 -0
  66. package/src/platform.ts +107 -3
  67. package/src/provider-health.ts +274 -0
  68. package/src/run-container.ts +175 -36
  69. package/src/runner.ts +435 -65
  70. package/src/script-tools.ts +18 -2
  71. package/src/secret-health.ts +185 -0
  72. package/src/secrets.ts +44 -10
  73. package/src/starter.ts +3 -0
  74. package/src/step-exec.ts +128 -13
  75. package/src/storage.ts +8 -2
  76. package/src/store.ts +746 -25
  77. package/src/tar.ts +2 -1
  78. package/src/test-mode.ts +311 -0
  79. package/src/tool-test.ts +49 -20
  80. package/src/translator.ts +2 -1
  81. package/src/trigger-log.ts +159 -0
  82. package/src/triggers.ts +92 -10
  83. package/src/webhook.ts +17 -11
@@ -0,0 +1,122 @@
1
+ // Why nothing happened.
2
+ //
3
+ // A run that exists can be read. A run that does not exist leaves no record
4
+ // anywhere, and "the hook fired but nothing ran" is the hardest question the
5
+ // platform gets asked — because every honest answer to it is a thing that
6
+ // deliberately did NOT happen:
7
+ //
8
+ // the delivery was a duplicate idempotency:
9
+ // the burst is still being waited out debounce:
10
+ // it arrived inside the window throttle:
11
+ // the flow is switched off disable_after:
12
+ // yesterday's run is still going overlap: skip
13
+ // the platform was down at 05:00 catchup: none
14
+ //
15
+ // Six different reasons, all of them correct behaviour, none of them visible.
16
+ // Before this they were console lines on a box nobody reads.
17
+ //
18
+ // This is deliberately NOT the hook delivery log next door. That one answers
19
+ // "did the request reach us and was it authentic" — a bad token, a failed
20
+ // signature. This one answers "did an event become a run". A delivery can be
21
+ // perfectly authentic and still not start anything, which is exactly the
22
+ // case that needed writing down.
23
+ //
24
+ // One bounded JSONL per workspace, the same shape and the same compaction as
25
+ // the delivery log: this is diagnostics, not an audit trail, and it must
26
+ // never become a disk-full incident with a delay.
27
+ import fs from "node:fs";
28
+ import path from "node:path";
29
+ import { workspaceDir } from "./store.js";
30
+ function logFile(tenant, workspace) {
31
+ return path.join(workspaceDir(tenant, workspace), "trigger-log.jsonl");
32
+ }
33
+ const KEEP = 500;
34
+ /** Never throws. A diagnostic that can break a trigger is worse than no
35
+ * diagnostic: the whole point is to be reading it on the bad morning. */
36
+ export function recordTriggerEvent(tenant, workspace, event) {
37
+ try {
38
+ const file = logFile(tenant, workspace);
39
+ if (!fs.existsSync(path.dirname(file)))
40
+ return;
41
+ fs.appendFileSync(file, JSON.stringify(event) + "\n");
42
+ const lines = fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
43
+ if (lines.length > KEEP * 2) {
44
+ const tmp = `${file}.tmp`;
45
+ fs.writeFileSync(tmp, lines.slice(-KEEP).join("\n") + "\n");
46
+ fs.renameSync(tmp, file);
47
+ }
48
+ }
49
+ catch {
50
+ // best effort, always
51
+ }
52
+ }
53
+ export function readTriggerEvents(tenant, workspace, limit = 200) {
54
+ let lines;
55
+ try {
56
+ lines = fs.readFileSync(logFile(tenant, workspace), "utf8").split("\n").filter(Boolean);
57
+ }
58
+ catch {
59
+ return [];
60
+ }
61
+ // One line at a time: a torn line — an append racing a compaction from
62
+ // another pod — must lose that line, not the whole log, on exactly the
63
+ // morning the log exists for.
64
+ const out = [];
65
+ for (const line of lines.slice(-limit).reverse()) {
66
+ try {
67
+ const e = JSON.parse(line);
68
+ if (e && typeof e.t === "string")
69
+ out.push(e);
70
+ }
71
+ catch {
72
+ // skip the torn line
73
+ }
74
+ }
75
+ return out;
76
+ }
77
+ /**
78
+ * One row per flow: how often it fired, how often that became a run, and
79
+ * what happened to the difference.
80
+ *
81
+ * The gap between `fired` and `started` is the number someone is looking
82
+ * for. A flow with forty fires and two runs is either working exactly as
83
+ * configured or badly misconfigured, and the reasons column is what tells
84
+ * those apart without opening the flow file.
85
+ */
86
+ export function summariseTriggers(tenant, workspace, sinceMs = null) {
87
+ const rows = new Map();
88
+ const reasons = new Map();
89
+ for (const e of readTriggerEvents(tenant, workspace, 500)) {
90
+ if (sinceMs !== null && new Date(e.t).getTime() < sinceMs)
91
+ continue;
92
+ let row = rows.get(e.flow);
93
+ if (!row) {
94
+ row = { flow: e.flow, trigger: e.trigger, fired: 0, started: 0, dropped: [], lastFiredAt: null, lastStartedAt: null };
95
+ rows.set(e.flow, row);
96
+ reasons.set(e.flow, new Map());
97
+ }
98
+ row.fired++;
99
+ // Events come back newest first, so the first of each is the latest.
100
+ row.lastFiredAt ??= e.t;
101
+ if (e.outcome === "started") {
102
+ row.started++;
103
+ row.lastStartedAt ??= e.t;
104
+ }
105
+ else {
106
+ const byReason = reasons.get(e.flow);
107
+ const seen = byReason.get(e.outcome);
108
+ // Keep the newest detail: "the last run started 4 minutes ago" is more
109
+ // use than the same sentence from Tuesday.
110
+ if (seen)
111
+ seen.count++;
112
+ else
113
+ byReason.set(e.outcome, { count: 1, detail: e.detail ?? "" });
114
+ }
115
+ }
116
+ for (const [flow, byReason] of reasons) {
117
+ rows.get(flow).dropped = [...byReason.entries()]
118
+ .map(([outcome, v]) => ({ outcome, ...v }))
119
+ .sort((a, b) => b.count - a.count);
120
+ }
121
+ return [...rows.values()].sort((a, b) => b.fired - a.fired || a.flow.localeCompare(b.flow));
122
+ }
@@ -22,6 +22,26 @@ export declare function underPrefix(rel: string, prefix: string | null): boolean
22
22
  * a loop — every other writer counts.
23
23
  */
24
24
  export declare function fireStorageTriggers(tenant: string, workspace: string, paths: string[], by: string): Promise<string[]>;
25
+ /**
26
+ * The value that identifies this delivery, as the flow's `idempotency:`
27
+ * names it.
28
+ *
29
+ * Two shapes, and only two:
30
+ *
31
+ * idempotency: x-github-delivery a request header
32
+ * idempotency: body:id a top-level field of a JSON body
33
+ *
34
+ * A NAME, never an expression — the grammar has no expressions, and this is
35
+ * not the place to grow one. Nothing is computed, combined or nested: if the
36
+ * sender does not put an identifier somewhere a name can reach, the flow
37
+ * cannot dedupe on it, and saying so is better than inventing a syntax.
38
+ *
39
+ * Returns null when the flow asks for no key, or when the delivery does not
40
+ * carry the one it asked for. A missing key admits the delivery: refusing
41
+ * one because a sender omitted a header would turn "dedupe when you can"
42
+ * into an outage.
43
+ */
44
+ export declare function deliveryKey(idempotency: string | null, header: (name: string) => string | null, body: string): string | null;
25
45
  export interface SignatureVerdict {
26
46
  ok: boolean;
27
47
  reason: string;
@@ -65,11 +65,24 @@ export async function fireChainedFlows(tenant, workspace, finished) {
65
65
  (result ? `\n${result}` : "");
66
66
  for (const flow of flows) {
67
67
  try {
68
- const run = await platform.enqueueFlowRun(tenant, workspace, withTask(flow.steps, "previous_run", body), flow.name, flow.model, [
69
- `after:${finished.flow}`,
70
- ]);
71
- started.push(run.id);
72
- console.log(`[foldrun] trigger: flow — ${tenant}/${workspace}/${flow.name} started after ${finished.flow} ${finished.status}`);
68
+ const gate = await platform.admitTrigger(tenant, workspace, flow, { body, tag: "previous_run" });
69
+ if (!gate.admit) {
70
+ console.log(`[foldrun] trigger: flow — ${tenant}/${workspace}/${flow.name} not started: ${gate.detail}`);
71
+ continue;
72
+ }
73
+ try {
74
+ // A test run's chain stays a test: the flow after it reads a result
75
+ // nothing real produced, and must not act on it for real either.
76
+ const run = await platform.enqueueFlowRun(tenant, workspace, withTask(flow.steps, "previous_run", body), flow.name, flow.model, [
77
+ `after:${finished.flow}`,
78
+ ], null, finished.test ? { test: true } : {});
79
+ started.push(run.id);
80
+ console.log(`[foldrun] trigger: flow — ${tenant}/${workspace}/${flow.name} started after ${finished.flow} ${finished.status}`);
81
+ }
82
+ catch (err) {
83
+ await platform.rollbackTrigger(tenant, workspace, flow, { reason: err instanceof Error ? err.message : String(err) });
84
+ throw err;
85
+ }
73
86
  }
74
87
  catch (err) {
75
88
  console.error(`[foldrun] trigger: flow — ${tenant}/${workspace}/${flow.name}:`, err instanceof Error ? err.message : err);
@@ -115,10 +128,23 @@ export async function fireStorageTriggers(tenant, workspace, paths, by) {
115
128
  continue;
116
129
  try {
117
130
  const body = `by: ${by}\n${hits.map((h) => `- storage/${h}`).join("\n")}`;
118
- const run = await platform.enqueueFlowRun(tenant, workspace, withTask(flow.steps, "storage_event", body), flow.name, flow.model, [
119
- "storage",
120
- ]);
121
- started.push(run.id);
131
+ // A deploy that writes forty files under one prefix is one event, not
132
+ // forty runs — which is exactly what `debounce:` is for.
133
+ const gate = await platform.admitTrigger(tenant, workspace, flow, { body, tag: "storage_event" });
134
+ if (!gate.admit) {
135
+ console.log(`[foldrun] trigger: storage — ${tenant}/${workspace}/${flow.name} not started: ${gate.detail}`);
136
+ continue;
137
+ }
138
+ try {
139
+ const run = await platform.enqueueFlowRun(tenant, workspace, withTask(flow.steps, "storage_event", body), flow.name, flow.model, [
140
+ "storage",
141
+ ]);
142
+ started.push(run.id);
143
+ }
144
+ catch (err) {
145
+ await platform.rollbackTrigger(tenant, workspace, flow, { reason: err instanceof Error ? err.message : String(err) });
146
+ throw err;
147
+ }
122
148
  }
123
149
  catch (err) {
124
150
  console.error(`[foldrun] trigger: storage — ${tenant}/${workspace}/${flow.name}:`, err instanceof Error ? err.message : err);
@@ -126,6 +152,61 @@ export async function fireStorageTriggers(tenant, workspace, paths, by) {
126
152
  }
127
153
  return started;
128
154
  }
155
+ /** HTML to the text a prompt reads. Style and script blocks are removed
156
+ * until none remain — one pass leaves `<sty<style>le>` behind, which is
157
+ * the shape a sanitiser checker (rightly) flags. Nothing here is rendered
158
+ * as HTML afterwards; it becomes a task for an agent. */
159
+ function stripTags(html) {
160
+ let out = html;
161
+ for (let prev = ""; prev !== out;) {
162
+ prev = out;
163
+ out = out.replace(/<(style|script)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, "");
164
+ }
165
+ return out.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
166
+ }
167
+ // ------------------------------------------------------- idempotency keys
168
+ /**
169
+ * The value that identifies this delivery, as the flow's `idempotency:`
170
+ * names it.
171
+ *
172
+ * Two shapes, and only two:
173
+ *
174
+ * idempotency: x-github-delivery a request header
175
+ * idempotency: body:id a top-level field of a JSON body
176
+ *
177
+ * A NAME, never an expression — the grammar has no expressions, and this is
178
+ * not the place to grow one. Nothing is computed, combined or nested: if the
179
+ * sender does not put an identifier somewhere a name can reach, the flow
180
+ * cannot dedupe on it, and saying so is better than inventing a syntax.
181
+ *
182
+ * Returns null when the flow asks for no key, or when the delivery does not
183
+ * carry the one it asked for. A missing key admits the delivery: refusing
184
+ * one because a sender omitted a header would turn "dedupe when you can"
185
+ * into an outage.
186
+ */
187
+ export function deliveryKey(idempotency, header, body) {
188
+ if (!idempotency)
189
+ return null;
190
+ if (idempotency.startsWith("body:")) {
191
+ const field = idempotency.slice(5).trim();
192
+ if (!field)
193
+ return null;
194
+ try {
195
+ const parsed = JSON.parse(body);
196
+ const value = parsed?.[field];
197
+ // A scalar only. An object stringifies to "[object Object]" for every
198
+ // delivery, which would make the second one and every one after it a
199
+ // "duplicate" of the first.
200
+ if (value === undefined || value === null || typeof value === "object")
201
+ return null;
202
+ return String(value).slice(0, 200);
203
+ }
204
+ catch {
205
+ return null; // not JSON, so no field to read
206
+ }
207
+ }
208
+ return header(idempotency)?.slice(0, 200) || null;
209
+ }
129
210
  function safeEqual(a, b) {
130
211
  const x = Buffer.from(a);
131
212
  const y = Buffer.from(b);
@@ -259,7 +340,7 @@ export function normaliseInboundEmail(contentType, raw) {
259
340
  const subject = pick("subject", "Subject");
260
341
  const html = pick("html", "HtmlBody", "body-html");
261
342
  const text = pick("text", "TextBody", "body-plain", "stripped-text", "plain") ||
262
- html.replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
343
+ stripTags(html);
263
344
  if (!from && !subject && !text)
264
345
  return null;
265
346
  return { from, to: pick("to", "To", "recipient", "ToFull"), subject, text };
@@ -1,6 +1,13 @@
1
1
  /**
2
- * The install's secret key. Every derived token — flow hooks, git secrets —
3
- * hangs off this one value, so rotating it invalidates all of them together.
2
+ * The install's secret key. Every derived token — flow hooks, git secrets,
3
+ * approval links — hangs off this one value, so rotating it invalidates all
4
+ * of them together.
5
+ *
6
+ * The same material the vault encrypts with, created on first use. This used
7
+ * to fall back to the constant "foldrun-dev-install" until the vault lazily
8
+ * wrote `.secret-key`, which meant two things nobody wanted: every hook and
9
+ * approval token on a fresh install was computable from the source, and all
10
+ * of them silently changed the first time a secret was stored.
4
11
  */
5
12
  export declare function installKey(): string;
6
13
  export declare function hookGeneration(tenant: string, workspace: string, flow: string): number;
@@ -38,7 +45,12 @@ export declare function publicUrl(): string | null;
38
45
  export interface HookDelivery {
39
46
  t: string;
40
47
  flow: string;
41
- outcome: "accepted" | "invalid-token" | "invalid-signature" | "not-webhook" | "no-flow" | "error";
48
+ /** `dropped` is a delivery that arrived, was authentic, and did not
49
+ * become a run because the flow's own gates said one was enough —
50
+ * a duplicate, a throttled burst, a quarantined flow. Distinct from
51
+ * `accepted` so "why did nothing run" has an answer in the log, and
52
+ * distinct from the refusals so nobody hunts for a bad token. */
53
+ outcome: "accepted" | "invalid-token" | "invalid-signature" | "not-webhook" | "no-flow" | "error" | "dropped";
42
54
  runId?: string;
43
55
  bytes?: number;
44
56
  detail?: string;
@@ -3,22 +3,22 @@
3
3
  // per flow, and rotating FOLDRUN_SECRET_KEY invalidates every hook at once.
4
4
  import fs from "node:fs";
5
5
  import path from "node:path";
6
- import { dataRoot } from "./paths.js";
7
6
  import { workspaceDir } from "./store.js";
7
+ import { installKeyMaterial } from "./secrets.js";
8
8
  import crypto from "node:crypto";
9
- const keyFile = () => path.join(dataRoot(), ".secret-key");
10
9
  /**
11
- * The install's secret key. Every derived token — flow hooks, git secrets —
12
- * hangs off this one value, so rotating it invalidates all of them together.
10
+ * The install's secret key. Every derived token — flow hooks, git secrets,
11
+ * approval links — hangs off this one value, so rotating it invalidates all
12
+ * of them together.
13
+ *
14
+ * The same material the vault encrypts with, created on first use. This used
15
+ * to fall back to the constant "foldrun-dev-install" until the vault lazily
16
+ * wrote `.secret-key`, which meant two things nobody wanted: every hook and
17
+ * approval token on a fresh install was computable from the source, and all
18
+ * of them silently changed the first time a secret was stored.
13
19
  */
14
20
  export function installKey() {
15
- if (process.env.FOLDRUN_SECRET_KEY)
16
- return process.env.FOLDRUN_SECRET_KEY;
17
- if (fs.existsSync(keyFile()))
18
- return fs.readFileSync(keyFile(), "utf8");
19
- // secrets.ts creates this on first use; fall back to a fixed dev value so
20
- // hook URLs stay stable before any secret has been set.
21
- return "foldrun-dev-install";
21
+ return installKeyMaterial();
22
22
  }
23
23
  function hooksFile(tenant, workspace) {
24
24
  return path.join(workspaceDir(tenant, workspace), "hooks.json");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldrun/core",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Agents are just folders. The format, the runner, the checks.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -36,13 +36,13 @@
36
36
  "node": ">=22"
37
37
  },
38
38
  "dependencies": {
39
- "@anthropic-ai/claude-agent-sdk": "0.3.220",
39
+ "@anthropic-ai/claude-agent-sdk": "^0.3.261",
40
40
  "gray-matter": "^4.0.3",
41
41
  "zod": "4.4.3"
42
42
  },
43
43
  "devDependencies": {
44
- "@types/node": "^22",
45
- "typescript": "^5"
44
+ "@types/node": "^26",
45
+ "typescript": "^7"
46
46
  },
47
47
  "repository": {
48
48
  "type": "git",
package/src/approvals.ts CHANGED
@@ -10,8 +10,10 @@
10
10
  // at a desk. So the decision lives in core and the routes only say who made
11
11
  // it.
12
12
 
13
- import { readRun, writeRun, type RunRecord } from "./store.ts";
13
+ import crypto from "node:crypto";
14
+ import { readRun, writeRun, readFlow, mayApprove, type FlowInfo, type RunRecord } from "./store.ts";
14
15
  import { platform } from "./platform.ts";
16
+ import { approveToken, installKey } from "./webhook.ts";
15
17
 
16
18
  export interface ApprovalDecision {
17
19
  decision: "approve" | "reject";
@@ -23,6 +25,174 @@ export interface ApprovalDecision {
23
25
  step?: number;
24
26
  /** Who decided, as it reads after the verb: "by a human", "via emailed link". */
25
27
  by: string;
28
+ /**
29
+ * The person behind the decision, when a session made it: their email and
30
+ * the role they hold. A link and an external event have neither, and are
31
+ * checked by their token instead — the token IS the authority there.
32
+ *
33
+ * Two rules read this. `approvers:` in the flow file names who may decide
34
+ * that flow's gates at all. And nobody approves a run they started
35
+ * themselves: a gate exists so that a second person looks, and a flow a
36
+ * person kicked off and then waved through is a gate that never happened.
37
+ */
38
+ actor?: { email?: string | null; role?: string | null } | null;
39
+ /**
40
+ * The emailed link's token, when a link is deciding. Read here rather
41
+ * than trusted from the route, because what a link may decide is policy:
42
+ * the step it was minted for, before it expires, once, and never a gate
43
+ * whose flow names its approvers unless the link was minted for one of
44
+ * them. See readApproveLink.
45
+ */
46
+ link?: string;
47
+ }
48
+
49
+ // ----------------------------------------------------------------- links
50
+
51
+ /** How long an emailed link decides for when the flow does not say. */
52
+ export const APPROVE_LINK_DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
53
+
54
+ /** What a link may decide: one step, until a moment, as somebody or as
55
+ * nobody. A `run` link is the older kind — bound to the run alone, and
56
+ * kept working, once, for runs that were already waiting when this
57
+ * changed. */
58
+ export type ApproveLink =
59
+ | { kind: "step"; step: number; expiresAt: string; approver: string | null }
60
+ | { kind: "run" };
61
+
62
+ /** The link's lifetime: the flow's `approve_within:` when it has one —
63
+ * after that the gate has expired on its own — else a week. */
64
+ export function approveLinkTtlMs(flow: Pick<FlowInfo, "approveWithin"> | null | undefined): number {
65
+ return flow?.approveWithin ? flow.approveWithin * 1000 : APPROVE_LINK_DEFAULT_TTL_MS;
66
+ }
67
+
68
+ const b64 = (s: string) => Buffer.from(s, "utf8").toString("base64url");
69
+ const unb64 = (s: string) => Buffer.from(s, "base64url").toString("utf8");
70
+
71
+ function signLink(tenant: string, workspace: string, runId: string, step: number, expires: number, approver: string): string {
72
+ return crypto
73
+ .createHmac("sha256", installKey())
74
+ .update(`approve-link:${tenant}/${workspace}/${runId}/${step}/${expires}/${approver}`)
75
+ .digest("hex")
76
+ .slice(0, 32);
77
+ }
78
+
79
+ /**
80
+ * The token an emailed link carries. Bound to one step of one run and to
81
+ * a moment, and — when the notification went to a named approver — to
82
+ * that person, so the link decides as them. A link that was only bound
83
+ * to the run approved every later gate of that run for as long as it ran,
84
+ * and a forwarded one let anyone past `approvers:`.
85
+ *
86
+ * `step.expires.approver.signature`; the signature covers all of it plus
87
+ * the run's identity, under the install key, so rotating the key kills
88
+ * every link at once, the way it kills every hook.
89
+ */
90
+ export function approveLinkToken(
91
+ tenant: string,
92
+ workspace: string,
93
+ runId: string,
94
+ link: { step: number; expiresAt: number; approver?: string | null },
95
+ ): string {
96
+ const approver = (link.approver ?? "").trim().toLowerCase();
97
+ const expires = Math.floor(link.expiresAt);
98
+ return `${link.step}.${expires}.${approver ? b64(approver) : "-"}.${signLink(tenant, workspace, runId, link.step, expires, approver)}`;
99
+ }
100
+
101
+ /** The path an emailed link opens — absolute when the caller prefixes the
102
+ * install's public origin. `decision=reject` pre-selects; a GET never
103
+ * decides. */
104
+ export function approveLinkPath(
105
+ tenant: string,
106
+ workspace: string,
107
+ runId: string,
108
+ link: { step: number; expiresAt: number; approver?: string | null },
109
+ ): string {
110
+ return `/api/approve/${tenant}/${workspace}/${runId}?token=${approveLinkToken(tenant, workspace, runId, link)}`;
111
+ }
112
+
113
+ const same = (a: string, b: string) => a.length === b.length && crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
114
+
115
+ /**
116
+ * What a presented token is, or why it is nothing. 401 for a token that
117
+ * was never minted for this run, 410 for one that was and has expired —
118
+ * the second deserves a sentence, because the person holding it did
119
+ * nothing wrong.
120
+ */
121
+ export function readApproveLink(
122
+ tenant: string,
123
+ workspace: string,
124
+ runId: string,
125
+ presented: string,
126
+ now = Date.now(),
127
+ ): { link: ApproveLink } | { error: string; status: 401 | 410 } {
128
+ const invalid = { error: "invalid token", status: 401 as const };
129
+ if (!presented) return invalid;
130
+ const parts = presented.split(".");
131
+ if (parts.length === 4) {
132
+ const [stepText, expText, approverText, sig] = parts;
133
+ if (!/^\d+$/.test(stepText) || !/^\d+$/.test(expText)) return invalid;
134
+ let approver = "";
135
+ try {
136
+ approver = approverText === "-" ? "" : unb64(approverText);
137
+ } catch {
138
+ return invalid;
139
+ }
140
+ const step = Number(stepText);
141
+ const expires = Number(expText);
142
+ if (!same(sig, signLink(tenant, workspace, runId, step, expires, approver))) return invalid;
143
+ if (now >= expires) return { error: "this link has expired — open the run in the dashboard to decide it", status: 410 };
144
+ return { link: { kind: "step", step, expiresAt: new Date(expires).toISOString(), approver: approver || null } };
145
+ }
146
+ if (same(presented, approveToken(tenant, workspace, runId))) return { link: { kind: "run" } };
147
+ return invalid;
148
+ }
149
+
150
+ /** A used link is written on the run as a digest, never the token. */
151
+ const linkDigest = (token: string) => crypto.createHash("sha256").update(token).digest("hex").slice(0, 24);
152
+
153
+ /**
154
+ * Everything a link has to pass before it decides anything, answered the
155
+ * same way for the page that shows the gate and the POST that decides it.
156
+ * Returns which waiting steps the link may decide and who it decides as.
157
+ */
158
+ export function checkApproveLink(
159
+ tenant: string,
160
+ workspace: string,
161
+ run: RunRecord,
162
+ presented: string,
163
+ now = Date.now(),
164
+ ): { ok: true; link: ApproveLink; steps: number[]; approver: string | null } | { ok: false; status: number; message: string } {
165
+ const read = readApproveLink(tenant, workspace, run.id, presented, now);
166
+ if ("error" in read) return { ok: false, status: read.status, message: read.error };
167
+ const { link } = read;
168
+ if (run.approvalLinksUsed?.includes(linkDigest(presented))) {
169
+ return { ok: false, status: 409, message: "this link was already used — a link decides once" };
170
+ }
171
+ // The older, run-bound link: kept for runs already waiting, but not for
172
+ // longer than a new link would have been.
173
+ if (link.kind === "run") {
174
+ const since = Date.parse(run.parkedAt ?? run.startedAt);
175
+ if (Number.isFinite(since) && now - since > APPROVE_LINK_DEFAULT_TTL_MS) {
176
+ return { ok: false, status: 410, message: "this link has expired — open the run in the dashboard to decide it" };
177
+ }
178
+ }
179
+ const waiting = run.steps
180
+ .map((s, i) => ({ s, i }))
181
+ .filter(({ s, i }) => s.status === "awaiting-approval" && s.waitFor !== "event" && (link.kind === "run" || i === link.step))
182
+ .map(({ i }) => i);
183
+ const approver = link.kind === "step" ? link.approver : null;
184
+ // A flow that names its approvers is asking for a person; a link is a
185
+ // possession. Only a link minted for one of the named approvers carries
186
+ // enough of an identity to be checked against the list.
187
+ const flow = readFlow(tenant, workspace, run.flow);
188
+ if (flow?.approvers?.length && !approver) {
189
+ return {
190
+ ok: false,
191
+ status: 403,
192
+ message: `this flow names who may approve it (${flow.approvers.join(", ")}), and an emailed link carries no identity — sign in to the dashboard to decide it`,
193
+ };
194
+ }
195
+ return { ok: true, link, steps: waiting, approver };
26
196
  }
27
197
 
28
198
  /** An error the HTTP layer can map straight to a status — see errorResponse. */
@@ -52,9 +222,19 @@ export async function decideApproval(
52
222
  const run = readRun(tenant, workspace, runId);
53
223
  if (!run) throw new ApprovalError("run not found", 404);
54
224
 
225
+ // A link decides what it was minted for, as whom it was minted for.
226
+ let allowed: number[] | null = null;
227
+ if (d.link !== undefined) {
228
+ const check = checkApproveLink(tenant, workspace, run, d.link);
229
+ if (!check.ok) throw new ApprovalError(check.message, check.status);
230
+ allowed = check.steps;
231
+ d = { ...d, actor: check.approver ? { email: check.approver, role: null } : null };
232
+ }
233
+ assertMayDecide(tenant, workspace, run, d);
234
+
55
235
  const waiting = run.steps
56
236
  .map((s, i) => ({ s, i }))
57
- .filter(({ s, i }) => s.status === "awaiting-approval" && (d.step === undefined || d.step === i));
237
+ .filter(({ s, i }) => s.status === "awaiting-approval" && (d.step === undefined || d.step === i) && (allowed === null || allowed.includes(i)));
58
238
  if (waiting.length === 0) throw new ApprovalError("nothing is awaiting approval", 409);
59
239
 
60
240
  const note = typeof d.note === "string" ? d.note.trim() : "";
@@ -83,6 +263,16 @@ export async function decideApproval(
83
263
  : `rejected ${d.by}${reason ? `: ${reason.slice(0, 200)}` : ""}`,
84
264
  });
85
265
  }
266
+ // The deadline belonged to the gate that just closed. Leaving it on the
267
+ // record would hand a later gate in the same run a deadline that has
268
+ // already passed.
269
+ if (run.steps.every((s) => s.status !== "awaiting-approval")) run.approveBy = null;
270
+ // A link decides once. Written as a digest on the record, so a step that
271
+ // parks again — a loop, a rerun from the gate — is not decided by the
272
+ // same forwarded mail.
273
+ if (d.link !== undefined) {
274
+ run.approvalLinksUsed = [...(run.approvalLinksUsed ?? []), linkDigest(d.link)].slice(-50);
275
+ }
86
276
  writeRun(tenant, workspace, run);
87
277
 
88
278
  // A parked run has no process polling for this decision — the worker
@@ -96,6 +286,67 @@ export async function decideApproval(
96
286
  return { run, steps: waiting.map(({ i }) => i) };
97
287
  }
98
288
 
289
+ /**
290
+ * The two rules a person's decision has to pass. A token-bearing door — the
291
+ * emailed link, an external event — carries no actor and is not checked
292
+ * here: possession of a token derived from the run is its own authority,
293
+ * and the link was mailed to whoever the account told us to ask.
294
+ *
295
+ * Throws 403, which the routes surface verbatim: a refusal that does not say
296
+ * which rule refused sends someone to the wrong settings page.
297
+ */
298
+ function assertMayDecide(tenant: string, workspace: string, run: RunRecord, d: ApprovalDecision): void {
299
+ if (!d.actor) return;
300
+ const email = (d.actor.email ?? "").trim().toLowerCase();
301
+ // Rejecting your own run is always allowed: stopping something you started
302
+ // needs no second opinion, and the gate's whole purpose is that it does
303
+ // not proceed. Only approval needs someone else.
304
+ if (d.decision === "approve" && email && run.startedBy && run.startedBy === email) {
305
+ throw new ApprovalError(
306
+ "you started this run — an approval gate asks a second person, so someone else has to answer it",
307
+ 403,
308
+ );
309
+ }
310
+ const flow = readFlow(tenant, workspace, run.flow);
311
+ if (!mayApprove(flow, d.actor)) {
312
+ throw new ApprovalError(
313
+ `this flow's approvers: list does not include you (${flow?.approvers?.join(", ")})`,
314
+ 403,
315
+ );
316
+ }
317
+ }
318
+
319
+ /**
320
+ * A gate that ran out of time. `approve_within:` in the flow file stamps a
321
+ * deadline when the run parks; past it, the gate is rejected and the run
322
+ * fails, exactly as if a person had said no.
323
+ *
324
+ * Rejection, not approval, is the only safe way for a clock to answer a
325
+ * question a person was asked. The whole reason the step is parked is that
326
+ * someone wanted to look at it first.
327
+ *
328
+ * Returns whether it acted. Called from the same sweep that closes abandoned
329
+ * runs, so an expiry needs nothing running of its own.
330
+ */
331
+ export async function expireStaleGate(
332
+ tenant: string,
333
+ workspace: string,
334
+ run: RunRecord,
335
+ now = Date.now(),
336
+ ): Promise<boolean> {
337
+ if (run.status !== "awaiting-approval" || !run.approveBy) return false;
338
+ if (now < new Date(run.approveBy).getTime()) return false;
339
+ // A step waiting on `wait: event` is not waiting on a person and is not
340
+ // this deadline's business.
341
+ if (!run.steps.some((s) => s.status === "awaiting-approval" && s.waitFor !== "event")) return false;
342
+ await decideApproval(tenant, workspace, run.id, {
343
+ decision: "reject",
344
+ by: "automatically — nobody answered in time",
345
+ reason: `no decision within the flow's approve_within: (due ${run.approveBy})`,
346
+ });
347
+ return true;
348
+ }
349
+
99
350
  /**
100
351
  * Release a run parked on `wait: event` with what arrived. The payload is
101
352
  * stored on the step — not as an approval note, which is a person's