@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.
Files changed (34) hide show
  1. package/.turbo/turbo-build.log +10 -10
  2. package/CHANGELOG.md +1023 -0
  3. package/dist/index.d.mts +650 -535
  4. package/dist/index.d.ts +650 -535
  5. package/dist/index.js +1713 -207
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +1711 -197
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +9 -7
  10. package/scripts/i18n-extract.config.ts +6 -1
  11. package/src/approval-actor-impersonation.test.ts +330 -0
  12. package/src/approval-node.test.ts +160 -0
  13. package/src/approval-node.ts +57 -0
  14. package/src/approval-revise.test.ts +41 -34
  15. package/src/approval-service.test.ts +1408 -40
  16. package/src/approval-service.ts +1364 -107
  17. package/src/approvals-plugin.ts +36 -5
  18. package/src/approver-cross-org.integration.test.ts +206 -0
  19. package/src/approver-org-scope.test.ts +201 -0
  20. package/src/approver-org-scope.ts +261 -0
  21. package/src/index.ts +3 -0
  22. package/src/lifecycle-hooks.ts +22 -0
  23. package/src/record-lock-schedule-run.integration.test.ts +206 -0
  24. package/src/status-mirror-cascade.integration.test.ts +224 -0
  25. package/src/sys-approval-action.object.ts +9 -0
  26. package/src/sys-approval-delegation.object.test.ts +42 -0
  27. package/src/sys-approval-delegation.object.ts +3 -3
  28. package/src/sys-approval-request.object.test.ts +13 -0
  29. package/src/sys-approval-request.object.ts +17 -5
  30. package/src/translations/bundle-ownership.test.ts +48 -0
  31. package/src/translations/en.objects.generated.ts +111 -5
  32. package/src/translations/es-ES.objects.generated.ts +111 -5
  33. package/src/translations/ja-JP.objects.generated.ts +111 -5
  34. package/src/translations/zh-CN.objects.generated.ts +110 -4
@@ -20,7 +20,14 @@ import { registerApprovalNode } from './approval-node.js';
20
20
  import { bindApprovalLockHook, APPROVALS_HOOK_PACKAGE } from './lifecycle-hooks.js';
21
21
 
22
22
  const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as any;
23
- const USER_CTX = { isSystem: false, positions: [], permissions: [] } as any;
23
+
24
+ /**
25
+ * The signed-in caller. An approval action is recorded against the
26
+ * AUTHENTICATED caller (#3800), so each call below presents the context of the
27
+ * person it names — an identity-less context can no longer act by naming one.
28
+ */
29
+ const asUser = (userId: string) =>
30
+ ({ isSystem: false, userId, positions: [], permissions: [] }) as any;
24
31
 
25
32
  const noopLogger = { info() {}, warn() {}, error() {}, debug() {} };
26
33
 
@@ -156,7 +163,7 @@ describe('Send back for revision (ADR-0044)', () => {
156
163
  registerReviseFlow();
157
164
  const { runId, req } = await startFlow();
158
165
 
159
- const sent = await service.sendBack(req.id, { actorId: 'u1', comment: 'fix the totals' }, USER_CTX);
166
+ const sent = await service.sendBack(req.id, { actorId: 'u1', comment: 'fix the totals' }, asUser('u1'));
160
167
  expect(sent.resumed).toBe(true);
161
168
  expect(sent.autoRejected).toBeUndefined();
162
169
  expect(sent.request.status).toBe('returned');
@@ -166,7 +173,7 @@ describe('Send back for revision (ADR-0044)', () => {
166
173
  expect(automation.listSuspendedRuns()).toMatchObject([{ runId, nodeId: 'wait_revision' }]);
167
174
  expect(await actionsOf(req.id)).toEqual(['submit', 'revise']);
168
175
 
169
- const re = await service.resubmit(req.id, { actorId: 'submitter', comment: 'totals fixed' }, USER_CTX);
176
+ const re = await service.resubmit(req.id, { actorId: 'submitter', comment: 'totals fixed' }, asUser('submitter'));
170
177
  expect(re.resumed).toBe(true);
171
178
  expect(await actionsOf(req.id)).toEqual(['submit', 'revise', 'resubmit']);
172
179
 
@@ -189,13 +196,13 @@ describe('Send back for revision (ADR-0044)', () => {
189
196
  const { req } = await startFlow();
190
197
  expect((await service.getRequest(req.id, SYSTEM_CTX))?.round).toBeUndefined(); // round 1
191
198
 
192
- await service.sendBack(req.id, { actorId: 'u1' }, USER_CTX);
193
- await service.resubmit(req.id, { actorId: 'submitter' }, USER_CTX);
199
+ await service.sendBack(req.id, { actorId: 'u1' }, asUser('u1'));
200
+ await service.resubmit(req.id, { actorId: 'submitter' }, asUser('submitter'));
194
201
  const round2 = await pendingReq();
195
202
  expect((await service.getRequest(round2.id, SYSTEM_CTX))?.round).toBe(2);
196
203
 
197
- await service.sendBack(round2.id, { actorId: 'u1' }, USER_CTX);
198
- await service.resubmit(round2.id, { actorId: 'submitter' }, USER_CTX);
204
+ await service.sendBack(round2.id, { actorId: 'u1' }, asUser('u1'));
205
+ await service.resubmit(round2.id, { actorId: 'submitter' }, asUser('submitter'));
199
206
  const round3 = await pendingReq();
200
207
  expect((await service.getRequest(round3.id, SYSTEM_CTX))?.round).toBe(3);
201
208
  });
@@ -205,12 +212,12 @@ describe('Send back for revision (ADR-0044)', () => {
205
212
  const { req } = await startFlow();
206
213
 
207
214
  // Send-back #1 fits the budget.
208
- await service.sendBack(req.id, { actorId: 'u1' }, USER_CTX);
209
- await service.resubmit(req.id, { actorId: 'submitter' }, USER_CTX);
215
+ await service.sendBack(req.id, { actorId: 'u1' }, asUser('u1'));
216
+ await service.resubmit(req.id, { actorId: 'submitter' }, asUser('submitter'));
210
217
  const round2 = await pendingReq();
211
218
 
212
219
  // Send-back #2 exceeds it → auto-reject, flow takes the reject branch.
213
- const out = await service.sendBack(round2.id, { actorId: 'u1', comment: 'still wrong' }, USER_CTX);
220
+ const out = await service.sendBack(round2.id, { actorId: 'u1', comment: 'still wrong' }, asUser('u1'));
214
221
  expect(out.autoRejected).toBe(true);
215
222
  expect(out.resumed).toBe(true);
216
223
  expect(out.request.status).toBe('rejected');
@@ -224,7 +231,7 @@ describe('Send back for revision (ADR-0044)', () => {
224
231
  it('maxRevisions 0 disables send-back (immediate auto-reject)', async () => {
225
232
  registerReviseFlow({ maxRevisions: 0 });
226
233
  const { req } = await startFlow();
227
- const out = await service.sendBack(req.id, { actorId: 'u1' }, USER_CTX);
234
+ const out = await service.sendBack(req.id, { actorId: 'u1' }, asUser('u1'));
228
235
  expect(out.autoRejected).toBe(true);
229
236
  expect(marks).toEqual(['on_rejected']);
230
237
  });
@@ -246,7 +253,7 @@ describe('Send back for revision (ADR-0044)', () => {
246
253
  await automation.execute('no_revise', { object: 'fin_expense', record: { id: 'x2' }, userId: 'submitter' });
247
254
  const req = await pendingReq();
248
255
 
249
- await expect(service.sendBack(req.id, { actorId: 'u1' }, USER_CTX)).rejects.toThrow(/no 'revise' out-edge/);
256
+ await expect(service.sendBack(req.id, { actorId: 'u1' }, asUser('u1'))).rejects.toThrow(/no 'revise' out-edge/);
250
257
  // Nothing moved: still pending, no revise audit row.
251
258
  expect((await fake.find('sys_approval_request', { where: { id: req.id } }))[0].status).toBe('pending');
252
259
  expect(await actionsOf(req.id)).toEqual(['submit']);
@@ -264,10 +271,10 @@ describe('Send back for revision (ADR-0044)', () => {
264
271
  expect(first.finalized).toBe(false);
265
272
 
266
273
  // u2 sends back instead: finalizes despite u1's earlier approval.
267
- const sent = await service.sendBack(req.id, { actorId: 'u2', comment: 'rework' }, USER_CTX);
274
+ const sent = await service.sendBack(req.id, { actorId: 'u2', comment: 'rework' }, asUser('u2'));
268
275
  expect(sent.request.status).toBe('returned');
269
276
 
270
- await service.resubmit(req.id, { actorId: 'submitter' }, USER_CTX);
277
+ await service.resubmit(req.id, { actorId: 'submitter' }, asUser('submitter'));
271
278
  const round2 = await pendingReq();
272
279
  // Fresh slate: BOTH approvers pending again — prior approvals are stale.
273
280
  expect((round2.pending_approvers as string).split(',').sort()).toEqual(['u1', 'u2']);
@@ -292,21 +299,21 @@ describe('Send back for revision (ADR-0044)', () => {
292
299
  });
293
300
 
294
301
  await expect(editAttempt()).rejects.toThrow(/RECORD_LOCKED/); // pending → locked
295
- await service.sendBack(req.id, { actorId: 'u1' }, USER_CTX);
302
+ await service.sendBack(req.id, { actorId: 'u1' }, asUser('u1'));
296
303
  await expect(editAttempt()).resolves.toBeUndefined(); // returned → unlocked
297
- await service.resubmit(req.id, { actorId: 'submitter' }, USER_CTX);
304
+ await service.resubmit(req.id, { actorId: 'submitter' }, asUser('submitter'));
298
305
  await expect(editAttempt()).rejects.toThrow(/RECORD_LOCKED/); // round 2 pending → re-locked
299
306
  });
300
307
 
301
308
  it('recall crossing the revise window cancels the run (returned → recalled)', async () => {
302
309
  registerReviseFlow();
303
310
  const { runId, req } = await startFlow();
304
- await service.sendBack(req.id, { actorId: 'u1' }, USER_CTX);
311
+ await service.sendBack(req.id, { actorId: 'u1' }, asUser('u1'));
305
312
 
306
313
  // Only the submitter may abandon the revision.
307
- await expect(service.recall(req.id, { actorId: 'u1' }, USER_CTX)).rejects.toThrow(/FORBIDDEN/);
314
+ await expect(service.recall(req.id, { actorId: 'u1' }, asUser('u1'))).rejects.toThrow(/FORBIDDEN/);
308
315
 
309
- const out = await service.recall(req.id, { actorId: 'submitter' }, USER_CTX);
316
+ const out = await service.recall(req.id, { actorId: 'submitter' }, asUser('submitter'));
310
317
  expect(out.request.status).toBe('recalled');
311
318
  expect(out.resumed).toBe(false);
312
319
  // The run was terminally cancelled, not resumed down any branch.
@@ -317,13 +324,13 @@ describe('Send back for revision (ADR-0044)', () => {
317
324
  expect(log.id).toBe(runId);
318
325
 
319
326
  // The window is closed: resubmit is no longer possible.
320
- await expect(service.resubmit(req.id, { actorId: 'submitter' }, USER_CTX)).rejects.toThrow(/INVALID_STATE/);
327
+ await expect(service.resubmit(req.id, { actorId: 'submitter' }, asUser('submitter'))).rejects.toThrow(/INVALID_STATE/);
321
328
  });
322
329
 
323
330
  it('refuses resubmit while another pending request collides on the record (run stays resumable)', async () => {
324
331
  registerReviseFlow();
325
332
  const { runId, req } = await startFlow();
326
- await service.sendBack(req.id, { actorId: 'u1' }, USER_CTX);
333
+ await service.sendBack(req.id, { actorId: 'u1' }, asUser('u1'));
327
334
 
328
335
  // Simulate a record-change trigger re-firing off an edit made inside the
329
336
  // revise window: a second, unrelated run opened its own pending request.
@@ -334,38 +341,38 @@ describe('Send back for revision (ADR-0044)', () => {
334
341
  created_at: new Date().toISOString(),
335
342
  });
336
343
 
337
- await expect(service.resubmit(req.id, { actorId: 'submitter' }, USER_CTX)).rejects.toThrow(/DUPLICATE_REQUEST/);
344
+ await expect(service.resubmit(req.id, { actorId: 'submitter' }, asUser('submitter'))).rejects.toThrow(/DUPLICATE_REQUEST/);
338
345
  // The refusal happened BEFORE the suspension was consumed — clearing the
339
346
  // collision makes the same resubmit succeed.
340
347
  expect(automation.listSuspendedRuns().some(r => r.runId === runId)).toBe(true);
341
348
  await fake.delete('sys_approval_request', { where: { id: 'areq_collider' } });
342
- const re = await service.resubmit(req.id, { actorId: 'submitter' }, USER_CTX);
349
+ const re = await service.resubmit(req.id, { actorId: 'submitter' }, asUser('submitter'));
343
350
  expect(re.resumed).toBe(true);
344
351
  });
345
352
 
346
353
  it('a superseded returned request can neither resubmit again nor be recalled', async () => {
347
354
  registerReviseFlow();
348
355
  const { req } = await startFlow();
349
- await service.sendBack(req.id, { actorId: 'u1' }, USER_CTX);
350
- await service.resubmit(req.id, { actorId: 'submitter' }, USER_CTX);
356
+ await service.sendBack(req.id, { actorId: 'u1' }, asUser('u1'));
357
+ await service.resubmit(req.id, { actorId: 'submitter' }, asUser('submitter'));
351
358
 
352
359
  // Round 2 is the live frontier; the round-1 row is history.
353
- await expect(service.resubmit(req.id, { actorId: 'submitter' }, USER_CTX)).rejects.toThrow(/supersedes/);
354
- await expect(service.recall(req.id, { actorId: 'submitter' }, USER_CTX)).rejects.toThrow(/supersedes/);
360
+ await expect(service.resubmit(req.id, { actorId: 'submitter' }, asUser('submitter'))).rejects.toThrow(/supersedes/);
361
+ await expect(service.recall(req.id, { actorId: 'submitter' }, asUser('submitter'))).rejects.toThrow(/supersedes/);
355
362
  });
356
363
 
357
364
  it('enforces the actor matrix: only pending approvers send back, only the submitter resubmits', async () => {
358
365
  registerReviseFlow();
359
366
  const { req } = await startFlow();
360
367
 
361
- await expect(service.sendBack(req.id, { actorId: 'intruder' }, USER_CTX)).rejects.toThrow(/FORBIDDEN/);
362
- await expect(service.sendBack(req.id, { actorId: 'submitter' }, USER_CTX)).rejects.toThrow(/FORBIDDEN/);
368
+ await expect(service.sendBack(req.id, { actorId: 'intruder' }, asUser('intruder'))).rejects.toThrow(/FORBIDDEN/);
369
+ await expect(service.sendBack(req.id, { actorId: 'submitter' }, asUser('submitter'))).rejects.toThrow(/FORBIDDEN/);
363
370
 
364
- await service.sendBack(req.id, { actorId: 'u1' }, USER_CTX);
365
- await expect(service.resubmit(req.id, { actorId: 'u1' }, USER_CTX)).rejects.toThrow(/FORBIDDEN/);
371
+ await service.sendBack(req.id, { actorId: 'u1' }, asUser('u1'));
372
+ await expect(service.resubmit(req.id, { actorId: 'u1' }, asUser('u1'))).rejects.toThrow(/FORBIDDEN/);
366
373
  // Resubmit only applies to returned requests — a pending one rejects.
367
374
  const fresh = await startFlowSecondRecord();
368
- await expect(service.resubmit(fresh.id, { actorId: 'submitter' }, USER_CTX)).rejects.toThrow(/INVALID_STATE/);
375
+ await expect(service.resubmit(fresh.id, { actorId: 'submitter' }, asUser('submitter'))).rejects.toThrow(/INVALID_STATE/);
369
376
  });
370
377
 
371
378
  /** A second record's pending request, to probe resubmit-on-pending. */
@@ -403,9 +410,9 @@ describe('Send back for revision (ADR-0044)', () => {
403
410
  const mirror = async () => (await fake.find('fin_expense', { where: { id: 'm1' } }))[0].approval_status;
404
411
 
405
412
  expect(await mirror()).toBe('pending');
406
- await service.sendBack(req.id, { actorId: 'u1' }, USER_CTX);
413
+ await service.sendBack(req.id, { actorId: 'u1' }, asUser('u1'));
407
414
  expect(await mirror()).toBe('returned');
408
- await service.resubmit(req.id, { actorId: 'submitter' }, USER_CTX);
415
+ await service.resubmit(req.id, { actorId: 'submitter' }, asUser('submitter'));
409
416
  expect(await mirror()).toBe('pending'); // round 2 re-mirrors
410
417
  });
411
418
  });