@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.
- package/CHANGELOG.md +439 -0
- package/dist/index.d.mts +2304 -3491
- package/dist/index.d.ts +2304 -3491
- package/dist/index.js +116 -43
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +114 -41
- package/dist/index.mjs.map +1 -1
- package/package.json +16 -9
- package/.turbo/turbo-build.log +0 -22
- package/scripts/i18n-extract.config.ts +0 -38
- package/src/action-link-pages.ts +0 -102
- package/src/approval-actor-impersonation.test.ts +0 -330
- package/src/approval-node.test.ts +0 -356
- package/src/approval-node.ts +0 -196
- package/src/approval-revise.test.ts +0 -418
- package/src/approval-service.test.ts +0 -2858
- package/src/approval-service.ts +0 -3617
- package/src/approvals-plugin.ts +0 -294
- package/src/approver-cross-org.integration.test.ts +0 -206
- package/src/approver-org-scope.test.ts +0 -201
- package/src/approver-org-scope.ts +0 -261
- package/src/index.ts +0 -42
- package/src/lifecycle-hooks.ts +0 -201
- package/src/nav-contribution.test.ts +0 -50
- package/src/record-lock-schedule-run.integration.test.ts +0 -206
- package/src/status-mirror-cascade.integration.test.ts +0 -224
- package/src/sys-approval-action.object.ts +0 -149
- package/src/sys-approval-approver.object.ts +0 -85
- package/src/sys-approval-delegation.object.test.ts +0 -42
- package/src/sys-approval-delegation.object.ts +0 -142
- package/src/sys-approval-request.object.test.ts +0 -116
- package/src/sys-approval-request.object.ts +0 -413
- package/src/sys-approval-token.object.ts +0 -101
- package/src/translations/bundle-ownership.test.ts +0 -48
- package/src/translations/en.objects.generated.ts +0 -311
- package/src/translations/es-ES.objects.generated.ts +0 -311
- package/src/translations/index.ts +0 -23
- package/src/translations/ja-JP.objects.generated.ts +0 -311
- package/src/translations/zh-CN.objects.generated.ts +0 -311
- package/tsconfig.json +0 -10
package/src/lifecycle-hooks.ts
DELETED
|
@@ -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
|
-
});
|