@objectstack/plugin-approvals 16.1.0 → 17.0.0-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +1023 -0
- package/dist/index.d.mts +650 -535
- package/dist/index.d.ts +650 -535
- package/dist/index.js +1713 -207
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1711 -197
- package/dist/index.mjs.map +1 -1
- package/package.json +9 -7
- package/scripts/i18n-extract.config.ts +6 -1
- package/src/approval-actor-impersonation.test.ts +330 -0
- package/src/approval-node.test.ts +160 -0
- package/src/approval-node.ts +57 -0
- package/src/approval-revise.test.ts +41 -34
- package/src/approval-service.test.ts +1408 -40
- package/src/approval-service.ts +1364 -107
- package/src/approvals-plugin.ts +36 -5
- package/src/approver-cross-org.integration.test.ts +206 -0
- package/src/approver-org-scope.test.ts +201 -0
- package/src/approver-org-scope.ts +261 -0
- package/src/index.ts +3 -0
- package/src/lifecycle-hooks.ts +22 -0
- package/src/record-lock-schedule-run.integration.test.ts +206 -0
- package/src/status-mirror-cascade.integration.test.ts +224 -0
- package/src/sys-approval-action.object.ts +9 -0
- package/src/sys-approval-delegation.object.test.ts +42 -0
- package/src/sys-approval-delegation.object.ts +3 -3
- package/src/sys-approval-request.object.test.ts +13 -0
- package/src/sys-approval-request.object.ts +17 -5
- package/src/translations/bundle-ownership.test.ts +48 -0
- package/src/translations/en.objects.generated.ts +111 -5
- package/src/translations/es-ES.objects.generated.ts +111 -5
- package/src/translations/ja-JP.objects.generated.ts +111 -5
- package/src/translations/zh-CN.objects.generated.ts +110 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@objectstack/plugin-approvals",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "17.0.0-rc.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "Multi-step approval engine for ObjectStack — sys_approval_process + sys_approval_request + sys_approval_action + IApprovalService.",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -13,17 +13,19 @@
|
|
|
13
13
|
}
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@objectstack/core": "
|
|
17
|
-
"@objectstack/formula": "
|
|
18
|
-
"@objectstack/
|
|
19
|
-
"@objectstack/
|
|
20
|
-
"@objectstack/
|
|
16
|
+
"@objectstack/core": "17.0.0-rc.0",
|
|
17
|
+
"@objectstack/formula": "17.0.0-rc.0",
|
|
18
|
+
"@objectstack/platform-objects": "17.0.0-rc.0",
|
|
19
|
+
"@objectstack/spec": "17.0.0-rc.0",
|
|
20
|
+
"@objectstack/metadata-core": "17.0.0-rc.0"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
23
|
"@types/node": "^26.1.1",
|
|
24
24
|
"typescript": "^6.0.3",
|
|
25
25
|
"vitest": "^4.1.10",
|
|
26
|
-
"@objectstack/
|
|
26
|
+
"@objectstack/objectql": "17.0.0-rc.0",
|
|
27
|
+
"@objectstack/trigger-record-change": "17.0.0-rc.0",
|
|
28
|
+
"@objectstack/service-automation": "17.0.0-rc.0"
|
|
27
29
|
},
|
|
28
30
|
"keywords": [
|
|
29
31
|
"objectstack",
|
|
@@ -8,8 +8,13 @@
|
|
|
8
8
|
* strings were seeded from @objectstack/platform-objects.)
|
|
9
9
|
*
|
|
10
10
|
* os i18n extract packages/plugins/plugin-approvals/scripts/i18n-extract.config.ts \
|
|
11
|
-
* --locales=zh-CN,ja-JP,es-ES --fill=default --objects-only \
|
|
11
|
+
* --locales=zh-CN,ja-JP,es-ES --fill=default --objects-only --no-metadata-forms \
|
|
12
12
|
* --out=packages/plugins/plugin-approvals/src/translations
|
|
13
|
+
*
|
|
14
|
+
* `--no-metadata-forms` because the Studio metadata-form baseline is owned by
|
|
15
|
+
* `@objectstack/platform-objects` — this plugin translates only the objects it
|
|
16
|
+
* owns, so it must not commit a second copy. Add `--check` to run the same
|
|
17
|
+
* command as a drift gate.
|
|
13
18
|
*/
|
|
14
19
|
|
|
15
20
|
import { defineStack } from '@objectstack/spec';
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The acting identity on an approval is the AUTHENTICATED CALLER, never a
|
|
5
|
+
* request-body field.
|
|
6
|
+
*
|
|
7
|
+
* Every mutating entrypoint on the service takes an `actorId` and — before this
|
|
8
|
+
* suite — authorized *that value* rather than the caller behind it. The REST
|
|
9
|
+
* routes fill it from `body.actorId ?? body.actor_id ?? context.userId`, so the
|
|
10
|
+
* body won. An authenticated user could therefore name any pending approver and
|
|
11
|
+
* have that approver's decision recorded, finalized, and the owning flow resumed
|
|
12
|
+
* — or name a request's submitter and recall it.
|
|
13
|
+
*
|
|
14
|
+
* #3783 drew exactly this line for the *data-write* identity (see the
|
|
15
|
+
* `actingUserId` docblock in the service) and left the authorization side
|
|
16
|
+
* body-driven, calling a mislabelled audit row "tolerable". It is not merely a
|
|
17
|
+
* label: `pending_approvers.includes(input.actorId)` is the authorization gate
|
|
18
|
+
* itself, so naming someone else does not just misattribute the row — it is how
|
|
19
|
+
* you get through the door.
|
|
20
|
+
*
|
|
21
|
+
* The tests below are all "mallory is logged in, names someone else". Each one
|
|
22
|
+
* must be FORBIDDEN. The final block is the load-bearing negative: the two
|
|
23
|
+
* legitimate callers that supply an actor with no session behind them — the SLA
|
|
24
|
+
* sweep (a reserved sentinel) and the ADR-0043 action link (a single-use token
|
|
25
|
+
* cryptographically bound to one approver) — must keep working, or the fix has
|
|
26
|
+
* simply broken the feature instead of securing it.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
30
|
+
import { ApprovalService, SLA_ACTOR_ID } from './approval-service.js';
|
|
31
|
+
|
|
32
|
+
interface FakeRow { [k: string]: any }
|
|
33
|
+
|
|
34
|
+
/** Equality/`$in`/`$ne`/`$contains` WHERE matcher — mirrors approval-service.test.ts. */
|
|
35
|
+
function makeFakeEngine() {
|
|
36
|
+
const tables: Record<string, FakeRow[]> = {};
|
|
37
|
+
const ensure = (n: string) => (tables[n] ??= []);
|
|
38
|
+
|
|
39
|
+
function matches(row: FakeRow, filter: any): boolean {
|
|
40
|
+
if (!filter || typeof filter !== 'object') return true;
|
|
41
|
+
for (const [k, v] of Object.entries(filter)) {
|
|
42
|
+
if (k === '$or') {
|
|
43
|
+
if (!(v as any[]).some(sub => matches(row, sub))) return false;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const rv = row[k];
|
|
47
|
+
if (v != null && typeof v === 'object' && '$in' in (v as any)) {
|
|
48
|
+
if (!(v as any).$in.includes(rv)) return false;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (v != null && typeof v === 'object' && '$ne' in (v as any)) {
|
|
52
|
+
if (rv === (v as any).$ne) return false;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (v != null && typeof v === 'object' && '$contains' in (v as any)) {
|
|
56
|
+
if (!String(rv ?? '').includes(String((v as any).$contains))) return false;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (rv !== v) return false;
|
|
60
|
+
}
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
_tables: tables,
|
|
66
|
+
async find(object: string, options?: any) {
|
|
67
|
+
const rows = ensure(object).filter(r => matches(r, options?.filter ?? options?.where));
|
|
68
|
+
if (options?.orderBy?.[0]) {
|
|
69
|
+
const { field, order } = options.orderBy[0];
|
|
70
|
+
rows.sort((a, b) => {
|
|
71
|
+
const av = a[field]; const bv = b[field];
|
|
72
|
+
if (av === bv) return 0;
|
|
73
|
+
const cmp = av > bv ? 1 : -1;
|
|
74
|
+
return order === 'desc' ? -cmp : cmp;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
const start = options?.offset ?? 0;
|
|
78
|
+
return rows.slice(start, start + (options?.limit ?? 1000));
|
|
79
|
+
},
|
|
80
|
+
async insert(object: string, data: any) {
|
|
81
|
+
ensure(object).push({ ...data });
|
|
82
|
+
return { ...data };
|
|
83
|
+
},
|
|
84
|
+
async update(object: string, idOrData: any, _opts?: any) {
|
|
85
|
+
const data = typeof idOrData === 'object' ? idOrData : _opts;
|
|
86
|
+
const id = typeof idOrData === 'object' ? idOrData.id : idOrData;
|
|
87
|
+
const table = ensure(object);
|
|
88
|
+
const i = table.findIndex(r => r.id === id);
|
|
89
|
+
if (i >= 0) table[i] = { ...table[i], ...data };
|
|
90
|
+
return table[i];
|
|
91
|
+
},
|
|
92
|
+
async delete(object: string, options?: any) {
|
|
93
|
+
const table = ensure(object);
|
|
94
|
+
const id = options?.where?.id ?? options?.id;
|
|
95
|
+
const i = table.findIndex(r => r.id === id);
|
|
96
|
+
if (i >= 0) table.splice(i, 1);
|
|
97
|
+
return { id };
|
|
98
|
+
},
|
|
99
|
+
registerHook() {},
|
|
100
|
+
unregisterHooksByPackage() { return 0; },
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The submitter, and the approver whose slot is up for grabs. */
|
|
105
|
+
const SUBMITTER = 'alice';
|
|
106
|
+
const APPROVER = 'bob';
|
|
107
|
+
/** Authenticated, ordinary, and on neither side of the request. */
|
|
108
|
+
const MALLORY = { userId: 'mallory', tenantId: 't1', positions: [], permissions: [] } as any;
|
|
109
|
+
const ALICE = { userId: SUBMITTER, tenantId: 't1', positions: [], permissions: [] } as any;
|
|
110
|
+
const SYS = { isSystem: true, positions: [], permissions: [] } as any;
|
|
111
|
+
|
|
112
|
+
function nodeConfig(approvers: string[], extra: Record<string, any> = {}) {
|
|
113
|
+
return {
|
|
114
|
+
approvers: approvers.map(v => ({ type: 'user' as const, value: v })),
|
|
115
|
+
behavior: 'first_response' as const,
|
|
116
|
+
...extra,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function openInput(approvers: string[], extra: Record<string, any> = {}, configExtra: Record<string, any> = {}) {
|
|
121
|
+
return {
|
|
122
|
+
object: 'opportunity',
|
|
123
|
+
recordId: 'opp1',
|
|
124
|
+
runId: 'run_1',
|
|
125
|
+
nodeId: 'approve_step',
|
|
126
|
+
flowName: 'deal_approval',
|
|
127
|
+
config: nodeConfig(approvers, configExtra),
|
|
128
|
+
record: { id: 'opp1', amount: 100 },
|
|
129
|
+
...extra,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Enough automation surface for the send-back path's ADR-0044 guards to pass,
|
|
135
|
+
* so a `sendBack` / `resubmit` test fails on the ACTOR check or not at all —
|
|
136
|
+
* never on a missing `revise` out-edge.
|
|
137
|
+
*/
|
|
138
|
+
function makeAutomationStub() {
|
|
139
|
+
const resumed: any[] = [];
|
|
140
|
+
const cancelled: string[] = [];
|
|
141
|
+
return {
|
|
142
|
+
resumed,
|
|
143
|
+
cancelled,
|
|
144
|
+
async getFlow() {
|
|
145
|
+
return {
|
|
146
|
+
name: 'deal_approval',
|
|
147
|
+
nodes: [{ id: 'approve_step', type: 'approval' }, { id: 'wait_revision', type: 'wait' }],
|
|
148
|
+
edges: [
|
|
149
|
+
{ id: 'e1', source: 'approve_step', target: 'ok', label: 'approve' },
|
|
150
|
+
{ id: 'e2', source: 'approve_step', target: 'no', label: 'reject' },
|
|
151
|
+
{ id: 'e3', source: 'approve_step', target: 'wait_revision', label: 'revise' },
|
|
152
|
+
{ id: 'e4', source: 'wait_revision', target: 'approve_step', label: 'resubmit', type: 'back' },
|
|
153
|
+
],
|
|
154
|
+
};
|
|
155
|
+
},
|
|
156
|
+
async resume(runId: string, signal: any) { resumed.push({ runId, signal }); },
|
|
157
|
+
async cancelRun(runId: string) { cancelled.push(runId); },
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
describe('approvals: the actor is the authenticated caller, not a body field', () => {
|
|
162
|
+
let engine: ReturnType<typeof makeFakeEngine>;
|
|
163
|
+
let svc: ApprovalService;
|
|
164
|
+
let n = 0;
|
|
165
|
+
const baseTime = new Date('2026-01-15T10:00:00Z').getTime();
|
|
166
|
+
|
|
167
|
+
beforeEach(() => {
|
|
168
|
+
engine = makeFakeEngine();
|
|
169
|
+
n = 0;
|
|
170
|
+
svc = new ApprovalService({
|
|
171
|
+
engine: engine as any,
|
|
172
|
+
clock: { now: () => new Date(baseTime + (n++) * 1000) },
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
/** Open a request submitted by alice and pending on bob. */
|
|
177
|
+
const open = (approvers = [APPROVER], configExtra: Record<string, any> = {}) =>
|
|
178
|
+
svc.openNodeRequest(openInput(approvers, {}, configExtra), ALICE);
|
|
179
|
+
|
|
180
|
+
// ── the decision itself ─────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
it('decideNode: mallory cannot approve by naming the pending approver', async () => {
|
|
183
|
+
const req = await open();
|
|
184
|
+
await expect(
|
|
185
|
+
svc.decideNode(req.id, { decision: 'approve', actorId: APPROVER }, MALLORY),
|
|
186
|
+
).rejects.toThrow(/FORBIDDEN/);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('decideNode: a refused impersonation writes no audit row and leaves the request pending', async () => {
|
|
190
|
+
const req = await open();
|
|
191
|
+
await expect(
|
|
192
|
+
svc.decideNode(req.id, { decision: 'approve', actorId: APPROVER }, MALLORY),
|
|
193
|
+
).rejects.toThrow(/FORBIDDEN/);
|
|
194
|
+
|
|
195
|
+
const decisions = (engine._tables['sys_approval_action'] ?? [])
|
|
196
|
+
.filter((a: any) => a.action === 'approve' || a.action === 'reject');
|
|
197
|
+
expect(decisions).toHaveLength(0);
|
|
198
|
+
expect(engine._tables['sys_approval_request'][0].status).toBe('pending');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('decideNode: mallory cannot reject by naming the pending approver', async () => {
|
|
202
|
+
const req = await open();
|
|
203
|
+
await expect(
|
|
204
|
+
svc.decideNode(req.id, { decision: 'reject', actorId: APPROVER, comment: 'no' }, MALLORY),
|
|
205
|
+
).rejects.toThrow(/FORBIDDEN/);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('decide: the flow is not resumed by an impersonated decision', async () => {
|
|
209
|
+
const resumed: any[] = [];
|
|
210
|
+
svc.attachAutomation({ async resume(runId: string, signal: any) { resumed.push({ runId, signal }); } } as any);
|
|
211
|
+
const req = await open();
|
|
212
|
+
await expect(
|
|
213
|
+
svc.decide(req.id, { decision: 'approve', actorId: APPROVER }, MALLORY),
|
|
214
|
+
).rejects.toThrow(/FORBIDDEN/);
|
|
215
|
+
expect(resumed).toHaveLength(0);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('decideNode: a unanimous slate cannot be filled by one user naming the others', async () => {
|
|
219
|
+
const req = await open(['bob', 'carol'], { behavior: 'unanimous' });
|
|
220
|
+
const asBob = { userId: 'bob', tenantId: 't1', positions: [], permissions: [] } as any;
|
|
221
|
+
const first = await svc.decideNode(req.id, { decision: 'approve', actorId: 'bob' }, asBob);
|
|
222
|
+
expect(first.finalized).toBe(false);
|
|
223
|
+
// Bob holds a slot, so he clears the "is a pending approver" gate — but the
|
|
224
|
+
// slot he clears it with is his own, not carol's.
|
|
225
|
+
await expect(
|
|
226
|
+
svc.decideNode(req.id, { decision: 'approve', actorId: 'carol' }, asBob),
|
|
227
|
+
).rejects.toThrow(/FORBIDDEN/);
|
|
228
|
+
expect(engine._tables['sys_approval_request'][0].status).toBe('pending');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// ── the submitter-only moves ────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
it('recall: mallory cannot withdraw the request by naming its submitter', async () => {
|
|
234
|
+
const req = await open();
|
|
235
|
+
await expect(
|
|
236
|
+
svc.recall(req.id, { actorId: SUBMITTER, comment: 'gone' }, MALLORY),
|
|
237
|
+
).rejects.toThrow(/FORBIDDEN/);
|
|
238
|
+
expect(engine._tables['sys_approval_request'][0].status).toBe('pending');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('resubmit: mallory cannot resubmit a returned request by naming its submitter', async () => {
|
|
242
|
+
svc.attachAutomation(makeAutomationStub() as any);
|
|
243
|
+
const req = await open();
|
|
244
|
+
const asBob = { userId: APPROVER, tenantId: 't1', positions: [], permissions: [] } as any;
|
|
245
|
+
await svc.sendBack(req.id, { actorId: APPROVER, comment: 'fix it' }, asBob);
|
|
246
|
+
expect(engine._tables['sys_approval_request'][0].status).toBe('returned');
|
|
247
|
+
await expect(
|
|
248
|
+
svc.resubmit(req.id, { actorId: SUBMITTER, comment: 'done' }, MALLORY),
|
|
249
|
+
).rejects.toThrow(/FORBIDDEN/);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('sendBack: mallory cannot return the request by naming the pending approver', async () => {
|
|
253
|
+
const req = await open();
|
|
254
|
+
await expect(
|
|
255
|
+
svc.sendBack(req.id, { actorId: APPROVER, comment: 'revise' }, MALLORY),
|
|
256
|
+
).rejects.toThrow(/FORBIDDEN/);
|
|
257
|
+
expect(engine._tables['sys_approval_request'][0].status).toBe('pending');
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// ── the thread moves ────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
it('reassign: mallory cannot move the slot by naming its holder', async () => {
|
|
263
|
+
const req = await open();
|
|
264
|
+
await expect(
|
|
265
|
+
svc.reassign(req.id, { actorId: APPROVER, to: 'mallory' }, MALLORY),
|
|
266
|
+
).rejects.toThrow(/FORBIDDEN/);
|
|
267
|
+
expect(engine._tables['sys_approval_request'][0].pending_approvers).toBe(APPROVER);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('requestInfo: mallory cannot post as the pending approver', async () => {
|
|
271
|
+
const req = await open();
|
|
272
|
+
await expect(
|
|
273
|
+
svc.requestInfo(req.id, { actorId: APPROVER, comment: 'send the contract' }, MALLORY),
|
|
274
|
+
).rejects.toThrow(/FORBIDDEN/);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('comment: mallory cannot post to the thread as the submitter', async () => {
|
|
278
|
+
const req = await open();
|
|
279
|
+
await expect(
|
|
280
|
+
svc.comment(req.id, { actorId: SUBMITTER, comment: 'looks fine to me' }, MALLORY),
|
|
281
|
+
).rejects.toThrow(/FORBIDDEN/);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('remind: mallory cannot nudge as the submitter', async () => {
|
|
285
|
+
const req = await open();
|
|
286
|
+
await expect(
|
|
287
|
+
svc.remind(req.id, { actorId: SUBMITTER }, MALLORY),
|
|
288
|
+
).rejects.toThrow(/FORBIDDEN/);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// ── the legitimate paths must survive ───────────────────────────
|
|
292
|
+
//
|
|
293
|
+
// Without these, "reject every actorId that isn't the caller" would pass the
|
|
294
|
+
// suite above by breaking the SLA sweep and the emailed action link — the two
|
|
295
|
+
// callers that hold a trustworthy actor with no session behind it.
|
|
296
|
+
|
|
297
|
+
it('the real approver still decides their own slot', async () => {
|
|
298
|
+
const req = await open();
|
|
299
|
+
const asBob = { userId: APPROVER, tenantId: 't1', positions: [], permissions: [] } as any;
|
|
300
|
+
const out = await svc.decideNode(req.id, { decision: 'approve', actorId: APPROVER }, asBob);
|
|
301
|
+
expect(out.finalized).toBe(true);
|
|
302
|
+
expect(out.request.status).toBe('approved');
|
|
303
|
+
const row = (engine._tables['sys_approval_action'] ?? []).find((a: any) => a.action === 'approve');
|
|
304
|
+
expect(row.actor_id).toBe(APPROVER);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('the real submitter still recalls their own request', async () => {
|
|
308
|
+
const req = await open();
|
|
309
|
+
const out = await svc.recall(req.id, { actorId: SUBMITTER }, ALICE);
|
|
310
|
+
expect(out.request.status).toBe('recalled');
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('a system context may still name an actor with no session behind it (SLA sweep)', async () => {
|
|
314
|
+
const req = await open();
|
|
315
|
+
const out = await svc.decideNode(req.id, { decision: 'approve', actorId: SLA_ACTOR_ID }, SYS);
|
|
316
|
+
expect(out.finalized).toBe(true);
|
|
317
|
+
const row = (engine._tables['sys_approval_action'] ?? []).find((a: any) => a.action === 'approve');
|
|
318
|
+
expect(row.actor_id).toBe(SLA_ACTOR_ID);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('a privileged admin may still override a stuck request', async () => {
|
|
322
|
+
const req = await open(['position:cfo']);
|
|
323
|
+
const admin = {
|
|
324
|
+
userId: 'root', tenantId: 't1', positions: [], permissions: ['admin_full_access'],
|
|
325
|
+
} as any;
|
|
326
|
+
const out = await svc.decideNode(req.id, { decision: 'approve', actorId: 'root' }, admin);
|
|
327
|
+
expect(out.finalized).toBe(true);
|
|
328
|
+
expect(out.request.status).toBe('approved');
|
|
329
|
+
});
|
|
330
|
+
});
|
|
@@ -154,6 +154,46 @@ describe('Approval node bridge (ADR-0019)', () => {
|
|
|
154
154
|
expect(paused.runId).toBeDefined();
|
|
155
155
|
});
|
|
156
156
|
|
|
157
|
+
// ── resume authorization gate (#3801) ───────────────────────────────
|
|
158
|
+
//
|
|
159
|
+
// `POST /automation/:name/runs/:runId/resume` reaches
|
|
160
|
+
// `AutomationEngine.resume` with a caller-supplied signal. Before the gate,
|
|
161
|
+
// the only thing between that route and the approvals rules was convention —
|
|
162
|
+
// a comment in the showcase. These pin the enforcement.
|
|
163
|
+
|
|
164
|
+
it('declares the approval node resumable only by its owning service', () => {
|
|
165
|
+
const approval = automation.getActionDescriptors().find(d => d.type === 'approval');
|
|
166
|
+
expect(approval!.resumeAuthority).toBe('service');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('refuses a raw engine resume of an approval pause, leaving the request untouched', async () => {
|
|
170
|
+
registerDecisionFlow(automation, [{ type: 'user', value: 'u1' }]);
|
|
171
|
+
const paused = await automation.execute('deal_approval', {
|
|
172
|
+
object: 'crm_deal', record: { id: 'd1' }, userId: 'submitter',
|
|
173
|
+
});
|
|
174
|
+
const request = (await fake.find('sys_approval_request', { where: { status: 'pending' } }))[0];
|
|
175
|
+
|
|
176
|
+
// Exactly the signal the resume route builds from `{ branchLabel }`.
|
|
177
|
+
const refused = await automation.resume(paused.runId!, {
|
|
178
|
+
branchLabel: 'approve', output: { decision: 'approve' },
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
expect(refused).toMatchObject({ success: false, code: 'forbidden' });
|
|
182
|
+
// The approve branch did NOT run…
|
|
183
|
+
expect(marks).toHaveLength(0);
|
|
184
|
+
// …the request is still pending, with no decision recorded…
|
|
185
|
+
const stillPending = (await fake.find('sys_approval_request', { where: { id: request.id } }))[0];
|
|
186
|
+
expect(stillPending.status).toBe('pending');
|
|
187
|
+
const actions = await fake.find('sys_approval_action', { where: { request_id: request.id } });
|
|
188
|
+
expect(actions.map((a: any) => a.action)).toEqual(['submit']);
|
|
189
|
+
// …and the run is still parked, so the real decision can still land.
|
|
190
|
+
expect(automation.listSuspendedRuns()).toHaveLength(1);
|
|
191
|
+
|
|
192
|
+
const out = await service.decide(request.id, { decision: 'approve', actorId: 'u1' }, SYSTEM_CTX);
|
|
193
|
+
expect(out).toMatchObject({ finalized: true, resumed: true });
|
|
194
|
+
expect(marks).toEqual(['on_approved']);
|
|
195
|
+
});
|
|
196
|
+
|
|
157
197
|
it('resumes down the reject branch on rejection', async () => {
|
|
158
198
|
registerDecisionFlow(automation, [{ type: 'user', value: 'u1' }]);
|
|
159
199
|
await automation.execute('deal_approval', { object: 'crm_deal', record: { id: 'd1' } });
|
|
@@ -193,4 +233,124 @@ describe('Approval node bridge (ADR-0019)', () => {
|
|
|
193
233
|
service.decideNode(request.id, { decision: 'approve', actorId: 'intruder' }, { isSystem: false, positions: [], permissions: [] } as any),
|
|
194
234
|
).rejects.toThrow(/FORBIDDEN/);
|
|
195
235
|
});
|
|
236
|
+
|
|
237
|
+
// ── #3447 P2: dynamic approvers end-to-end ────────────────────────
|
|
238
|
+
//
|
|
239
|
+
// The issue's headline scenario as one flow: the first approver PICKS the
|
|
240
|
+
// next step's approvers in their decision, the next approval node resolves
|
|
241
|
+
// them from `vars.*` at entry — no record-field detour, no snapshot staleness.
|
|
242
|
+
|
|
243
|
+
it('decide outputs feed the NEXT approval node via vars.<nodeId>.<key> (#3447 P2)', async () => {
|
|
244
|
+
automation.registerFlow('two_stage', {
|
|
245
|
+
name: 'two_stage',
|
|
246
|
+
label: 'Two Stage',
|
|
247
|
+
type: 'autolaunched',
|
|
248
|
+
nodes: [
|
|
249
|
+
{ id: 'start', type: 'start', label: 'Start' },
|
|
250
|
+
{
|
|
251
|
+
id: 'lead_review', type: 'approval', label: 'Lead Review',
|
|
252
|
+
config: { approvers: [{ type: 'user', value: 'lead' }], decisionOutputs: ['next_reviewers'] },
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
id: 'co_sign', type: 'approval', label: 'Co-sign',
|
|
256
|
+
config: {
|
|
257
|
+
approvers: [{ type: 'expression', value: 'vars.lead_review.next_reviewers' }],
|
|
258
|
+
behavior: 'unanimous',
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
{ id: 'on_approved', type: 'mark', label: 'Approved' },
|
|
262
|
+
{ id: 'on_rejected', type: 'mark', label: 'Rejected' },
|
|
263
|
+
],
|
|
264
|
+
edges: [
|
|
265
|
+
{ id: 'e1', source: 'start', target: 'lead_review' },
|
|
266
|
+
{ id: 'e2', source: 'lead_review', target: 'co_sign', label: 'approve' },
|
|
267
|
+
{ id: 'e3', source: 'lead_review', target: 'on_rejected', label: 'reject' },
|
|
268
|
+
{ id: 'e4', source: 'co_sign', target: 'on_approved', label: 'approve' },
|
|
269
|
+
{ id: 'e5', source: 'co_sign', target: 'on_rejected', label: 'reject' },
|
|
270
|
+
],
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const paused = await automation.execute('two_stage', {
|
|
274
|
+
object: 'crm_deal', record: { id: 'd1' }, userId: 'submitter',
|
|
275
|
+
});
|
|
276
|
+
expect(paused.status).toBe('paused');
|
|
277
|
+
|
|
278
|
+
// The lead approves AND hands the co-reviewers to the flow.
|
|
279
|
+
const first = (await fake.find('sys_approval_request', { where: { status: 'pending' } }))[0];
|
|
280
|
+
await service.decide(first.id, {
|
|
281
|
+
decision: 'approve', actorId: 'lead', outputs: { next_reviewers: ['u2', 'u3'] },
|
|
282
|
+
}, SYSTEM_CTX);
|
|
283
|
+
|
|
284
|
+
// The co-sign node resolved its slate from the lead's decision outputs.
|
|
285
|
+
const second = (await fake.find('sys_approval_request', { where: { status: 'pending' } }))[0];
|
|
286
|
+
expect(second).toBeDefined();
|
|
287
|
+
expect(second.flow_node_id).toBe('co_sign');
|
|
288
|
+
expect(String(second.pending_approvers).split(',').sort()).toEqual(['u2', 'u3']);
|
|
289
|
+
expect(marks).toHaveLength(0);
|
|
290
|
+
|
|
291
|
+
// Both picked reviewers sign off → the run completes down `approve`.
|
|
292
|
+
await service.decide(second.id, { decision: 'approve', actorId: 'u2' }, SYSTEM_CTX);
|
|
293
|
+
await service.decide(second.id, { decision: 'approve', actorId: 'u3' }, SYSTEM_CTX);
|
|
294
|
+
expect(marks).toEqual(['on_approved']);
|
|
295
|
+
expect(automation.listSuspendedRuns()).toHaveLength(0);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('expression current.* resolves against the LIVE row at node entry (#3447 P2)', async () => {
|
|
299
|
+
fake.tables.set('crm_deal', [{ id: 'd1', reviewers: ['u7', 'u8'] }]);
|
|
300
|
+
registerDecisionFlow(automation, [{ type: 'expression', value: 'current.reviewers' }], 'unanimous');
|
|
301
|
+
// The trigger snapshot carries an EMPTY reviewers field — only the live
|
|
302
|
+
// row names them, exactly the mid-flow-written-field shape of the issue.
|
|
303
|
+
await automation.execute('deal_approval', {
|
|
304
|
+
object: 'crm_deal', record: { id: 'd1', reviewers: [] }, userId: 'submitter',
|
|
305
|
+
});
|
|
306
|
+
const request = (await fake.find('sys_approval_request', { where: { status: 'pending' } }))[0];
|
|
307
|
+
expect(String(request.pending_approvers).split(',').sort()).toEqual(['u7', 'u8']);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("onEmptyApprovers 'auto_approve' completes down the approve edge without suspending (#3447 P2)", async () => {
|
|
311
|
+
automation.registerFlow('auto_ok', {
|
|
312
|
+
name: 'auto_ok',
|
|
313
|
+
label: 'Auto OK',
|
|
314
|
+
type: 'autolaunched',
|
|
315
|
+
nodes: [
|
|
316
|
+
{ id: 'start', type: 'start', label: 'Start' },
|
|
317
|
+
{
|
|
318
|
+
id: 'gate', type: 'approval', label: 'Gate',
|
|
319
|
+
config: {
|
|
320
|
+
// Present-but-empty (a missing key would fail loudly instead).
|
|
321
|
+
approvers: [{ type: 'expression', value: 'trigger.reviewers' }],
|
|
322
|
+
onEmptyApprovers: 'auto_approve',
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
{ id: 'on_approved', type: 'mark', label: 'Approved' },
|
|
326
|
+
{ id: 'on_rejected', type: 'mark', label: 'Rejected' },
|
|
327
|
+
],
|
|
328
|
+
edges: [
|
|
329
|
+
{ id: 'e1', source: 'start', target: 'gate' },
|
|
330
|
+
{ id: 'e2', source: 'gate', target: 'on_approved', label: 'approve' },
|
|
331
|
+
{ id: 'e3', source: 'gate', target: 'on_rejected', label: 'reject' },
|
|
332
|
+
],
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
const result = await automation.execute('auto_ok', {
|
|
336
|
+
object: 'crm_deal', record: { id: 'd1', reviewers: [] }, userId: 'submitter',
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// No pause, no request row — the empty slate waved through, down `approve`
|
|
340
|
+
// ONLY (the branchLabel wiring; unlabelled traversal would hit both marks).
|
|
341
|
+
expect(result.status).not.toBe('paused');
|
|
342
|
+
expect(marks).toEqual(['on_approved']);
|
|
343
|
+
expect(await fake.find('sys_approval_request', {})).toHaveLength(0);
|
|
344
|
+
expect(automation.listSuspendedRuns()).toHaveLength(0);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('an expression referencing `record` fails the node loudly, not as an empty slate (#3447 P2)', async () => {
|
|
348
|
+
registerDecisionFlow(automation, [{ type: 'expression', value: 'record.reviewers' }]);
|
|
349
|
+
const result = await automation.execute('deal_approval', {
|
|
350
|
+
object: 'crm_deal', record: { id: 'd1', reviewers: ['u1'] }, userId: 'submitter',
|
|
351
|
+
});
|
|
352
|
+
expect(result.success).toBe(false);
|
|
353
|
+
expect(String(result.error ?? '')).toMatch(/current\.<field>|current\./);
|
|
354
|
+
expect(await fake.find('sys_approval_request', {})).toHaveLength(0);
|
|
355
|
+
});
|
|
196
356
|
});
|
package/src/approval-node.ts
CHANGED
|
@@ -38,6 +38,12 @@ export interface ApprovalAutomationSurface {
|
|
|
38
38
|
error?: string;
|
|
39
39
|
suspend?: boolean;
|
|
40
40
|
correlation?: string;
|
|
41
|
+
/**
|
|
42
|
+
* #3447 P2: walk this labelled out-edge on normal (non-suspend)
|
|
43
|
+
* completion — how an `onEmptyApprovers: 'auto_approve'` node continues
|
|
44
|
+
* down `approve` without a decision. Mirrors NodeExecutionResult.
|
|
45
|
+
*/
|
|
46
|
+
branchLabel?: string;
|
|
41
47
|
}>;
|
|
42
48
|
}): void;
|
|
43
49
|
resume?(runId: string, signal?: { output?: Record<string, unknown>; branchLabel?: string }): Promise<unknown>;
|
|
@@ -50,6 +56,29 @@ interface MinimalLogger {
|
|
|
50
56
|
|
|
51
57
|
const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;
|
|
52
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Rebuild the nested object the engine's CEL conditions see from the flow's
|
|
61
|
+
* flat variable Map — dotted keys (`get_rec.record`) become nested paths, so an
|
|
62
|
+
* `expression` approver's `vars.get_rec.record.owner_id` reads exactly like a
|
|
63
|
+
* condition's. Mirrors the engine's own evaluateCondition rebuild; kept local
|
|
64
|
+
* because this plugin deliberately does not depend on service-automation.
|
|
65
|
+
*/
|
|
66
|
+
function nestVariables(variables: Map<string, unknown>): Record<string, unknown> {
|
|
67
|
+
const vars: Record<string, unknown> = {};
|
|
68
|
+
for (const [key, value] of variables) {
|
|
69
|
+
const segs = key.split('.');
|
|
70
|
+
let cursor = vars;
|
|
71
|
+
for (let i = 0; i < segs.length - 1; i++) {
|
|
72
|
+
if (typeof cursor[segs[i]] !== 'object' || cursor[segs[i]] === null) {
|
|
73
|
+
cursor[segs[i]] = {};
|
|
74
|
+
}
|
|
75
|
+
cursor = cursor[segs[i]] as Record<string, unknown>;
|
|
76
|
+
}
|
|
77
|
+
cursor[segs[segs.length - 1]] = value;
|
|
78
|
+
}
|
|
79
|
+
return vars;
|
|
80
|
+
}
|
|
81
|
+
|
|
53
82
|
/**
|
|
54
83
|
* Register the `approval` node executor on the automation engine. Idempotent at
|
|
55
84
|
* the engine level (re-registering replaces). Safe to skip when no automation
|
|
@@ -75,6 +104,15 @@ export function registerApprovalNode(
|
|
|
75
104
|
// Human decision: the run suspends here awaiting an external reply.
|
|
76
105
|
supportsPause: true,
|
|
77
106
|
isAsync: true,
|
|
107
|
+
// #3801: this pause is NOT resumable through the generic run-resume
|
|
108
|
+
// route. Continuing an approval is a side effect of a DECISION, and the
|
|
109
|
+
// decision is the thing that must be authorized (the approver slate),
|
|
110
|
+
// recorded (`sys_approval_action`) and mirrored (the status field) —
|
|
111
|
+
// all of which lives in `ApprovalService.decide`. The engine now refuses
|
|
112
|
+
// any resume of an approval suspension that does not carry the service's
|
|
113
|
+
// in-process marker, so "decide via the approvals API, never a raw engine
|
|
114
|
+
// resume" is enforced rather than merely documented.
|
|
115
|
+
resumeAuthority: 'service',
|
|
78
116
|
// Publish the node's config contract (ADR-0018 §configSchema) so the
|
|
79
117
|
// Studio flow designer renders the Approval property form from the engine
|
|
80
118
|
// rather than a hardcoded client form — the engine owns the shape.
|
|
@@ -116,6 +154,10 @@ export function registerApprovalNode(
|
|
|
116
154
|
submitterId: context?.userId ?? null,
|
|
117
155
|
record,
|
|
118
156
|
organizationId: context?.organizationId ?? context?.tenantId ?? null,
|
|
157
|
+
// #3447 P2: flow variables (nested, as CEL conditions see them) — the
|
|
158
|
+
// `vars.*` root for `expression` approvers; `record` above doubles as
|
|
159
|
+
// their `trigger.*` snapshot root.
|
|
160
|
+
variables: nestVariables(variables),
|
|
119
161
|
}, {
|
|
120
162
|
...SYSTEM_CTX,
|
|
121
163
|
userId: context?.userId,
|
|
@@ -123,6 +165,21 @@ export function registerApprovalNode(
|
|
|
123
165
|
tenantId: context?.tenantId,
|
|
124
166
|
} as unknown as SharingExecutionContext);
|
|
125
167
|
|
|
168
|
+
// #3447 P2: empty slate + onEmptyApprovers: 'auto_approve' — nobody to
|
|
169
|
+
// ask, no request row. Complete (don't suspend) straight down the
|
|
170
|
+
// `approve` edge; `autoApproved` on the output keeps the waved-through
|
|
171
|
+
// hop distinguishable from a human decision in the run trace.
|
|
172
|
+
if ('autoApproved' in request) {
|
|
173
|
+
logger?.info?.('[approvals] approval node auto-approved (empty approver slate)', {
|
|
174
|
+
node: node.id, run: String(runId),
|
|
175
|
+
});
|
|
176
|
+
return {
|
|
177
|
+
success: true,
|
|
178
|
+
branchLabel: 'approve',
|
|
179
|
+
output: { decision: 'approve', autoApproved: true },
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
126
183
|
logger?.info?.('[approvals] approval node suspended run', {
|
|
127
184
|
node: node.id, request: request.id, run: String(runId),
|
|
128
185
|
});
|