@foldrun/core 0.3.0 → 0.5.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 (175) hide show
  1. package/README.md +27 -10
  2. package/dist/index.d.ts +3 -0
  3. package/dist/index.js +3 -0
  4. package/dist/src/account.d.ts +45 -0
  5. package/dist/src/account.js +130 -0
  6. package/dist/src/api-tools.d.ts +8 -0
  7. package/dist/src/api-tools.js +22 -4
  8. package/dist/src/approvals.d.ts +104 -1
  9. package/dist/src/approvals.js +192 -2
  10. package/dist/src/browser-pod.d.ts +129 -0
  11. package/dist/src/browser-pod.js +284 -0
  12. package/dist/src/budget.d.ts +32 -0
  13. package/dist/src/budget.js +177 -0
  14. package/dist/src/catalog.js +3 -1
  15. package/dist/src/clock.d.ts +49 -0
  16. package/dist/src/clock.js +121 -0
  17. package/dist/src/completions.js +17 -8
  18. package/dist/src/confine.d.ts +22 -0
  19. package/dist/src/confine.js +224 -4
  20. package/dist/src/deploy.d.ts +33 -0
  21. package/dist/src/deploy.js +145 -6
  22. package/dist/src/egress.d.ts +26 -2
  23. package/dist/src/egress.js +8 -1
  24. package/dist/src/eol.d.ts +2 -0
  25. package/dist/src/eol.js +7 -0
  26. package/dist/src/evals.d.ts +9 -0
  27. package/dist/src/evals.js +26 -13
  28. package/dist/src/flow-lint.d.ts +12 -0
  29. package/dist/src/flow-lint.js +331 -1
  30. package/dist/src/flow-patterns.d.ts +141 -0
  31. package/dist/src/flow-patterns.js +568 -0
  32. package/dist/src/gitrepo.d.ts +18 -0
  33. package/dist/src/gitrepo.js +49 -0
  34. package/dist/src/host-env.d.ts +8 -0
  35. package/dist/src/host-env.js +48 -0
  36. package/dist/src/json-schema.d.ts +12 -0
  37. package/dist/src/json-schema.js +202 -0
  38. package/dist/src/kinds.js +1 -1
  39. package/dist/src/language.d.ts +17 -0
  40. package/dist/src/language.js +55 -0
  41. package/dist/src/layout.d.ts +69 -0
  42. package/dist/src/layout.js +200 -0
  43. package/dist/src/library.d.ts +9 -0
  44. package/dist/src/library.js +43 -4
  45. package/dist/src/limits.d.ts +84 -0
  46. package/dist/src/limits.js +287 -0
  47. package/dist/src/locale.d.ts +47 -0
  48. package/dist/src/locale.js +177 -0
  49. package/dist/src/mail.d.ts +88 -0
  50. package/dist/src/mail.js +245 -0
  51. package/dist/src/model-probe.d.ts +3 -0
  52. package/dist/src/model-probe.js +1 -0
  53. package/dist/src/notify.d.ts +132 -8
  54. package/dist/src/notify.js +360 -58
  55. package/dist/src/openapi.js +5 -3
  56. package/dist/src/operator.d.ts +201 -0
  57. package/dist/src/operator.js +251 -0
  58. package/dist/src/paths.d.ts +33 -0
  59. package/dist/src/paths.js +87 -0
  60. package/dist/src/platform.d.ts +144 -4
  61. package/dist/src/platform.js +27 -3
  62. package/dist/src/provider-health.d.ts +55 -0
  63. package/dist/src/provider-health.js +233 -0
  64. package/dist/src/providers.d.ts +481 -0
  65. package/dist/src/providers.js +1105 -6
  66. package/dist/src/refusal.d.ts +83 -0
  67. package/dist/src/refusal.js +281 -0
  68. package/dist/src/run-container.d.ts +206 -17
  69. package/dist/src/run-container.js +547 -80
  70. package/dist/src/runner.d.ts +179 -10
  71. package/dist/src/runner.js +1423 -169
  72. package/dist/src/runtime.d.ts +52 -0
  73. package/dist/src/runtime.js +493 -58
  74. package/dist/src/script-tools.d.ts +15 -2
  75. package/dist/src/script-tools.js +44 -4
  76. package/dist/src/secret-files.d.ts +6 -5
  77. package/dist/src/secret-files.js +24 -11
  78. package/dist/src/secret-health.d.ts +83 -0
  79. package/dist/src/secret-health.js +177 -0
  80. package/dist/src/secrets.d.ts +38 -1
  81. package/dist/src/secrets.js +197 -96
  82. package/dist/src/starter.d.ts +9 -0
  83. package/dist/src/starter.js +95 -11
  84. package/dist/src/step-exec.d.ts +106 -1
  85. package/dist/src/step-exec.js +558 -85
  86. package/dist/src/step-removal.d.ts +15 -0
  87. package/dist/src/step-removal.js +49 -0
  88. package/dist/src/storage.d.ts +23 -4
  89. package/dist/src/storage.js +125 -23
  90. package/dist/src/store.d.ts +492 -38
  91. package/dist/src/store.js +882 -69
  92. package/dist/src/subagents.d.ts +75 -0
  93. package/dist/src/subagents.js +149 -0
  94. package/dist/src/tar.js +2 -1
  95. package/dist/src/test-mode.d.ts +111 -0
  96. package/dist/src/test-mode.js +285 -0
  97. package/dist/src/tool-names.d.ts +13 -1
  98. package/dist/src/tool-names.js +38 -11
  99. package/dist/src/tool-programs.d.ts +26 -0
  100. package/dist/src/tool-programs.js +172 -0
  101. package/dist/src/tool-test.js +152 -33
  102. package/dist/src/translator.d.ts +8 -2
  103. package/dist/src/translator.js +71 -24
  104. package/dist/src/trigger-log.d.ts +43 -0
  105. package/dist/src/trigger-log.js +122 -0
  106. package/dist/src/triggers.d.ts +23 -0
  107. package/dist/src/triggers.js +101 -12
  108. package/dist/src/untrusted-fetch.d.ts +11 -0
  109. package/dist/src/untrusted-fetch.js +77 -0
  110. package/dist/src/web-actions.d.ts +91 -0
  111. package/dist/src/web-actions.js +199 -0
  112. package/dist/src/webhook.d.ts +19 -3
  113. package/dist/src/webhook.js +50 -11
  114. package/index.ts +3 -0
  115. package/package.json +5 -5
  116. package/src/account.ts +149 -0
  117. package/src/api-tools.ts +32 -4
  118. package/src/approvals.ts +253 -2
  119. package/src/browser-pod.ts +313 -0
  120. package/src/budget.ts +178 -0
  121. package/src/catalog.ts +3 -1
  122. package/src/clock.ts +149 -0
  123. package/src/completions.ts +17 -8
  124. package/src/confine.ts +224 -4
  125. package/src/deploy.ts +137 -6
  126. package/src/egress.ts +32 -4
  127. package/src/eol.ts +7 -0
  128. package/src/evals.ts +36 -11
  129. package/src/flow-lint.ts +371 -1
  130. package/src/flow-patterns.ts +597 -0
  131. package/src/gitrepo.ts +48 -0
  132. package/src/host-env.ts +51 -0
  133. package/src/json-schema.ts +184 -0
  134. package/src/kinds.ts +1 -1
  135. package/src/language.ts +62 -0
  136. package/src/layout.ts +228 -0
  137. package/src/library.ts +50 -4
  138. package/src/limits.ts +290 -0
  139. package/src/locale.ts +217 -0
  140. package/src/mail.ts +316 -0
  141. package/src/model-probe.ts +4 -0
  142. package/src/notify.ts +391 -44
  143. package/src/openapi.ts +5 -3
  144. package/src/operator.ts +300 -0
  145. package/src/paths.ts +83 -0
  146. package/src/platform.ts +158 -5
  147. package/src/provider-health.ts +275 -0
  148. package/src/providers.ts +1368 -6
  149. package/src/refusal.ts +320 -0
  150. package/src/run-container.ts +611 -76
  151. package/src/runner.ts +1463 -165
  152. package/src/runtime.ts +487 -55
  153. package/src/script-tools.ts +55 -6
  154. package/src/secret-files.ts +22 -10
  155. package/src/secret-health.ts +230 -0
  156. package/src/secrets.ts +191 -95
  157. package/src/starter.ts +96 -11
  158. package/src/step-exec.ts +548 -41
  159. package/src/step-removal.ts +59 -0
  160. package/src/storage.ts +149 -24
  161. package/src/store.ts +1227 -74
  162. package/src/subagents.ts +189 -0
  163. package/src/tar.ts +2 -1
  164. package/src/test-mode.ts +311 -0
  165. package/src/tool-names.ts +40 -11
  166. package/src/tool-programs.ts +179 -0
  167. package/src/tool-test.ts +159 -32
  168. package/src/translator.ts +74 -23
  169. package/src/trigger-log.ts +160 -0
  170. package/src/triggers.ts +102 -12
  171. package/src/untrusted-fetch.ts +88 -0
  172. package/src/web-actions.ts +237 -0
  173. package/src/webhook.ts +58 -11
  174. package/templates/hello/agents/curator/agent.md +1 -1
  175. package/templates/hello/agents/notetaker/agent.md +1 -1
package/src/api-tools.ts CHANGED
@@ -101,6 +101,14 @@ export interface ApiToolResult {
101
101
  server: ReturnType<typeof createSdkMcpServer> | null;
102
102
  /** Tool names to allow, e.g. mcp__foldrun_apis__call_google_ads. */
103
103
  toolNames: string[];
104
+ /** SDK tool name → the API it was built for, recorded as each tool is
105
+ * made: `crm`'s operation `admin_list` is `crm_admin_list` beside an API
106
+ * named `crm_admin`, so the name alone cannot say whose it is. */
107
+ owners: Record<string, string>;
108
+ /** SDK tool name → the HTTP method of a typed operation. A generic
109
+ * call_<api> carries its method in each call's input instead. Read by the
110
+ * browser-pod fallback (browser-pod.ts) to tell a GET from a write. */
111
+ methods: Record<string, string>;
104
112
  /** Secrets referenced but not set — surfaced as run warnings. */
105
113
  missingSecrets: string[];
106
114
  /** Human-readable lines describing each API, appended to the system prompt. */
@@ -318,7 +326,7 @@ export function buildApiTools(
318
326
  resolved?: { env: Record<string, string | undefined>; missing: string[] },
319
327
  ): ApiToolResult {
320
328
  if (apis.length === 0) {
321
- return { server: null, toolNames: [], missingSecrets: [], promptLines: [], drainLog: () => [] };
329
+ return { server: null, toolNames: [], owners: {}, methods: {}, missingSecrets: [], promptLines: [], drainLog: () => [] };
322
330
  }
323
331
 
324
332
  const neededSecrets = [...new Set(apis.flatMap(secretsUsedByApi))];
@@ -337,6 +345,8 @@ export function buildApiTools(
337
345
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- the SDK types its own list this way
338
346
  const tools: SdkMcpToolDefinition<any>[] = [];
339
347
  const toolNames: string[] = [];
348
+ const owners: Record<string, string> = {};
349
+ const methods: Record<string, string> = {};
340
350
  const promptLines: string[] = [];
341
351
 
342
352
  for (const api of apis) {
@@ -344,18 +354,34 @@ export function buildApiTools(
344
354
  // this process. Two steps on two workers each get their own.
345
355
  const bucket = api.rate ? new TokenBucket(api.rate.count, api.rate.perSec) : null;
346
356
  const ops = api.resolvedOperations ?? [];
347
- // An explicit `operations:` list means "only these": no escape hatch.
348
- const withGeneric = !(api.operations && ops.length);
357
+ // An explicit `operations:` list means "only these": no escape hatch,
358
+ // ever. It fails closed. If the document did not load, or yielded none of
359
+ // the listed operations, the API gets no tools this run rather than the
360
+ // generic one: an allowlist that opens when a vendor renames an
361
+ // operation or a URL moves is not an allowlist.
362
+ const allowlisted = !!api.operations?.length;
363
+ const withGeneric = !allowlisted;
364
+ if (allowlisted && !ops.length) {
365
+ promptLines.push(
366
+ `- **${api.name}** — unavailable this run: its \`operations:\` allowlist resolved no operations, ` +
367
+ `so no tool is exposed. Report it as blocked; do not reach this API another way.`,
368
+ );
369
+ continue;
370
+ }
349
371
 
350
372
  const typedNames: string[] = [];
351
373
  for (const op of ops) {
352
374
  tools.push(typedTool(api, op, env, bucket, log));
353
375
  typedNames.push(typedToolName(api.name, op.id));
376
+ methods[`mcp__foldrun_apis__${typedToolName(api.name, op.id)}`] = String(op.method ?? "").toUpperCase();
354
377
  }
355
378
  if (withGeneric) tools.push(genericTool(api, env, bucket, log));
356
379
 
357
380
  const names = [...typedNames, ...(withGeneric ? [`call_${api.name}`] : [])];
358
- toolNames.push(...names.map((n) => `mcp__foldrun_apis__${n}`));
381
+ for (const n of names) {
382
+ toolNames.push(`mcp__foldrun_apis__${n}`);
383
+ owners[`mcp__foldrun_apis__${n}`] = api.name;
384
+ }
359
385
 
360
386
  const rateLine = api.rate ? ` Rate limit ${api.rate.count}/${Math.round(api.rate.count / api.rate.perSec)}s — over it, calls wait.` : "";
361
387
  if (ops.length) {
@@ -382,6 +408,8 @@ export function buildApiTools(
382
408
  return {
383
409
  server,
384
410
  toolNames,
411
+ owners,
412
+ methods,
385
413
  missingSecrets: missing,
386
414
  promptLines,
387
415
  drainLog: () => log.splice(0, log.length),
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