@objectstack/plugin-approvals 17.0.0-rc.0 → 17.0.0-rc.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 (40) hide show
  1. package/CHANGELOG.md +439 -0
  2. package/dist/index.d.mts +2304 -3491
  3. package/dist/index.d.ts +2304 -3491
  4. package/dist/index.js +116 -43
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +114 -41
  7. package/dist/index.mjs.map +1 -1
  8. package/package.json +16 -9
  9. package/.turbo/turbo-build.log +0 -22
  10. package/scripts/i18n-extract.config.ts +0 -38
  11. package/src/action-link-pages.ts +0 -102
  12. package/src/approval-actor-impersonation.test.ts +0 -330
  13. package/src/approval-node.test.ts +0 -356
  14. package/src/approval-node.ts +0 -196
  15. package/src/approval-revise.test.ts +0 -418
  16. package/src/approval-service.test.ts +0 -2858
  17. package/src/approval-service.ts +0 -3617
  18. package/src/approvals-plugin.ts +0 -294
  19. package/src/approver-cross-org.integration.test.ts +0 -206
  20. package/src/approver-org-scope.test.ts +0 -201
  21. package/src/approver-org-scope.ts +0 -261
  22. package/src/index.ts +0 -42
  23. package/src/lifecycle-hooks.ts +0 -201
  24. package/src/nav-contribution.test.ts +0 -50
  25. package/src/record-lock-schedule-run.integration.test.ts +0 -206
  26. package/src/status-mirror-cascade.integration.test.ts +0 -224
  27. package/src/sys-approval-action.object.ts +0 -149
  28. package/src/sys-approval-approver.object.ts +0 -85
  29. package/src/sys-approval-delegation.object.test.ts +0 -42
  30. package/src/sys-approval-delegation.object.ts +0 -142
  31. package/src/sys-approval-request.object.test.ts +0 -116
  32. package/src/sys-approval-request.object.ts +0 -413
  33. package/src/sys-approval-token.object.ts +0 -101
  34. package/src/translations/bundle-ownership.test.ts +0 -48
  35. package/src/translations/en.objects.generated.ts +0 -311
  36. package/src/translations/es-ES.objects.generated.ts +0 -311
  37. package/src/translations/index.ts +0 -23
  38. package/src/translations/ja-JP.objects.generated.ts +0 -311
  39. package/src/translations/zh-CN.objects.generated.ts +0 -311
  40. package/tsconfig.json +0 -10
@@ -1,3617 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import { createHash, randomBytes } from 'node:crypto';
4
- import {
5
- APPROVAL_BRANCH_LABELS,
6
- approverTypeIsOrgScoped,
7
- canonicalApproverType,
8
- normalizeDecisionOutputs,
9
- type ApprovalNodeConfig,
10
- } from '@objectstack/spec/automation';
11
- import { ExpressionEngine, collectCelRootIdentifiers } from '@objectstack/formula';
12
- import {
13
- ADMIN_FULL_ACCESS,
14
- ORGANIZATION_ADMIN_GRANTS,
15
- BUILTIN_IDENTITY_PLATFORM_ADMIN,
16
- BUILTIN_IDENTITY_ORG_OWNER,
17
- BUILTIN_IDENTITY_ORG_ADMIN,
18
- } from '@objectstack/spec/identity';
19
- import type {
20
- IApprovalService,
21
- ApprovalRequestRow,
22
- ApprovalActionRow,
23
- ApprovalActionAttachment,
24
- ApprovalDecisionInput,
25
- ApprovalDecisionResult,
26
- ApprovalRecallInput,
27
- ApprovalRecallResult,
28
- ApprovalSendBackInput,
29
- ApprovalSendBackResult,
30
- ApprovalResubmitInput,
31
- ApprovalResubmitResult,
32
- ApprovalStatus,
33
- SharingExecutionContext,
34
- } from '@objectstack/spec/contracts';
35
- import { RESUME_AUTHORITY_SERVICE } from '@objectstack/spec/contracts';
36
- import { isFileIdToken } from '@objectstack/spec/data';
37
- import { isGrantActive } from '@objectstack/core';
38
- import {
39
- filterApproversWhoCanRead,
40
- resolveApproverDirectoryOrg,
41
- type ApproverOrgScopeDeps,
42
- type ApproverOrgScopeEngine,
43
- } from './approver-org-scope.js';
44
-
45
- /**
46
- * Node-era approval runtime (ADR-0019).
47
- *
48
- * Approval is no longer a standalone engine — it is a **flow node**. A flow's
49
- * Approval node opens a request via {@link ApprovalService.openNodeRequest} and
50
- * the run suspends; a human decision via {@link ApprovalService.decide}
51
- * finalises the request and resumes the owning run down the matching
52
- * `approve` / `reject` edge.
53
- *
54
- * This service owns the durable approval *state* — `sys_approval_request` /
55
- * `sys_approval_action`, approver resolution (team / department / position /
56
- * role / manager graph), and the optional status-field mirror — plus the decision
57
- * API. It does not author processes, submit, or walk multi-step machinery
58
- * anymore; that orchestration lives on the one automation engine.
59
- */
60
- export interface ApprovalEngine {
61
- find(object: string, options?: any): Promise<any[]>;
62
- insert(object: string, data: any, options?: any): Promise<any>;
63
- update(object: string, idOrData: any, dataOrOptions?: any, options?: any): Promise<any>;
64
- delete(object: string, options?: any): Promise<any>;
65
- }
66
-
67
- export interface ApprovalClock { now(): Date }
68
-
69
- /**
70
- * Minimal automation surface the service uses to resume a suspended flow run
71
- * once a decision finalises a node-driven request. Optional — attached by the
72
- * plugin when an automation engine is present (see `approval-node.ts`).
73
- */
74
- export interface ApprovalResumeSurface {
75
- resume?(runId: string, signal?: {
76
- output?: Record<string, unknown>;
77
- branchLabel?: string;
78
- /**
79
- * #3801: the engine refuses a resume of an `approval` suspension unless
80
- * the signal carries this marker — the proof that the resume is the tail
81
- * of a decision THIS service already authorized and recorded, not a raw
82
- * `POST …/runs/:runId/resume` around it. Every resume below stamps it via
83
- * {@link ApprovalService.serviceResume}.
84
- */
85
- [RESUME_AUTHORITY_SERVICE]?: true;
86
- }): Promise<unknown>;
87
- /** Flow definition lookup, used to derive step-progress display data. */
88
- getFlow?(name: string): Promise<any | null>;
89
- /**
90
- * Terminally cancel a suspended run (ADR-0044). Used when a recall lands
91
- * during a revision window — the run is paused at the revise wait node,
92
- * which has no reject edge to resume down.
93
- */
94
- cancelRun?(runId: string, reason?: string): Promise<unknown>;
95
- /**
96
- * Look up a run's recorded outcome (#3456). Used by the dead-run sweep to ask
97
- * "is the run behind this pending request still alive?".
98
- *
99
- * The contract that makes the sweep safe is the answer for a run that is
100
- * merely SUSPENDED (the normal state of a run waiting on an approval): the
101
- * engine writes no execution-log entry until a run reaches a terminal state,
102
- * so a suspended run resolves to `null`, never to a status. The sweep
103
- * therefore acts only on an explicit terminal-failure status and treats
104
- * `null` — unknown run, evicted log, no durable store, no automation engine —
105
- * as "still alive".
106
- */
107
- getRun?(runId: string): Promise<{ status?: string } | null>;
108
- }
109
-
110
- /**
111
- * Optional messaging surface (ADR-0012 `messaging` service). When attached,
112
- * thread interactions (reassign / remind / request-info / comment) notify the
113
- * affected users; without it they degrade to audit-only.
114
- */
115
- export interface ApprovalMessagingSurface {
116
- emit(input: {
117
- topic: string;
118
- audience: string[];
119
- payload?: Record<string, unknown>;
120
- severity?: string;
121
- dedupKey?: string;
122
- source?: { object: string; id: string };
123
- actorId?: string;
124
- }): Promise<unknown>;
125
- }
126
-
127
- /** Minimum time between submitter reminders on one request. */
128
- export const REMIND_COOLDOWN_MS = 4 * 60 * 60 * 1000;
129
-
130
- /** Named job under which the SLA escalation scan is registered (ADR-0042). */
131
- export const ESCALATION_JOB_NAME = 'approvals-sla-escalation';
132
- /** Default interval between SLA escalation scans. */
133
- export const ESCALATION_SCAN_INTERVAL_MS = 5 * 60 * 1000;
134
- /** Reserved actor id for machine decisions made by the SLA scanner. */
135
- export const SLA_ACTOR_ID = 'system:sla';
136
- /** Reserved actor id for requests abandoned because their run died (#3456). */
137
- export const DEAD_RUN_ACTOR_ID = 'system:dead-run';
138
- /**
139
- * Run statuses that mean "this run will never resume", so a request still
140
- * pending on it is orphaned (#3456). A CLOSED set, deliberately: the dead-run
141
- * sweep treats every other answer — `paused` (a run waiting on its approval,
142
- * the normal case), `running`, an unknown status, or no answer at all — as
143
- * alive, so an unrecognised state can never cost someone a live approval.
144
- *
145
- * `completed` belongs here with the failure states. The approval node only
146
- * writes a request row on the path where it also suspends the run, and every
147
- * in-band transition (decide / recall / send-back / resubmit) finalises the
148
- * request *before* it resumes the run — so a completed run with a still-pending
149
- * request means the run was resumed out of band and left the request behind.
150
- */
151
- const TERMINAL_RUN_STATUSES: ReadonlySet<string> = new Set([
152
- 'completed', 'failed', 'cancelled', 'timed_out',
153
- ]);
154
-
155
- /** Default lifetime of an actionable-link token (ADR-0043). */
156
- export const ACTION_TOKEN_TTL_MS = 72 * 60 * 60 * 1000;
157
-
158
- /** Outcome of redeeming (or peeking) an actionable-link token. */
159
- export type ActionTokenOutcome =
160
- | { ok: true; action: 'approve' | 'reject'; request: ApprovalRequestRow; approverId: string }
161
- | { ok: false; reason: 'invalid' | 'expired' | 'consumed' | 'not_pending' | 'not_approver'; request?: ApprovalRequestRow };
162
-
163
- const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;
164
-
165
- /**
166
- * Who is acting, for the purpose of a data write made on their behalf (#3783).
167
- *
168
- * Reads the AUTHENTICATED principal off the execution context — deliberately not
169
- * `input.actorId`. When this was written the two could still disagree: every
170
- * public entrypoint took `actorId` from the request body (`body.actorId ??
171
- * context.userId`, see the REST approval routes) and the service only checked
172
- * that it named a pending approver, never that it was the caller. That was
173
- * called tolerable on an audit row — but the same unchecked value was the
174
- * authorization key, so it was in fact impersonation, and #3800 closed it:
175
- * {@link ApprovalService.resolveActor} now pins the actor to an identity the
176
- * server can prove belongs to the caller. This helper stays the separate,
177
- * stricter answer for a DATA WRITE, which wants the bare human id and never a
178
- * `type:value` slot literal or a machine sentinel.
179
- *
180
- * A caller holding a trustworthy actor with no session behind it — the ADR-0043
181
- * action link, whose token cryptographically binds exactly one approver — puts
182
- * that actor ON the context instead of relying on this.
183
- *
184
- * `null` for a machine caller (the SLA sweep passes {@link SYSTEM_CTX}), so a
185
- * reserved sentinel like {@link SLA_ACTOR_ID} can never surface as a `userId`.
186
- */
187
- function actingUserId(context: SharingExecutionContext | undefined): string | null {
188
- const userId = (context as { userId?: unknown } | undefined)?.userId;
189
- return typeof userId === 'string' && userId ? userId : null;
190
- }
191
-
192
- /**
193
- * Max hops when following an OOO delegation chain (#1322 M1): A out → B, B out
194
- * → C, … Bounds the walk so a mis-configured chain can't loop or resolve
195
- * unboundedly; a cycle or self-reference also stops it early.
196
- */
197
- const OOO_MAX_CHAIN = 8;
198
-
199
- /**
200
- * Approver types resolved by QUERYING a graph rather than by taking `value`
201
- * literally (#3807). Each can legitimately come back empty — an unstaffed
202
- * position, an emptied team, a mis-pointed unit — and the caller then falls
203
- * back to a `type:value` literal that no user can act on. They are listed here
204
- * so that dead end gets one warning instead of passing in silence.
205
- *
206
- * `user` / `field` are deliberately absent: they resolve to the id they were
207
- * given without a lookup, so there is no "expanded to nobody" state to report.
208
- * `business_unit` / `bu` are the accepted dialects of `department`.
209
- */
210
- const GRAPH_APPROVER_TYPES: ReadonlySet<string> = new Set([
211
- 'team', 'department', 'business_unit', 'bu', 'position', 'org_membership_level', 'manager',
212
- ]);
213
-
214
- /** One OOO delegation hop applied while resolving an approver (#1322 M1/M4). */
215
- interface OooSubstitution {
216
- /** The approver who was skipped (out of office). */
217
- from: string;
218
- /** The delegate the slot was routed to. */
219
- to: string;
220
- /** The delegator's declared reason, if any. */
221
- reason: string | null;
222
- }
223
-
224
- /**
225
- * The CLOSED set of namespace roots an `expression` approver may reference
226
- * (#3447 P2). Three explicit times/sources, no `record`, no bare field names:
227
- * `record` means "the record at event time" everywhere else on the platform
228
- * (flow conditions: trigger snapshot; hooks: the write payload), so binding it
229
- * here — to either time — would silently alias one meaning to the other. The
230
- * runtime CEL env treats unknown roots as `dyn` (→ `null` → an empty slate),
231
- * so out-of-contract roots MUST be rejected before evaluation; both this
232
- * pre-check and the lint rule read the roots via
233
- * {@link collectCelRootIdentifiers} so they can never drift.
234
- */
235
- const APPROVER_EXPRESSION_ROOTS = new Set(['current', 'trigger', 'vars']);
236
-
237
- /**
238
- * Evaluation context an approval node hands to `expression` approvers
239
- * (#3447 P2). `current` (the live record) is supplied by openNodeRequest's
240
- * re-read; these two carry the other roots.
241
- */
242
- export interface ApproverExpressionContext {
243
- /** Submit-time snapshot (the flow's `$record`) — bound as `trigger.*`. */
244
- trigger?: Record<string, unknown> | null;
245
- /** Flow variables at node entry (nested by dotted key) — bound as `vars.*`. */
246
- vars?: Record<string, unknown> | null;
247
- }
248
-
249
- /**
250
- * Non-request outcome of {@link ApprovalService.openNodeRequest}: the node
251
- * resolved an empty approver slate and its `onEmptyApprovers: 'auto_approve'`
252
- * policy waved it through (#3447 P2). No `sys_approval_request` row exists —
253
- * nobody was ever asked — so the node must complete down its `approve` edge
254
- * instead of suspending.
255
- */
256
- export interface ApprovalNodeAutoOutcome {
257
- autoApproved: true;
258
- reason: 'empty_approvers';
259
- }
260
-
261
- function uid(prefix: string): string {
262
- const g: any = globalThis as any;
263
- if (g.crypto?.randomUUID) return `${prefix}_${g.crypto.randomUUID()}`;
264
- return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
265
- }
266
-
267
- function parseJson<T = any>(raw: unknown, fallback: T): T {
268
- if (raw == null || raw === '') return fallback;
269
- if (typeof raw === 'string') {
270
- try { return JSON.parse(raw) as T; } catch { return fallback; }
271
- }
272
- return raw as T;
273
- }
274
-
275
- function csvSplit(raw: unknown): string[] {
276
- if (!raw) return [];
277
- if (Array.isArray(raw)) return raw.map(String).filter(Boolean);
278
- return String(raw).split(',').map(s => s.trim()).filter(Boolean);
279
- }
280
-
281
- /**
282
- * Humanize a machine name for display fallback: strips a `flow:` prefix and
283
- * title-cases underscore/dash segments (`flow:manager_review` → "Manager
284
- * Review"). Used only when no authored label was snapshotted on the row.
285
- */
286
- function prettifyMachineName(raw: string | null | undefined): string | undefined {
287
- if (!raw) return undefined;
288
- const base = String(raw).replace(/^flow:/, '').trim();
289
- if (!base) return undefined;
290
- return base
291
- .split(/[_\-\s]+/)
292
- .filter(Boolean)
293
- .map(w => w.charAt(0).toUpperCase() + w.slice(1))
294
- .join(' ');
295
- }
296
-
297
- function rowFromRequest(row: any): ApprovalRequestRow {
298
- // Authored display labels ride the node-config snapshot (`__flowLabel` /
299
- // `__nodeLabel`) so they survive without a schema migration; fall back to a
300
- // prettified machine name for rows written before labels were captured.
301
- const cfg = parseJson<any>(row.node_config_json, undefined);
302
- return {
303
- id: String(row.id),
304
- organization_id: row.organization_id ?? undefined,
305
- process_name: String(row.process_name ?? ''),
306
- object_name: String(row.object_name ?? ''),
307
- record_id: String(row.record_id ?? ''),
308
- submitter_id: row.submitter_id ?? undefined,
309
- submitter_comment: row.submitter_comment ?? undefined,
310
- status: (row.status as ApprovalStatus) ?? 'pending',
311
- current_step: row.current_step ?? undefined,
312
- current_step_index: row.current_step_index ?? undefined,
313
- pending_approvers: csvSplit(row.pending_approvers),
314
- payload: parseJson(row.payload_json, undefined),
315
- flow_run_id: row.flow_run_id ?? undefined,
316
- flow_node_id: row.flow_node_id ?? undefined,
317
- completed_at: row.completed_at ?? undefined,
318
- created_at: row.created_at ?? undefined,
319
- updated_at: row.updated_at ?? undefined,
320
- // The row is created at submission time; expose the stable inbox-facing name.
321
- submitted_at: row.created_at ?? undefined,
322
- process_label: cfg?.__flowLabel ?? prettifyMachineName(row.process_name),
323
- step_label: cfg?.__nodeLabel ?? prettifyMachineName(row.current_step),
324
- sla_due_at: slaDueAt(row.created_at, cfg),
325
- // ADR-0044 revision round (rides the config snapshot; absent ⇒ round 1).
326
- round: typeof cfg?.__round === 'number' ? cfg.__round : undefined,
327
- // objectui#2902: the node's record-lock policy. The lock is enforced
328
- // server-side in `lifecycle-hooks.ts` off THIS SAME snapshot with the
329
- // same `!== false` default, so the flag a client renders and the rule the
330
- // server applies can never drift. Without it a console can only see
331
- // "a pending request exists" and has to assume the record is locked —
332
- // which mislabels every `lockRecord: false` node as locked and hides an
333
- // edit the server would have accepted.
334
- lock_record: cfg?.lockRecord !== false,
335
- // #3447 P2: the node's author-declared decision outputs, surfaced so a
336
- // decision UI can render input fields for them and POST `outputs` on
337
- // approve/reject. Per-request (each node declares its own), which is why
338
- // this rides the row instead of the static action params. Two shapes for
339
- // version skew: `decision_outputs` stays the bare KEY list an older
340
- // console renders as text inputs; `decision_output_defs` carries the
341
- // normalized typed declarations a picker-aware console prefers.
342
- ...(() => {
343
- const defs = normalizeDecisionOutputs(cfg?.decisionOutputs);
344
- return defs.length
345
- ? { decision_outputs: defs.map(d => d.key), decision_output_defs: defs }
346
- : {};
347
- })(),
348
- } as any;
349
- }
350
-
351
- /** `created_at + escalation.timeoutHours`, when the node declares an SLA. */
352
- function slaDueAt(createdAt: unknown, cfg: any): string | undefined {
353
- const hours = cfg?.escalation?.timeoutHours;
354
- if (typeof hours !== 'number' || hours <= 0 || !createdAt) return undefined;
355
- const t = Date.parse(String(createdAt));
356
- if (Number.isNaN(t)) return undefined;
357
- return new Date(t + hours * 3600_000).toISOString();
358
- }
359
-
360
- /**
361
- * Normalize one raw `attachments` entry into an {@link ApprovalActionAttachment}.
362
- *
363
- * `sys_approval_action.attachments` is a `Field.file` (multiple), so the column
364
- * **stores opaque `sys_file` ids** — that is the stored form of every media
365
- * field (ADR-0104 D3). What arrives here is whichever of three forms the read
366
- * path produced:
367
- *
368
- * 1. the **expanded** `{ id, name, size, mimeType, url }` the ObjectQL read
369
- * path resolves a stored id into — the normal case;
370
- * 2. a **bare id**, when there was nothing to expand it into (storage service
371
- * absent, file not committed);
372
- * 3. a **legacy inline blob** (`{ file_id, name, mime_type, url, … }`) written
373
- * before file-as-reference, until the backfill converts it.
374
- *
375
- * The original mapping did `String(entry)`, which turned form 1 into the
376
- * literal `"[object Object]"` — so the inbox timeline showed a nameless,
377
- * un-openable attachment chip (#3266 follow-up; caught by browser verification).
378
- *
379
- * Note the casing: the expanded form carries `mimeType`, the legacy blob
380
- * `mime_type`. Both are accepted for the duration of the migration window.
381
- */
382
- function normalizeActionAttachment(entry: any): ApprovalActionAttachment | undefined {
383
- if (entry == null) return undefined;
384
- // Form 2 — a bare reference. `isFileIdToken` is the platform's single arbiter
385
- // of "is this string an opaque file id, or a URL?", shared with the engine's
386
- // read resolver, so the two cannot disagree about what counts as an id.
387
- if (typeof entry === 'string') {
388
- const id = entry.trim();
389
- if (!id) return undefined;
390
- return isFileIdToken(id) ? { id } : { id, url: id };
391
- }
392
- if (typeof entry === 'object') {
393
- // Forms 1 and 3 — `file_id` is the legacy blob's key for the same thing.
394
- const id = entry.id ?? entry.file_id;
395
- if (id == null || String(id) === '') return undefined;
396
- const mimeType = entry.mimeType ?? entry.mime_type;
397
- return {
398
- id: String(id),
399
- name: typeof entry.name === 'string' ? entry.name : undefined,
400
- url: typeof entry.url === 'string' ? entry.url : undefined,
401
- mimeType: typeof mimeType === 'string' ? mimeType : undefined,
402
- size: typeof entry.size === 'number' ? entry.size : undefined,
403
- };
404
- }
405
- return undefined;
406
- }
407
-
408
- function rowFromAction(row: any): ApprovalActionRow {
409
- const attachments = Array.isArray(row.attachments)
410
- ? row.attachments.map(normalizeActionAttachment).filter((a: ApprovalActionAttachment | undefined): a is ApprovalActionAttachment => !!a)
411
- : [];
412
- return {
413
- id: String(row.id),
414
- request_id: String(row.request_id),
415
- step_name: row.step_name ?? undefined,
416
- step_index: row.step_index ?? undefined,
417
- action: row.action,
418
- actor_id: row.actor_id ?? undefined,
419
- comment: row.comment ?? undefined,
420
- // Decision attachments (#3266): rich descriptors carrying the display name +
421
- // download URL, so consumers label/open them without reading `sys_file`.
422
- attachments: attachments.length ? attachments : undefined,
423
- created_at: row.created_at ?? undefined,
424
- };
425
- }
426
-
427
- export interface ApprovalServiceOptions {
428
- engine: ApprovalEngine;
429
- clock?: ApprovalClock;
430
- logger?: { info?: (msg: any, ...rest: any[]) => void; warn?: (msg: any, ...rest: any[]) => void; error?: (msg: any, ...rest: any[]) => void; debug?: (msg: any, ...rest: any[]) => void };
431
- /**
432
- * Optional automation surface used to resume a suspended flow run when a
433
- * decision finalises a request. Usually attached after construction via
434
- * {@link ApprovalService.attachAutomation} once the automation engine is
435
- * available.
436
- */
437
- automation?: ApprovalResumeSurface;
438
- /** Optional messaging service for thread notifications. */
439
- messaging?: ApprovalMessagingSurface;
440
- /**
441
- * Absolute origin prefixed onto actionable links (ADR-0043), e.g.
442
- * `https://app.example.com`. Defaults to relative URLs, which work inside
443
- * the Console and IM webviews; outbound email needs the absolute form.
444
- */
445
- publicBaseUrl?: string;
446
- /**
447
- * [ADR-0105 D9] The tenancy posture in force. Cross-organization approver
448
- * targeting is a `group`-posture capability; the resolver refuses the
449
- * declaration under any other posture rather than silently ignoring it.
450
- * Absent (a stack booted with no tenancy service) reads as "unknown" and the
451
- * guard stands down.
452
- */
453
- tenancyPosture?: () => string | undefined;
454
- }
455
-
456
- export class ApprovalService implements IApprovalService {
457
- private readonly engine: ApprovalEngine;
458
- private readonly clock: ApprovalClock;
459
- private readonly logger?: ApprovalServiceOptions['logger'];
460
- private automation?: ApprovalResumeSurface;
461
- private messaging?: ApprovalMessagingSurface;
462
- private publicBaseUrl: string;
463
- private tenancyPosture?: () => string | undefined;
464
-
465
- constructor(opts: ApprovalServiceOptions) {
466
- this.engine = opts.engine;
467
- this.clock = opts.clock ?? { now: () => new Date() };
468
- this.logger = opts.logger;
469
- this.automation = opts.automation;
470
- this.messaging = opts.messaging;
471
- this.publicBaseUrl = (opts.publicBaseUrl ?? '').replace(/\/$/, '');
472
- this.tenancyPosture = opts.tenancyPosture;
473
- }
474
-
475
- /** Attach (or replace) the ADR-0105 D9 posture provider. */
476
- attachTenancyPosture(provider: () => string | undefined): void {
477
- this.tenancyPosture = provider;
478
- }
479
-
480
- /** Deps bundle for the ADR-0105 D9 org-scope helpers. */
481
- private get orgScopeDeps(): ApproverOrgScopeDeps {
482
- return {
483
- engine: this.engine as unknown as ApproverOrgScopeEngine,
484
- posture: this.tenancyPosture,
485
- logger: this.logger,
486
- };
487
- }
488
-
489
- /**
490
- * [ADR-0105 D9] Which organization's directory resolves ONE approver spec.
491
- * Absent declaration ⇒ the request's own organization (unchanged, no reads).
492
- */
493
- private async directoryOrgFor(a: any, requestOrgId: string | null | undefined): Promise<string | null | undefined> {
494
- const rawType = String(a?.type ?? '');
495
- return resolveApproverDirectoryOrg(
496
- this.orgScopeDeps,
497
- a?.organization,
498
- requestOrgId,
499
- rawType,
500
- approverTypeIsOrgScoped(rawType),
501
- );
502
- }
503
-
504
- /** Attach (or replace) the automation surface used to resume flow runs. */
505
- attachAutomation(automation: ApprovalResumeSurface): void {
506
- this.automation = automation;
507
- }
508
-
509
- /** Attach (or replace) the messaging surface used for thread notifications. */
510
- attachMessaging(messaging: ApprovalMessagingSurface): void {
511
- this.messaging = messaging;
512
- }
513
-
514
- /** Best-effort notification fan-out — failures only log. */
515
- private async notify(input: {
516
- topic: string;
517
- audience: string[];
518
- payload?: Record<string, unknown>;
519
- dedupKey?: string;
520
- source?: { object: string; id: string };
521
- actorId?: string;
522
- }): Promise<number> {
523
- const audience = input.audience.filter(a => a && !a.includes(':'));
524
- if (!this.messaging || !audience.length) return 0;
525
- // Deep-link the inbox (#2678 P1.5): a notification about one request should
526
- // land on that request, not the bare inbox. Rewritten centrally so every
527
- // call site — and any future one — inherits it; the query param is read by
528
- // the console inbox to auto-open the drawer.
529
- let payload = input.payload;
530
- if (
531
- payload?.actionUrl === '/system/approvals'
532
- && input.source?.object === 'sys_approval_request'
533
- && input.source.id
534
- ) {
535
- payload = { ...payload, actionUrl: `/system/approvals?request=${encodeURIComponent(input.source.id)}` };
536
- }
537
- try {
538
- await this.messaging.emit({ severity: 'info', ...input, payload, audience });
539
- return audience.length;
540
- } catch (err: any) {
541
- this.logger?.warn?.('[approvals] notification failed', {
542
- topic: input.topic, error: err?.message ?? String(err),
543
- });
544
- return 0;
545
- }
546
- }
547
-
548
- /** Load a request row and assert it is still pending. */
549
- private async loadPendingRow(requestId: string): Promise<any> {
550
- if (!requestId) throw new Error('VALIDATION_FAILED: requestId is required');
551
- const rows = await this.engine.find('sys_approval_request', {
552
- where: { id: requestId }, limit: 1, context: SYSTEM_CTX,
553
- });
554
- const raw: any = Array.isArray(rows) ? rows[0] : null;
555
- if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
556
- if (raw.status !== 'pending') throw new Error(`INVALID_STATE: request is ${raw.status}`);
557
- return raw;
558
- }
559
-
560
- /**
561
- * Privileged-override gate (#3424). A stuck approval — one routed to a
562
- * position/team with no holders (so its `pending_approvers` is only an
563
- * unresolvable `type:value` literal) or to approvers who have all since left —
564
- * is otherwise undecidable: no concrete user is in the slate, so every normal
565
- * `decide` / `reassign` / `recall` is `FORBIDDEN` and (with `lockRecord`) the
566
- * record stays locked forever with no in-product recovery. A platform or
567
- * tenant admin — the same posture the engine's superuser bypass already
568
- * trusts — may always act on a PENDING request to release it: approve, reject,
569
- * reassign it to a real approver, or recall it.
570
- *
571
- * A platform admin crosses the tenant wall (matching the unscoped
572
- * `admin_full_access` evidence); a tenant admin may override only within their
573
- * own org (or an org-less request). A system context always passes. Signals are
574
- * read defensively off the resolved exec context (`permissions` / `positions` /
575
- * the derived `posture`, ADR-0095) so any transport that resolves through the
576
- * shared authz resolver lights this up without extra wiring.
577
- */
578
- private isOverrideActor(context: SharingExecutionContext, requestOrg?: string | null): boolean {
579
- if (!context) return false;
580
- if (context.isSystem) return true;
581
- const perms = Array.isArray(context.permissions) ? context.permissions : [];
582
- const positions = Array.isArray(context.positions) ? context.positions : [];
583
- const posture = (context as any).posture;
584
- const isPlatformAdmin = posture === 'PLATFORM_ADMIN'
585
- || perms.includes(ADMIN_FULL_ACCESS)
586
- || positions.includes(BUILTIN_IDENTITY_PLATFORM_ADMIN);
587
- if (isPlatformAdmin) return true;
588
- const isTenantAdmin = posture === 'TENANT_ADMIN'
589
- || ORGANIZATION_ADMIN_GRANTS.some((n) => perms.includes(n))
590
- || positions.includes(BUILTIN_IDENTITY_ORG_OWNER)
591
- || positions.includes(BUILTIN_IDENTITY_ORG_ADMIN);
592
- if (!isTenantAdmin) return false;
593
- // A tenant admin's authority stops at their own org; a null-org request is
594
- // global and any admin may release it.
595
- const actorTenant = (context as any).tenantId ?? (context as any).organizationId ?? null;
596
- return requestOrg == null || (actorTenant != null && String(requestOrg) === String(actorTenant));
597
- }
598
-
599
- /**
600
- * Pin the acting identity to the AUTHENTICATED CALLER (#3800).
601
- *
602
- * Every public entrypoint accepts an `actorId`, and the REST routes fill it
603
- * from `body.actorId ?? body.actor_id ?? context.userId` — so before this
604
- * gate the body won. The authorization checks downstream all read that value
605
- * (`pending_approvers.includes(input.actorId)`, `submitter_id === actorId`),
606
- * which made the body-supplied string not merely the audit label but the key
607
- * that opens the door: any authenticated user could name a pending approver
608
- * and have that approver's decision recorded and the owning flow resumed.
609
- * #3783 drew this line for the data-write identity ({@link actingUserId});
610
- * this closes the authorization half.
611
- *
612
- * A caller may still name an identity OTHER than their bare user id, because
613
- * a slot legitimately can be keyed by one: `resolveApproverSpec` stores the
614
- * `type:value` literal when a graph lookup yields nothing, and an author may
615
- * write an email as a `user` approver. So the rule is not "actorId must equal
616
- * userId" — it is **"actorId must be an identity the SERVER can prove belongs
617
- * to the caller"**. Anything else is `FORBIDDEN`.
618
- *
619
- * A system context is exempt and keeps its explicit actor: the SLA sweep
620
- * passes the reserved {@link SLA_ACTOR_ID} sentinel, and the ADR-0043 action
621
- * link passes the approver its single-use token is cryptographically bound to
622
- * (having also put them on the context). Those are the only two callers that
623
- * hold a trustworthy actor with no session behind them.
624
- *
625
- * A caller with NO identity at all cannot act. That case is reachable: the
626
- * REST anonymous-deny only fires when `api.requireAuth` is set, so without it
627
- * an anonymous request previously decided approvals outright by naming one.
628
- */
629
- private async resolveActor(
630
- actorId: string | undefined,
631
- context: SharingExecutionContext,
632
- ): Promise<string> {
633
- // The machine callers — their actor is server-minted, not caller-supplied.
634
- if (context?.isSystem) {
635
- if (!actorId) throw new Error('VALIDATION_FAILED: actorId is required');
636
- return actorId;
637
- }
638
- const uid = actingUserId(context);
639
- if (!uid) {
640
- throw new Error('FORBIDDEN: an approval action requires an authenticated caller');
641
- }
642
- // The common case: no actor named, or the caller named themselves.
643
- if (!actorId || String(actorId) === uid) return uid;
644
-
645
- // Named something else — allow it ONLY if the server can prove the caller
646
- // holds that identity. `positions` is resolved by the shared authz resolver
647
- // (never client-supplied); `role:` is the ADR-0090 D3 deprecated spelling
648
- // that 15.x-era slots and the Console's own identity list still carry.
649
- const named = String(actorId);
650
- for (const position of context.positions ?? []) {
651
- if (named === `position:${position}` || named === `role:${position}`) return named;
652
- }
653
- // Email last — it costs a read, so only when nothing cheaper matched.
654
- if (named.includes('@') && await this.callerHasEmail(uid, named)) return named;
655
-
656
- throw new Error(
657
- `FORBIDDEN: cannot act as '${named}' — an approval action is recorded against the authenticated caller`,
658
- );
659
- }
660
-
661
- /** Does `userId`'s own account carry `email`? (Slots keyed by email, #3800.) */
662
- private async callerHasEmail(userId: string, email: string): Promise<boolean> {
663
- try {
664
- const rows = await this.engine.find('sys_user', {
665
- where: { id: userId }, limit: 1, context: SYSTEM_CTX,
666
- });
667
- const row: any = Array.isArray(rows) ? rows[0] : null;
668
- return !!row?.email && String(row.email).toLowerCase() === email.toLowerCase();
669
- } catch {
670
- return false;
671
- }
672
- }
673
-
674
- /**
675
- * Expand the approvers on an Approval node into user IDs by querying the
676
- * graph tables for `team:` / `department:` / `position:` /
677
- * `org_membership_level:` / `manager:` approver types. Falls back to a
678
- * prefixed literal (`type:value`) when graph lookups produce nothing — so
679
- * existing fixtures and flows that rely on substring matching keep working.
680
- *
681
- * **Graph semantics:**
682
- * - `team` → flat members of `sys_team` (better-auth; no BFS)
683
- * - `department` → recursive BFS of `sys_business_unit.parent_business_unit_id`
684
- * → members of every descendant via `sys_business_unit_member`
685
- * - `position` → holders via `sys_user_position` ∪ `sys_member.role`
686
- * transition source (ADR-0090 D3 / ADR-0057 D4)
687
- * - `org_membership_level`
688
- * → users with `sys_member.role = value` in tenant — the
689
- * better-auth MEMBERSHIP TIER (owner/admin/member), not a
690
- * position; author `position` for org positions
691
- * - `manager` → `sys_user.manager_id` of `record[value] ?? record.owner_id`
692
- * - `field` → literal user id stored in `record[value]`
693
- * - `user` → literal value
694
- *
695
- * `role` is accepted as the deprecated spelling of `org_membership_level`
696
- * (ADR-0090 D3) for one window: it resolves identically and logs a warning.
697
- *
698
- * **Out-of-office (#1322 M1):** individually-routed approvers — the ones that
699
- * resolve to a specific person (`user` / `field` / `manager`) — are passed
700
- * through {@link ApprovalService.applyOooDelegation}, which reroutes them onto
701
- * an active delegate when the resolved user has declared OOO. Group/graph
702
- * approvers (`team` / `department` / `position` / `org_membership_level`) are
703
- * left untouched: a group still has its other members, and position-routed
704
- * leave is already covered by ADR-0091 job delegation. Pass an `opts.now` /
705
- * `opts.substitutions` collector to record the hops for audit + notification.
706
- */
707
- private async expandApprovers(
708
- step: any,
709
- record?: any,
710
- organizationId?: string | null,
711
- opts?: {
712
- now?: number;
713
- substitutions?: OooSubstitution[];
714
- groups?: Record<string, string[]>;
715
- /** #3447 P2: `trigger`/`vars` roots for `expression` approvers. */
716
- exprCtx?: ApproverExpressionContext;
717
- /**
718
- * #3447 P2 audit collector: what each dynamic spec resolved FROM (the
719
- * live field value / the expression's intermediate values), snapshotted
720
- * as `__resolvedFrom` so "why these people" stays answerable later.
721
- */
722
- resolvedFrom?: Record<string, unknown>;
723
- },
724
- ): Promise<string[]> {
725
- if (!step || !Array.isArray(step.approvers)) return [];
726
- const now = opts?.now ?? this.clock.now().getTime();
727
- const out: string[] = [];
728
- const specs: any[] = step.approvers;
729
- for (let idx = 0; idx < specs.length; idx++) {
730
- const a = specs[idx];
731
- if (!a) continue;
732
- // Approvers without an explicit `group` each form their own group keyed
733
- // by position (#3266), so a plain per-approver list behaves predictably.
734
- const groupKey = a.group != null && String(a.group) !== '' ? String(a.group) : `#${idx}`;
735
-
736
- // #3447 P2: `expression` approvers resolve OUTSIDE resolveApproverSpec —
737
- // a graph-expanded expression (resolveAs: department/…) must key each
738
- // intermediate value as its own per_group group, which the flat string[]
739
- // contract of resolveApproverSpec cannot carry.
740
- if (canonicalApproverType(String(a.type)) === 'expression') {
741
- const resolved = await this.resolveExpressionApprovers(
742
- a, record, organizationId, now, opts?.substitutions, opts?.exprCtx,
743
- );
744
- if (opts?.resolvedFrom) opts.resolvedFrom[`expression#${idx}`] = resolved.raw;
745
- for (const entry of resolved.slots) {
746
- if (!entry.id) continue;
747
- out.push(entry.id);
748
- if (opts?.groups) {
749
- (opts.groups[entry.id] ??= []).push(entry.subGroup ? `${groupKey}:${entry.subGroup}` : groupKey);
750
- }
751
- }
752
- continue;
753
- }
754
-
755
- if (opts?.resolvedFrom && canonicalApproverType(String(a.type)) === 'field' && a.value != null) {
756
- opts.resolvedFrom[`field:${a.value}`] = (record as any)?.[a.value] ?? null;
757
- }
758
- const ids = await this.resolveApproverSpec(a, record, organizationId, now, opts?.substitutions);
759
- // per_group (#3266): tag each resolved id with this spec's group.
760
- for (const u of ids) {
761
- if (!u) continue;
762
- out.push(u);
763
- if (opts?.groups) (opts.groups[u] ??= []).push(groupKey);
764
- }
765
- }
766
- return out.filter(Boolean);
767
- }
768
-
769
- /**
770
- * Resolve ONE approver spec to concrete approver identities, applying OOO
771
- * substitution (#1322) to individually-routed types. Extracted from
772
- * {@link ApprovalService.expandApprovers} so the caller can tag each spec's
773
- * resolved ids with a group (#3266) without duplicating the resolution logic.
774
- * Returns the `type:value` literal as a single-element fallback when a graph
775
- * lookup yields nothing — same behaviour as before the extraction.
776
- */
777
- private async resolveApproverSpec(
778
- a: any,
779
- record: any,
780
- organizationId: string | null | undefined,
781
- now: number,
782
- substitutions?: OooSubstitution[],
783
- ): Promise<string[]> {
784
- // ADR-0090 D3: `role` is the deprecated spelling of `org_membership_level`.
785
- // Resolve on the canonical type, but keep the AUTHORED spelling in the
786
- // `type:value` fallback below — stored `sys_approval_approver` rows and
787
- // `pending_approvers` slots from 15.x carry the old literal.
788
- const type = canonicalApproverType(String(a.type));
789
- if (type !== a.type) {
790
- this.logger?.warn?.(
791
- `[approvals] approver type '${a.type}' is deprecated (ADR-0090 D3) — author '${type}' instead`,
792
- { deprecated: a.type, canonical: type },
793
- );
794
- }
795
- // [ADR-0105 D9] WHERE this approver is looked up — the request's own
796
- // organization unless the spec targets another one in the same group.
797
- //
798
- // Resolved HERE, above the `user` / `field` / `manager` early returns,
799
- // because refusing a declaration on a directory-less type is one of the
800
- // things this resolution DOES (those types name a person outright, so
801
- // `organization` on them cannot narrow anything and an author who wrote it
802
- // misunderstood the field). Resolving it after those returns made the
803
- // refusal unreachable and the declaration silently inert — exactly the
804
- // "ignored, not refused" behaviour ADR-0105 D9 rules out, and what the
805
- // cloud group-posture dogfood caught.
806
- //
807
- // Costs nothing on the overwhelmingly common path: with no `organization`
808
- // declared, the resolver returns the request org without reading anything.
809
- const directoryOrg = await this.directoryOrgFor(a, organizationId);
810
- const crossOrg = directoryOrg !== organizationId;
811
-
812
- if (type === 'user') {
813
- return this.applyOooDelegation(String(a.value), now, organizationId, substitutions);
814
- }
815
- if (type === 'field' && record) {
816
- // #3447: a record field can name MANY approvers — a multi-select user
817
- // field arrives as an array (or a legacy CSV string). Fan each out into
818
- // its own slot and OOO-substitute per person; collapsing to `String(...)`
819
- // (→ `'u1,u2'`) would mint one bogus approver id and skip every delegate.
820
- const out: string[] = [];
821
- for (const id of csvSplit((record as any)[a.value])) {
822
- out.push(...await this.applyOooDelegation(id, now, organizationId, substitutions));
823
- }
824
- return out;
825
- }
826
- // `directoryOrg` / `crossOrg` were resolved at the TOP of this method, so
827
- // the refusal reaches directory-less types too. Resolution failures
828
- // propagate: they are routing bugs, and that call sits OUTSIDE the
829
- // swallowing try below on purpose (see the catch's comment).
830
- //
831
- // A cross-org slate is filtered to the people who can actually READ the
832
- // request (D2 union); same-org routing is untouched and does no extra read.
833
- const bounded = async (users: string[]): Promise<string[]> => (
834
- crossOrg
835
- ? filterApproversWhoCanRead(this.orgScopeDeps, users, organizationId, {
836
- approverType: type, value: a.value != null ? String(a.value) : undefined,
837
- directoryOrgId: directoryOrg,
838
- })
839
- : users
840
- );
841
-
842
- try {
843
- if (type === 'team') {
844
- const users = await this.expandTeamUsers(String(a.value));
845
- if (users.length) return users;
846
- } else if (type === 'department' || type === 'business_unit' || type === 'bu') {
847
- const users = await bounded(await this.expandBusinessUnitUsers(String(a.value), directoryOrg));
848
- if (users.length) return users;
849
- } else if (type === 'position') {
850
- const users = await bounded(await this.expandPositionUsers(String(a.value), directoryOrg));
851
- if (users.length) return users;
852
- } else if (type === 'org_membership_level') {
853
- const users = await bounded(await this.expandMembershipTierUsers(String(a.value), directoryOrg));
854
- if (users.length) return users;
855
- } else if (type === 'manager' && record) {
856
- const subject = (record as any)[a.value] ?? (record as any).owner_id;
857
- if (subject) {
858
- const mgr = await this.lookupManager(String(subject));
859
- if (mgr) return this.applyOooDelegation(mgr, now, organizationId, substitutions);
860
- }
861
- }
862
- } catch { /* a directory lookup failed → fall through to the literal slot */ }
863
- // #3508: `queue` is declared-but-unenforced — there is no queue branch
864
- // above, so a queue approver always lands here and the `queue:<id>` slot
865
- // routes to nobody. The spec marks it non-authorable
866
- // (NON_AUTHORABLE_APPROVER_TYPES) so designers stop offering it; warn for
867
- // the stored flows that still carry one, so the silent dead slot is at
868
- // least visible to operators.
869
- if (type === 'queue') {
870
- this.logger?.warn?.(
871
- `[approvals] approver type 'queue' is not implemented — the slot resolves to nobody (#3508)`,
872
- { value: a.value },
873
- );
874
- } else if (GRAPH_APPROVER_TYPES.has(type)) {
875
- // #3807 follow-up — every OTHER way to land here is a graph type whose
876
- // lookup produced nobody, and the literal below is a slot no user can
877
- // ever act on. That silence is what let #3807 hide: a `department`
878
- // approver pointing at a seeded (env-wide) unit resolved to
879
- // `department:<id>` on every request, the request opened with an empty
880
- // slate, and nothing in the logs said so — the first symptom was a
881
- // permanently stuck approval (#3424). The fallback itself stays (a
882
- // literal keeps 15.x slots and substring fixtures working); it just
883
- // stops being invisible.
884
- this.logger?.warn?.(
885
- `[approvals] approver '${type}:${a.value}' expanded to nobody — the slot routes to no one `
886
- + `and the request cannot advance until someone is added or the approver is re-pointed (#3807)`,
887
- { type, value: a.value, organizationId: organizationId ?? null },
888
- );
889
- }
890
- return [`${a.type}:${a.value}`];
891
- }
892
-
893
- /**
894
- * Resolve an `expression` approver (#3447 P2): evaluate its CEL source at
895
- * node entry against the three explicit roots — `current` (live record),
896
- * `trigger` (submit snapshot), `vars` (flow variables) — then expand the
897
- * result into people per `resolveAs`.
898
- *
899
- * Every failure here THROWS (config/parse errors as `VALIDATION_FAILED`,
900
- * evaluation faults as `EXPRESSION_FAILED`) so the approval node fails
901
- * loudly instead of opening a request routed to nobody — an approver
902
- * expression that cannot run is a routing bug, never "condition not met".
903
- * Error messages carry the correct spelling because their primary reader is
904
- * the AI author fixing the flow on the next validate pass.
905
- *
906
- * Returns `slots` (approver id + optional per_group sub-key) and `raw` (the
907
- * expression's own values, pre-expansion) for the `__resolvedFrom` audit.
908
- */
909
- private async resolveExpressionApprovers(
910
- a: any,
911
- liveRecord: any,
912
- organizationId: string | null | undefined,
913
- now: number,
914
- substitutions?: OooSubstitution[],
915
- exprCtx?: ApproverExpressionContext,
916
- ): Promise<{ slots: Array<{ id: string; subGroup?: string }>; raw: string[] }> {
917
- const source = String(a.value ?? '').trim();
918
- if (!source) {
919
- throw new Error('VALIDATION_FAILED: expression approver has an empty expression');
920
- }
921
-
922
- // Closed-root pre-check. The runtime env resolves ANY unknown root as dyn →
923
- // null, so `record.x` / a bare field would silently yield an empty slate;
924
- // reject it here with the correct spelling instead.
925
- const parsed = collectCelRootIdentifiers(source);
926
- if (!parsed.ok) {
927
- throw new Error(`VALIDATION_FAILED: expression approver does not parse: ${parsed.error} — source: \`${source}\``);
928
- }
929
- const illegal = parsed.roots.filter(r => !APPROVER_EXPRESSION_ROOTS.has(r));
930
- if (illegal.length) {
931
- const hint = illegal.includes('record') || illegal.includes('previous')
932
- ? `\`record\`/\`previous\` are not bound here — write \`current.<field>\` for the record's live state `
933
- + `at node entry, or \`trigger.<field>\` for the submit-time snapshot (\`vars.previous\` carries the pre-update row)`
934
- : `did you mean \`current.<field>\` (live record), \`trigger.<field>\` (submit snapshot), or \`vars.<name>\` (flow variable)?`;
935
- throw new Error(
936
- `VALIDATION_FAILED: expression approver references \`${illegal.join('`, `')}\` — `
937
- + `only \`current.*\`, \`trigger.*\` and \`vars.*\` are available; ${hint}. Source: \`${source}\``,
938
- );
939
- }
940
-
941
- const result = ExpressionEngine.evaluate(
942
- { dialect: 'cel', source },
943
- { extra: { current: liveRecord ?? {}, trigger: exprCtx?.trigger ?? {}, vars: exprCtx?.vars ?? {} } },
944
- );
945
- if (!result.ok) {
946
- throw new Error(
947
- `EXPRESSION_FAILED: expression approver failed to evaluate (${result.error.kind}): `
948
- + `${result.error.message} — source: \`${source}\``,
949
- );
950
- }
951
-
952
- // Normalize to a string list: a user-id/CSV string, an array of ids, or
953
- // null/empty (an EMPTY slate — legal, handled by onEmptyApprovers). Any
954
- // other shape is a config bug, rejected loudly.
955
- const value = result.value as unknown;
956
- let raw: string[];
957
- if (value == null || value === '') {
958
- raw = [];
959
- } else if (typeof value === 'string') {
960
- raw = csvSplit(value);
961
- } else if (Array.isArray(value)) {
962
- const bad = value.find(v => v != null && typeof v !== 'string' && typeof v !== 'number');
963
- if (bad !== undefined) {
964
- throw new Error(
965
- `EXPRESSION_FAILED: expression approver must yield ids (string / CSV / string array), `
966
- + `got an array containing ${typeof bad} — source: \`${source}\``,
967
- );
968
- }
969
- raw = value.map(v => String(v ?? '').trim()).filter(Boolean);
970
- } else {
971
- throw new Error(
972
- `EXPRESSION_FAILED: expression approver must yield ids (string / CSV / string array), `
973
- + `got ${typeof value} — source: \`${source}\``,
974
- );
975
- }
976
-
977
- // `resolveAs` expansion. `user` (default): each value IS a person —
978
- // individually routed, so OOO delegation applies (#1322). Graph kinds
979
- // re-expand each value through the same lookups the static types use; a
980
- // group still has its other members, so like the static graph types they
981
- // are NOT OOO-substituted, and with per_group each intermediate value
982
- // forms its own sub-group (one sign-off per returned department). A value
983
- // whose expansion is empty keeps a `<kind>:<value>` literal slot — same
984
- // unstaffed-target behaviour (and #3424 admin rescue) as the static types.
985
- const resolveAs = String(a.resolveAs ?? 'user');
986
- if (resolveAs === 'user') {
987
- const slots: Array<{ id: string }> = [];
988
- for (const id of raw) {
989
- for (const routed of await this.applyOooDelegation(id, now, organizationId, substitutions)) {
990
- slots.push({ id: routed });
991
- }
992
- }
993
- return { slots, raw };
994
- }
995
- // [ADR-0105 D9] An expression that re-expands into a graph kind consults the
996
- // same org-scoped directories the static types do, so it honours the same
997
- // targeting. Resolved once for the whole slate, before the per-value loop —
998
- // the declaration is a property of the spec, not of what the CEL returned.
999
- const directoryOrg = await this.directoryOrgFor(a, organizationId);
1000
- const crossOrg = directoryOrg !== organizationId;
1001
- const slots: Array<{ id: string; subGroup: string }> = [];
1002
- for (const key of raw) {
1003
- let users: string[] = [];
1004
- try {
1005
- if (resolveAs === 'department') users = await this.expandBusinessUnitUsers(key, directoryOrg);
1006
- else if (resolveAs === 'position') users = await this.expandPositionUsers(key, directoryOrg);
1007
- else if (resolveAs === 'team') users = await this.expandTeamUsers(key);
1008
- else {
1009
- throw new Error(
1010
- `VALIDATION_FAILED: expression approver has unknown resolveAs '${resolveAs}' — `
1011
- + `use 'user', 'department', 'position', or 'team'`,
1012
- );
1013
- }
1014
- } catch (err: any) {
1015
- if (String(err?.message ?? '').startsWith('VALIDATION_FAILED')) throw err;
1016
- users = [];
1017
- }
1018
- if (crossOrg && users.length) {
1019
- users = await filterApproversWhoCanRead(this.orgScopeDeps, users, organizationId, {
1020
- approverType: resolveAs, value: key, directoryOrgId: directoryOrg,
1021
- });
1022
- }
1023
- if (!users.length) {
1024
- slots.push({ id: `${resolveAs}:${key}`, subGroup: key });
1025
- continue;
1026
- }
1027
- for (const u of users) slots.push({ id: u, subGroup: key });
1028
- }
1029
- return { slots, raw };
1030
- }
1031
-
1032
- /** Flat team — `sys_team` is better-auth's collaboration grouping (no hierarchy). */
1033
- private async expandTeamUsers(teamId: string): Promise<string[]> {
1034
- if (!teamId) return [];
1035
- let rows: any[] = [];
1036
- try {
1037
- rows = await this.engine.find('sys_team_member', {
1038
- filter: { team_id: teamId },
1039
- fields: ['user_id'],
1040
- limit: 10000,
1041
- context: SYSTEM_CTX,
1042
- } as any);
1043
- } catch { rows = []; }
1044
- return Array.from(new Set((rows ?? []).map((r: any) => String(r.user_id ?? '')).filter(Boolean)));
1045
- }
1046
-
1047
- /**
1048
- * Tenant scope for a `sys_business_unit` read that may legitimately be
1049
- * env-wide (#3807).
1050
- *
1051
- * `organization_id = null` on a platform object means "owned by no
1052
- * organization" — a row written by a seed, the file layer, or bootstrap,
1053
- * i.e. before (or outside) any org exists. A strict
1054
- * `organization_id = <request org>` equality made every such row invisible:
1055
- * the seed check below found nothing, the whole expansion returned `[]`, and
1056
- * the approver fell back to the dead `department:<id>` literal that routes to
1057
- * nobody. That is not an edge case — an app's org tree is normally seeded
1058
- * (a seed cannot know the org id the runtime mints at boot) while the
1059
- * approval request always carries one, so EVERY department approver a
1060
- * designer could pick resolved to nobody.
1061
- *
1062
- * Widen to "this org ∪ env-wide", the same predicate `sys_metadata`'s
1063
- * pending-draft listing settled on for the identical reason. Another org's
1064
- * unit still fails the match, so the wall between two organizations is
1065
- * unchanged — only rows belonging to no org at all become visible.
1066
- */
1067
- private businessUnitOrgScope(
1068
- filter: Record<string, unknown>,
1069
- organizationId?: string | null,
1070
- ): Record<string, unknown> {
1071
- if (!organizationId) return filter;
1072
- return { ...filter, $or: [{ organization_id: organizationId }, { organization_id: null }] };
1073
- }
1074
-
1075
- /** Recursive department — walks `sys_business_unit.parent_business_unit_id`. */
1076
- private async expandBusinessUnitUsers(businessUnitId: string, organizationId?: string | null): Promise<string[]> {
1077
- if (!businessUnitId) return [];
1078
- // Seed sanity check: skip if dept doesn't exist or is inactive within tenant.
1079
- try {
1080
- const seed = await this.engine.find('sys_business_unit', {
1081
- filter: this.businessUnitOrgScope({ id: businessUnitId }, organizationId),
1082
- fields: ['id', 'active'],
1083
- limit: 1,
1084
- context: SYSTEM_CTX,
1085
- } as any);
1086
- const seedRow: any = Array.isArray(seed) ? seed[0] : null;
1087
- if (!seedRow || seedRow.active === false) return [];
1088
- } catch { return []; }
1089
-
1090
- const seen = new Set<string>([businessUnitId]);
1091
- const queue: string[] = [businessUnitId];
1092
- while (queue.length) {
1093
- const parent = queue.shift()!;
1094
- let kids: any[] = [];
1095
- try {
1096
- const filter = this.businessUnitOrgScope(
1097
- { parent_business_unit_id: parent, active: { $ne: false } },
1098
- organizationId,
1099
- );
1100
- kids = await this.engine.find('sys_business_unit', { filter, fields: ['id'], limit: 1000, context: SYSTEM_CTX } as any);
1101
- } catch { kids = []; }
1102
- for (const k of kids ?? []) {
1103
- const kid = String((k as any).id ?? '');
1104
- if (kid && !seen.has(kid)) { seen.add(kid); queue.push(kid); }
1105
- }
1106
- }
1107
- let rows: any[] = [];
1108
- try {
1109
- rows = await this.engine.find('sys_business_unit_member', {
1110
- filter: { business_unit_id: { $in: Array.from(seen) } },
1111
- fields: ['user_id'],
1112
- limit: 10000,
1113
- context: SYSTEM_CTX,
1114
- } as any);
1115
- } catch { rows = []; }
1116
- return Array.from(new Set((rows ?? []).map((r: any) => String(r.user_id ?? '')).filter(Boolean)));
1117
- }
1118
-
1119
- /**
1120
- * Position holders (ADR-0090 D3): `sys_user_position` is the platform-owned
1121
- * assignment table, keyed by the position's machine name (ADR-0057 D4),
1122
- * unioned with the better-auth membership string (`sys_member.role`) as a
1123
- * transition source — the same semantics as `PositionGraphService` in
1124
- * `plugin-sharing`, so an approval routes to exactly the users the sharing
1125
- * engine would expand for the same position.
1126
- */
1127
- private async expandPositionUsers(positionName: string, organizationId?: string | null): Promise<string[]> {
1128
- if (!positionName) return [];
1129
- const users = new Set<string>();
1130
- const filter: any = { position: positionName };
1131
- if (organizationId) filter.organization_id = organizationId;
1132
- try {
1133
- const rows = await this.engine.find('sys_user_position', {
1134
- filter, fields: ['user_id'], limit: 10000, context: SYSTEM_CTX,
1135
- } as any);
1136
- for (const r of (rows ?? []) as any[]) {
1137
- const uid = String(r.user_id ?? '');
1138
- if (uid) users.add(uid);
1139
- }
1140
- } catch { /* table may not exist on minimal stacks — union source below still applies */ }
1141
- // ADR-0057 D4 transition source: pre-migration stacks still carry the
1142
- // position name in better-auth's `sys_member.role` column, so the same
1143
- // lookup serves a position name here and a membership tier for
1144
- // `org_membership_level` — the column is one, the two concepts are not.
1145
- for (const uid of await this.expandMembershipTierUsers(positionName, organizationId)) users.add(uid);
1146
- return Array.from(users);
1147
- }
1148
-
1149
- /**
1150
- * better-auth org-membership tier (`sys_member.role`: owner/admin/member) —
1151
- * NOT positions. Named for the projection (`org_membership_level`, ADR-0057
1152
- * D7 / ADR-0090 D3), not for better-auth's column: the column name is theirs
1153
- * and stays, the platform-facing word does not.
1154
- */
1155
- private async expandMembershipTierUsers(tier: string, organizationId?: string | null): Promise<string[]> {
1156
- if (!tier) return [];
1157
- const filter: any = { role: tier };
1158
- if (organizationId) filter.organization_id = organizationId;
1159
- let rows: any[] = [];
1160
- try {
1161
- rows = await this.engine.find('sys_member', { filter, fields: ['user_id'], limit: 10000, context: SYSTEM_CTX } as any);
1162
- } catch { rows = []; }
1163
- return Array.from(new Set((rows ?? []).map((r: any) => String(r.user_id ?? '')).filter(Boolean)));
1164
- }
1165
-
1166
- private async lookupManager(userId: string): Promise<string | null> {
1167
- try {
1168
- const rows = await this.engine.find('sys_user', {
1169
- filter: { id: userId }, fields: ['id', 'manager_id'], limit: 1, context: SYSTEM_CTX,
1170
- } as any);
1171
- const row: any = Array.isArray(rows) ? rows[0] : null;
1172
- return row?.manager_id ? String(row.manager_id) : null;
1173
- } catch { return null; }
1174
- }
1175
-
1176
- /**
1177
- * Out-of-office auto-skip (#1322 M1). Given an individually-routed approver
1178
- * id, follow any active `sys_approval_delegation` chain and return the id the
1179
- * slot should actually go to — the delegate acts under their own identity, so
1180
- * no impersonation is involved. Returns `[userId]` unchanged when there is no
1181
- * active delegation. Each hop is appended to `collector` (when supplied) so
1182
- * the caller can audit + notify (M4).
1183
- *
1184
- * The chain (A out → B, B out → C, …) is bounded by {@link OOO_MAX_CHAIN} and
1185
- * stops on a self-reference or a cycle, so a mis-declared loop degrades to the
1186
- * last reachable delegate rather than hanging.
1187
- */
1188
- private async applyOooDelegation(
1189
- userId: string,
1190
- now: number,
1191
- organizationId?: string | null,
1192
- collector?: OooSubstitution[],
1193
- ): Promise<string[]> {
1194
- const start = String(userId ?? '').trim();
1195
- if (!start) return [];
1196
- let current = start;
1197
- const visited = new Set<string>([current]);
1198
- for (let hop = 0; hop < OOO_MAX_CHAIN; hop++) {
1199
- const del = await this.lookupActiveDelegation(current, now, organizationId);
1200
- if (!del) break;
1201
- const to = String(del.delegate_id ?? '').trim();
1202
- if (!to || to === current || visited.has(to)) break; // no-op / self / cycle
1203
- collector?.push({ from: current, to, reason: del.reason != null ? String(del.reason) : null });
1204
- visited.add(to);
1205
- current = to;
1206
- }
1207
- return [current];
1208
- }
1209
-
1210
- /**
1211
- * The active OOO delegation for a delegator at `now`, or null. Validity is the
1212
- * shared `isGrantActive` half-open window (ADR-0091 D2), enforced here at
1213
- * resolution time — never by a background job. When several rows are active,
1214
- * the one expiring soonest wins (the most specific coverage window).
1215
- */
1216
- private async lookupActiveDelegation(
1217
- delegatorId: string,
1218
- now: number,
1219
- organizationId?: string | null,
1220
- ): Promise<any | null> {
1221
- if (!delegatorId) return null;
1222
- let rows: any[] = [];
1223
- try {
1224
- rows = await this.engine.find('sys_approval_delegation', {
1225
- filter: { delegator_id: delegatorId },
1226
- fields: ['id', 'delegator_id', 'delegate_id', 'valid_from', 'valid_until', 'reason', 'organization_id'],
1227
- limit: 50,
1228
- context: SYSTEM_CTX,
1229
- } as any);
1230
- } catch { return null; } // table absent on minimal stacks — no OOO, resolve as-is
1231
- const active = (rows ?? []).filter((r: any) =>
1232
- isGrantActive(r, now)
1233
- // A null-org rule applies across tenants; a scoped rule only within its tenant.
1234
- && (organizationId == null || r.organization_id == null || String(r.organization_id) === String(organizationId)));
1235
- if (!active.length) return null;
1236
- active.sort((a: any, b: any) => {
1237
- const au = a.valid_until ? Date.parse(String(a.valid_until)) : Number.POSITIVE_INFINITY;
1238
- const bu = b.valid_until ? Date.parse(String(b.valid_until)) : Number.POSITIVE_INFINITY;
1239
- return au - bu;
1240
- });
1241
- return active[0];
1242
- }
1243
-
1244
- /**
1245
- * Mirror a request status onto a business-object field, if configured.
1246
- *
1247
- * **Elevated, but not anonymous (#3783).** The write stays `isSystem`: the
1248
- * record is normally LOCKED while its approval is live and the submitter
1249
- * cannot edit it, so only a platform write can land the status — that is what
1250
- * the lock hook's system exemption (`lifecycle-hooks.ts`) is for. What it must
1251
- * NOT do is throw away *who* caused the transition. Every status below is
1252
- * something a specific human just did — a submitter submitting or recalling,
1253
- * an approver deciding or sending back — and this write is what fires the
1254
- * target object's record-change flows. With no `userId` on it those cascades
1255
- * inherit no trigger user, and since #3760 a `runAs:'user'` run with no trigger
1256
- * user has its data ops REFUSED — so "when the invoice is approved, do X", the
1257
- * most natural approvals automation there is, had to declare `runAs:'system'`
1258
- * and take blanket elevation for a case where a perfectly good scoped identity
1259
- * existed. Re-attaching the actor lets those cascades run as the deciding user
1260
- * with RLS enforced. Same shape the approval node already uses when it calls
1261
- * into this service (`approval-node.ts`).
1262
- *
1263
- * `actorId` is `null` for the genuinely machine-driven transitions (the SLA
1264
- * escalation's auto-decision, the dead-run sweep). There is no human to name
1265
- * there, and naming a sentinel would put a non-user in `updated_by` and in
1266
- * every downstream flow's identity. Those cascades stay user-less — a flow
1267
- * that wants to react to them still has to declare `runAs:'system'`, which is
1268
- * the honest answer rather than an oversight.
1269
- *
1270
- * Deliberately carries `userId` ONLY, not the request's org. On an
1271
- * ExecutionContext `tenantId` is a driver-scoping knob, not attribution
1272
- * (`buildDriverOptions` turns it into a tenant predicate on the update), so
1273
- * passing it would newly org-scope this write and silently no-op the mirror on
1274
- * a record whose org differs from the request's — while buying nothing: the
1275
- * automation engine back-fills the run's `tenantId` from the resolved user's
1276
- * own grants.
1277
- */
1278
- private async mirrorStatusField(
1279
- object: string,
1280
- recordId: string,
1281
- field: string,
1282
- status: string,
1283
- actorId: string | null,
1284
- ): Promise<void> {
1285
- try {
1286
- const context = actorId ? { ...SYSTEM_CTX, userId: actorId } : SYSTEM_CTX;
1287
- await this.engine.update(object, { id: recordId, [field]: status }, { context });
1288
- } catch (err: any) {
1289
- this.logger?.warn?.(`[approvals] mirrorStatusField failed: ${err?.message ?? err}`);
1290
- }
1291
- }
1292
-
1293
- /**
1294
- * Re-read a business record's CURRENT state by id so approver resolution binds
1295
- * to live data at node entry, not the trigger snapshot the flow froze into
1296
- * `$record` at submit time (#3447). A `field` / `manager` approver names *who*
1297
- * decides, and an earlier node — or the approver of an earlier step — may have
1298
- * written that routing field after submit (e.g. a lead reviewer picking which
1299
- * departments co-review). Graph approvers (team / position / …) already query
1300
- * live; this brings the in-record types into line.
1301
- *
1302
- * Read under system identity: approver routing is a platform concern and the
1303
- * record is the flow's own subject, so the submitter's RLS/FLS must not narrow
1304
- * it. Degrades to `fallback` (the snapshot) when the record can't be re-read —
1305
- * hard-deleted between submit and node entry, or an object whose backend can't
1306
- * serve a point read — warning rather than throwing so a transient miss can't
1307
- * wedge an approval. That "warn but proceed" stance matches the
1308
- * no-concrete-approver guard (#3424) and the "record is gone" enrichment path
1309
- * that already falls back to the payload snapshot.
1310
- */
1311
- private async loadLiveRecord(object: string, recordId: string, fallback?: any): Promise<any> {
1312
- try {
1313
- const rows = await this.engine.find(object, {
1314
- where: { id: recordId }, limit: 1, context: SYSTEM_CTX,
1315
- } as any);
1316
- const live = Array.isArray(rows) ? rows[0] : rows;
1317
- if (live) return live;
1318
- this.logger?.warn?.(
1319
- `[approvals] live record ${object}/${recordId} not found at node entry — `
1320
- + 'resolving approvers against the trigger snapshot (#3447 fallback).',
1321
- { object, recordId },
1322
- );
1323
- } catch (err: any) {
1324
- this.logger?.warn?.(
1325
- `[approvals] live record re-read failed for ${object}/${recordId}: ${err?.message ?? err} — `
1326
- + 'resolving approvers against the trigger snapshot (#3447 fallback).',
1327
- );
1328
- }
1329
- return fallback ?? {};
1330
- }
1331
-
1332
- // ── ADR-0019: Approval-as-flow-node ──────────────────────────
1333
- //
1334
- // A flow's Approval node opens a request via `openNodeRequest` (carrying its
1335
- // own approvers/behavior config and the suspended run id), then suspends. A
1336
- // later `decide` finalizes it and resumes the flow run down the matching
1337
- // `approve`/`reject` edge. The record lock is enforced by a beforeUpdate hook
1338
- // keyed on a *pending* request, so finalizing auto-releases it.
1339
-
1340
- /**
1341
- * Open a pending approval request on behalf of a flow's Approval node. The
1342
- * node config (approvers / behavior / status field) is snapshotted on the row
1343
- * so a decision can be made without any process to resolve against.
1344
- *
1345
- * #3447 P2: may instead return an {@link ApprovalNodeAutoOutcome} — no
1346
- * request opened — when the slate resolves empty and the node's
1347
- * `onEmptyApprovers` policy is `auto_approve`.
1348
- */
1349
- async openNodeRequest(
1350
- input: {
1351
- object: string;
1352
- recordId: string;
1353
- runId: string;
1354
- nodeId: string;
1355
- config: ApprovalNodeConfig;
1356
- flowName?: string;
1357
- /** Authored flow label, snapshotted for inbox display. */
1358
- flowLabel?: string;
1359
- /** Authored node label, snapshotted for inbox display. */
1360
- nodeLabel?: string;
1361
- submitterId?: string | null;
1362
- record?: any;
1363
- organizationId?: string | null;
1364
- /**
1365
- * #3447 P2: flow variables at node entry (nested by dotted key, as the
1366
- * engine's CEL conditions see them) — the `vars.*` root for `expression`
1367
- * approvers. `input.record` doubles as their `trigger.*` root.
1368
- */
1369
- variables?: Record<string, unknown> | null;
1370
- },
1371
- context: SharingExecutionContext,
1372
- ): Promise<ApprovalRequestRow | ApprovalNodeAutoOutcome> {
1373
- if (!input.object) throw new Error('VALIDATION_FAILED: object is required');
1374
- if (!input.recordId) throw new Error('VALIDATION_FAILED: recordId is required');
1375
- if (!input.runId) throw new Error('VALIDATION_FAILED: runId is required');
1376
-
1377
- // One pending request per (object, record).
1378
- const existing = await this.engine.find('sys_approval_request', {
1379
- where: { object_name: input.object, record_id: input.recordId, status: 'pending' },
1380
- limit: 1, context: SYSTEM_CTX,
1381
- });
1382
- if (Array.isArray(existing) && existing[0]) {
1383
- throw new Error(`DUPLICATE_REQUEST: a pending approval already exists for ${input.object}/${input.recordId}`);
1384
- }
1385
-
1386
- const ctxOrg = (context as any)?.organizationId ?? (context as any)?.tenantId ?? input.organizationId ?? null;
1387
- const nowDate = this.clock.now();
1388
- // OOO auto-skip (#1322 M1): reroute individually-routed approvers who are
1389
- // out of office. Collected hops drive the audit + notification below (M4).
1390
- const substitutions: OooSubstitution[] = [];
1391
- // Group membership per resolved approver (#3266) — snapshotted so quorum /
1392
- // per_group finalization is decided against the slate resolved at OPEN time
1393
- // (OOO-substituted), not re-resolved live at each decision.
1394
- const groups: Record<string, string[]> = {};
1395
- // #3447: resolve approvers against the record's LIVE state at node entry, not
1396
- // the trigger snapshot carried in `input.record`. This is the whole fix — an
1397
- // earlier step may have written the field this node routes on.
1398
- const liveRecord = await this.loadLiveRecord(input.object, input.recordId, input.record);
1399
- const resolvedFrom: Record<string, unknown> = {};
1400
- const approvers = await this.expandApprovers(
1401
- { approvers: input.config.approvers }, liveRecord, ctxOrg, {
1402
- now: nowDate.getTime(), substitutions, groups,
1403
- exprCtx: { trigger: input.record ?? null, vars: input.variables ?? null },
1404
- resolvedFrom,
1405
- },
1406
- );
1407
-
1408
- // Empty-slate policy (#3447 P2). "Empty" = no CONCRETE person — an
1409
- // unstaffed position / empty expression result leaves only `type:value`
1410
- // literal slots, decidable by nobody.
1411
- if (!approvers.some(a => a && !a.includes(':'))) {
1412
- const emptyPolicy = (input.config as any).onEmptyApprovers ?? 'admin_rescue';
1413
- if (emptyPolicy === 'fail') {
1414
- throw new Error(
1415
- `NO_APPROVERS: approval node '${input.nodeId}' on ${input.object}/${input.recordId} resolved to no `
1416
- + `concrete approver and its onEmptyApprovers policy is 'fail'. Check that the approver target(s) `
1417
- + `are staffed / the routing field or expression yields user ids at node entry.`,
1418
- );
1419
- }
1420
- if (emptyPolicy === 'auto_approve') {
1421
- this.logger?.warn?.(
1422
- `[approvals] approval node '${input.nodeId}' on ${input.object}/${input.recordId} resolved to no `
1423
- + `concrete approver — auto-approving per onEmptyApprovers: 'auto_approve' (no request opened).`,
1424
- { object: input.object, recordId: input.recordId, node: input.nodeId, resolved: approvers },
1425
- );
1426
- return { autoApproved: true, reason: 'empty_approvers' };
1427
- }
1428
- // #3424 admin_rescue (default): the request is still opened (a privileged
1429
- // admin can override it, and legacy 15.x literal slots stay queryable) —
1430
- // the only option that neither waves the record through nor kills the
1431
- // run — but warn loudly so the misconfiguration surfaces instead of
1432
- // silently locking the record with no obvious cause.
1433
- this.logger?.warn?.(
1434
- `[approvals] approval node '${input.nodeId}' on ${input.object}/${input.recordId} resolved to no concrete approver`
1435
- + ' — the request is decidable only by a privileged admin. Check that the approver target(s) are staffed.',
1436
- { object: input.object, recordId: input.recordId, node: input.nodeId, resolved: approvers },
1437
- );
1438
- }
1439
-
1440
- const now = nowDate.toISOString();
1441
- const id = uid('areq');
1442
- const processName = `flow:${input.flowName ?? input.nodeId}`;
1443
- // Display labels ride the config snapshot (no schema migration needed);
1444
- // `rowFromRequest` surfaces them as `process_label` / `step_label`.
1445
- const configSnapshot: any = { ...input.config };
1446
- if (input.flowLabel) configSnapshot.__flowLabel = input.flowLabel;
1447
- if (input.nodeLabel) configSnapshot.__nodeLabel = input.nodeLabel;
1448
- // Snapshot the resolved approver→group map for EVERY multi-approver
1449
- // behavior (was quorum/per_group only). #3447 P2 makes this load-bearing
1450
- // for unanimous too: an `expression` approver can only resolve at OPEN
1451
- // time (decide has no flow variables to evaluate against), so the tally
1452
- // must read the open-time slate — which also pins unanimous+field to the
1453
- // slate the approvers actually saw, instead of re-reading a field that may
1454
- // have changed again since.
1455
- if (input.config.behavior && input.config.behavior !== 'first_response') {
1456
- configSnapshot.__approverGroups = groups;
1457
- }
1458
- // #3447 P2: snapshot what the dynamic approver sources resolved FROM (the
1459
- // live routing-field value / the expression's intermediate values) so the
1460
- // audit trail answers "why these people" — the resolution INPUT, pairing
1461
- // the resolution RESULT already persisted as `pending_approvers`.
1462
- if (Object.keys(resolvedFrom).length) {
1463
- configSnapshot.__resolvedFrom = resolvedFrom;
1464
- }
1465
- // ADR-0044 round numbering: rounds of a revise loop share the run — count
1466
- // this (run, node)'s prior requests; the new one is round N+1. Stamped on
1467
- // the snapshot (precedent: __flowLabel), so no schema migration.
1468
- try {
1469
- const prior = await this.engine.find('sys_approval_request', {
1470
- where: { flow_run_id: input.runId, flow_node_id: input.nodeId }, limit: 500, context: SYSTEM_CTX,
1471
- });
1472
- const n = Array.isArray(prior) ? prior.length : 0;
1473
- if (n > 0) configSnapshot.__round = n + 1;
1474
- } catch { /* round display is best-effort */ }
1475
- const row: any = {
1476
- id,
1477
- process_name: processName,
1478
- object_name: input.object,
1479
- record_id: input.recordId,
1480
- submitter_id: input.submitterId ?? context.userId ?? null,
1481
- status: 'pending',
1482
- current_step: input.nodeId,
1483
- current_step_index: 0,
1484
- pending_approvers: approvers.join(','),
1485
- payload_json: input.record != null ? JSON.stringify(input.record) : null,
1486
- flow_run_id: input.runId,
1487
- flow_node_id: input.nodeId,
1488
- node_config_json: JSON.stringify(configSnapshot),
1489
- organization_id: ctxOrg,
1490
- created_at: now,
1491
- updated_at: now,
1492
- };
1493
- await this.engine.insert('sys_approval_request', row, { context: SYSTEM_CTX });
1494
- await this.syncApproverIndex(id, approvers, ctxOrg, now);
1495
- await this.engine.insert('sys_approval_action', {
1496
- id: uid('aact'), request_id: id, organization_id: ctxOrg,
1497
- step_name: input.nodeId, step_index: 0, action: 'submit',
1498
- actor_id: input.submitterId ?? context.userId ?? null, comment: null, created_at: now,
1499
- }, { context: SYSTEM_CTX });
1500
-
1501
- // OOO substitution audit + notification (#1322 M4). Each hop that rerouted
1502
- // an approver away from an out-of-office user is recorded on the request's
1503
- // audit trail (a system action, no human actor) and notified to both the
1504
- // delegate — who now owns the slot — and the skipped approver.
1505
- for (const sub of substitutions) {
1506
- await this.engine.insert('sys_approval_action', {
1507
- id: uid('aact'), request_id: id, organization_id: ctxOrg,
1508
- step_name: input.nodeId, step_index: 0, action: 'ooo_substitute',
1509
- actor_id: null,
1510
- comment: `${sub.from} → ${sub.to}${sub.reason ? ` — ${sub.reason}` : ''}`,
1511
- created_at: now,
1512
- }, { context: SYSTEM_CTX });
1513
- await this.notify({
1514
- topic: 'approval.ooo_substituted',
1515
- audience: [sub.to],
1516
- source: { object: 'sys_approval_request', id },
1517
- dedupKey: `approval-ooo-${id}-${sub.to}`,
1518
- payload: {
1519
- title: 'Approval routed to you (out-of-office cover)',
1520
- message: `You are covering an approval on ${input.object}/${input.recordId} while ${sub.from} is out of office.`,
1521
- actionUrl: '/system/approvals',
1522
- },
1523
- });
1524
- await this.notify({
1525
- topic: 'approval.ooo_skipped',
1526
- audience: [sub.from],
1527
- source: { object: 'sys_approval_request', id },
1528
- dedupKey: `approval-ooo-skip-${id}-${sub.from}`,
1529
- payload: {
1530
- title: 'Approval routed to your delegate',
1531
- message: `An approval on ${input.object}/${input.recordId} was routed to ${sub.to} while you are out of office.`,
1532
- actionUrl: '/system/approvals',
1533
- },
1534
- });
1535
- }
1536
-
1537
- // Record lock (when `lockRecord !== false`) is enforced by the beforeUpdate
1538
- // hook keyed on the now-pending request; no extra write needed here.
1539
- if (input.config.approvalStatusField) {
1540
- // Attributed to whoever the row itself calls the submitter (#3783), so
1541
- // there is exactly one answer to "who submitted this". Not the
1542
- // {@link actingUserId} route: `submitterId` is server-supplied here (the
1543
- // approval node passes the run's own trigger user) and unreachable from a
1544
- // request body, so it carries none of the caller-controlled risk that rule
1545
- // exists for — and it already resolves to `context.userId` in every
1546
- // first-party path.
1547
- await this.mirrorStatusField(
1548
- input.object, input.recordId, input.config.approvalStatusField, 'pending',
1549
- row.submitter_id ?? null,
1550
- );
1551
- }
1552
-
1553
- return rowFromRequest(row);
1554
- }
1555
-
1556
- /**
1557
- * True when the approve tally satisfies the node's `behavior` (#3266):
1558
- * - `unanimous` — every resolved approver approved.
1559
- * - `quorum` — at least `minApprovals` distinct approvals (default = all).
1560
- * - `per_group` — every group reached `minApprovals` approvals (default 1).
1561
- * Thresholds are clamped to the resolvable count / group size, so a mis-set
1562
- * value can never deadlock a request.
1563
- */
1564
- private isApprovalSatisfied(
1565
- behavior: string,
1566
- config: ApprovalNodeConfig,
1567
- original: string[],
1568
- groupMap: Record<string, string[]>,
1569
- approved: Set<string>,
1570
- ): boolean {
1571
- if (behavior === 'unanimous') {
1572
- return original.length > 0 && original.every(a => approved.has(a));
1573
- }
1574
- if (behavior === 'quorum') {
1575
- const n = original.length || 1;
1576
- const need = Math.min(Math.max(1, config.minApprovals ?? n), n);
1577
- // Count distinct approvals (robust to OOO/reassign changing who holds a slot).
1578
- return approved.size >= need;
1579
- }
1580
- if (behavior === 'per_group') {
1581
- const perGroupNeed = Math.max(1, config.minApprovals ?? 1);
1582
- const size: Record<string, number> = {};
1583
- for (const gs of Object.values(groupMap)) for (const g of gs) size[g] = (size[g] ?? 0) + 1;
1584
- const groups = Object.keys(size);
1585
- if (!groups.length) return true; // nothing to gate
1586
- const got: Record<string, number> = {};
1587
- for (const a of approved) for (const g of (groupMap[a] ?? [])) got[g] = (got[g] ?? 0) + 1;
1588
- return groups.every(g => (got[g] ?? 0) >= Math.min(perGroupNeed, size[g]));
1589
- }
1590
- return true; // first_response and unknown → first approval finalizes
1591
- }
1592
-
1593
- /**
1594
- * Record a decision on a node-driven request. Honours the node's `behavior`
1595
- * (#3266): `first_response` finalizes on the first approval; `unanimous`,
1596
- * `quorum`, and `per_group` hold the request open until their tally is met
1597
- * (see {@link ApprovalService.isApprovalSatisfied}). A rejection always
1598
- * finalizes the node (one veto). When the request finalizes, returns the
1599
- * suspended run id + node id so the caller (or {@link ApprovalService.decide})
1600
- * can resume the flow down the matching branch.
1601
- */
1602
- async decideNode(
1603
- requestId: string,
1604
- input: { decision: 'approve' | 'reject'; actorId: string; comment?: string; attachments?: string[]; outputs?: Record<string, unknown> },
1605
- context: SharingExecutionContext,
1606
- ): Promise<{ request: ApprovalRequestRow; runId: string | null; nodeId: string | null; finalized: boolean; decision: 'approve' | 'reject'; outputs?: Record<string, unknown> }> {
1607
- if (!requestId) throw new Error('VALIDATION_FAILED: requestId is required');
1608
- const actorId = await this.resolveActor(input?.actorId, context);
1609
- if (input.decision !== 'approve' && input.decision !== 'reject') {
1610
- throw new Error('VALIDATION_FAILED: decision must be approve|reject');
1611
- }
1612
-
1613
- // Read the raw row to reach flow_* correlation + the node config snapshot.
1614
- const rawRows = await this.engine.find('sys_approval_request', {
1615
- where: { id: requestId }, limit: 1, context: SYSTEM_CTX,
1616
- });
1617
- const raw: any = Array.isArray(rawRows) ? rawRows[0] : null;
1618
- if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
1619
- if (raw.status !== 'pending') throw new Error(`INVALID_STATE: request is ${raw.status}`);
1620
-
1621
- const pendingApprovers = csvSplit(raw.pending_approvers);
1622
- // A privileged admin may override a stuck request (#3424) even when they
1623
- // hold no slot — the escape hatch for an approval routed to an unstaffed
1624
- // position or to approvers who have all left.
1625
- const isOverride = this.isOverrideActor(context, raw.organization_id ?? null);
1626
- const isSlotHolder = pendingApprovers.includes(actorId);
1627
- if (!isSlotHolder && !isOverride) {
1628
- throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
1629
- }
1630
-
1631
- const config = parseJson<ApprovalNodeConfig>(raw.node_config_json, { approvers: [], behavior: 'first_response' } as any);
1632
- const org = raw.organization_id ?? null;
1633
- const nodeId: string | null = raw.flow_node_id ?? raw.current_step ?? null;
1634
- const runId: string | null = raw.flow_run_id ?? null;
1635
- const now = this.clock.now().toISOString();
1636
-
1637
- // #3447 P2: decision outputs — validated BEFORE any write (audit included)
1638
- // so an out-of-contract payload rejects atomically. The trust model is a
1639
- // `screen` node's: the AUTHOR declares the keys (`config.decisionOutputs`),
1640
- // the approver only fills values. A decision carrying undeclared keys is a
1641
- // caller bug; `decision`/`requestId` are reserved by the resume envelope.
1642
- const outputKeys = input.outputs ? Object.keys(input.outputs) : [];
1643
- let acceptedOutputs: Record<string, unknown> | undefined;
1644
- if (outputKeys.length) {
1645
- // Typed declarations and bare keys whitelist identically — one
1646
- // normalizer (spec) is the single reader of the union shape.
1647
- const declared = normalizeDecisionOutputs((config as any).decisionOutputs).map(d => d.key);
1648
- if (!declared.length) {
1649
- throw new Error(
1650
- `VALIDATION_FAILED: this approval node declares no decisionOutputs — outputs are not accepted. `
1651
- + `Declare the keys on the node config (decisionOutputs: [${outputKeys.map(k => `'${k}'`).join(', ')}]) `
1652
- + `to let approvers hand them to the flow.`,
1653
- );
1654
- }
1655
- const reserved = outputKeys.filter(k => k === 'decision' || k === 'requestId');
1656
- if (reserved.length) {
1657
- throw new Error(
1658
- `VALIDATION_FAILED: decision output key(s) \`${reserved.join('`, `')}\` are reserved by the resume `
1659
- + `envelope — pick different names.`,
1660
- );
1661
- }
1662
- const undeclared = outputKeys.filter(k => !declared.includes(k));
1663
- if (undeclared.length) {
1664
- throw new Error(
1665
- `VALIDATION_FAILED: decision output key(s) \`${undeclared.join('`, `')}\` are not declared on this `
1666
- + `node — declared keys: ${declared.map(k => `'${k}'`).join(', ') || '(none)'}.`,
1667
- );
1668
- }
1669
- acceptedOutputs = { ...input.outputs };
1670
- }
1671
-
1672
- // Audit the decision first so the quorum/per_group tally below sees it.
1673
- await this.engine.insert('sys_approval_action', {
1674
- id: uid('aact'), request_id: requestId, organization_id: org,
1675
- step_name: nodeId, step_index: 0, action: input.decision,
1676
- actor_id: actorId, comment: input.comment ?? null,
1677
- attachments: input.attachments?.length ? input.attachments : null,
1678
- created_at: now,
1679
- }, { context: SYSTEM_CTX });
1680
-
1681
- // Multi-approver aggregation on approve (#3266). A rejection always
1682
- // finalizes the node (one veto), so only the approve path can hold it open.
1683
- // `first_response` finalizes on the first approval (falls straight through).
1684
- const behavior = config.behavior ?? 'first_response';
1685
- // A privileged override (an admin rescuing a stuck request, #3424) is an
1686
- // authoritative decision, not one vote among the resolved slate — it
1687
- // finalizes the node immediately, regardless of `unanimous`/`quorum`/
1688
- // `per_group`. Only a real slot holder's approval feeds the multi-approver
1689
- // tally below.
1690
- if (input.decision === 'approve' && behavior !== 'first_response' && isSlotHolder) {
1691
- const acts = await this.engine.find('sys_approval_action', {
1692
- where: { request_id: requestId, step_index: 0, action: 'approve' }, limit: 1000, context: SYSTEM_CTX,
1693
- });
1694
- const approved = new Set<string>((acts ?? []).map((a: any) => String(a.actor_id ?? '')).filter(Boolean));
1695
-
1696
- // Tally against the OPEN-time snapshot (already OOO-substituted) for
1697
- // every behavior that carries one. Re-resolution survives ONLY as the
1698
- // back-compat path for requests opened before the snapshot existed —
1699
- // it cannot ever run for an `expression` approver (#3447 P2: decide has
1700
- // no flow variables to evaluate against; open time is the only
1701
- // resolution point), and those always have a snapshot.
1702
- const snapshotGroups = (config as any).__approverGroups as Record<string, string[]> | undefined;
1703
- let original: string[];
1704
- let groupMap: Record<string, string[]>;
1705
- if (snapshotGroups) {
1706
- groupMap = snapshotGroups;
1707
- original = Object.keys(snapshotGroups);
1708
- } else {
1709
- original = await this.expandApprovers(
1710
- { approvers: config.approvers }, parseJson(raw.payload_json, undefined), org,
1711
- );
1712
- groupMap = {};
1713
- }
1714
-
1715
- if (!this.isApprovalSatisfied(behavior, config, original, groupMap, approved)) {
1716
- const stillPending = original.filter(a => !approved.has(a));
1717
- await this.engine.update('sys_approval_request', {
1718
- id: requestId, pending_approvers: stillPending.join(','), updated_at: now,
1719
- // #3447 P2: a mid-tally approval may carry outputs too (unanimous /
1720
- // per_group co-sign, each approver contributing their declared keys)
1721
- // — accumulate them on the snapshot so the FINALIZING decision hands
1722
- // the merged set to the flow.
1723
- ...(acceptedOutputs ? {
1724
- node_config_json: JSON.stringify({
1725
- ...config,
1726
- __decisionOutputs: { ...((config as any).__decisionOutputs ?? {}), ...acceptedOutputs },
1727
- }),
1728
- } : {}),
1729
- }, { context: SYSTEM_CTX });
1730
- await this.syncApproverIndex(requestId, stillPending, org, now);
1731
- const fresh = await this.readBackRequest(requestId, context);
1732
- return { request: fresh!, runId, nodeId, finalized: false, decision: input.decision };
1733
- }
1734
- }
1735
-
1736
- const finalStatus = input.decision === 'approve' ? 'approved' : 'rejected';
1737
- // #3447 P2: the full accumulated output set — earlier co-sign votes' plus
1738
- // this finalizing decision's — resumes the run and stays snapshotted for
1739
- // the audit trail ("what did the approvers hand the flow").
1740
- const mergedOutputs: Record<string, unknown> | undefined =
1741
- acceptedOutputs || (config as any).__decisionOutputs
1742
- ? { ...((config as any).__decisionOutputs ?? {}), ...(acceptedOutputs ?? {}) }
1743
- : undefined;
1744
- await this.engine.update('sys_approval_request', {
1745
- id: requestId, status: finalStatus, pending_approvers: null, completed_at: now, updated_at: now,
1746
- ...(mergedOutputs ? {
1747
- node_config_json: JSON.stringify({ ...config, __decisionOutputs: mergedOutputs }),
1748
- } : {}),
1749
- }, { context: SYSTEM_CTX });
1750
- await this.syncApproverIndex(requestId, [], org, now);
1751
- if (config.approvalStatusField) {
1752
- await this.mirrorStatusField(
1753
- raw.object_name, raw.record_id, config.approvalStatusField, finalStatus,
1754
- actingUserId(context),
1755
- );
1756
- }
1757
- const fresh = await this.readBackRequest(requestId, context);
1758
- return { request: fresh!, runId, nodeId, finalized: true, decision: input.decision, outputs: mergedOutputs };
1759
- }
1760
-
1761
- /**
1762
- * Continue the owning flow run after an outcome this service has already
1763
- * authorized and written down (#3801).
1764
- *
1765
- * The `approval` node declares `resumeAuthority: 'service'`, so the engine
1766
- * refuses any resume of an approval suspension that does not carry
1767
- * {@link RESUME_AUTHORITY_SERVICE}. Every approvals-side resume goes through
1768
- * here so the marker is stamped in ONE place — a new outcome path cannot
1769
- * quietly ship a resume that the gate then rejects at runtime, and nothing
1770
- * in this file hands the marker to a caller-supplied signal.
1771
- *
1772
- * Callers still guard on `typeof this.automation?.resume === 'function'`
1773
- * (approvals runs fine with no automation attached) and keep their own
1774
- * try/catch, because what a failed resume means differs per path.
1775
- */
1776
- private async serviceResume(
1777
- runId: string,
1778
- signal: { output?: Record<string, unknown>; branchLabel?: string },
1779
- ): Promise<void> {
1780
- await this.automation!.resume!(runId, { ...signal, [RESUME_AUTHORITY_SERVICE]: true });
1781
- }
1782
-
1783
- /**
1784
- * Public contract entrypoint (ADR-0019). Records a decision on a node-driven
1785
- * request via {@link ApprovalService.decideNode} and, when it finalizes,
1786
- * resumes the owning flow run down the matching `approve` / `reject` edge.
1787
- */
1788
- async decide(
1789
- requestId: string,
1790
- input: ApprovalDecisionInput,
1791
- context: SharingExecutionContext,
1792
- ): Promise<ApprovalDecisionResult> {
1793
- const result = await this.decideNode(requestId, input, context);
1794
-
1795
- let resumed = false;
1796
- if (result.finalized && result.runId && typeof this.automation?.resume === 'function') {
1797
- const branchLabel = result.decision === 'approve'
1798
- ? APPROVAL_BRANCH_LABELS.approve
1799
- : APPROVAL_BRANCH_LABELS.reject;
1800
- try {
1801
- await this.serviceResume(result.runId, {
1802
- branchLabel,
1803
- // #3447 P2: accepted decision outputs ride the resume envelope and
1804
- // land as `<nodeId>.<key>` flow variables — a later approval node's
1805
- // `expression` approver reads them as `vars.<nodeId>.<key>`.
1806
- // Reserved keys are spread LAST so no output can shadow them (the
1807
- // whitelist already rejects them; this is defense in depth).
1808
- output: { ...(result.outputs ?? {}), decision: result.decision, requestId },
1809
- });
1810
- resumed = true;
1811
- } catch (err: any) {
1812
- this.logger?.warn?.('[approvals] resume after decision failed', {
1813
- request: requestId, run: result.runId, error: err?.message ?? String(err),
1814
- });
1815
- }
1816
- }
1817
-
1818
- return {
1819
- request: result.request,
1820
- finalized: result.finalized,
1821
- decision: result.decision,
1822
- runId: result.runId,
1823
- resumed,
1824
- };
1825
- }
1826
-
1827
- /**
1828
- * Withdraw a pending request (submitter only). Finalises the row as
1829
- * `recalled`, releases the record lock (keyed on pending status), mirrors
1830
- * the status field when configured, and resumes the owning flow run down
1831
- * the `reject` branch with `output.decision = 'recall'` — leaving the run
1832
- * suspended forever would leak it.
1833
- *
1834
- * ADR-0044: also valid on the LATEST `returned` request of its run — the
1835
- * submitter abandons the revision window instead of resubmitting. The run
1836
- * is then paused at the revise wait node (no reject edge), so it is
1837
- * terminally cancelled via {@link ApprovalResumeSurface.cancelRun} rather
1838
- * than resumed.
1839
- */
1840
- async recall(
1841
- requestId: string,
1842
- input: ApprovalRecallInput,
1843
- context: SharingExecutionContext,
1844
- ): Promise<ApprovalRecallResult> {
1845
- if (!requestId) throw new Error('VALIDATION_FAILED: requestId is required');
1846
- const actorId = await this.resolveActor(input?.actorId, context);
1847
-
1848
- const rawRows = await this.engine.find('sys_approval_request', {
1849
- where: { id: requestId }, limit: 1, context: SYSTEM_CTX,
1850
- });
1851
- const raw: any = Array.isArray(rawRows) ? rawRows[0] : null;
1852
- if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
1853
- const inReviseWindow = raw.status === 'returned';
1854
- if (raw.status !== 'pending' && !inReviseWindow) {
1855
- throw new Error(`INVALID_STATE: request is ${raw.status}`);
1856
- }
1857
- // The submitter withdraws their own request; a privileged admin may recall
1858
- // any pending request to release a stuck record (#3424).
1859
- if (!this.isOverrideActor(context, raw.organization_id ?? null)
1860
- && raw.submitter_id && String(raw.submitter_id) !== String(actorId)) {
1861
- throw new Error(`FORBIDDEN: only the submitter may recall this request`);
1862
- }
1863
- // A returned request is only recallable while it is still the run's live
1864
- // frontier — a resubmitted (or later-node) request supersedes it.
1865
- if (inReviseWindow) await this.assertLatestForRun(raw);
1866
-
1867
- const config = parseJson<ApprovalNodeConfig>(raw.node_config_json, { approvers: [], behavior: 'first_response' } as any);
1868
- const org = raw.organization_id ?? null;
1869
- const nodeId: string | null = raw.flow_node_id ?? raw.current_step ?? null;
1870
- const runId: string | null = raw.flow_run_id ?? null;
1871
- const now = this.clock.now().toISOString();
1872
-
1873
- await this.engine.insert('sys_approval_action', {
1874
- id: uid('aact'), request_id: requestId, organization_id: org,
1875
- step_name: nodeId, step_index: 0, action: 'recall',
1876
- actor_id: actorId, comment: input.comment ?? null, created_at: now,
1877
- }, { context: SYSTEM_CTX });
1878
-
1879
- await this.engine.update('sys_approval_request', {
1880
- id: requestId, status: 'recalled', pending_approvers: null, completed_at: now, updated_at: now,
1881
- }, { context: SYSTEM_CTX });
1882
- await this.syncApproverIndex(requestId, [], org, now);
1883
- if (config.approvalStatusField) {
1884
- await this.mirrorStatusField(
1885
- raw.object_name, raw.record_id, config.approvalStatusField, 'recalled',
1886
- actingUserId(context),
1887
- );
1888
- }
1889
-
1890
- let resumed = false;
1891
- if (inReviseWindow) {
1892
- // ADR-0044: the run is paused at the revise wait node, which has no
1893
- // reject out-edge to resume down — terminally cancel it instead.
1894
- if (runId && typeof this.automation?.cancelRun === 'function') {
1895
- try {
1896
- await this.automation.cancelRun(runId, `approval request ${requestId} recalled during revision`);
1897
- } catch (err: any) {
1898
- this.logger?.warn?.('[approvals] cancelRun after revise-window recall failed', {
1899
- request: requestId, run: runId, error: err?.message ?? String(err),
1900
- });
1901
- }
1902
- }
1903
- } else if (runId && typeof this.automation?.resume === 'function') {
1904
- try {
1905
- await this.serviceResume(runId, {
1906
- branchLabel: APPROVAL_BRANCH_LABELS.reject,
1907
- output: { decision: 'recall', requestId },
1908
- });
1909
- resumed = true;
1910
- } catch (err: any) {
1911
- this.logger?.warn?.('[approvals] resume after recall failed', {
1912
- request: requestId, run: runId, error: err?.message ?? String(err),
1913
- });
1914
- }
1915
- }
1916
-
1917
- const fresh = await this.readBackRequest(requestId, context);
1918
- return { request: fresh!, runId, resumed };
1919
- }
1920
-
1921
- // ── Send back for revision / resubmit (ADR-0044) ─────────────
1922
-
1923
- /**
1924
- * ADR-0044 send back for revision. Finalises the pending request as
1925
- * `returned` (a third terminal state — approver-initiated rework, distinct
1926
- * from submitter-initiated `recalled`) and resumes the owning flow run down
1927
- * its `revise` edge to a wait point: the record lock (keyed on `pending`)
1928
- * releases, the submitter reworks the data, then {@link resubmit}s.
1929
- *
1930
- * Requires the approval node to declare a `revise` out-edge — validated
1931
- * BEFORE any mutation, because resuming with an unmatched `branchLabel`
1932
- * falls back to *all* out-edges. Past the node's `maxRevisions` budget the
1933
- * request auto-rejects instead (resumes down `reject` with
1934
- * `output.autoRejected = true`) so instances cannot orbit forever.
1935
- */
1936
- async sendBack(
1937
- requestId: string,
1938
- input: ApprovalSendBackInput,
1939
- context: SharingExecutionContext,
1940
- ): Promise<ApprovalSendBackResult> {
1941
- const actorId = await this.resolveActor(input?.actorId, context);
1942
- const raw = await this.loadPendingRow(requestId);
1943
- const pending = csvSplit(raw.pending_approvers);
1944
- if (!context.isSystem && !pending.includes(actorId)) {
1945
- throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
1946
- }
1947
-
1948
- const config = parseJson<ApprovalNodeConfig>(raw.node_config_json, { approvers: [], behavior: 'first_response' } as any);
1949
- const org = raw.organization_id ?? null;
1950
- const nodeId: string | null = raw.flow_node_id ?? raw.current_step ?? null;
1951
- const runId: string | null = raw.flow_run_id ?? null;
1952
-
1953
- await this.assertReviseEdge(raw, nodeId);
1954
-
1955
- const now = this.clock.now().toISOString();
1956
- const maxRevisions = typeof (config as any).maxRevisions === 'number' ? (config as any).maxRevisions : 3;
1957
- let priorSendBacks = 0;
1958
- if (runId && nodeId) {
1959
- const siblings = await this.engine.find('sys_approval_request', {
1960
- where: { flow_run_id: runId, flow_node_id: nodeId, status: 'returned' }, limit: 500, context: SYSTEM_CTX,
1961
- });
1962
- priorSendBacks = Array.isArray(siblings) ? siblings.length : 0;
1963
- }
1964
-
1965
- // Audit the revise intent first (audit-first, like decideNode) — on the
1966
- // auto-reject path the trail then reads `revise → reject`, preserving
1967
- // what the approver actually asked for.
1968
- await this.engine.insert('sys_approval_action', {
1969
- id: uid('aact'), request_id: requestId, organization_id: org,
1970
- step_name: nodeId, step_index: 0, action: 'revise',
1971
- actor_id: actorId, comment: input.comment ?? null, created_at: now,
1972
- }, { context: SYSTEM_CTX });
1973
-
1974
- if (priorSendBacks >= maxRevisions) {
1975
- // Revision budget exhausted — auto-reject (ADR-0044 loop guard).
1976
- await this.engine.insert('sys_approval_action', {
1977
- id: uid('aact'), request_id: requestId, organization_id: org,
1978
- step_name: nodeId, step_index: 0, action: 'reject',
1979
- actor_id: actorId,
1980
- comment: `Auto-rejected: revision limit (${maxRevisions}) exceeded`, created_at: now,
1981
- }, { context: SYSTEM_CTX });
1982
- await this.engine.update('sys_approval_request', {
1983
- id: requestId, status: 'rejected', pending_approvers: null, completed_at: now, updated_at: now,
1984
- }, { context: SYSTEM_CTX });
1985
- await this.syncApproverIndex(requestId, [], org, now);
1986
- if (config.approvalStatusField) {
1987
- await this.mirrorStatusField(
1988
- raw.object_name, raw.record_id, config.approvalStatusField, 'rejected',
1989
- actingUserId(context),
1990
- );
1991
- }
1992
- let resumed = false;
1993
- if (runId && typeof this.automation?.resume === 'function') {
1994
- try {
1995
- await this.serviceResume(runId, {
1996
- branchLabel: APPROVAL_BRANCH_LABELS.reject,
1997
- output: { decision: 'reject', autoRejected: true, requestId },
1998
- });
1999
- resumed = true;
2000
- } catch (err: any) {
2001
- this.logger?.warn?.('[approvals] resume after auto-reject failed', {
2002
- request: requestId, run: runId, error: err?.message ?? String(err),
2003
- });
2004
- }
2005
- }
2006
- if (raw.submitter_id) {
2007
- await this.notify({
2008
- topic: 'approval.returned',
2009
- audience: [String(raw.submitter_id)],
2010
- actorId: actorId,
2011
- source: { object: 'sys_approval_request', id: requestId },
2012
- payload: {
2013
- title: 'Approval auto-rejected',
2014
- message: `Your ${raw.object_name}/${raw.record_id} exceeded the revision limit (${maxRevisions}) and was rejected.`,
2015
- actionUrl: '/system/approvals',
2016
- },
2017
- });
2018
- }
2019
- const fresh = await this.readBackRequest(requestId, context);
2020
- return { request: fresh!, runId, resumed, autoRejected: true };
2021
- }
2022
-
2023
- await this.engine.update('sys_approval_request', {
2024
- id: requestId, status: 'returned', pending_approvers: null, completed_at: now, updated_at: now,
2025
- }, { context: SYSTEM_CTX });
2026
- await this.syncApproverIndex(requestId, [], org, now);
2027
- if (config.approvalStatusField) {
2028
- await this.mirrorStatusField(
2029
- raw.object_name, raw.record_id, config.approvalStatusField, 'returned',
2030
- actingUserId(context),
2031
- );
2032
- }
2033
-
2034
- let resumed = false;
2035
- if (runId && typeof this.automation?.resume === 'function') {
2036
- try {
2037
- await this.serviceResume(runId, {
2038
- branchLabel: APPROVAL_BRANCH_LABELS.revise,
2039
- output: { decision: 'revise', requestId },
2040
- });
2041
- resumed = true;
2042
- } catch (err: any) {
2043
- this.logger?.warn?.('[approvals] resume after send-back failed', {
2044
- request: requestId, run: runId, error: err?.message ?? String(err),
2045
- });
2046
- }
2047
- }
2048
-
2049
- if (raw.submitter_id) {
2050
- await this.notify({
2051
- topic: 'approval.returned',
2052
- audience: [String(raw.submitter_id)],
2053
- actorId: actorId,
2054
- source: { object: 'sys_approval_request', id: requestId },
2055
- payload: {
2056
- title: 'Sent back for revision',
2057
- message: input.comment?.trim() || `Your ${raw.object_name}/${raw.record_id} needs rework before it can be approved.`,
2058
- actionUrl: '/system/approvals',
2059
- },
2060
- });
2061
- }
2062
-
2063
- const fresh = await this.readBackRequest(requestId, context);
2064
- return { request: fresh!, runId, resumed };
2065
- }
2066
-
2067
- /**
2068
- * ADR-0044 resubmit after rework. Valid on the LATEST `returned` request of
2069
- * its run, submitter-only. Audits `resubmit` on the returned (round-N)
2070
- * request and resumes the run from the revise wait node; traversal walks
2071
- * the declared back-edge into the approval node, whose executor opens the
2072
- * round-N+1 request — fresh approver slate, record re-locks.
2073
- */
2074
- async resubmit(
2075
- requestId: string,
2076
- input: ApprovalResubmitInput,
2077
- context: SharingExecutionContext,
2078
- ): Promise<ApprovalResubmitResult> {
2079
- const actorId = await this.resolveActor(input?.actorId, context);
2080
- const rawRows = await this.engine.find('sys_approval_request', {
2081
- where: { id: requestId }, limit: 1, context: SYSTEM_CTX,
2082
- });
2083
- const raw: any = Array.isArray(rawRows) ? rawRows[0] : null;
2084
- if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
2085
- if (raw.status !== 'returned') {
2086
- throw new Error(`INVALID_STATE: request is ${raw.status} (resubmit applies to returned requests)`);
2087
- }
2088
- if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(actorId)) {
2089
- throw new Error('FORBIDDEN: only the submitter may resubmit');
2090
- }
2091
- await this.assertLatestForRun(raw);
2092
-
2093
- // A colliding pending request on the same record (e.g. a record-change
2094
- // trigger re-fired off an edit made inside the revise window) would make
2095
- // the approval node's re-entry fail AFTER the engine consumed the
2096
- // suspension — permanently killing the run. Refuse up front instead; the
2097
- // submitter resolves the collision (recall the other request) first.
2098
- const colliding = await this.engine.find('sys_approval_request', {
2099
- where: { object_name: raw.object_name, record_id: raw.record_id, status: 'pending' },
2100
- limit: 1, context: SYSTEM_CTX,
2101
- });
2102
- if (Array.isArray(colliding) && colliding[0]) {
2103
- throw new Error(
2104
- `DUPLICATE_REQUEST: another approval request is already pending on ${raw.object_name}/${raw.record_id} — resolve it before resubmitting`,
2105
- );
2106
- }
2107
-
2108
- const org = raw.organization_id ?? null;
2109
- const nodeId: string | null = raw.flow_node_id ?? raw.current_step ?? null;
2110
- const runId: string | null = raw.flow_run_id ?? null;
2111
- const now = this.clock.now().toISOString();
2112
-
2113
- await this.engine.insert('sys_approval_action', {
2114
- id: uid('aact'), request_id: requestId, organization_id: org,
2115
- step_name: nodeId, step_index: 0, action: 'resubmit',
2116
- actor_id: actorId, comment: input.comment ?? null, created_at: now,
2117
- }, { context: SYSTEM_CTX });
2118
-
2119
- // The next round only exists if this resume lands — surface `resumed`
2120
- // honestly so a stuck run is visible instead of silently swallowed.
2121
- let resumed = false;
2122
- if (runId && typeof this.automation?.resume === 'function') {
2123
- try {
2124
- await this.serviceResume(runId, {
2125
- branchLabel: APPROVAL_BRANCH_LABELS.resubmit,
2126
- output: { resubmitted: true, requestId },
2127
- });
2128
- resumed = true;
2129
- } catch (err: any) {
2130
- this.logger?.warn?.('[approvals] resume after resubmit failed', {
2131
- request: requestId, run: runId, error: err?.message ?? String(err),
2132
- });
2133
- }
2134
- }
2135
-
2136
- const fresh = await this.readBackRequest(requestId, context);
2137
- return { request: fresh!, runId, resumed };
2138
- }
2139
-
2140
- /**
2141
- * ADR-0044 guard: the flow's approval node must declare a `revise`
2142
- * out-edge before send-back is allowed — the engine's branch-label fallback
2143
- * (no matching label ⇒ ALL out-edges) must never be reachable from a user
2144
- * action.
2145
- */
2146
- private async assertReviseEdge(raw: any, nodeId: string | null): Promise<void> {
2147
- const processName = String(raw.process_name ?? '');
2148
- const flowName = processName.startsWith('flow:') ? processName.slice('flow:'.length) : undefined;
2149
- if (!flowName || !nodeId || typeof this.automation?.getFlow !== 'function') {
2150
- throw new Error('VALIDATION_FAILED: send-back requires the owning flow definition (automation engine unavailable)');
2151
- }
2152
- const flow: any = await this.automation.getFlow(flowName);
2153
- const hasRevise = Array.isArray(flow?.edges)
2154
- && flow.edges.some((e: any) => e?.source === nodeId && e?.label === APPROVAL_BRANCH_LABELS.revise);
2155
- if (!hasRevise) {
2156
- throw new Error(
2157
- `VALIDATION_FAILED: approval node '${nodeId}' has no '${APPROVAL_BRANCH_LABELS.revise}' out-edge — ` +
2158
- 'the flow does not support send-back for revision',
2159
- );
2160
- }
2161
- }
2162
-
2163
- /**
2164
- * ADR-0044 guard: a `returned` request is only actionable (resubmit /
2165
- * recall) while it is still the newest request on its run — a later round
2166
- * or a later node's request supersedes it.
2167
- */
2168
- private async assertLatestForRun(raw: any): Promise<void> {
2169
- const runId = raw.flow_run_id;
2170
- if (!runId) return;
2171
- // SortNode's key is `order` (spec/data/query.zod.ts) — `direction` would
2172
- // silently default to ascending and return the OLDEST row.
2173
- const rows = await this.engine.find('sys_approval_request', {
2174
- where: { flow_run_id: runId },
2175
- orderBy: [{ field: 'created_at', order: 'desc' }], limit: 1, context: SYSTEM_CTX,
2176
- });
2177
- const latest: any = Array.isArray(rows) ? rows[0] : null;
2178
- if (latest && String(latest.id) !== String(raw.id)) {
2179
- throw new Error('INVALID_STATE: a newer approval request supersedes this one');
2180
- }
2181
- }
2182
-
2183
- // ── Thread interactions (no flow movement) ───────────────────
2184
-
2185
- /**
2186
- * Hand a pending-approver slot to someone else. `from` defaults to the
2187
- * actor itself; the actor must hold the slot being handed over (or be a
2188
- * system caller). A privileged admin (#3424) may reassign a request whose
2189
- * slate holds no real user — an unstaffed-position literal — by handing the
2190
- * whole request to a real approver, rescuing it from the locked dead-end.
2191
- * Audits `reassign` and notifies the new approver.
2192
- */
2193
- async reassign(
2194
- requestId: string,
2195
- input: { actorId: string; to: string; from?: string; comment?: string },
2196
- context: SharingExecutionContext,
2197
- ): Promise<{ request: ApprovalRequestRow }> {
2198
- const actorId = await this.resolveActor(input?.actorId, context);
2199
- const to = String(input?.to ?? '').trim();
2200
- if (!to) throw new Error('VALIDATION_FAILED: `to` (new approver) is required');
2201
- const raw = await this.loadPendingRow(requestId);
2202
-
2203
- const pending = csvSplit(raw.pending_approvers);
2204
- if (pending.includes(to)) {
2205
- throw new Error(`VALIDATION_FAILED: '${to}' is already a pending approver`);
2206
- }
2207
- const isOverride = this.isOverrideActor(context, raw.organization_id ?? null);
2208
- const from = String(input.from ?? actorId).trim();
2209
- let next: string[];
2210
- if (pending.includes(from)) {
2211
- // Normal hand-off: the actor holds the slot being moved (or is a
2212
- // system/admin caller acting on a real holder's slot).
2213
- if (!context.isSystem && !isOverride && actorId !== from && !pending.includes(actorId)) {
2214
- throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
2215
- }
2216
- next = pending.map(a => (a === from ? to : a));
2217
- } else if (isOverride) {
2218
- // Admin rescue (#3424): the caller holds no slot — the slate is an
2219
- // unstaffed-position literal or a set of departed approvers. Reassign the
2220
- // whole request to a real approver so the normal decision flow can resume.
2221
- next = [to];
2222
- } else {
2223
- throw new Error(`FORBIDDEN: '${from}' is not a pending approver on this request`);
2224
- }
2225
- const now = this.clock.now().toISOString();
2226
- // Audit first, then mutate — mirrors decideNode(), so a failed audit
2227
- // write can never leave a moved slot without a trail.
2228
- await this.engine.insert('sys_approval_action', {
2229
- id: uid('aact'), request_id: requestId, organization_id: raw.organization_id ?? null,
2230
- step_name: raw.flow_node_id ?? raw.current_step ?? null, step_index: 0, action: 'reassign',
2231
- actor_id: actorId, comment: input.comment ?? `${from} → ${to}`, created_at: now,
2232
- }, { context: SYSTEM_CTX });
2233
- // per_group / quorum (#3266): carry the delegated slot's group membership to
2234
- // the new approver in the snapshot, so their approval still counts for the
2235
- // original group.
2236
- let configPatch: Record<string, unknown> = {};
2237
- try {
2238
- const cfg = parseJson<any>(raw.node_config_json, null);
2239
- const groups = cfg?.__approverGroups as Record<string, string[]> | undefined;
2240
- if (groups && groups[from] && !groups[to]) {
2241
- groups[to] = groups[from];
2242
- delete groups[from];
2243
- configPatch = { node_config_json: JSON.stringify(cfg) };
2244
- }
2245
- } catch { /* snapshot left untouched on parse failure */ }
2246
- await this.engine.update('sys_approval_request', {
2247
- id: requestId, pending_approvers: next.join(','), updated_at: now, ...configPatch,
2248
- }, { context: SYSTEM_CTX });
2249
- await this.syncApproverIndex(requestId, next, raw.organization_id ?? null, now);
2250
-
2251
- await this.notify({
2252
- topic: 'approval.reassigned',
2253
- audience: [to],
2254
- actorId: actorId,
2255
- source: { object: 'sys_approval_request', id: requestId },
2256
- dedupKey: `approval-reassign-${requestId}-${to}`,
2257
- payload: {
2258
- title: 'Approval handed to you',
2259
- message: `You are now an approver on ${raw.object_name}/${raw.record_id}.`,
2260
- actionUrl: '/system/approvals',
2261
- },
2262
- });
2263
-
2264
- const fresh = await this.readBackRequest(requestId, context);
2265
- return { request: fresh! };
2266
- }
2267
-
2268
- /**
2269
- * Submitter nudge — notify every pending approver. Throttled to one
2270
- * reminder per {@link REMIND_COOLDOWN_MS} per request.
2271
- */
2272
- async remind(
2273
- requestId: string,
2274
- input: { actorId: string; comment?: string },
2275
- context: SharingExecutionContext,
2276
- ): Promise<{ request: ApprovalRequestRow; notified: number }> {
2277
- const actorId = await this.resolveActor(input?.actorId, context);
2278
- const raw = await this.loadPendingRow(requestId);
2279
- if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(actorId)) {
2280
- throw new Error('FORBIDDEN: only the submitter may send reminders');
2281
- }
2282
-
2283
- const acts = await this.engine.find('sys_approval_action', {
2284
- where: { request_id: requestId, action: 'remind' },
2285
- orderBy: [{ field: 'created_at', order: 'desc' }], limit: 1, context: SYSTEM_CTX,
2286
- });
2287
- const last: any = Array.isArray(acts) ? acts[0] : null;
2288
- const now = this.clock.now();
2289
- if (last?.created_at && now.getTime() - Date.parse(last.created_at) < REMIND_COOLDOWN_MS) {
2290
- throw new Error('THROTTLED: a reminder was already sent recently');
2291
- }
2292
-
2293
- const pending = csvSplit(raw.pending_approvers);
2294
- const nowIso = now.toISOString();
2295
- await this.engine.insert('sys_approval_action', {
2296
- id: uid('aact'), request_id: requestId, organization_id: raw.organization_id ?? null,
2297
- step_name: raw.flow_node_id ?? raw.current_step ?? null, step_index: 0, action: 'remind',
2298
- actor_id: actorId, comment: input.comment ?? null, created_at: nowIso,
2299
- }, { context: SYSTEM_CTX });
2300
-
2301
- // Per-approver fan-out: concrete identities (user ids / emails) each get
2302
- // their OWN one-tap approve/reject links (ADR-0043); `role:*`-style
2303
- // literals can't carry a personal token and fall back to a plain nudge.
2304
- let notified = 0;
2305
- const concrete = pending.filter(a => a && !a.includes(':'));
2306
- const literals = pending.filter(a => a && a.includes(':'));
2307
- for (const approver of concrete) {
2308
- try {
2309
- const tokens = await this.issueActionTokens(requestId, approver);
2310
- notified += await this.notify({
2311
- topic: 'approval.reminder',
2312
- audience: [approver],
2313
- actorId: actorId,
2314
- source: { object: 'sys_approval_request', id: requestId },
2315
- dedupKey: `approval-remind-${requestId}-${nowIso}-${approver}`,
2316
- payload: {
2317
- title: 'Approval reminder',
2318
- message: `A decision on ${raw.object_name}/${raw.record_id} is still waiting on you.`,
2319
- actionUrl: '/system/approvals',
2320
- actions: [
2321
- { label: 'Approve', url: this.actionLinkUrl(tokens.approve) },
2322
- { label: 'Reject', url: this.actionLinkUrl(tokens.reject) },
2323
- ],
2324
- },
2325
- });
2326
- } catch (err: any) {
2327
- this.logger?.warn?.('[approvals] reminder with action links failed', {
2328
- request: requestId, approver, error: err?.message ?? String(err),
2329
- });
2330
- }
2331
- }
2332
- if (literals.length) {
2333
- notified += await this.notify({
2334
- topic: 'approval.reminder',
2335
- audience: literals,
2336
- actorId: actorId,
2337
- source: { object: 'sys_approval_request', id: requestId },
2338
- dedupKey: `approval-remind-${requestId}-${nowIso}`,
2339
- payload: {
2340
- title: 'Approval reminder',
2341
- message: `A decision on ${raw.object_name}/${raw.record_id} is still waiting on you.`,
2342
- actionUrl: '/system/approvals',
2343
- },
2344
- });
2345
- }
2346
-
2347
- const fresh = await this.readBackRequest(requestId, context);
2348
- return { request: fresh!, notified };
2349
- }
2350
-
2351
- // ── Actionable links (ADR-0043) ──────────────────────────────
2352
-
2353
- /** Build the session-less confirm-page URL for a raw token. */
2354
- actionLinkUrl(rawToken: string): string {
2355
- return `${this.publicBaseUrl}/api/v1/approvals/act?token=${encodeURIComponent(rawToken)}`;
2356
- }
2357
-
2358
- /**
2359
- * Issue one-tap approve/reject tokens for one approver on one pending
2360
- * request. Raw tokens are returned ONCE; only SHA-256 hashes are stored
2361
- * (`sys_approval_token`), so a DB leak yields no usable links.
2362
- */
2363
- async issueActionTokens(
2364
- requestId: string,
2365
- approverId: string,
2366
- opts?: { ttlMs?: number },
2367
- ): Promise<{ approve: string; reject: string }> {
2368
- if (!approverId?.trim()) throw new Error('VALIDATION_FAILED: approverId is required');
2369
- const raw = await this.loadPendingRow(requestId);
2370
- const pending = csvSplit(raw.pending_approvers);
2371
- if (!pending.includes(approverId)) {
2372
- throw new Error(`FORBIDDEN: '${approverId}' is not a pending approver on this request`);
2373
- }
2374
- const now = this.clock.now();
2375
- const expires = new Date(now.getTime() + (opts?.ttlMs ?? ACTION_TOKEN_TTL_MS)).toISOString();
2376
- const out = { approve: '', reject: '' };
2377
- for (const action of ['approve', 'reject'] as const) {
2378
- const rawToken = randomBytes(32).toString('base64url');
2379
- await this.engine.insert('sys_approval_token', {
2380
- id: uid('atok'),
2381
- organization_id: raw.organization_id ?? null,
2382
- token_hash: createHash('sha256').update(rawToken).digest('hex'),
2383
- request_id: requestId,
2384
- action,
2385
- approver_id: approverId,
2386
- expires_at: expires,
2387
- consumed_at: null,
2388
- created_at: now.toISOString(),
2389
- }, { context: SYSTEM_CTX });
2390
- out[action] = rawToken;
2391
- }
2392
- return out;
2393
- }
2394
-
2395
- /** Shared validation chain for peek/redeem. Returns the token row when live. */
2396
- private async resolveActionToken(rawToken: string): Promise<
2397
- { ok: true; token: any; request: ApprovalRequestRow } | Extract<ActionTokenOutcome, { ok: false }>
2398
- > {
2399
- const trimmed = rawToken?.trim();
2400
- if (!trimmed) return { ok: false, reason: 'invalid' };
2401
- const hash = createHash('sha256').update(trimmed).digest('hex');
2402
- const rows = await this.engine.find('sys_approval_token', {
2403
- where: { token_hash: hash }, limit: 1, context: SYSTEM_CTX,
2404
- });
2405
- const token: any = Array.isArray(rows) ? rows[0] : null;
2406
- if (!token) return { ok: false, reason: 'invalid' };
2407
- if (token.consumed_at) return { ok: false, reason: 'consumed' };
2408
- if (Date.parse(token.expires_at) < this.clock.now().getTime()) {
2409
- return { ok: false, reason: 'expired' };
2410
- }
2411
- const request = await this.getRequest(token.request_id, SYSTEM_CTX as unknown as SharingExecutionContext);
2412
- if (!request || request.status !== 'pending') {
2413
- return { ok: false, reason: 'not_pending', request: request ?? undefined };
2414
- }
2415
- if (!(request.pending_approvers ?? []).includes(token.approver_id)) {
2416
- // Reassigned away / slot consumed by a unanimous round — the link died
2417
- // with the slot (ADR-0043 invalidation row).
2418
- return { ok: false, reason: 'not_approver', request };
2419
- }
2420
- return { ok: true, token, request };
2421
- }
2422
-
2423
- /** GET confirm page: validate WITHOUT consuming — never mutates. */
2424
- async peekActionToken(rawToken: string): Promise<ActionTokenOutcome> {
2425
- const res = await this.resolveActionToken(rawToken);
2426
- if (!res.ok) return res;
2427
- return { ok: true, action: res.token.action, request: res.request, approverId: res.token.approver_id };
2428
- }
2429
-
2430
- /**
2431
- * POST redemption: consume the token FIRST (a failed decide still burns
2432
- * it — replay-safe), then decide as the bound approver.
2433
- */
2434
- async redeemActionToken(rawToken: string): Promise<ActionTokenOutcome> {
2435
- const res = await this.resolveActionToken(rawToken);
2436
- if (!res.ok) return res;
2437
- await this.engine.update('sys_approval_token', {
2438
- id: res.token.id, consumed_at: this.clock.now().toISOString(),
2439
- }, { context: SYSTEM_CTX });
2440
- const out = await this.decide(res.token.request_id, {
2441
- decision: res.token.action,
2442
- actorId: res.token.approver_id,
2443
- comment: 'Via action link',
2444
- // The token IS the authentication (#3783): it is single-use, hashed at
2445
- // rest and bound to one approver, who `resolveActionToken` has just
2446
- // re-checked still holds a pending slot. So this decision has a real
2447
- // acting user even though no session carried it — name them on the
2448
- // context, so the status mirror and every flow it cascades into are
2449
- // attributed exactly like a decision made through the UI. Elevation is
2450
- // unchanged: `isSystem` still stands in for the missing session.
2451
- }, { ...SYSTEM_CTX, userId: res.token.approver_id } as unknown as SharingExecutionContext);
2452
- return { ok: true, action: res.token.action, request: out.request, approverId: res.token.approver_id };
2453
- }
2454
-
2455
- /**
2456
- * Approver asks the submitter for more information. The request stays
2457
- * pending — a thread interaction, not a flow decision.
2458
- */
2459
- async requestInfo(
2460
- requestId: string,
2461
- input: { actorId: string; comment: string },
2462
- context: SharingExecutionContext,
2463
- ): Promise<{ request: ApprovalRequestRow }> {
2464
- const actorId = await this.resolveActor(input?.actorId, context);
2465
- if (!input?.comment?.trim()) throw new Error('VALIDATION_FAILED: comment is required');
2466
- const raw = await this.loadPendingRow(requestId);
2467
- const pending = csvSplit(raw.pending_approvers);
2468
- if (!context.isSystem && !pending.includes(actorId)) {
2469
- throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
2470
- }
2471
-
2472
- const now = this.clock.now().toISOString();
2473
- await this.engine.insert('sys_approval_action', {
2474
- id: uid('aact'), request_id: requestId, organization_id: raw.organization_id ?? null,
2475
- step_name: raw.flow_node_id ?? raw.current_step ?? null, step_index: 0, action: 'request_info',
2476
- actor_id: actorId, comment: input.comment.trim(), created_at: now,
2477
- }, { context: SYSTEM_CTX });
2478
-
2479
- if (raw.submitter_id) {
2480
- await this.notify({
2481
- topic: 'approval.request_info',
2482
- audience: [String(raw.submitter_id)],
2483
- actorId: actorId,
2484
- source: { object: 'sys_approval_request', id: requestId },
2485
- payload: {
2486
- title: 'More information requested',
2487
- message: input.comment.trim(),
2488
- actionUrl: '/system/approvals',
2489
- },
2490
- });
2491
- }
2492
-
2493
- const fresh = await this.readBackRequest(requestId, context);
2494
- return { request: fresh! };
2495
- }
2496
-
2497
- /** Free-form reply on the thread (submitter or any pending approver). */
2498
- async comment(
2499
- requestId: string,
2500
- input: { actorId: string; comment: string; attachments?: string[] },
2501
- context: SharingExecutionContext,
2502
- ): Promise<{ request: ApprovalRequestRow }> {
2503
- const actorId = await this.resolveActor(input?.actorId, context);
2504
- if (!input?.comment?.trim()) throw new Error('VALIDATION_FAILED: comment is required');
2505
- const raw = await this.loadPendingRow(requestId);
2506
- const pending = csvSplit(raw.pending_approvers);
2507
- const isSubmitter = raw.submitter_id && String(raw.submitter_id) === String(actorId);
2508
- if (!context.isSystem && !isSubmitter && !pending.includes(actorId)) {
2509
- throw new Error(`FORBIDDEN: actor '${actorId}' is not on this request`);
2510
- }
2511
-
2512
- const now = this.clock.now().toISOString();
2513
- await this.engine.insert('sys_approval_action', {
2514
- id: uid('aact'), request_id: requestId, organization_id: raw.organization_id ?? null,
2515
- step_name: raw.flow_node_id ?? raw.current_step ?? null, step_index: 0, action: 'comment',
2516
- actor_id: actorId, comment: input.comment.trim(),
2517
- attachments: input.attachments?.length ? input.attachments : null,
2518
- created_at: now,
2519
- }, { context: SYSTEM_CTX });
2520
-
2521
- // Notify the other side of the thread.
2522
- const audience = isSubmitter ? pending : [String(raw.submitter_id ?? '')].filter(Boolean);
2523
- await this.notify({
2524
- topic: 'approval.comment',
2525
- audience,
2526
- actorId: actorId,
2527
- source: { object: 'sys_approval_request', id: requestId },
2528
- payload: {
2529
- title: 'New comment on an approval',
2530
- message: input.comment.trim(),
2531
- actionUrl: '/system/approvals',
2532
- },
2533
- });
2534
-
2535
- const fresh = await this.readBackRequest(requestId, context);
2536
- return { request: fresh! };
2537
- }
2538
-
2539
- // ── SLA escalation (ADR-0042) ─────────────────────────────────
2540
-
2541
- /**
2542
- * One escalation sweep: every *pending* request whose node config declares
2543
- * `escalation.timeoutHours` and whose deadline has passed is escalated
2544
- * **at most once, ever** — the `escalate` audit row is the idempotency
2545
- * marker, written before any mutation (audit-first, like reassign). One
2546
- * bad row never stops the sweep.
2547
- */
2548
- async runEscalations(): Promise<{ scanned: number; escalated: number }> {
2549
- let rows: any[] = [];
2550
- try {
2551
- rows = await this.engine.find('sys_approval_request', {
2552
- where: { status: 'pending' }, limit: 500, context: SYSTEM_CTX,
2553
- }) ?? [];
2554
- } catch (err: any) {
2555
- this.logger?.warn?.('[approvals] escalation scan failed to list requests', {
2556
- error: err?.message ?? String(err),
2557
- });
2558
- return { scanned: 0, escalated: 0 };
2559
- }
2560
-
2561
- let escalated = 0;
2562
- for (const raw of rows) {
2563
- try {
2564
- const cfg = parseJson<any>(raw.node_config_json, undefined);
2565
- const esc = cfg?.escalation;
2566
- if (!esc || typeof esc.timeoutHours !== 'number' || esc.timeoutHours <= 0) continue;
2567
- const due = slaDueAt(raw.created_at, cfg);
2568
- if (!due || Date.parse(due) > this.clock.now().getTime()) continue;
2569
-
2570
- // Single-shot: a prior 'escalate' action means this request is done.
2571
- const prior = await this.engine.find('sys_approval_action', {
2572
- where: { request_id: raw.id, action: 'escalate' }, limit: 1, context: SYSTEM_CTX,
2573
- });
2574
- if (Array.isArray(prior) && prior[0]) continue;
2575
-
2576
- await this.escalateRequest(raw, esc);
2577
- escalated++;
2578
- } catch (err: any) {
2579
- this.logger?.warn?.('[approvals] escalation failed for request', {
2580
- request: raw?.id, error: err?.message ?? String(err),
2581
- });
2582
- }
2583
- }
2584
- if (escalated > 0) {
2585
- this.logger?.info?.('[approvals] SLA escalation sweep', { scanned: rows.length, escalated });
2586
- }
2587
- return { scanned: rows.length, escalated };
2588
- }
2589
-
2590
- // ── Dead-run release (#3456) ──────────────────────────────────
2591
-
2592
- /**
2593
- * One dead-run sweep: a pending request whose owning flow run has reached a
2594
- * TERMINAL state can never be decided — nothing is left to resume — so the
2595
- * request is finalised as `recalled` and, with `lockRecord`, the record it was
2596
- * holding is released.
2597
- *
2598
- * This is the recovery half of #3456. The prevention half is the record lock's
2599
- * owning-run exemption (`lifecycle-hooks.ts`), which stops a run from killing
2600
- * itself on its own lock in the first place; this sweep cleans up the runs that
2601
- * still die — for any reason, including a process crash, which no in-band
2602
- * handler can catch because the process that would have run it is gone.
2603
- *
2604
- * **Fail-safe by construction.** It acts only on an explicit terminal status
2605
- * from a closed set. Every other answer — `paused` (the normal state of a run
2606
- * waiting on its approval), `running`, an unrecognised status, `null` (unknown
2607
- * run, evicted log, no durable store), a `getRun` that throws, or no automation
2608
- * engine at all — is read as "still alive" and left strictly alone. The failure
2609
- * mode is therefore "a dead run's lock survives until an admin recalls it"
2610
- * (today's behaviour, #3424), never "a live approval is destroyed".
2611
- *
2612
- * `recalled` is the finalisation because it is the platform's existing terminal
2613
- * state for *a live request that ended without a decision*; the audit row names
2614
- * the real cause and {@link DEAD_RUN_ACTOR_ID} the real actor, so a dead-run
2615
- * release is never mistaken for a submitter's withdrawal.
2616
- */
2617
- async releaseDeadRunRequests(): Promise<{ scanned: number; released: number }> {
2618
- // No liveness oracle → no basis to declare anything dead.
2619
- if (typeof this.automation?.getRun !== 'function') return { scanned: 0, released: 0 };
2620
-
2621
- let rows: any[] = [];
2622
- try {
2623
- rows = await this.engine.find('sys_approval_request', {
2624
- where: { status: 'pending' }, limit: 500, context: SYSTEM_CTX,
2625
- }) ?? [];
2626
- } catch (err: any) {
2627
- this.logger?.warn?.('[approvals] dead-run sweep failed to list requests', {
2628
- error: err?.message ?? String(err),
2629
- });
2630
- return { scanned: 0, released: 0 };
2631
- }
2632
-
2633
- let released = 0;
2634
- for (const raw of rows) {
2635
- try {
2636
- const runId = raw?.flow_run_id ? String(raw.flow_run_id) : '';
2637
- if (!runId) continue; // not node-driven — no run owns it, nothing to check
2638
-
2639
- let status: string | undefined;
2640
- try {
2641
- const run = await this.automation.getRun!(runId);
2642
- status = typeof run?.status === 'string' ? run.status : undefined;
2643
- } catch (err: any) {
2644
- // Unknown liveness is NOT death — leave the request pending.
2645
- this.logger?.warn?.('[approvals] dead-run sweep could not read run status', {
2646
- request: raw?.id, run: runId, error: err?.message ?? String(err),
2647
- });
2648
- continue;
2649
- }
2650
- if (!status || !TERMINAL_RUN_STATUSES.has(status)) continue;
2651
-
2652
- await this.abandonForDeadRun(raw, runId, status);
2653
- released++;
2654
- } catch (err: any) {
2655
- // One bad row never stops the sweep (mirrors runEscalations).
2656
- this.logger?.warn?.('[approvals] dead-run release failed for request', {
2657
- request: raw?.id, error: err?.message ?? String(err),
2658
- });
2659
- }
2660
- }
2661
- if (released > 0) {
2662
- this.logger?.info?.('[approvals] dead-run sweep', { scanned: rows.length, released });
2663
- }
2664
- return { scanned: rows.length, released };
2665
- }
2666
-
2667
- /**
2668
- * Finalise one pending request whose owning run is terminal. Mirrors the
2669
- * shape of {@link recall} — audit row first (so a crash mid-release leaves a
2670
- * trace of the intent), then the status transition, approver-index sync and
2671
- * the optional status-field mirror. No resume/cancel of the run: it is already
2672
- * terminal, which is precisely why we are here.
2673
- */
2674
- private async abandonForDeadRun(raw: any, runId: string, runStatus: string): Promise<void> {
2675
- const org = raw.organization_id ?? null;
2676
- const nodeId: string | null = raw.flow_node_id ?? raw.current_step ?? null;
2677
- const now = this.clock.now().toISOString();
2678
-
2679
- await this.engine.insert('sys_approval_action', {
2680
- id: uid('aact'), request_id: raw.id, organization_id: org,
2681
- step_name: nodeId, step_index: 0, action: 'recall',
2682
- actor_id: DEAD_RUN_ACTOR_ID,
2683
- comment: `owning flow run ${runId} is ${runStatus} — request abandoned and record lock released`,
2684
- created_at: now,
2685
- }, { context: SYSTEM_CTX });
2686
-
2687
- await this.engine.update('sys_approval_request', {
2688
- id: raw.id, status: 'recalled', pending_approvers: null, completed_at: now, updated_at: now,
2689
- }, { context: SYSTEM_CTX });
2690
- await this.syncApproverIndex(raw.id, [], org, now);
2691
-
2692
- const config = parseJson<ApprovalNodeConfig>(
2693
- raw.node_config_json, { approvers: [], behavior: 'first_response' } as any,
2694
- );
2695
- if (config.approvalStatusField) {
2696
- // No human did this — a sweep did. Left user-less on purpose (#3783): a
2697
- // flow that wants to react to a dead-run release declares runAs:'system'.
2698
- await this.mirrorStatusField(
2699
- raw.object_name, raw.record_id, config.approvalStatusField, 'recalled', null,
2700
- );
2701
- }
2702
-
2703
- this.logger?.warn?.('[approvals] released a record held by a dead approval run', {
2704
- request: raw.id, run: runId, runStatus, object: raw.object_name, record: raw.record_id,
2705
- });
2706
- }
2707
-
2708
- /** Execute the configured escalation action for one overdue request. */
2709
- private async escalateRequest(raw: any, esc: any): Promise<void> {
2710
- const action: string = esc.action ?? 'notify';
2711
- const escalateTo: string | undefined =
2712
- typeof esc.escalateTo === 'string' && esc.escalateTo.trim() ? esc.escalateTo.trim() : undefined;
2713
- const now = this.clock.now().toISOString();
2714
- const pending = csvSplit(raw.pending_approvers);
2715
-
2716
- // `escalateTo` is a position machine name or a user id (same contract as
2717
- // the `position` ApproverType, ADR-0090 D3). Position holders win; an
2718
- // empty expansion falls back to the literal, so a config naming a
2719
- // specific user id keeps working unchanged.
2720
- let escalatees: string[] = [];
2721
- if (escalateTo) {
2722
- try {
2723
- escalatees = await this.expandPositionUsers(escalateTo, raw.organization_id ?? null);
2724
- } catch { escalatees = []; }
2725
- if (!escalatees.length) escalatees = [escalateTo];
2726
- }
2727
-
2728
- // Audit first — this row IS the idempotency marker (ADR-0042 §1).
2729
- await this.engine.insert('sys_approval_action', {
2730
- id: uid('aact'), request_id: raw.id, organization_id: raw.organization_id ?? null,
2731
- step_name: raw.flow_node_id ?? raw.current_step ?? null, step_index: 0, action: 'escalate',
2732
- actor_id: SLA_ACTOR_ID,
2733
- comment: `${action}${escalateTo ? ` → ${escalateTo}` : ''}`,
2734
- created_at: now,
2735
- }, { context: SYSTEM_CTX });
2736
-
2737
- if (action === 'reassign' && escalatees.length) {
2738
- await this.engine.update('sys_approval_request', {
2739
- id: raw.id, pending_approvers: escalatees.join(','), updated_at: now,
2740
- }, { context: SYSTEM_CTX });
2741
- await this.syncApproverIndex(raw.id, escalatees, raw.organization_id ?? null, now);
2742
- await this.notify({
2743
- topic: 'approval.escalated',
2744
- audience: escalatees,
2745
- actorId: SLA_ACTOR_ID,
2746
- source: { object: 'sys_approval_request', id: raw.id },
2747
- payload: {
2748
- title: 'Approval escalated to you',
2749
- message: `An overdue approval on ${raw.object_name}/${raw.record_id} was escalated to you.`,
2750
- actionUrl: '/system/approvals',
2751
- },
2752
- });
2753
- } else if (action === 'auto_approve' || action === 'auto_reject') {
2754
- await this.decide(raw.id, {
2755
- decision: action === 'auto_approve' ? 'approve' : 'reject',
2756
- actorId: SLA_ACTOR_ID,
2757
- comment: 'SLA escalation',
2758
- }, SYSTEM_CTX as unknown as SharingExecutionContext);
2759
- } else {
2760
- // 'notify' (and the reassign-without-target fallback)
2761
- await this.notify({
2762
- topic: 'approval.sla_breached',
2763
- audience: [...pending, ...escalatees],
2764
- actorId: SLA_ACTOR_ID,
2765
- source: { object: 'sys_approval_request', id: raw.id },
2766
- payload: {
2767
- title: 'Approval SLA breached',
2768
- message: `A decision on ${raw.object_name}/${raw.record_id} is overdue.`,
2769
- actionUrl: '/system/approvals',
2770
- },
2771
- });
2772
- }
2773
-
2774
- if (esc.notifySubmitter !== false && raw.submitter_id) {
2775
- await this.notify({
2776
- topic: 'approval.sla_breached',
2777
- audience: [String(raw.submitter_id)],
2778
- actorId: SLA_ACTOR_ID,
2779
- source: { object: 'sys_approval_request', id: raw.id },
2780
- payload: {
2781
- title: 'Your approval request breached its SLA',
2782
- message: `${raw.object_name}/${raw.record_id}: escalation action '${action}' was taken.`,
2783
- actionUrl: '/system/approvals',
2784
- },
2785
- });
2786
- }
2787
- }
2788
-
2789
- // ── Display enrichment ───────────────────────────────────────
2790
-
2791
- /**
2792
- * Resolve the schema-declared display field for an object, when the engine
2793
- * exposes schema metadata (`getSchema`). Falls back to common title-ish
2794
- * field names so plain `ApprovalEngine` fakes still enrich sensibly.
2795
- */
2796
- private resolveDisplayField(object: string): string | undefined {
2797
- try {
2798
- const schema: any = (this.engine as any).getSchema?.(object);
2799
- const fields = schema?.fields ?? {};
2800
- // [ADR-0079] `nameField` is the canonical primary-title pointer;
2801
- // `displayNameField` is the deprecated alias (still honored).
2802
- const declared = schema?.nameField ?? schema?.displayNameField;
2803
- if (declared && declared !== 'id' && fields[declared]) return declared;
2804
- for (const cand of ['name', 'title', 'subject', 'label']) {
2805
- if (fields[cand]) return cand;
2806
- }
2807
- } catch { /* schema unavailable — heuristics below still apply */ }
2808
- return undefined;
2809
- }
2810
-
2811
- private static pickTitle(rec: any, displayField?: string): string | undefined {
2812
- const candidates = displayField
2813
- ? [displayField, 'name', 'title', 'subject', 'label']
2814
- : ['name', 'title', 'subject', 'label'];
2815
- for (const f of candidates) {
2816
- const v = rec?.[f];
2817
- if (v != null && String(v).trim() && f !== 'id') return String(v);
2818
- }
2819
- return undefined;
2820
- }
2821
-
2822
- /**
2823
- * Batch-resolve `sys_user` display names for identifiers that may be user
2824
- * ids or emails. Best-effort — failures leave entries unresolved.
2825
- */
2826
- private async resolveUserNames(identifiers: Array<string | null | undefined>): Promise<Map<string, string>> {
2827
- const names = new Map<string, string>();
2828
- const targets = Array.from(new Set(identifiers.filter(Boolean))) as string[];
2829
- if (!targets.length) return names;
2830
- try {
2831
- const users = await this.engine.find('sys_user', {
2832
- where: { id: { $in: targets } }, fields: ['id', 'name', 'email'],
2833
- limit: targets.length, context: SYSTEM_CTX,
2834
- });
2835
- for (const u of (users ?? []) as any[]) {
2836
- if (u?.id && (u.name || u.email)) names.set(String(u.id), String(u.name ?? u.email));
2837
- }
2838
- } catch { /* best-effort */ }
2839
- const unresolvedEmails = targets.filter(t => !names.has(t) && t.includes('@'));
2840
- if (unresolvedEmails.length) {
2841
- try {
2842
- const users = await this.engine.find('sys_user', {
2843
- where: { email: { $in: unresolvedEmails } }, fields: ['email', 'name'],
2844
- limit: unresolvedEmails.length, context: SYSTEM_CTX,
2845
- });
2846
- for (const u of (users ?? []) as any[]) {
2847
- if (u?.email && u.name) names.set(String(u.email), String(u.name));
2848
- }
2849
- } catch { /* best-effort */ }
2850
- }
2851
- return names;
2852
- }
2853
-
2854
- /** Lookup-typed fields (key + referenced object) of an object's schema. */
2855
- private resolveLookupFields(object: string): Array<{ key: string; reference: string }> {
2856
- try {
2857
- const schema: any = (this.engine as any).getSchema?.(object);
2858
- const fields = schema?.fields ?? {};
2859
- const out: Array<{ key: string; reference: string }> = [];
2860
- for (const [key, f] of Object.entries<any>(fields)) {
2861
- if ((f?.type === 'lookup' || f?.type === 'master_detail' || f?.type === 'user') && f?.reference) {
2862
- out.push({ key, reference: String(f.reference) });
2863
- }
2864
- }
2865
- return out;
2866
- } catch { return []; }
2867
- }
2868
-
2869
- /**
2870
- * Field key → display label for an object's schema. Lets the inbox summary
2871
- * show a human field name ("考核状态") instead of a title-cased machine key
2872
- * ("Assessment Status"). For a single-locale project the schema label already
2873
- * IS the localized string; symmetric with `resolveDisplayField`/lookup
2874
- * resolution that power `payload_display`.
2875
- */
2876
- private resolveFieldLabels(object: string): Record<string, string> {
2877
- try {
2878
- const schema: any = (this.engine as any).getSchema?.(object);
2879
- const fields = schema?.fields ?? {};
2880
- const out: Record<string, string> = {};
2881
- for (const [key, f] of Object.entries<any>(fields)) {
2882
- if (f?.label) out[key] = String(f.label);
2883
- }
2884
- return out;
2885
- } catch { return {}; }
2886
- }
2887
-
2888
- /**
2889
- * Attach inbox display fields to rows so clients never render a raw
2890
- * identifier: `record_title`, `submitter_name`, `object_label`,
2891
- * `pending_approver_names` (user-id approvers), `payload_display`
2892
- * (lookup foreign keys in the snapshot → referenced record titles), and
2893
- * `payload_labels` (snapshot field keys → the target object's field labels).
2894
- * Batched: one query per distinct object (target + referenced) plus one
2895
- * `sys_user` lookup. Best-effort — a deleted record falls back to the
2896
- * payload snapshot, and any failure leaves the field unset rather than
2897
- * failing the list.
2898
- */
2899
- private async enrichRows(rows: ApprovalRequestRow[]): Promise<void> {
2900
- if (!rows.length) return;
2901
-
2902
- // Record titles + object labels, batched per object.
2903
- const byObject = new Map<string, Set<string>>();
2904
- for (const r of rows) {
2905
- if (!r.object_name || !r.record_id) continue;
2906
- let set = byObject.get(r.object_name);
2907
- if (!set) { set = new Set(); byObject.set(r.object_name, set); }
2908
- set.add(r.record_id);
2909
- }
2910
- const titles = new Map<string, string>();
2911
- const objectLabels = new Map<string, string>();
2912
- for (const [object, idSet] of byObject) {
2913
- try {
2914
- const schema: any = (this.engine as any).getSchema?.(object);
2915
- if (schema?.label) objectLabels.set(object, String(schema.label));
2916
- } catch { /* label optional */ }
2917
- const ids = Array.from(idSet);
2918
- const displayField = this.resolveDisplayField(object);
2919
- try {
2920
- const recs = await this.engine.find(object, {
2921
- where: { id: { $in: ids } }, limit: ids.length, context: SYSTEM_CTX,
2922
- });
2923
- for (const rec of (recs ?? []) as any[]) {
2924
- const title = ApprovalService.pickTitle(rec, displayField);
2925
- if (rec?.id && title) titles.set(`${object} ${rec.id}`, title);
2926
- }
2927
- } catch { /* object may be unregistered — payload fallback below */ }
2928
- }
2929
-
2930
- // Lookup foreign keys inside payload snapshots → referenced record titles.
2931
- const lookupFieldsByObject = new Map<string, Array<{ key: string; reference: string }>>();
2932
- // Field key → label per object, for the snapshot summary's field names.
2933
- const fieldLabelsByObject = new Map<string, Record<string, string>>();
2934
- for (const object of byObject.keys()) {
2935
- const lookups = this.resolveLookupFields(object);
2936
- if (lookups.length) lookupFieldsByObject.set(object, lookups);
2937
- const labels = this.resolveFieldLabels(object);
2938
- if (Object.keys(labels).length) fieldLabelsByObject.set(object, labels);
2939
- }
2940
- const refIds = new Map<string, Set<string>>();
2941
- for (const r of rows) {
2942
- const lookups = lookupFieldsByObject.get(r.object_name);
2943
- const payload: any = r.payload;
2944
- if (!lookups || !payload || typeof payload !== 'object') continue;
2945
- for (const { key, reference } of lookups) {
2946
- const v = payload[key];
2947
- if (v == null || typeof v === 'object' || !String(v).trim()) continue;
2948
- let set = refIds.get(reference);
2949
- if (!set) { set = new Set(); refIds.set(reference, set); }
2950
- set.add(String(v));
2951
- }
2952
- }
2953
- const refTitles = new Map<string, string>();
2954
- for (const [object, idSet] of refIds) {
2955
- const ids = Array.from(idSet);
2956
- const displayField = this.resolveDisplayField(object);
2957
- try {
2958
- const recs = await this.engine.find(object, {
2959
- where: { id: { $in: ids } }, limit: ids.length, context: SYSTEM_CTX,
2960
- });
2961
- for (const rec of (recs ?? []) as any[]) {
2962
- const title = ApprovalService.pickTitle(rec, displayField);
2963
- if (rec?.id && title) refTitles.set(`${object} ${rec.id}`, title);
2964
- }
2965
- } catch { /* referenced object unreadable — leave unresolved */ }
2966
- }
2967
-
2968
- // Display names for submitters AND user-id approvers in one lookup.
2969
- // `role:<r>` (and other `type:value` literals) are already readable.
2970
- const userIdentifiers: Array<string | null | undefined> = [];
2971
- for (const r of rows) {
2972
- userIdentifiers.push(r.submitter_id);
2973
- for (const a of r.pending_approvers ?? []) {
2974
- if (a && !a.includes(':')) userIdentifiers.push(a);
2975
- }
2976
- }
2977
- const names = await this.resolveUserNames(userIdentifiers);
2978
-
2979
- for (const r of rows as any[]) {
2980
- const title = titles.get(`${r.object_name} ${r.record_id}`)
2981
- ?? ApprovalService.pickTitle(r.payload, undefined);
2982
- if (title) r.record_title = title;
2983
- const name = r.submitter_id ? names.get(String(r.submitter_id)) : undefined;
2984
- if (name) r.submitter_name = name;
2985
- const label = objectLabels.get(r.object_name);
2986
- if (label) r.object_label = label;
2987
-
2988
- const approverNames: Record<string, string> = {};
2989
- for (const a of r.pending_approvers ?? []) {
2990
- const n = names.get(String(a));
2991
- if (n) approverNames[a] = n;
2992
- }
2993
- if (Object.keys(approverNames).length) r.pending_approver_names = approverNames;
2994
-
2995
- const lookups = lookupFieldsByObject.get(r.object_name);
2996
- if (lookups && r.payload && typeof r.payload === 'object') {
2997
- const display: Record<string, string> = {};
2998
- for (const { key, reference } of lookups) {
2999
- const v = (r.payload as any)[key];
3000
- if (v == null) continue;
3001
- const t = refTitles.get(`${reference} ${String(v)}`);
3002
- if (t) display[key] = t;
3003
- }
3004
- if (Object.keys(display).length) r.payload_display = display;
3005
- }
3006
-
3007
- // Field labels for the snapshot keys the summary renders (only keys
3008
- // actually present in the payload — a deleted field's label is noise).
3009
- const fieldLabels = fieldLabelsByObject.get(r.object_name);
3010
- if (fieldLabels && r.payload && typeof r.payload === 'object') {
3011
- const labels: Record<string, string> = {};
3012
- for (const key of Object.keys(r.payload as Record<string, unknown>)) {
3013
- const l = fieldLabels[key];
3014
- if (l) labels[key] = l;
3015
- }
3016
- if (Object.keys(labels).length) r.payload_labels = labels;
3017
- }
3018
- }
3019
- }
3020
-
3021
- // ── Pending-approver index (issue #1745) ─────────────────────
3022
-
3023
- /**
3024
- * Mirror one request's `pending_approvers` CSV into the normalized
3025
- * `sys_approval_approver` index. Called by every write path that changes
3026
- * the approver set; an empty `approvers` clears the request's rows (the
3027
- * request left `pending`). Diff-based so reassign/unanimous churn doesn't
3028
- * rewrite untouched rows.
3029
- */
3030
- private async syncApproverIndex(
3031
- requestId: string,
3032
- approvers: string[],
3033
- org: string | null,
3034
- now: string,
3035
- ): Promise<void> {
3036
- const desired = new Set(approvers.map(a => String(a).trim()).filter(Boolean));
3037
- const existing = await this.engine.find('sys_approval_approver', {
3038
- where: { request_id: requestId }, limit: 500, context: SYSTEM_CTX,
3039
- });
3040
- const rows: any[] = Array.isArray(existing) ? existing : [];
3041
- for (const row of rows) {
3042
- if (desired.has(String(row.approver))) desired.delete(String(row.approver));
3043
- else await this.engine.delete('sys_approval_approver', { where: { id: row.id }, context: SYSTEM_CTX });
3044
- }
3045
- for (const approver of desired) {
3046
- await this.engine.insert('sys_approval_approver', {
3047
- id: uid('aapr'), request_id: requestId, approver,
3048
- organization_id: org, created_at: now,
3049
- }, { context: SYSTEM_CTX });
3050
- }
3051
- }
3052
-
3053
- /**
3054
- * Rebuild the whole `sys_approval_approver` index from the CSV source of
3055
- * truth. Idempotent; run at plugin start so rows written before the index
3056
- * existed (or drifted past a crashed sync) become queryable. Cost tracks
3057
- * the number of *pending* requests, not the request history.
3058
- */
3059
- async rebuildApproverIndex(): Promise<{ requests: number; inserted: number; deleted: number }> {
3060
- // Desired state: every pending request's CSV entries.
3061
- const desired = new Map<string, { approvers: Set<string>; org: string | null }>();
3062
- const PAGE = 500;
3063
- for (let offset = 0; ; offset += PAGE) {
3064
- const batch = await this.engine.find('sys_approval_request', {
3065
- where: { status: 'pending' },
3066
- fields: ['id', 'pending_approvers', 'organization_id'],
3067
- limit: PAGE, offset, context: SYSTEM_CTX,
3068
- });
3069
- const rows: any[] = Array.isArray(batch) ? batch : [];
3070
- for (const r of rows) {
3071
- desired.set(String(r.id), {
3072
- approvers: new Set(csvSplit(r.pending_approvers)),
3073
- org: r.organization_id ?? null,
3074
- });
3075
- }
3076
- if (rows.length < PAGE) break;
3077
- }
3078
-
3079
- // Current state: read the whole index first (bounded by the live work
3080
- // queue), THEN mutate — deleting while paginating would shift the cursor.
3081
- const indexRows: any[] = [];
3082
- for (let offset = 0; ; offset += PAGE) {
3083
- const batch = await this.engine.find('sys_approval_approver', {
3084
- orderBy: [{ field: 'created_at', order: 'asc' }],
3085
- limit: PAGE, offset, context: SYSTEM_CTX,
3086
- });
3087
- const rows: any[] = Array.isArray(batch) ? batch : [];
3088
- indexRows.push(...rows);
3089
- if (rows.length < PAGE) break;
3090
- }
3091
- let inserted = 0; let deleted = 0;
3092
- const seen = new Map<string, Set<string>>();
3093
- for (const row of indexRows) {
3094
- const reqId = String(row.request_id);
3095
- const want = desired.get(reqId);
3096
- const have = seen.get(reqId) ?? seen.set(reqId, new Set()).get(reqId)!;
3097
- // Orphan (request no longer pending), stale entry, or duplicate → drop.
3098
- if (!want || !want.approvers.has(String(row.approver)) || have.has(String(row.approver))) {
3099
- await this.engine.delete('sys_approval_approver', { where: { id: row.id }, context: SYSTEM_CTX });
3100
- deleted++;
3101
- continue;
3102
- }
3103
- have.add(String(row.approver));
3104
- }
3105
-
3106
- const now = this.clock.now().toISOString();
3107
- for (const [reqId, want] of desired) {
3108
- const have = seen.get(reqId);
3109
- for (const approver of want.approvers) {
3110
- if (have?.has(approver)) continue;
3111
- await this.engine.insert('sys_approval_approver', {
3112
- id: uid('aapr'), request_id: reqId, approver,
3113
- organization_id: want.org, created_at: now,
3114
- }, { context: SYSTEM_CTX });
3115
- inserted++;
3116
- }
3117
- }
3118
- return { requests: desired.size, inserted, deleted };
3119
- }
3120
-
3121
- // ── Read API ─────────────────────────────────────────────────
3122
-
3123
- /** Filter type accepted by {@link listRequests} / {@link countRequests}. */
3124
- private buildRequestWhere(
3125
- filter: {
3126
- object?: string;
3127
- recordId?: string;
3128
- status?: ApprovalStatus | ApprovalStatus[];
3129
- submitterId?: string;
3130
- q?: string;
3131
- } | undefined,
3132
- context: SharingExecutionContext,
3133
- ): { where: any; tenantOrg: string | null } {
3134
- const f: any = {};
3135
- if (filter?.object) f.object_name = filter.object;
3136
- if (filter?.recordId) f.record_id = filter.recordId;
3137
- if (filter?.submitterId) f.submitter_id = filter.submitterId;
3138
- // Tenant isolation: when a caller context carries a tenant identifier
3139
- // (organizationId / tenantId), scope the query to that tenant. SYSTEM
3140
- // callers (no tenant) see all rows. This prevents the bespoke endpoint
3141
- // from leaking other-tenant rows since we deliberately query with
3142
- // SYSTEM_CTX to bypass RLS on the engine (the approver-visibility rule
3143
- // spans three identity forms, which RLS can't model cleanly).
3144
- const tenantOrg = (context as any)?.organizationId ?? (context as any)?.tenantId ?? null;
3145
- if (tenantOrg) f.organization_id = tenantOrg;
3146
- // Free-text search, pushed down: `payload_json` carries the record
3147
- // snapshot, so record titles match without any join. `$contains` is the
3148
- // driver's escaped-LIKE operator.
3149
- const q = filter?.q?.trim();
3150
- if (q) {
3151
- f.$or = [
3152
- { process_name: { $contains: q } },
3153
- { object_name: { $contains: q } },
3154
- { record_id: { $contains: q } },
3155
- { submitter_id: { $contains: q } },
3156
- { payload_json: { $contains: q } },
3157
- ];
3158
- }
3159
- // Status pushes down whole: `$in` for arrays (all bundled drivers
3160
- // support it), equality for a single value.
3161
- if (Array.isArray(filter?.status)) {
3162
- const statuses = (filter!.status as ApprovalStatus[]).filter(Boolean);
3163
- if (statuses.length === 1) f.status = statuses[0];
3164
- else if (statuses.length > 1) f.status = { $in: statuses };
3165
- } else if (filter?.status) {
3166
- f.status = filter.status;
3167
- }
3168
- return { where: f, tenantOrg };
3169
- }
3170
-
3171
- /** Window the approver-index probe — pending queues live far below this. */
3172
- private static readonly APPROVER_INDEX_CAP = 10_000;
3173
-
3174
- /**
3175
- * Resolve an approver filter to matching request ids via the normalized
3176
- * `sys_approval_approver` index — the indexed replacement for the old
3177
- * in-memory CSV scan, and what makes approver-filtered pagination correct
3178
- * past any scan window (issue #1745). A request matches when ANY of the
3179
- * caller's identities (user id / email / role:<r>) holds a pending slot.
3180
- * Returns null when the filter is absent (callers skip the id constraint).
3181
- */
3182
- private async approverRequestIds(
3183
- targets: string[],
3184
- tenantOrg: string | null,
3185
- ): Promise<string[] | null> {
3186
- if (!targets.length) return null;
3187
- const where: any = targets.length === 1
3188
- ? { approver: targets[0] }
3189
- : { approver: { $in: targets } };
3190
- if (tenantOrg) where.organization_id = tenantOrg;
3191
- const rows = await this.engine.find('sys_approval_approver', {
3192
- where, fields: ['request_id'],
3193
- limit: ApprovalService.APPROVER_INDEX_CAP, context: SYSTEM_CTX,
3194
- });
3195
- const list: any[] = Array.isArray(rows) ? rows : [];
3196
- if (list.length >= ApprovalService.APPROVER_INDEX_CAP) {
3197
- this.logger?.warn?.('[approvals] approver index probe hit its window — results may be truncated', {
3198
- cap: ApprovalService.APPROVER_INDEX_CAP, targets: targets.length,
3199
- });
3200
- }
3201
- return [...new Set<string>(list.map(r => String(r.request_id)))];
3202
- }
3203
-
3204
- /**
3205
- * The request ids this caller is a PARTICIPANT of, or `null` for a caller
3206
- * who may see everything in scope (#3590).
3207
- *
3208
- * These reads deliberately run with `SYSTEM_CTX` to bypass RLS — the
3209
- * approver-visibility rule spans several identity forms that RLS cannot model
3210
- * cleanly, which is why it has to be expressed here. Until now only the
3211
- * TENANT half of that rule was applied, so any authenticated user could read
3212
- * any request in their tenant (and, once attachments derived their access
3213
- * from the request, its files too). This adds the participant half.
3214
- *
3215
- * A participant is the submitter, a current approver, or someone who has
3216
- * already acted on the request (a past approver whose slot has moved on, a
3217
- * commenter). Admins with override authority keep the unrestricted view the
3218
- * "all requests" console surface depends on.
3219
- *
3220
- * Keying on the concrete user id is sufficient rather than an approximation:
3221
- * position/team/manager/field approvers are resolved to concrete user ids at
3222
- * open time, and the `type:value` literal is only the fallback for a spec
3223
- * that resolved to NOBODY — a slot no one can act on either way (`can_act`
3224
- * is a plain membership test over the resolved ids). So this cannot hide a
3225
- * request from someone who could actually act on it.
3226
- */
3227
- private async visibleRequestIds(
3228
- context: SharingExecutionContext,
3229
- tenantOrg: string | null,
3230
- ): Promise<Set<string> | null> {
3231
- if (this.isOverrideActor(context, tenantOrg)) return null;
3232
- const uid = (context as any)?.userId != null ? String((context as any).userId) : '';
3233
- // A tokenless/anonymous caller participates in nothing. Fail closed.
3234
- if (!uid) return new Set<string>();
3235
-
3236
- const ids = new Set<string>();
3237
- const cap = ApprovalService.APPROVER_INDEX_CAP;
3238
- const add = (rows: unknown, key: string) => {
3239
- const list: any[] = Array.isArray(rows) ? rows : [];
3240
- for (const r of list) if (r?.[key] != null) ids.add(String(r[key]));
3241
- if (list.length >= cap) {
3242
- this.logger?.warn?.(
3243
- '[approvals] participant-visibility probe hit its window — some requests may be hidden from a legitimate participant',
3244
- { cap, key },
3245
- );
3246
- }
3247
- };
3248
-
3249
- try {
3250
- // Current approver — via the normalized index, so every identity form
3251
- // the write path recorded is covered.
3252
- for (const id of (await this.approverRequestIds([uid], tenantOrg)) ?? []) ids.add(id);
3253
-
3254
- const orgWhere = tenantOrg ? { organization_id: tenantOrg } : {};
3255
- add(
3256
- await this.engine.find('sys_approval_request', {
3257
- where: { submitter_id: uid, ...orgWhere },
3258
- fields: ['id'], limit: cap, context: SYSTEM_CTX,
3259
- }),
3260
- 'id',
3261
- );
3262
- // Already acted on it: a past approver whose slot has moved on, or a
3263
- // commenter. They saw it legitimately; keep it that way.
3264
- add(
3265
- await this.engine.find('sys_approval_action', {
3266
- where: { actor_id: uid },
3267
- fields: ['request_id'], limit: cap, context: SYSTEM_CTX,
3268
- }),
3269
- 'request_id',
3270
- );
3271
- } catch (err) {
3272
- // Never widen on error: a failed probe yields whatever was collected.
3273
- this.logger?.warn?.('[approvals] participant-visibility probe failed', {
3274
- error: err instanceof Error ? err.message : String(err),
3275
- });
3276
- }
3277
- return ids;
3278
- }
3279
-
3280
- /** Intersect an existing `where.id` constraint with the participant set. */
3281
- private applyVisibility(where: any, visible: Set<string> | null): boolean {
3282
- if (!visible) return true;
3283
- if (visible.size === 0) return false;
3284
- let allowed = [...visible];
3285
- const current = where.id;
3286
- if (typeof current === 'string') allowed = allowed.filter((x) => x === current);
3287
- else if (current && typeof current === 'object' && Array.isArray(current.$in)) {
3288
- const set = new Set(current.$in.map((v: unknown) => String(v)));
3289
- allowed = allowed.filter((x) => set.has(x));
3290
- }
3291
- if (allowed.length === 0) return false;
3292
- where.id = allowed.length === 1 ? allowed[0] : { $in: allowed };
3293
- return true;
3294
- }
3295
-
3296
- async listRequests(
3297
- filter: {
3298
- object?: string;
3299
- recordId?: string;
3300
- status?: ApprovalStatus | ApprovalStatus[];
3301
- approverId?: string | string[];
3302
- submitterId?: string;
3303
- q?: string;
3304
- limit?: number;
3305
- offset?: number;
3306
- } | undefined,
3307
- context: SharingExecutionContext,
3308
- ): Promise<ApprovalRequestRow[]> {
3309
- const { where, tenantOrg } = this.buildRequestWhere(filter, context);
3310
- const approverTargets = (Array.isArray(filter?.approverId) ? filter!.approverId : filter?.approverId ? [filter.approverId] : [])
3311
- .map(t => String(t).trim())
3312
- .filter(Boolean);
3313
-
3314
- // Every filter now pushes into the engine (issue #1745): approver via
3315
- // the normalized index, status arrays via $in — so the page window is
3316
- // always engine-side and correct at any table size.
3317
- const ids = await this.approverRequestIds(approverTargets, tenantOrg);
3318
- if (ids) {
3319
- if (ids.length === 0) return [];
3320
- where.id = ids.length === 1 ? ids[0] : { $in: ids };
3321
- }
3322
-
3323
- // #3590: the caller-supplied `approverId` is a FILTER, not authorization —
3324
- // omitting it used to return every request in the tenant. Intersect with
3325
- // what this caller actually participates in.
3326
- if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg))) return [];
3327
-
3328
- const findOpts: any = {
3329
- where,
3330
- orderBy: [{ field: 'created_at', order: 'desc' }],
3331
- context: SYSTEM_CTX,
3332
- };
3333
- if (filter?.limit != null || filter?.offset != null) {
3334
- findOpts.limit = Math.min(Math.max(filter?.limit ?? 50, 1), 200);
3335
- if (filter?.offset) findOpts.offset = Math.max(filter.offset, 0);
3336
- } else {
3337
- // Unpaginated callers keep the legacy bounded window.
3338
- findOpts.limit = 500;
3339
- }
3340
-
3341
- const rows = await this.engine.find('sys_approval_request', findOpts);
3342
- const list = Array.isArray(rows) ? rows.map(rowFromRequest) : [];
3343
- await this.enrichRows(list);
3344
- this.attachViewers(list, context);
3345
- return list;
3346
- }
3347
-
3348
- async countRequests(
3349
- filter: Parameters<IApprovalService['listRequests']>[0],
3350
- context: SharingExecutionContext,
3351
- ): Promise<number> {
3352
- const { where, tenantOrg } = this.buildRequestWhere(filter, context);
3353
- const approverTargets = (Array.isArray(filter?.approverId) ? filter!.approverId : filter?.approverId ? [filter.approverId] : [])
3354
- .map(t => String(t).trim())
3355
- .filter(Boolean);
3356
-
3357
- const ids = await this.approverRequestIds(approverTargets, tenantOrg);
3358
- if (ids) {
3359
- if (ids.length === 0) return 0;
3360
- where.id = ids.length === 1 ? ids[0] : { $in: ids };
3361
- }
3362
-
3363
- // #3590 — the count must agree with the list it paginates.
3364
- if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg))) return 0;
3365
-
3366
- const countFn = (this.engine as any).count;
3367
- if (typeof countFn === 'function') {
3368
- try {
3369
- const n = await countFn.call(this.engine, 'sys_approval_request', { where, context: SYSTEM_CTX });
3370
- if (typeof n === 'number') return n;
3371
- } catch { /* fall through to scan */ }
3372
- }
3373
- // Engine without count(): bounded scan. The approver-filtered case is
3374
- // exact (the id set bounds it); the unfiltered case keeps the legacy
3375
- // 500 window.
3376
- const rows = await this.engine.find('sys_approval_request', {
3377
- where, fields: ['id'], limit: ids ? Math.max(500, ids.length) : 500, context: SYSTEM_CTX,
3378
- });
3379
- return Array.isArray(rows) ? rows.length : 0;
3380
- }
3381
-
3382
- /**
3383
- * Read the request a write path just changed, to echo back as its result.
3384
- *
3385
- * NOT participant-gated (#3590), deliberately: the operation authorized
3386
- * itself by its own rule before writing, so re-asking "may you see this?"
3387
- * for the echo answers a question that has already been settled — and would
3388
- * answer it WRONG for a caller context that carries no `userId` (a
3389
- * flow-driven resume, a service-to-service call), turning a successful write
3390
- * into a `null` result. Gating belongs on the read API, not on an
3391
- * operation's own return value.
3392
- */
3393
- private async readBackRequest(
3394
- requestId: string,
3395
- context: SharingExecutionContext,
3396
- ): Promise<ApprovalRequestRow | null> {
3397
- return this.loadRequest(requestId, context, false);
3398
- }
3399
-
3400
- async getRequest(requestId: string, context: SharingExecutionContext): Promise<ApprovalRequestRow | null> {
3401
- return this.loadRequest(requestId, context, true);
3402
- }
3403
-
3404
- private async loadRequest(
3405
- requestId: string,
3406
- context: SharingExecutionContext,
3407
- enforceVisibility: boolean,
3408
- ): Promise<ApprovalRequestRow | null> {
3409
- if (!requestId) return null;
3410
- const where: any = { id: requestId };
3411
- const tenantOrg = (context as any)?.organizationId ?? (context as any)?.tenantId;
3412
- if (tenantOrg) where.organization_id = tenantOrg;
3413
- const rows = await this.engine.find('sys_approval_request', {
3414
- where, limit: 1, context: SYSTEM_CTX,
3415
- });
3416
- if (!Array.isArray(rows) || !rows[0]) return null;
3417
- // #3590: tenant scoping alone let any authenticated user read any request
3418
- // — and, once decision attachments derived their access from the request
3419
- // (#3580), its files too. Participation is the rest of the rule.
3420
- if (enforceVisibility) {
3421
- const visible = await this.visibleRequestIds(context, tenantOrg ?? null);
3422
- if (visible && !visible.has(String(rows[0].id))) return null;
3423
- }
3424
- const row = rowFromRequest(rows[0]);
3425
- await this.enrichRows([row]);
3426
- await this.attachFlowSteps(row);
3427
- await this.attachDecisionProgress(row, rows[0]);
3428
- this.attachViewers([row], context);
3429
- return row;
3430
- }
3431
-
3432
- /**
3433
- * Server-computed decision aggregation progress (#3266 / objectui#2678 P1.5).
3434
- * Single-read enrichment only (like {@link ApprovalService.attachFlowSteps}):
3435
- * for a PENDING request whose behavior aggregates multiple approvals
3436
- * (`unanimous` / `quorum` / `per_group`), expose
3437
- * `decision_progress: { behavior, got, need, groups? }` so any client renders
3438
- * "2 of 3" or per-group ticks without re-deriving the engine's tally rules.
3439
- * `first_response` requests carry no progress (one approval finalizes).
3440
- * Display-only and best-effort — errors leave the row untouched.
3441
- */
3442
- private async attachDecisionProgress(row: ApprovalRequestRow, raw: any): Promise<void> {
3443
- try {
3444
- if (row.status !== 'pending') return;
3445
- const cfg = parseJson<any>(raw.node_config_json, undefined);
3446
- const behavior = cfg?.behavior ?? 'first_response';
3447
- if (behavior !== 'unanimous' && behavior !== 'quorum' && behavior !== 'per_group') return;
3448
-
3449
- const acts = await this.engine.find('sys_approval_action', {
3450
- where: { request_id: row.id, step_index: 0, action: 'approve' }, limit: 1000, context: SYSTEM_CTX,
3451
- });
3452
- const approved = new Set<string>((acts ?? []).map((a: any) => String(a.actor_id ?? '')).filter(Boolean));
3453
-
3454
- const snapshot = cfg?.__approverGroups as Record<string, string[]> | undefined;
3455
- const slate = snapshot ? Object.keys(snapshot) : [...approved, ...(row.pending_approvers ?? [])];
3456
- const total = slate.length || 1;
3457
-
3458
- const progress: any = { behavior, got: approved.size, need: total };
3459
- if (behavior === 'quorum') {
3460
- progress.need = Math.min(Math.max(1, cfg?.minApprovals ?? total), total);
3461
- } else if (behavior === 'per_group' && snapshot) {
3462
- const perGroupNeed = Math.max(1, cfg?.minApprovals ?? 1);
3463
- const size: Record<string, number> = {};
3464
- for (const gs of Object.values(snapshot)) for (const g of gs) size[g] = (size[g] ?? 0) + 1;
3465
- const got: Record<string, number> = {};
3466
- for (const a of approved) for (const g of (snapshot[a] ?? [])) got[g] = (got[g] ?? 0) + 1;
3467
- progress.groups = Object.keys(size).sort().map(g => {
3468
- const need = Math.min(perGroupNeed, size[g]);
3469
- return { group: g, got: Math.min(got[g] ?? 0, need), need, satisfied: (got[g] ?? 0) >= need };
3470
- });
3471
- progress.got = progress.groups.filter((g: any) => g.satisfied).length;
3472
- progress.need = progress.groups.length;
3473
-
3474
- // Approver→group(s) for the STILL-PENDING slots (objectui#2807), so the
3475
- // console can label each "waiting on" chip with the group it represents
3476
- // rather than showing duplicate, context-free names. Only pending slots
3477
- // matter — a resolved approver has dropped out of `pending_approvers`.
3478
- // Synthetic (unnamed, `#N`) group keys are dropped: a `· #0` sub-tag is
3479
- // noise, and the client would have to filter it anyway.
3480
- const pendingGroups: Record<string, string[]> = {};
3481
- for (const a of (row.pending_approvers ?? [])) {
3482
- const named = (snapshot[a] ?? []).filter((g) => !/^#\d+$/.test(g));
3483
- if (named.length) pendingGroups[a] = named;
3484
- }
3485
- if (Object.keys(pendingGroups).length) {
3486
- (row as any).pending_approver_groups = pendingGroups;
3487
- }
3488
- }
3489
- (row as any).decision_progress = progress;
3490
- } catch { /* display-only enrichment */ }
3491
- }
3492
-
3493
- /**
3494
- * Attach the per-viewer capability block (#3310) from the caller's context.
3495
- * `can_act` mirrors the exact authorization the decision methods enforce — the
3496
- * caller's user id is in the resolved `pending_approvers` while the request is
3497
- * still `pending` (position/team/manager approvers are already resolved to
3498
- * concrete user ids at open time, so a plain membership test is faithful).
3499
- * `is_submitter` is a straight owner check. `can_override` (#3424) is true for
3500
- * a platform/tenant admin on a PENDING request — the recovery path for an
3501
- * approval routed to an unstaffed position or to approvers who have all left;
3502
- * clients OR it into the decision actions' `visible` gate so an admin can act
3503
- * even when they hold no slot. System/tokenless contexts get a both-false
3504
- * `can_act`/`is_submitter` block (system gets `can_override` too — it may act
3505
- * on anything). Cheap + synchronous — safe on list reads.
3506
- */
3507
- private attachViewers(rows: ApprovalRequestRow[], context: SharingExecutionContext): void {
3508
- const uid = (context as any)?.userId != null ? String((context as any).userId) : null;
3509
- for (const row of rows) {
3510
- const pending = row.pending_approvers ?? [];
3511
- (row as any).viewer = {
3512
- can_act: row.status === 'pending' && !!uid && pending.includes(uid),
3513
- is_submitter: !!uid && row.submitter_id != null && String(row.submitter_id) === uid,
3514
- can_override: row.status === 'pending'
3515
- && this.isOverrideActor(context, (row as any).organization_id ?? null),
3516
- };
3517
- }
3518
- }
3519
-
3520
- /**
3521
- * Derive approval-step progress from the owning flow's graph (single-read
3522
- * enrichment only — list reads skip it). Walks from the start node
3523
- * preferring `approve`/`true` edges, so the result is the flow's main
3524
- * approval trunk; conditional side-steps show as part of the potential
3525
- * path. Display-only and best-effort.
3526
- */
3527
- private async attachFlowSteps(row: ApprovalRequestRow): Promise<void> {
3528
- try {
3529
- const flowName = row.process_name?.startsWith('flow:') ? row.process_name.slice(5) : undefined;
3530
- if (!flowName || typeof this.automation?.getFlow !== 'function') return;
3531
- const flow: any = await this.automation.getFlow(flowName);
3532
- if (!flow?.nodes?.length) return;
3533
- const nodesById = new Map<string, any>(flow.nodes.map((n: any) => [n.id, n]));
3534
- const steps: Array<{ id: string; label: string }> = [];
3535
- const seen = new Set<string>();
3536
- let cur: any = flow.nodes.find((n: any) => n.type === 'start');
3537
- while (cur && !seen.has(cur.id)) {
3538
- seen.add(cur.id);
3539
- if (cur.type === 'approval') steps.push({ id: cur.id, label: cur.label || cur.id });
3540
- const out = (flow.edges ?? []).filter((e: any) => e.source === cur.id);
3541
- if (!out.length) break;
3542
- const pick = out.find((e: any) => e.label === 'approve')
3543
- ?? out.find((e: any) => e.label === 'true')
3544
- ?? out[0];
3545
- cur = nodesById.get(pick.target);
3546
- }
3547
- if (steps.length === 0) return;
3548
- const currentId = row.flow_node_id ?? row.current_step;
3549
- const currentIdx = steps.findIndex(s => s.id === currentId);
3550
- (row as any).flow_steps = steps.map((s, i) => ({
3551
- ...s,
3552
- state: currentIdx < 0 ? 'upcoming'
3553
- : i < currentIdx ? 'done'
3554
- : i === currentIdx ? (row.status === 'approved' ? 'done' : 'current')
3555
- : 'upcoming',
3556
- }));
3557
- } catch { /* display-only — never fail the read */ }
3558
- }
3559
-
3560
- async listActions(requestId: string, context: SharingExecutionContext): Promise<ApprovalActionRow[]> {
3561
- if (!requestId) return [];
3562
- // Tenant gate: ensure the caller can see the parent request before
3563
- // returning its action history. Skipping this would leak history rows
3564
- // across tenants the same way the unscoped list-requests path did.
3565
- const req = await this.getRequest(requestId, context);
3566
- if (!req) return [];
3567
- const rows = await this.engine.find('sys_approval_action', {
3568
- where: { request_id: requestId },
3569
- limit: 500,
3570
- orderBy: [{ field: 'created_at', order: 'asc' }],
3571
- context: SYSTEM_CTX,
3572
- });
3573
- const actions = Array.isArray(rows) ? rows.map(rowFromAction) : [];
3574
- // Timeline display: resolve actor ids to names so the audit trail never
3575
- // shows a raw identifier. Role/team literals are already readable.
3576
- const names = await this.resolveUserNames(
3577
- actions.map(a => a.actor_id).filter(id => id && !id.includes(':')),
3578
- );
3579
- for (const a of actions as any[]) {
3580
- const n = a.actor_id ? names.get(String(a.actor_id)) : undefined;
3581
- if (n) a.actor_name = n;
3582
- }
3583
- return actions;
3584
- }
3585
-
3586
- /**
3587
- * `IFileAccessDelegate` — may this caller download a decision attachment?
3588
- * (ADR-0104 D3 wave 2; declared by `sys_approval_action.fileAccessDelegate`.)
3589
- *
3590
- * A file referenced by `sys_approval_action.attachments` is owned by that
3591
- * audit row, so the storage service would otherwise authorize the download by
3592
- * testing whether the caller can READ the row. It cannot: `sys_approval_action`
3593
- * is deliberately closed to ordinary approver positions, so that test denies
3594
- * the very approver the attachment was filed for.
3595
- *
3596
- * The rule that actually governs seeing a decision is the one `listActions`
3597
- * applies — can the caller see the PARENT REQUEST? — so this reuses it
3598
- * exactly, rather than inventing a second, looser rule for the bytes. Fails
3599
- * closed on any error.
3600
- */
3601
- async authorizeFileRead(actionId: string, context: SharingExecutionContext): Promise<boolean> {
3602
- if (!actionId) return false;
3603
- try {
3604
- const rows = await this.engine.find('sys_approval_action', {
3605
- where: { id: actionId },
3606
- limit: 1,
3607
- context: SYSTEM_CTX,
3608
- });
3609
- const requestId = (Array.isArray(rows) ? rows[0] : undefined)?.request_id;
3610
- if (!requestId) return false;
3611
- // Same gate as listActions: visibility of the decision's parent request.
3612
- return !!(await this.getRequest(String(requestId), context));
3613
- } catch {
3614
- return false;
3615
- }
3616
- }
3617
- }