@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,201 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- /**
4
- * Lifecycle Hooks — node-era record lock (ADR-0019).
5
- *
6
- * Approval is now a flow node, so there is no per-object process registry to
7
- * bind auto-trigger hooks against — a flow decides *when* to open an approval.
8
- * What remains worth enforcing at the data layer is the **record lock**: while
9
- * a record has a pending `sys_approval_request`, block edits to it.
10
- *
11
- * A single global `beforeUpdate` hook handles every object (the target object
12
- * of an approval node is only known at flow-run time). For each update it:
13
- *
14
- * 1. Skips engine self-writes (status mirror) and `sys_approval_*` bookkeeping.
15
- * 2. Looks up a pending request for `(object, recordId)`.
16
- * 3. Reads the lock policy from that request's `node_config_json` snapshot:
17
- * - `lockRecord === false` → allow.
18
- * - otherwise block, EXCEPT when the only changed field is the configured
19
- * `approvalStatusField` (so the status mirror is never blocked) or the
20
- * caller is an `admin`.
21
- *
22
- * Registered under `packageId: 'plugin-approvals:lock'` so it can be cleanly
23
- * unbound on plugin stop.
24
- */
25
-
26
- export const APPROVALS_HOOK_PACKAGE = 'plugin-approvals:lock';
27
-
28
- interface MinimalEngine {
29
- registerHook(event: string, handler: (ctx: any) => any | Promise<any>, options?: {
30
- object?: string | string[];
31
- priority?: number;
32
- packageId?: string;
33
- }): void;
34
- unregisterHooksByPackage(packageId: string): number;
35
- find<T = any>(object: string, args: any, opts?: any): Promise<T[]>;
36
- }
37
-
38
- interface MinimalLogger {
39
- debug?: (msg: any, ...rest: any[]) => void;
40
- info?: (msg: any, ...rest: any[]) => void;
41
- warn?: (msg: any, ...rest: any[]) => void;
42
- error?: (msg: any, ...rest: any[]) => void;
43
- }
44
-
45
- function parseJson<T = any>(raw: unknown, fallback: T): T {
46
- if (raw == null || raw === '') return fallback;
47
- if (typeof raw === 'string') {
48
- try { return JSON.parse(raw) as T; } catch { return fallback; }
49
- }
50
- return raw as T;
51
- }
52
-
53
- /** The pending request gating a record, plus its snapshotted node config. */
54
- async function pendingRequestFor(
55
- engine: MinimalEngine,
56
- objectName: string,
57
- recordId: string,
58
- ): Promise<any | null> {
59
- try {
60
- const rows = await engine.find('sys_approval_request', {
61
- where: { object_name: objectName, record_id: String(recordId), status: 'pending' },
62
- limit: 1,
63
- } as any);
64
- return Array.isArray(rows) && rows[0] ? rows[0] : null;
65
- } catch {
66
- return null;
67
- }
68
- }
69
-
70
- /**
71
- * Bind the global record-lock hook. Caller is responsible for calling
72
- * {@link unbindAllHooks} first if re-binding.
73
- */
74
- export function bindApprovalLockHook(engine: MinimalEngine, logger?: MinimalLogger): void {
75
- engine.registerHook('beforeUpdate', async (ctx: any) => {
76
- const id = String((ctx?.input?.id ?? '') as string);
77
- if (!id) return;
78
- const object = (ctx?.object ?? ctx?.objectName) as string | undefined;
79
- // No object name (shouldn't happen) or our own bookkeeping objects → skip.
80
- if (!object || String(object).startsWith('sys_approval')) return;
81
-
82
- const data = (ctx?.input?.data ?? {}) as Record<string, unknown>;
83
- const changedFields = Object.keys(data).filter((k) => k !== 'id' && k !== 'updated_at');
84
- if (changedFields.length === 0) return;
85
-
86
- // Allow engine self-writes (status mirror from the approvals service, etc).
87
- if ((ctx?.session as any)?.isSystem) return;
88
-
89
- // Allow admin override.
90
- const roles = (ctx?.session?.roles ?? []) as string[];
91
- if (Array.isArray(roles) && roles.includes('admin')) return;
92
-
93
- const pending = await pendingRequestFor(engine, object, id);
94
- if (!pending) return;
95
-
96
- // The run that OPENED this approval may still write its own target record
97
- // (#3456). Without this the lock cannot tell "the run that owns this pending
98
- // request" from "an unrelated user edit", so a flow that touches the record
99
- // between opening the approval and the decision — or a manual `resume` with
100
- // no decision — dies on its own `RECORD_LOCKED` and leaves the record locked
101
- // behind it.
102
- //
103
- // Keyed on run identity, NOT on elevation: a `runAs:'user'` run must stay
104
- // RLS-scoped, so widening it to `isSystem` would be the wrong tool. The
105
- // automation engine stamps `flowRunId` into the server-built ExecutionContext
106
- // (never client-supplied, like `isSystem`) and it grants nothing by itself —
107
- // the only write it permits is to the one record this very run already holds
108
- // a pending request against.
109
- //
110
- // Read off `provenance`, not `session`: provenance says WHAT produced the
111
- // write, and a run can own its writes while resolving no principal at all.
112
- // A schedule-triggered run is exactly that — it reaches here with NO
113
- // session, and that shape is the one that used to die on its own lock
114
- // (#3712).
115
- const writerRun = (ctx?.provenance as any)?.flowRunId;
116
- if (writerRun && pending.flow_run_id && String(writerRun) === String(pending.flow_run_id)) return;
117
-
118
- const config = parseJson<any>(pending.node_config_json, {});
119
- if (config?.lockRecord === false) return;
120
-
121
- // Allow when every changed field is the approval status mirror.
122
- const mirror = config?.approvalStatusField;
123
- if (typeof mirror === 'string' && mirror && changedFields.every((f) => f === mirror)) return;
124
-
125
- const err: any = new Error('RECORD_LOCKED: record is locked while an approval is in progress');
126
- err.code = 'RECORD_LOCKED';
127
- err.statusCode = 409;
128
- throw err;
129
- }, { packageId: APPROVALS_HOOK_PACKAGE, priority: 50 });
130
-
131
- logger?.info?.('[approvals] record-lock hook bound');
132
- }
133
-
134
- /** The self-service out-of-office delegation object (#1322). */
135
- export const DELEGATION_OBJECT = 'sys_approval_delegation';
136
-
137
- /**
138
- * Self-service write guard for `sys_approval_delegation` (#1322 follow-up).
139
- *
140
- * The object is `apiEnabled` CRUD so a user can declare their own out-of-office
141
- * delegation. But it is a system object: it gets no auto `owner_id` anchor and
142
- * (with no `sharingModel`) defaults to a `public` sharing model, so an
143
- * unguarded member could **forge a delegation for someone else**
144
- * (`delegator_id = victim`) and reroute the victim's individually-routed
145
- * approvals to themselves. This guard forces a normal user's writes to name
146
- * themselves as the delegator:
147
- *
148
- * - **system** context (service / seed / import) → bypass;
149
- * - **admin** (`roles` includes `'admin'`) → may set `delegator_id` to anyone;
150
- * - otherwise `delegator_id` must equal the acting user — an absent delegator
151
- * on insert is stamped to the caller, a foreign delegator is rejected.
152
- *
153
- * Row-level ownership on update/delete (you can only touch a delegation you
154
- * created) is already enforced by `member_default`'s wildcard
155
- * `created_by == current_user.id` RLS; this guard adds the delegator-identity
156
- * check that RLS alone can't express. Mirrors the ADR-0092 identity write-guard
157
- * shape and the security plugin's `owner_id` anchor guard, scoped to this one
158
- * object.
159
- */
160
- export function bindDelegationWriteGuard(engine: MinimalEngine, logger?: MinimalLogger): void {
161
- const makeGuard = (isInsert: boolean) => async (ctx: any) => {
162
- const session = (ctx?.session ?? {}) as any;
163
- if (session.isSystem) return; // service / seed / import
164
- const roles = (session.roles ?? []) as unknown[];
165
- if (Array.isArray(roles) && roles.includes('admin')) return; // admin may act for anyone
166
- const userId = session.userId != null ? String(session.userId) : '';
167
- const data = ctx?.input?.data;
168
- const rows = Array.isArray(data) ? data : (data && typeof data === 'object' ? [data] : []);
169
- const deny = (): never => {
170
- const err: any = new Error(
171
- 'FORBIDDEN: you may only manage out-of-office delegations where you are the delegator'
172
- + (userId ? ` ('${userId}')` : ''),
173
- );
174
- err.code = 'FORBIDDEN';
175
- err.statusCode = 403;
176
- throw err;
177
- };
178
- for (const row of rows) {
179
- if (!row || typeof row !== 'object' || Array.isArray(row)) continue;
180
- const has = Object.prototype.hasOwnProperty.call(row, 'delegator_id');
181
- const supplied = has ? String((row as any).delegator_id ?? '') : '';
182
- if (isInsert && (!has || supplied === '')) {
183
- // Self-service: stamp the caller as delegator when omitted (the schema's
184
- // `required` is the fallback if the engine doesn't persist the stamp).
185
- if (!userId) deny();
186
- (row as any).delegator_id = userId;
187
- continue;
188
- }
189
- // A foreign delegator on insert (forge) or update (relabel/hijack) → deny.
190
- if (has && supplied !== userId) deny();
191
- }
192
- };
193
- engine.registerHook('beforeInsert', makeGuard(true), { object: DELEGATION_OBJECT, packageId: APPROVALS_HOOK_PACKAGE, priority: 50 });
194
- engine.registerHook('beforeUpdate', makeGuard(false), { object: DELEGATION_OBJECT, packageId: APPROVALS_HOOK_PACKAGE, priority: 50 });
195
- logger?.info?.('[approvals] delegation write-guard bound');
196
- }
197
-
198
- /** Unregister every hook the lock module registered. */
199
- export function unbindAllHooks(engine: MinimalEngine): number {
200
- return engine.unregisterHooksByPackage(APPROVALS_HOOK_PACKAGE);
201
- }
@@ -1,50 +0,0 @@
1
- // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import { describe, it, expect } from 'vitest';
4
- import { ApprovalsServicePlugin } from './approvals-plugin.js';
5
-
6
- /**
7
- * ADR-0029 K2.b / D7 — the approvals plugin owns sys_approval_request /
8
- * sys_approval_action and ships their Setup-app menu as a navigation
9
- * contribution (rather than the entries living statically in the
10
- * platform-objects Setup shell).
11
- */
12
- describe('ApprovalsServicePlugin schema + nav contribution (ADR-0029 K2.b)', () => {
13
- it('registers the approval objects and contributes the group_approvals slot', async () => {
14
- const registered: any[] = [];
15
- const ctx: any = {
16
- getService: (name: string) =>
17
- name === 'manifest' ? { register: (m: any) => registered.push(m) } : undefined,
18
- logger: { info: () => {}, warn: () => {} },
19
- };
20
-
21
- const plugin = new ApprovalsServicePlugin({ disableService: true });
22
- await plugin.init(ctx);
23
-
24
- expect(registered).toHaveLength(1);
25
- const manifest = registered[0];
26
-
27
- // Owns the approval objects (moved out of platform-objects).
28
- expect(manifest.objects.map((o: any) => o.name).sort()).toEqual([
29
- 'sys_approval_action',
30
- 'sys_approval_approver',
31
- 'sys_approval_delegation',
32
- 'sys_approval_request',
33
- 'sys_approval_token',
34
- ]);
35
-
36
- // Contributes its menu into the Setup app's approvals slot.
37
- expect(manifest.navigationContributions).toHaveLength(1);
38
- const contribution = manifest.navigationContributions[0];
39
- expect(contribution).toMatchObject({ app: 'setup', group: 'group_approvals' });
40
- expect(contribution.items.map((i: any) => i.objectName).sort()).toEqual([
41
- 'sys_approval_action',
42
- 'sys_approval_delegation',
43
- 'sys_approval_request',
44
- ]);
45
- // Each entry is gated so the slot stays empty when the plugin is absent.
46
- for (const item of contribution.items) {
47
- expect(item.requiresObject).toBe(item.objectName);
48
- }
49
- });
50
- });
@@ -1,206 +0,0 @@
1
- // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- /**
4
- * #3703 / #3712 / #3760 — the run that opened a pending approval may write its
5
- * own locked record.
6
- *
7
- * #3703 exempted "the run that opened the pending request" from the approvals
8
- * record lock, keyed on `flowRunId`. #3712 extended that to the run that
9
- * resolved no identity — an effective `runAs:'user'` run with no trigger user —
10
- * by giving it a provenance-only ObjectQL context carrying just the run id.
11
- *
12
- * #3760 then closed the fail-open that path depended on: a run with no trigger
13
- * user may no longer perform a data operation at all, because presenting no
14
- * principal is precisely what made the write UNSCOPED. So the user-less variant
15
- * is now REFUSED rather than exempted, and a schedule reaches its own record the
16
- * explicit way — `runAs:'system'`, which the hook exempts on its own
17
- * `isSystem` branch. The `flowRunId` exemption remains live and load-bearing for
18
- * what it was built for: a `runAs:'user'` run that DOES have a user.
19
- *
20
- * The original miss was a HAND-OFF gap, not a logic gap: every hop worked in
21
- * isolation. So this test still refuses to stub any hop. It runs the real
22
- * `resolveRunDataContext` from the automation runtime, feeds its output to a
23
- * real {@link ObjectQL} engine, and lets the real lock hook decide — the same
24
- * three layers, in the same order, as a live deployment.
25
- */
26
-
27
- import { describe, it, expect, beforeEach } from 'vitest';
28
- import { ObjectQL } from '@objectstack/objectql';
29
- import { resolveRunDataContext } from '@objectstack/service-automation';
30
- import { bindApprovalLockHook } from './lifecycle-hooks.js';
31
-
32
- const opportunity = {
33
- name: 'opportunity',
34
- label: 'Opportunity',
35
- fields: {
36
- id: { name: 'id', type: 'text' as const, primaryKey: true },
37
- name: { name: 'name', type: 'text' as const },
38
- amount: { name: 'amount', type: 'number' as const },
39
- },
40
- };
41
-
42
- /** The lock hook reads pending requests off this object. */
43
- const approvalRequest = {
44
- name: 'sys_approval_request',
45
- label: 'Approval Request',
46
- fields: {
47
- id: { name: 'id', type: 'text' as const, primaryKey: true },
48
- object_name: { name: 'object_name', type: 'text' as const },
49
- record_id: { name: 'record_id', type: 'text' as const },
50
- status: { name: 'status', type: 'text' as const },
51
- flow_run_id: { name: 'flow_run_id', type: 'text' as const },
52
- node_config_json: { name: 'node_config_json', type: 'text' as const },
53
- },
54
- };
55
-
56
- function makeMemoryDriver() {
57
- const stores = new Map<string, Map<string, Record<string, unknown>>>();
58
- const storeFor = (o: string) => {
59
- let s = stores.get(o);
60
- if (!s) { s = new Map(); stores.set(o, s); }
61
- return s;
62
- };
63
- let nextId = 0;
64
- const matches = (row: Record<string, unknown>, where: any): boolean => {
65
- if (!where || typeof where !== 'object') return true;
66
- for (const [k, v] of Object.entries(where)) {
67
- if (k.startsWith('$')) continue;
68
- const exp = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v;
69
- if ((row[k] ?? null) !== (exp ?? null)) return false;
70
- }
71
- return true;
72
- };
73
- const driver: any = {
74
- name: 'memory', version: '0.0.0', supports: {},
75
- async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; },
76
- async find(o: string, ast: any) { return Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)); },
77
- findStream() { throw new Error('ns'); },
78
- async findOne(o: string, ast: any) { for (const r of storeFor(o).values()) if (matches(r, ast?.where)) return r; return null; },
79
- async create(o: string, data: Record<string, unknown>) {
80
- nextId += 1;
81
- const id = (data.id as string) ?? `r_${nextId}`;
82
- const row = { ...data, id };
83
- storeFor(o).set(id, row);
84
- return row;
85
- },
86
- async update(o: string, id: string, data: Record<string, unknown>) {
87
- const s = storeFor(o);
88
- const cur = s.get(id);
89
- if (!cur) throw new Error(`nf ${o}/${id}`);
90
- const up = { ...cur, ...data, id };
91
- s.set(id, up);
92
- return up;
93
- },
94
- async upsert(o: string, data: Record<string, unknown>) {
95
- const id = data.id as string | undefined;
96
- return id && storeFor(o).has(id) ? this.update(o, id, data) : this.create(o, data);
97
- },
98
- async delete(o: string, id: string) { return storeFor(o).delete(id); },
99
- async count(o: string, ast: any) { return (await this.find(o, ast)).length; },
100
- async bulkCreate(o: string, rows: Record<string, unknown>[]) { return Promise.all(rows.map((r) => this.create(o, r))); },
101
- async bulkUpdate() { return []; },
102
- async bulkDelete() {},
103
- async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
104
- async commit() {}, async rollback() {},
105
- };
106
- return driver;
107
- }
108
-
109
- /**
110
- * A `runAs:'user'` run that HAS a trigger user — the live consumer of the
111
- * `flowRunId` exemption, and the shape #3703 built it for (a record-change or
112
- * screen flow that opened the approval and now writes its own target record).
113
- */
114
- const OWNING_RUN = (flowRunId: string) => ({ runAs: 'user' as const, userId: 'u1', flowRunId });
115
-
116
- /** What a schedule trigger produces: an event, and NO user. Refused since #3760. */
117
- const USER_LESS_RUN = (flowRunId: string) => ({ runAs: 'user' as const, flowRunId });
118
-
119
- /** The supported shape for a schedule that must write records (ADR-0049). */
120
- const SYSTEM_RUN = (flowRunId: string) => ({ runAs: 'system' as const, flowRunId });
121
-
122
- describe('an owning run and the approvals record lock (#3703 / #3712 / #3760)', () => {
123
- let engine: ObjectQL;
124
- let oppId: string;
125
-
126
- beforeEach(async () => {
127
- engine = new ObjectQL();
128
- engine.registerDriver(makeMemoryDriver(), true);
129
- await engine.init();
130
- for (const o of [opportunity, approvalRequest]) engine.registry.registerObject(o as any);
131
-
132
- const opp = await engine.insert('opportunity', { name: 'Deal', amount: 100 });
133
- oppId = String(opp.id);
134
- await engine.insert('sys_approval_request', {
135
- object_name: 'opportunity',
136
- record_id: oppId,
137
- status: 'pending',
138
- flow_run_id: 'run_1',
139
- node_config_json: JSON.stringify({ lockRecord: true }),
140
- });
141
-
142
- bindApprovalLockHook(engine as any);
143
- });
144
-
145
- const writeAs = (context: unknown) =>
146
- engine.update('opportunity', { id: oppId, amount: 200 }, { context } as any);
147
-
148
- it('lets the OWNING run write its own target record', async () => {
149
- // The full hand-off, unstubbed: the automation runtime resolves the run's
150
- // ObjectQL context, the engine turns it into hook provenance, the lock hook
151
- // matches it against the pending request it opened.
152
- const dataCtx = resolveRunDataContext(OWNING_RUN('run_1'));
153
- expect(dataCtx, 'nothing could carry the run id').toMatchObject({ flowRunId: 'run_1' });
154
-
155
- await expect(writeAs(dataCtx)).resolves.toBeDefined();
156
- const row = await engine.findOne('opportunity', { where: { id: oppId } });
157
- expect(row.amount).toBe(200);
158
- });
159
-
160
- it('still blocks a DIFFERENT run', async () => {
161
- await expect(writeAs(resolveRunDataContext(OWNING_RUN('run_other'))))
162
- .rejects.toThrow(/RECORD_LOCKED/);
163
- });
164
-
165
- it('still blocks an ordinary user edit', async () => {
166
- await expect(writeAs({ isSystem: false, userId: 'u1', positions: [], permissions: [] }))
167
- .rejects.toThrow(/RECORD_LOCKED/);
168
- });
169
-
170
- it('still blocks a context-less write', async () => {
171
- await expect(writeAs(undefined)).rejects.toThrow(/RECORD_LOCKED/);
172
- });
173
-
174
- it('the exemption is PROVENANCE, not privilege — the exempted write is not elevated', async () => {
175
- // The exemption must not have been bought with elevation: the run that
176
- // writes its own locked record is still a plain `runAs:'user'` principal,
177
- // subject to the same RLS as the user who triggered it. Only `flowRunId`
178
- // distinguishes it, and `flowRunId` grants nothing on its own.
179
- const dataCtx = resolveRunDataContext(OWNING_RUN('run_1')) as Record<string, unknown>;
180
- expect(dataCtx.isSystem, 'the exemption rode in on isSystem').toBe(false);
181
- expect(dataCtx.flowRunId).toBe('run_1');
182
- expect(dataCtx.userId).toBe('u1');
183
- });
184
-
185
- // #3760 — the case #3712 solved by handing the lock a provenance-only context
186
- // is gone at the root: such a run may not perform a data operation at all,
187
- // because presenting no principal is exactly what made the write unscoped. It
188
- // is refused BEFORE the lock is ever consulted.
189
- it('a USER-LESS run never reaches the lock — it cannot perform a data op at all', async () => {
190
- expect(() => resolveRunDataContext(USER_LESS_RUN('run_1')))
191
- .toThrow(/no trigger user could be resolved/);
192
- });
193
-
194
- // ...and the capability itself survives, via the explicit route: a schedule
195
- // that must write records declares `runAs:'system'`, which the lock hook
196
- // exempts on its own isSystem branch. Elevation is now declared rather than
197
- // acquired by having no identity.
198
- it("a schedule that declares runAs:'system' still writes its own target record", async () => {
199
- const dataCtx = resolveRunDataContext(SYSTEM_RUN('run_1'));
200
- expect(dataCtx).toMatchObject({ isSystem: true, flowRunId: 'run_1' });
201
-
202
- await expect(writeAs(dataCtx)).resolves.toBeDefined();
203
- const row = await engine.findOne('opportunity', { where: { id: oppId } });
204
- expect(row.amount).toBe(200);
205
- });
206
- });
@@ -1,224 +0,0 @@
1
- // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- /**
4
- * #3783 — an approval decision cascades as the deciding user.
5
- *
6
- * "When the invoice is approved, do X" is the single most natural approvals
7
- * automation there is, and until now it could not be written the obvious way.
8
- * The status mirror — the write that puts `approved` on the business record, and
9
- * therefore the write that fires that object's record-change flows — presented a
10
- * bare `{ isSystem: true }` context with no `userId`. `isSystem` does not
11
- * suppress trigger dispatch, so the flow DID fire; it just fired with no trigger
12
- * user, and since #3760 a `runAs:'user'` run with no trigger user has its data
13
- * operations refused. Authors were pushed to declare `runAs:'system'` — blanket
14
- * elevation — for a case where a perfectly good scoped identity existed all
15
- * along, sitting right there at the call site.
16
- *
17
- * The seam is invisible in a unit test of any single hop, so this one refuses to
18
- * stub any of them: a real {@link ObjectKernel} with the real ObjectQL engine,
19
- * the real record-change trigger, the real automation engine, and the real
20
- * {@link ApprovalService}. The mirror is produced by an actual decision, not
21
- * hand-written.
22
- *
23
- * The negative case is load-bearing, not decoration. It fires the SAME flow off
24
- * the dead-run sweep's mirror, which has no human behind it and stays user-less
25
- * on purpose — and shows it is still refused. Without it, the positive case
26
- * would prove only that the flow runs, never that it runs *because* the identity
27
- * arrived.
28
- *
29
- * The record lock is deliberately not bound here: it is a separate concern with
30
- * its own end-to-end coverage (`record-lock-schedule-run.integration.test.ts`).
31
- */
32
-
33
- import { describe, it, expect, beforeEach } from 'vitest';
34
- import { ObjectKernel } from '@objectstack/core';
35
- import { ObjectQLPlugin } from '@objectstack/objectql';
36
- import { AutomationServicePlugin, type AutomationEngine } from '@objectstack/service-automation';
37
- import { RecordChangeTriggerPlugin } from '@objectstack/trigger-record-change';
38
- import { ApprovalService } from './approval-service.js';
39
- import { SysApprovalRequest } from './sys-approval-request.object.js';
40
- import { SysApprovalAction } from './sys-approval-action.object.js';
41
- import { SysApprovalApprover } from './sys-approval-approver.object.js';
42
-
43
- const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
44
-
45
- const SUBMITTER = { userId: 'submitter', positions: [], permissions: [] } as any;
46
- const APPROVER = { userId: 'approver', positions: [], permissions: [] } as any;
47
-
48
- /** Equality-WHERE in-memory driver — the same shape the trigger's own e2e uses. */
49
- function makeMemoryDriver(): any {
50
- const stores = new Map<string, Map<string, Record<string, unknown>>>();
51
- const storeFor = (obj: string) => {
52
- let s = stores.get(obj);
53
- if (!s) { s = new Map(); stores.set(obj, s); }
54
- return s;
55
- };
56
- let nextId = 0;
57
- const matches = (row: Record<string, unknown>, where: any): boolean => {
58
- if (!where || typeof where !== 'object') return true;
59
- if (Array.isArray(where.$and)) return where.$and.every((w: any) => matches(row, w));
60
- if (Array.isArray(where.$or)) return where.$or.some((w: any) => matches(row, w));
61
- for (const [k, v] of Object.entries(where)) {
62
- if (k.startsWith('$')) continue;
63
- const expected = v && typeof v === 'object' && '$eq' in (v as any) ? (v as any).$eq : v;
64
- const a = row[k] === undefined ? null : row[k];
65
- const b = expected === undefined ? null : expected;
66
- if (a !== b) return false;
67
- }
68
- return true;
69
- };
70
- return {
71
- name: 'memory', version: '0.0.0', supports: {},
72
- async connect() {}, async disconnect() {}, async checkHealth() { return true; },
73
- async execute() { return null; }, async syncSchema() {},
74
- async find(object: string, ast: any) {
75
- return Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where));
76
- },
77
- findStream() { throw new Error('not implemented'); },
78
- async findOne(object: string, ast: any) {
79
- for (const r of storeFor(object).values()) if (matches(r, ast?.where)) return r;
80
- return null;
81
- },
82
- async create(object: string, data: Record<string, unknown>) {
83
- nextId += 1;
84
- const id = (data.id as string) ?? `r_${nextId}`;
85
- const row = { ...data, id };
86
- storeFor(object).set(id, row);
87
- return row;
88
- },
89
- async update(object: string, id: string, data: Record<string, unknown>) {
90
- const s = storeFor(object);
91
- const cur = s.get(id);
92
- if (!cur) throw new Error(`not found: ${object}/${id}`);
93
- const updated = { ...cur, ...data, id };
94
- s.set(id, updated);
95
- return updated;
96
- },
97
- async upsert(object: string, data: Record<string, unknown>) {
98
- const id = data.id as string | undefined;
99
- if (id && storeFor(object).has(id)) return this.update(object, id, data);
100
- return this.create(object, data);
101
- },
102
- async delete(object: string, id: string) { return storeFor(object).delete(id); },
103
- async count(object: string, ast: any) { return (await this.find(object, ast)).length; },
104
- async bulkCreate(object: string, rows: Record<string, unknown>[]) {
105
- return Promise.all(rows.map((r) => this.create(object, r)));
106
- },
107
- async bulkUpdate() { return []; }, async bulkDelete() {},
108
- async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
109
- async commit() {}, async rollback() {},
110
- };
111
- }
112
-
113
- const opportunity = {
114
- name: 'opportunity',
115
- label: 'Opportunity',
116
- fields: {
117
- amount: { name: 'amount', label: 'Amount', type: 'number' },
118
- approval_status: { name: 'approval_status', label: 'Approval Status', type: 'text' },
119
- cascaded: { name: 'cascaded', label: 'Cascaded', type: 'text' },
120
- },
121
- };
122
-
123
- /**
124
- * The automation an author actually wants to write: react to the approval, no
125
- * `runAs` — the spec default `'user'`. That default is the whole point; a flow
126
- * forced to say `runAs:'system'` to work at all is the bug being fixed.
127
- */
128
- const onApprovedFlow = {
129
- name: 'on_approved',
130
- label: 'On Approved',
131
- type: 'record_change',
132
- nodes: [
133
- {
134
- id: 'start', type: 'start', label: 'Start',
135
- config: {
136
- objectName: 'opportunity',
137
- triggerType: 'record-after-update',
138
- // Fires on BOTH terminal mirrors, so the two cases below differ only in
139
- // whether the mirror that fired it carried a user — nothing else.
140
- condition: "approval_status == 'approved' || approval_status == 'recalled'",
141
- },
142
- },
143
- {
144
- id: 'stamp', type: 'update_record', label: 'Stamp',
145
- config: { objectName: 'opportunity', filter: { id: '{record.id}' }, fields: { cascaded: 'yes' } },
146
- },
147
- { id: 'end', type: 'end', label: 'End' },
148
- ],
149
- edges: [
150
- { id: 'e1', source: 'start', target: 'stamp' },
151
- { id: 'e2', source: 'stamp', target: 'end' },
152
- ],
153
- };
154
-
155
- const nodeConfig = {
156
- approvers: [{ type: 'user' as const, value: 'approver' }],
157
- behavior: 'first_response' as const,
158
- lockRecord: false,
159
- approvalStatusField: 'approval_status',
160
- };
161
-
162
- describe('an approval decision cascades as the deciding user (#3783)', () => {
163
- let data: any;
164
- let svc: ApprovalService;
165
-
166
- beforeEach(async () => {
167
- const kernel = new ObjectKernel({ logLevel: 'silent' });
168
- await kernel.use(new ObjectQLPlugin());
169
- await kernel.use(new AutomationServicePlugin());
170
- await kernel.use(new RecordChangeTriggerPlugin());
171
- await kernel.bootstrap();
172
-
173
- const objectql = kernel.getService('objectql') as any;
174
- data = kernel.getService('data') as any;
175
- const automation = kernel.getService<AutomationEngine>('automation');
176
-
177
- objectql.registerDriver(makeMemoryDriver(), true);
178
- for (const def of [opportunity, SysApprovalRequest, SysApprovalAction, SysApprovalApprover]) {
179
- objectql.registry.registerObject(def as any, 'approvals-test', 'approvals-test');
180
- }
181
- automation.registerFlow('on_approved', onApprovedFlow as any);
182
-
183
- svc = new ApprovalService({ engine: objectql });
184
- await data.insert('opportunity', { id: 'opp1', amount: 100 }, { context: { isSystem: true } });
185
- });
186
-
187
- const readBack = () => data.findOne('opportunity', { where: { id: 'opp1' } });
188
-
189
- const openRequest = () => svc.openNodeRequest({
190
- object: 'opportunity', recordId: 'opp1', runId: 'run_1', nodeId: 'approve_step',
191
- flowName: 'deal_approval', config: nodeConfig, submitterId: 'submitter',
192
- record: { id: 'opp1', amount: 100 },
193
- }, SUBMITTER);
194
-
195
- it('the approve mirror hands the flow a trigger user, so its data node runs', async () => {
196
- const req = await openRequest();
197
- await svc.decide(req.id as string, { decision: 'approve', actorId: 'approver' }, APPROVER);
198
- await sleep(300);
199
-
200
- const row = await readBack();
201
- expect(row?.approval_status, 'the mirror itself must still land').toBe('approved');
202
- // Before #3783 this stayed undefined: the run inherited no trigger user, so
203
- // `resolveRunDataContext` refused its `update_record` outright.
204
- expect(row?.cascaded).toBe('yes');
205
- // ObjectQL's audit stamp is gated on the write context's `userId` alone —
206
- // `isSystem` buys no exemption — so this is direct evidence the elevated
207
- // mirror named a user rather than nobody.
208
- expect(row?.updated_by).toBe('approver');
209
- });
210
-
211
- it('a dead-run release cascades user-less, and is still refused', async () => {
212
- await openRequest();
213
- svc.attachAutomation({ getRun: async () => ({ status: 'failed' }) } as any);
214
- await svc.releaseDeadRunRequests();
215
- await sleep(300);
216
-
217
- const row = await readBack();
218
- expect(row?.approval_status, 'the sweep must still release the record').toBe('recalled');
219
- // No human abandoned this request — a sweep did. The cascade therefore has
220
- // no identity to inherit and stays refused; an author who wants to react to
221
- // a dead-run release declares `runAs:'system'` and means it.
222
- expect(row?.cascaded).toBeFalsy();
223
- });
224
- });