@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.
- package/README.md +27 -10
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/src/account.d.ts +45 -0
- package/dist/src/account.js +130 -0
- package/dist/src/api-tools.d.ts +8 -0
- package/dist/src/api-tools.js +22 -4
- package/dist/src/approvals.d.ts +104 -1
- package/dist/src/approvals.js +192 -2
- package/dist/src/browser-pod.d.ts +129 -0
- package/dist/src/browser-pod.js +284 -0
- package/dist/src/budget.d.ts +32 -0
- package/dist/src/budget.js +177 -0
- package/dist/src/catalog.js +3 -1
- package/dist/src/clock.d.ts +49 -0
- package/dist/src/clock.js +121 -0
- package/dist/src/completions.js +17 -8
- package/dist/src/confine.d.ts +22 -0
- package/dist/src/confine.js +224 -4
- package/dist/src/deploy.d.ts +33 -0
- package/dist/src/deploy.js +145 -6
- package/dist/src/egress.d.ts +26 -2
- package/dist/src/egress.js +8 -1
- package/dist/src/eol.d.ts +2 -0
- package/dist/src/eol.js +7 -0
- package/dist/src/evals.d.ts +9 -0
- package/dist/src/evals.js +26 -13
- package/dist/src/flow-lint.d.ts +12 -0
- package/dist/src/flow-lint.js +331 -1
- package/dist/src/flow-patterns.d.ts +141 -0
- package/dist/src/flow-patterns.js +568 -0
- package/dist/src/gitrepo.d.ts +18 -0
- package/dist/src/gitrepo.js +49 -0
- package/dist/src/host-env.d.ts +8 -0
- package/dist/src/host-env.js +48 -0
- package/dist/src/json-schema.d.ts +12 -0
- package/dist/src/json-schema.js +202 -0
- package/dist/src/kinds.js +1 -1
- package/dist/src/language.d.ts +17 -0
- package/dist/src/language.js +55 -0
- package/dist/src/layout.d.ts +69 -0
- package/dist/src/layout.js +200 -0
- package/dist/src/library.d.ts +9 -0
- package/dist/src/library.js +43 -4
- package/dist/src/limits.d.ts +84 -0
- package/dist/src/limits.js +287 -0
- package/dist/src/locale.d.ts +47 -0
- package/dist/src/locale.js +177 -0
- package/dist/src/mail.d.ts +88 -0
- package/dist/src/mail.js +245 -0
- package/dist/src/model-probe.d.ts +3 -0
- package/dist/src/model-probe.js +1 -0
- package/dist/src/notify.d.ts +132 -8
- package/dist/src/notify.js +360 -58
- package/dist/src/openapi.js +5 -3
- package/dist/src/operator.d.ts +201 -0
- package/dist/src/operator.js +251 -0
- package/dist/src/paths.d.ts +33 -0
- package/dist/src/paths.js +87 -0
- package/dist/src/platform.d.ts +144 -4
- package/dist/src/platform.js +27 -3
- package/dist/src/provider-health.d.ts +55 -0
- package/dist/src/provider-health.js +233 -0
- package/dist/src/providers.d.ts +481 -0
- package/dist/src/providers.js +1105 -6
- package/dist/src/refusal.d.ts +83 -0
- package/dist/src/refusal.js +281 -0
- package/dist/src/run-container.d.ts +206 -17
- package/dist/src/run-container.js +547 -80
- package/dist/src/runner.d.ts +179 -10
- package/dist/src/runner.js +1423 -169
- package/dist/src/runtime.d.ts +52 -0
- package/dist/src/runtime.js +493 -58
- package/dist/src/script-tools.d.ts +15 -2
- package/dist/src/script-tools.js +44 -4
- package/dist/src/secret-files.d.ts +6 -5
- package/dist/src/secret-files.js +24 -11
- package/dist/src/secret-health.d.ts +83 -0
- package/dist/src/secret-health.js +177 -0
- package/dist/src/secrets.d.ts +38 -1
- package/dist/src/secrets.js +197 -96
- package/dist/src/starter.d.ts +9 -0
- package/dist/src/starter.js +95 -11
- package/dist/src/step-exec.d.ts +106 -1
- package/dist/src/step-exec.js +558 -85
- package/dist/src/step-removal.d.ts +15 -0
- package/dist/src/step-removal.js +49 -0
- package/dist/src/storage.d.ts +23 -4
- package/dist/src/storage.js +125 -23
- package/dist/src/store.d.ts +492 -38
- package/dist/src/store.js +882 -69
- package/dist/src/subagents.d.ts +75 -0
- package/dist/src/subagents.js +149 -0
- package/dist/src/tar.js +2 -1
- package/dist/src/test-mode.d.ts +111 -0
- package/dist/src/test-mode.js +285 -0
- package/dist/src/tool-names.d.ts +13 -1
- package/dist/src/tool-names.js +38 -11
- package/dist/src/tool-programs.d.ts +26 -0
- package/dist/src/tool-programs.js +172 -0
- package/dist/src/tool-test.js +152 -33
- package/dist/src/translator.d.ts +8 -2
- package/dist/src/translator.js +71 -24
- package/dist/src/trigger-log.d.ts +43 -0
- package/dist/src/trigger-log.js +122 -0
- package/dist/src/triggers.d.ts +23 -0
- package/dist/src/triggers.js +101 -12
- package/dist/src/untrusted-fetch.d.ts +11 -0
- package/dist/src/untrusted-fetch.js +77 -0
- package/dist/src/web-actions.d.ts +91 -0
- package/dist/src/web-actions.js +199 -0
- package/dist/src/webhook.d.ts +19 -3
- package/dist/src/webhook.js +50 -11
- package/index.ts +3 -0
- package/package.json +5 -5
- package/src/account.ts +149 -0
- package/src/api-tools.ts +32 -4
- package/src/approvals.ts +253 -2
- package/src/browser-pod.ts +313 -0
- package/src/budget.ts +178 -0
- package/src/catalog.ts +3 -1
- package/src/clock.ts +149 -0
- package/src/completions.ts +17 -8
- package/src/confine.ts +224 -4
- package/src/deploy.ts +137 -6
- package/src/egress.ts +32 -4
- package/src/eol.ts +7 -0
- package/src/evals.ts +36 -11
- package/src/flow-lint.ts +371 -1
- package/src/flow-patterns.ts +597 -0
- package/src/gitrepo.ts +48 -0
- package/src/host-env.ts +51 -0
- package/src/json-schema.ts +184 -0
- package/src/kinds.ts +1 -1
- package/src/language.ts +62 -0
- package/src/layout.ts +228 -0
- package/src/library.ts +50 -4
- package/src/limits.ts +290 -0
- package/src/locale.ts +217 -0
- package/src/mail.ts +316 -0
- package/src/model-probe.ts +4 -0
- package/src/notify.ts +391 -44
- package/src/openapi.ts +5 -3
- package/src/operator.ts +300 -0
- package/src/paths.ts +83 -0
- package/src/platform.ts +158 -5
- package/src/provider-health.ts +275 -0
- package/src/providers.ts +1368 -6
- package/src/refusal.ts +320 -0
- package/src/run-container.ts +611 -76
- package/src/runner.ts +1463 -165
- package/src/runtime.ts +487 -55
- package/src/script-tools.ts +55 -6
- package/src/secret-files.ts +22 -10
- package/src/secret-health.ts +230 -0
- package/src/secrets.ts +191 -95
- package/src/starter.ts +96 -11
- package/src/step-exec.ts +548 -41
- package/src/step-removal.ts +59 -0
- package/src/storage.ts +149 -24
- package/src/store.ts +1227 -74
- package/src/subagents.ts +189 -0
- package/src/tar.ts +2 -1
- package/src/test-mode.ts +311 -0
- package/src/tool-names.ts +40 -11
- package/src/tool-programs.ts +179 -0
- package/src/tool-test.ts +159 -32
- package/src/translator.ts +74 -23
- package/src/trigger-log.ts +160 -0
- package/src/triggers.ts +102 -12
- package/src/untrusted-fetch.ts +88 -0
- package/src/web-actions.ts +237 -0
- package/src/webhook.ts +58 -11
- package/templates/hello/agents/curator/agent.md +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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
|