@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/approvals-plugin.ts
DELETED
|
@@ -1,294 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
import type { Plugin, PluginContext } from '@objectstack/core';
|
|
4
|
-
import { SysApprovalRequest } from './sys-approval-request.object.js';
|
|
5
|
-
import { SysApprovalAction } from './sys-approval-action.object.js';
|
|
6
|
-
import { SysApprovalApprover } from './sys-approval-approver.object.js';
|
|
7
|
-
import { SysApprovalToken } from './sys-approval-token.object.js';
|
|
8
|
-
import { SysApprovalDelegation } from './sys-approval-delegation.object.js';
|
|
9
|
-
import { renderConfirmPage, renderResultPage } from './action-link-pages.js';
|
|
10
|
-
import {
|
|
11
|
-
ApprovalService,
|
|
12
|
-
ESCALATION_JOB_NAME,
|
|
13
|
-
ESCALATION_SCAN_INTERVAL_MS,
|
|
14
|
-
type ApprovalEngine,
|
|
15
|
-
} from './approval-service.js';
|
|
16
|
-
import { bindApprovalLockHook, bindDelegationWriteGuard, unbindAllHooks } from './lifecycle-hooks.js';
|
|
17
|
-
import { registerApprovalNode, type ApprovalAutomationSurface } from './approval-node.js';
|
|
18
|
-
|
|
19
|
-
export interface ApprovalsPluginOptions {
|
|
20
|
-
/** Disable runtime registration (schemas still register). */
|
|
21
|
-
disableService?: boolean;
|
|
22
|
-
/**
|
|
23
|
-
* Interval between SLA escalation scans (ADR-0042). Defaults to
|
|
24
|
-
* {@link ESCALATION_SCAN_INTERVAL_MS} (5 min). Only takes effect when a
|
|
25
|
-
* `job` service is installed; without one, SLA stays display-only.
|
|
26
|
-
*/
|
|
27
|
-
escalationScanIntervalMs?: number;
|
|
28
|
-
/**
|
|
29
|
-
* Absolute origin for actionable links in outbound notifications
|
|
30
|
-
* (ADR-0043), e.g. `https://app.example.com`. Relative by default.
|
|
31
|
-
*/
|
|
32
|
-
publicBaseUrl?: string;
|
|
33
|
-
/**
|
|
34
|
-
* Disable the record-lock hook. Schema + service stay intact; only the
|
|
35
|
-
* engine-level lock wiring is suppressed. Useful when a caller wants the
|
|
36
|
-
* manual API only (e.g. tests).
|
|
37
|
-
*/
|
|
38
|
-
disableAutoHooks?: boolean;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* ApprovalsServicePlugin — registers sys_approval_{request,action}, the
|
|
43
|
-
* `approvals` service, the `approval` flow node executor (ADR-0019), and the
|
|
44
|
-
* record-lock hook.
|
|
45
|
-
*
|
|
46
|
-
* ADR-0019: approval is no longer a standalone process engine. A flow's
|
|
47
|
-
* Approval node opens a request and suspends the run; a decision via the
|
|
48
|
-
* service resumes it down the matching branch.
|
|
49
|
-
*/
|
|
50
|
-
export class ApprovalsServicePlugin implements Plugin {
|
|
51
|
-
name = 'com.objectstack.service.approvals';
|
|
52
|
-
version = '1.0.0';
|
|
53
|
-
type = 'standard';
|
|
54
|
-
dependencies = ['com.objectstack.engine.objectql'];
|
|
55
|
-
|
|
56
|
-
private readonly options: ApprovalsPluginOptions;
|
|
57
|
-
private service?: ApprovalService;
|
|
58
|
-
private engine?: any;
|
|
59
|
-
private escalationJobScheduled = false;
|
|
60
|
-
|
|
61
|
-
constructor(options: ApprovalsPluginOptions = {}) {
|
|
62
|
-
this.options = options;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
async init(ctx: PluginContext): Promise<void> {
|
|
66
|
-
ctx.getService<{ register(m: any): void }>('manifest').register({
|
|
67
|
-
id: 'com.objectstack.service.approvals',
|
|
68
|
-
name: 'Approvals Service',
|
|
69
|
-
version: '1.0.0',
|
|
70
|
-
type: 'plugin',
|
|
71
|
-
scope: 'system',
|
|
72
|
-
defaultDatasource: 'cloud',
|
|
73
|
-
namespace: 'sys',
|
|
74
|
-
objects: [SysApprovalRequest, SysApprovalAction, SysApprovalApprover, SysApprovalToken, SysApprovalDelegation],
|
|
75
|
-
// ADR-0029 D7 — contribute the Approvals entries into the Setup app's
|
|
76
|
-
// `group_approvals` slot. This plugin owns these objects (K2.b), so it
|
|
77
|
-
// ships their menu too; when the plugin isn't installed the slot is empty.
|
|
78
|
-
navigationContributions: [
|
|
79
|
-
{
|
|
80
|
-
app: 'setup',
|
|
81
|
-
group: 'group_approvals',
|
|
82
|
-
priority: 100,
|
|
83
|
-
items: [
|
|
84
|
-
{ id: 'nav_approval_requests', type: 'object', label: 'Requests', objectName: 'sys_approval_request', icon: 'inbox', requiresObject: 'sys_approval_request' },
|
|
85
|
-
{ id: 'nav_approval_actions', type: 'object', label: 'Action History', objectName: 'sys_approval_action', icon: 'history', requiresObject: 'sys_approval_action' },
|
|
86
|
-
{ id: 'nav_approval_delegations', type: 'object', label: 'Delegations (OOO)', objectName: 'sys_approval_delegation', icon: 'user-clock', requiresObject: 'sys_approval_delegation' },
|
|
87
|
-
],
|
|
88
|
-
},
|
|
89
|
-
],
|
|
90
|
-
});
|
|
91
|
-
// ADR-0029 D8 — contribute this plugin's object translations to the i18n
|
|
92
|
-
// service on kernel:ready (the i18n plugin may register after this one).
|
|
93
|
-
if (typeof (ctx as any).hook === 'function') {
|
|
94
|
-
(ctx as any).hook('kernel:ready', async () => {
|
|
95
|
-
try {
|
|
96
|
-
const i18n = ctx.getService<any>('i18n');
|
|
97
|
-
if (i18n && typeof i18n.loadTranslations === 'function') {
|
|
98
|
-
const { ApprovalsTranslations } = await import('./translations/index.js');
|
|
99
|
-
for (const [locale, data] of Object.entries(ApprovalsTranslations)) {
|
|
100
|
-
i18n.loadTranslations(locale, data as Record<string, unknown>);
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
} catch { /* i18n optional */ }
|
|
104
|
-
});
|
|
105
|
-
}
|
|
106
|
-
ctx.logger.info('ApprovalsServicePlugin: schemas registered');
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
async start(ctx: PluginContext): Promise<void> {
|
|
110
|
-
if (this.options.disableService) return;
|
|
111
|
-
let engine: any = null;
|
|
112
|
-
try { engine = ctx.getService<any>('objectql'); }
|
|
113
|
-
catch { try { engine = ctx.getService<any>('data'); } catch { /* ignore */ } }
|
|
114
|
-
if (!engine) {
|
|
115
|
-
ctx.logger.warn('ApprovalsServicePlugin: no ObjectQL engine — service NOT registered');
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
this.engine = engine;
|
|
119
|
-
|
|
120
|
-
this.service = new ApprovalService({
|
|
121
|
-
engine: engine as ApprovalEngine,
|
|
122
|
-
logger: ctx.logger,
|
|
123
|
-
publicBaseUrl: this.options.publicBaseUrl,
|
|
124
|
-
// [ADR-0105 D9] Cross-organization approver targeting is a `group`-posture
|
|
125
|
-
// capability. Read LAZILY (not captured at start) because the tenancy
|
|
126
|
-
// service resolves its posture during its own start, which may not have
|
|
127
|
-
// run yet; an unresolvable posture reads as "unknown" and the guard
|
|
128
|
-
// stands down rather than refusing a legitimate flow on a minimal stack.
|
|
129
|
-
tenancyPosture: () => {
|
|
130
|
-
try {
|
|
131
|
-
const tenancy = ctx.getService<{ posture?: string }>('tenancy');
|
|
132
|
-
const posture = tenancy?.posture;
|
|
133
|
-
return typeof posture === 'string' && posture ? posture : undefined;
|
|
134
|
-
} catch {
|
|
135
|
-
return undefined;
|
|
136
|
-
}
|
|
137
|
-
},
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
// Record lock: block edits to a record while it has a pending request.
|
|
141
|
-
// Delegation write-guard: a self-service OOO delegation may only name the
|
|
142
|
-
// acting user as delegator (#1322 follow-up). Both bind under the same
|
|
143
|
-
// package id, so unbindAllHooks clears them together.
|
|
144
|
-
if (!this.options.disableAutoHooks) {
|
|
145
|
-
try {
|
|
146
|
-
unbindAllHooks(engine);
|
|
147
|
-
bindApprovalLockHook(engine, ctx.logger);
|
|
148
|
-
bindDelegationWriteGuard(engine, ctx.logger);
|
|
149
|
-
} catch (err: any) {
|
|
150
|
-
ctx.logger.warn?.('[approvals] failed to bind approval hooks', { error: err?.message });
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
ctx.registerService('approvals', this.service);
|
|
155
|
-
ctx.logger.info('ApprovalsServicePlugin: service registered');
|
|
156
|
-
|
|
157
|
-
// Optional messaging service (ADR-0012): thread interactions (reassign /
|
|
158
|
-
// remind / request-info / comment) notify users when present; without it
|
|
159
|
-
// they degrade to audit-only.
|
|
160
|
-
try {
|
|
161
|
-
const messaging = ctx.getService<any>('messaging');
|
|
162
|
-
if (messaging && typeof messaging.emit === 'function') {
|
|
163
|
-
this.service.attachMessaging(messaging);
|
|
164
|
-
}
|
|
165
|
-
} catch { /* messaging not installed */ }
|
|
166
|
-
|
|
167
|
-
// SLA escalation clock (ADR-0042): a plugin-internal job, deliberately
|
|
168
|
-
// NOT a flow trigger (ADR-0041 §1). Interval sweep + one catch-up scan at
|
|
169
|
-
// boot so a restart doesn't extend a breach by a scan period. Wired on
|
|
170
|
-
// kernel:ready — the job service may start after this plugin. No `job`
|
|
171
|
-
// service → SLA stays display-only.
|
|
172
|
-
const wireEscalationClock = async () => {
|
|
173
|
-
try {
|
|
174
|
-
const jobs = ctx.getService<any>('job');
|
|
175
|
-
if (!jobs || typeof jobs.schedule !== 'function' || !this.service) return;
|
|
176
|
-
const svc = this.service;
|
|
177
|
-
const intervalMs = this.options.escalationScanIntervalMs ?? ESCALATION_SCAN_INTERVAL_MS;
|
|
178
|
-
// Both sweeps ride this one clock: they walk the same `pending` set, and
|
|
179
|
-
// the dead-run release (#3456) is reconciliation with the same "catch up
|
|
180
|
-
// after a restart" requirement — a run killed BY the restart is exactly
|
|
181
|
-
// the shape no in-band handler can clean up.
|
|
182
|
-
// Genuinely independent — an escalation failure must not strand locked
|
|
183
|
-
// records, and vice versa, so neither can short-circuit the other.
|
|
184
|
-
const sweep = async () => {
|
|
185
|
-
const results = await Promise.allSettled([
|
|
186
|
-
svc.runEscalations(),
|
|
187
|
-
svc.releaseDeadRunRequests(),
|
|
188
|
-
]);
|
|
189
|
-
for (const r of results) {
|
|
190
|
-
if (r.status === 'rejected') {
|
|
191
|
-
ctx.logger.warn?.('[approvals] periodic sweep leg failed', {
|
|
192
|
-
error: (r.reason as any)?.message ?? String(r.reason),
|
|
193
|
-
});
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
};
|
|
197
|
-
await jobs.schedule(ESCALATION_JOB_NAME, { type: 'interval', intervalMs }, sweep);
|
|
198
|
-
this.escalationJobScheduled = true;
|
|
199
|
-
void sweep().catch((err: any) => {
|
|
200
|
-
ctx.logger.warn?.('[approvals] boot sweep failed', { error: err?.message });
|
|
201
|
-
});
|
|
202
|
-
ctx.logger.info('ApprovalsServicePlugin: SLA escalation scan scheduled', { intervalMs });
|
|
203
|
-
} catch { /* job service not installed */ }
|
|
204
|
-
};
|
|
205
|
-
// Actionable-link pages (ADR-0043): session-less confirm + redemption,
|
|
206
|
-
// mounted straight on the host Hono app. GET only renders; the decision
|
|
207
|
-
// happens exclusively on the POST (mail-gateway prefetch safe).
|
|
208
|
-
const mountActionPages = async () => {
|
|
209
|
-
try {
|
|
210
|
-
const http = ctx.getService<any>('http-server');
|
|
211
|
-
const rawApp = http && typeof http.getRawApp === 'function' ? http.getRawApp() : null;
|
|
212
|
-
if (!rawApp || !this.service) return;
|
|
213
|
-
const svc = this.service;
|
|
214
|
-
const ACT_PATH = '/api/v1/approvals/act';
|
|
215
|
-
const html = (c: any, body: string, status = 200) =>
|
|
216
|
-
c.body(body, status, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
217
|
-
rawApp.get(ACT_PATH, async (c: any) => {
|
|
218
|
-
const token = String(c.req.query('token') ?? '');
|
|
219
|
-
const peek = await svc.peekActionToken(token);
|
|
220
|
-
if (!peek.ok) return html(c, renderResultPage(peek.reason, peek.request), 200);
|
|
221
|
-
return html(c, renderConfirmPage({
|
|
222
|
-
request: peek.request, action: peek.action, approverId: peek.approverId,
|
|
223
|
-
token, actPath: ACT_PATH,
|
|
224
|
-
}));
|
|
225
|
-
});
|
|
226
|
-
rawApp.post(ACT_PATH, async (c: any) => {
|
|
227
|
-
let token = '';
|
|
228
|
-
try {
|
|
229
|
-
const body = await c.req.parseBody();
|
|
230
|
-
token = String(body?.token ?? '');
|
|
231
|
-
} catch { /* fall through to invalid */ }
|
|
232
|
-
const out = await svc.redeemActionToken(token);
|
|
233
|
-
if (!out.ok) return html(c, renderResultPage(out.reason, out.request), 200);
|
|
234
|
-
return html(c, renderResultPage(out.action === 'approve' ? 'approved' : 'rejected', out.request));
|
|
235
|
-
});
|
|
236
|
-
ctx.logger.info(`ApprovalsServicePlugin: actionable-link pages mounted at ${ACT_PATH}`);
|
|
237
|
-
} catch { /* http server not installed */ }
|
|
238
|
-
};
|
|
239
|
-
|
|
240
|
-
// Pending-approver index backfill (issue #1745): rebuild the normalized
|
|
241
|
-
// sys_approval_approver rows from the pending_approvers CSV so requests
|
|
242
|
-
// written before the index existed (or drifted past a crashed sync) are
|
|
243
|
-
// queryable. Idempotent; cost tracks the live pending queue.
|
|
244
|
-
const backfillApproverIndex = async () => {
|
|
245
|
-
try {
|
|
246
|
-
const svc = this.service;
|
|
247
|
-
if (!svc) return;
|
|
248
|
-
const out = await svc.rebuildApproverIndex();
|
|
249
|
-
if (out.inserted > 0 || out.deleted > 0) {
|
|
250
|
-
ctx.logger.info('ApprovalsServicePlugin: approver index rebuilt', out);
|
|
251
|
-
}
|
|
252
|
-
} catch (err: any) {
|
|
253
|
-
ctx.logger.warn?.('[approvals] approver index backfill failed', { error: err?.message });
|
|
254
|
-
}
|
|
255
|
-
};
|
|
256
|
-
|
|
257
|
-
if (typeof (ctx as any).hook === 'function') {
|
|
258
|
-
(ctx as any).hook('kernel:ready', wireEscalationClock);
|
|
259
|
-
(ctx as any).hook('kernel:ready', mountActionPages);
|
|
260
|
-
(ctx as any).hook('kernel:ready', backfillApproverIndex);
|
|
261
|
-
} else {
|
|
262
|
-
await wireEscalationClock();
|
|
263
|
-
await mountActionPages();
|
|
264
|
-
await backfillApproverIndex();
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// ADR-0019: contribute the `approval` node to the flow engine when one is
|
|
268
|
-
// present. The node lets a flow suspend on an approval and resume on
|
|
269
|
-
// decision; the service is wired to the same engine so `decide()` can
|
|
270
|
-
// resume the suspended run.
|
|
271
|
-
try {
|
|
272
|
-
const automation = ctx.getService<ApprovalAutomationSurface>('automation');
|
|
273
|
-
if (automation && typeof automation.registerNodeExecutor === 'function') {
|
|
274
|
-
this.service.attachAutomation(automation);
|
|
275
|
-
registerApprovalNode(automation, this.service, ctx.logger);
|
|
276
|
-
}
|
|
277
|
-
} catch {
|
|
278
|
-
ctx.logger.info('ApprovalsServicePlugin: no automation engine — approval node not registered');
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
async stop(ctx: PluginContext): Promise<void> {
|
|
283
|
-
if (this.escalationJobScheduled) {
|
|
284
|
-
try {
|
|
285
|
-
const jobs = ctx.getService<any>('job');
|
|
286
|
-
await jobs?.cancel?.(ESCALATION_JOB_NAME);
|
|
287
|
-
} catch { /* ignore */ }
|
|
288
|
-
this.escalationJobScheduled = false;
|
|
289
|
-
}
|
|
290
|
-
if (this.engine) {
|
|
291
|
-
try { unbindAllHooks(this.engine); } catch { /* ignore */ }
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
}
|
|
@@ -1,206 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* [ADR-0105 D9] Cross-organization approver targeting, through the SERVICE.
|
|
5
|
-
*
|
|
6
|
-
* `approver-org-scope.test.ts` owns the resolver's own rules. What only this
|
|
7
|
-
* level can show is that the resolved organization actually reaches the
|
|
8
|
-
* directory lookups — the wiring D9 is: one organization id used to decide
|
|
9
|
-
* three different things at once (where the request lives, where its inbox rows
|
|
10
|
-
* live, where its approvers are looked up), and only the third moves.
|
|
11
|
-
*
|
|
12
|
-
* The scenario is the one D9 exists for: a purchase order raised in PLANT A
|
|
13
|
-
* needs the group CFO, who holds `cfo` in the GROUP organization and would have
|
|
14
|
-
* matched nobody before.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import { describe, it, expect, beforeEach } from 'vitest';
|
|
18
|
-
import { ApprovalService } from './approval-service.js';
|
|
19
|
-
|
|
20
|
-
interface Row { [k: string]: any }
|
|
21
|
-
|
|
22
|
-
function makeEngine(seed: Record<string, Row[]> = {}) {
|
|
23
|
-
const tables: Record<string, Row[]> = { ...seed };
|
|
24
|
-
const ensure = (n: string) => (tables[n] ??= []);
|
|
25
|
-
const matches = (row: Row, filter: any): boolean => {
|
|
26
|
-
if (!filter || typeof filter !== 'object') return true;
|
|
27
|
-
for (const [k, v] of Object.entries(filter)) {
|
|
28
|
-
if (k === '$or') {
|
|
29
|
-
if (!(v as any[]).some((sub) => matches(row, sub))) return false;
|
|
30
|
-
continue;
|
|
31
|
-
}
|
|
32
|
-
const rv = row[k];
|
|
33
|
-
if (v != null && typeof v === 'object' && '$in' in (v as any)) {
|
|
34
|
-
if (!(v as any).$in.includes(rv)) return false;
|
|
35
|
-
continue;
|
|
36
|
-
}
|
|
37
|
-
if (v != null && typeof v === 'object' && '$ne' in (v as any)) {
|
|
38
|
-
if (rv === (v as any).$ne) return false;
|
|
39
|
-
continue;
|
|
40
|
-
}
|
|
41
|
-
if (rv !== v) return false;
|
|
42
|
-
}
|
|
43
|
-
return true;
|
|
44
|
-
};
|
|
45
|
-
return {
|
|
46
|
-
_tables: tables,
|
|
47
|
-
async find(object: string, options?: any) {
|
|
48
|
-
return ensure(object).filter((r) => matches(r, options?.filter ?? options?.where));
|
|
49
|
-
},
|
|
50
|
-
async insert(object: string, data: Row) { ensure(object).push({ ...data }); return { ...data }; },
|
|
51
|
-
async update(_o: string, _w: any, _d: any) { return {}; },
|
|
52
|
-
async delete() { return {}; },
|
|
53
|
-
async count(object: string) { return ensure(object).length; },
|
|
54
|
-
registerHook() { /* no-op */ },
|
|
55
|
-
unregisterHooksByPackage() { /* no-op */ },
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** Plant A sits under the group; the CFO's position lives in the group org. */
|
|
60
|
-
const SEED = () => ({
|
|
61
|
-
sys_organization: [
|
|
62
|
-
{ id: 'o_group', slug: 'acme-group', parent_organization_id: null },
|
|
63
|
-
{ id: 'o_plant', slug: 'acme-plant-a', parent_organization_id: 'o_group' },
|
|
64
|
-
],
|
|
65
|
-
sys_user_position: [
|
|
66
|
-
{ id: 'up1', user_id: 'u_cfo', position: 'cfo', organization_id: 'o_group' },
|
|
67
|
-
{ id: 'up2', user_id: 'u_plant_mgr', position: 'plant_manager', organization_id: 'o_plant' },
|
|
68
|
-
],
|
|
69
|
-
sys_member: [
|
|
70
|
-
// The intended group shape: group staff hold a membership in every plant
|
|
71
|
-
// (so D2's union lets them READ the request) while their POSITION lives in
|
|
72
|
-
// the group organization.
|
|
73
|
-
{ id: 'm1', user_id: 'u_cfo', organization_id: 'o_plant', role: 'member' },
|
|
74
|
-
{ id: 'm2', user_id: 'u_plant_mgr', organization_id: 'o_plant', role: 'member' },
|
|
75
|
-
],
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
const CTX = { userId: 'u_submitter', organizationId: 'o_plant', positions: [], permissions: [] } as any;
|
|
79
|
-
|
|
80
|
-
function openInput(approvers: any[], extra: Record<string, any> = {}) {
|
|
81
|
-
return {
|
|
82
|
-
object: 'purchase_order',
|
|
83
|
-
recordId: 'po1',
|
|
84
|
-
runId: 'run_1',
|
|
85
|
-
nodeId: 'group_signoff',
|
|
86
|
-
flowName: 'po_approval',
|
|
87
|
-
config: { approvers, behavior: 'unanimous' as const, lockRecord: false },
|
|
88
|
-
record: { id: 'po1', amount: 500000 },
|
|
89
|
-
...extra,
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
describe('ADR-0105 D9 — cross-org approver targeting through ApprovalService', () => {
|
|
94
|
-
let engine: ReturnType<typeof makeEngine>;
|
|
95
|
-
let svc: ApprovalService;
|
|
96
|
-
let n = 0;
|
|
97
|
-
|
|
98
|
-
beforeEach(() => {
|
|
99
|
-
engine = makeEngine(SEED());
|
|
100
|
-
n = 0;
|
|
101
|
-
svc = new ApprovalService({
|
|
102
|
-
engine: engine as any,
|
|
103
|
-
clock: { now: () => new Date(1767000000000 + (n++) * 1000) },
|
|
104
|
-
tenancyPosture: () => 'group',
|
|
105
|
-
});
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
it('without targeting, a group position matches NOBODY — the gap D9 closes', async () => {
|
|
109
|
-
// The `cfo` position exists, but in the group org; the request is Plant A's.
|
|
110
|
-
// Pre-D9 this was the only possible outcome, and it was silent.
|
|
111
|
-
const req = await svc.openNodeRequest(openInput([{ type: 'position', value: 'cfo' }]), CTX);
|
|
112
|
-
expect(req.pending_approvers).toEqual(['position:cfo']); // the dead literal slot
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it('`organization: $root` resolves the CFO against the GROUP directory', async () => {
|
|
116
|
-
const req = await svc.openNodeRequest(
|
|
117
|
-
openInput([{ type: 'position', value: 'cfo', organization: '$root' }]),
|
|
118
|
-
CTX,
|
|
119
|
-
);
|
|
120
|
-
expect(req.pending_approvers).toEqual(['u_cfo']);
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it('the request and its inbox rows still belong to the REQUEST org — only the lookup moved', async () => {
|
|
124
|
-
await svc.openNodeRequest(
|
|
125
|
-
openInput([{ type: 'position', value: 'cfo', organization: '$root' }]),
|
|
126
|
-
CTX,
|
|
127
|
-
);
|
|
128
|
-
// This is the whole point of the split: targeting must not relocate the
|
|
129
|
-
// request into the approver's organization, or the plant would lose its own
|
|
130
|
-
// audit trail to the group.
|
|
131
|
-
expect(engine._tables['sys_approval_request'][0].organization_id).toBe('o_plant');
|
|
132
|
-
expect(engine._tables['sys_approval_approver'][0].organization_id).toBe('o_plant');
|
|
133
|
-
expect(engine._tables['sys_approval_action'][0].organization_id).toBe('o_plant');
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it('mixes a plant approver and a group approver in ONE node — why targeting is per-approver', async () => {
|
|
137
|
-
// A node-level declaration could not express this: the plant manager and the
|
|
138
|
-
// group CFO sign off in parallel, each resolved in a different directory.
|
|
139
|
-
const req = await svc.openNodeRequest(
|
|
140
|
-
openInput([
|
|
141
|
-
{ type: 'position', value: 'plant_manager', group: 'plant' },
|
|
142
|
-
{ type: 'position', value: 'cfo', organization: '$root', group: 'finance' },
|
|
143
|
-
]),
|
|
144
|
-
CTX,
|
|
145
|
-
);
|
|
146
|
-
expect(new Set(req.pending_approvers)).toEqual(new Set(['u_plant_mgr', 'u_cfo']));
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it('drops a targeted approver who could not READ the request, leaving the empty-slate path', async () => {
|
|
150
|
-
// Same flow, but the CFO holds no membership in Plant A — D2's union wall
|
|
151
|
-
// would hide the request from her. Better an empty slate (which the node has
|
|
152
|
-
// an `onEmptyApprovers` policy for) than a task she cannot open.
|
|
153
|
-
engine._tables['sys_member'] = engine._tables['sys_member'].filter((m) => m.user_id !== 'u_cfo');
|
|
154
|
-
const req = await svc.openNodeRequest(
|
|
155
|
-
openInput([{ type: 'position', value: 'cfo', organization: '$root' }]),
|
|
156
|
-
CTX,
|
|
157
|
-
);
|
|
158
|
-
expect(req.pending_approvers).toEqual(['position:cfo']);
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
// Regression: `user` / `field` / `manager` return EARLY in
|
|
162
|
-
// `resolveApproverSpec`, before the graph-expansion branch. D9's resolution
|
|
163
|
-
// originally sat after those returns, so the declaration on a directory-less
|
|
164
|
-
// type was silently INERT rather than refused — the one behaviour ADR-0105 D9
|
|
165
|
-
// and the authoring docs both promise it is not. The resolver's own unit test
|
|
166
|
-
// could not see it (it calls the resolver directly); only a request opened
|
|
167
|
-
// through the service reaches the early return. Caught by cloud's
|
|
168
|
-
// group-posture dogfood.
|
|
169
|
-
it('refuses `organization` on a directory-less type — the early return must not skip the check', async () => {
|
|
170
|
-
for (const spec of [
|
|
171
|
-
{ type: 'user', value: 'u_cfo', organization: '$root' },
|
|
172
|
-
{ type: 'field', value: 'owner_id', organization: '$root' },
|
|
173
|
-
{ type: 'manager', organization: '$root' },
|
|
174
|
-
]) {
|
|
175
|
-
await expect(
|
|
176
|
-
svc.openNodeRequest(openInput([spec]), CTX),
|
|
177
|
-
`approver type '${spec.type}' must refuse a cross-org declaration`,
|
|
178
|
-
).rejects.toThrow(/VALIDATION_FAILED.*no effect/);
|
|
179
|
-
}
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
it('a directory-less approver with NO declaration is untouched by D9', async () => {
|
|
183
|
-
// The guard must not cost the ordinary case anything.
|
|
184
|
-
const req = await svc.openNodeRequest(openInput([{ type: 'user', value: 'u_plant_mgr' }]), CTX);
|
|
185
|
-
expect(req.pending_approvers).toEqual(['u_plant_mgr']);
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
it('refuses a target outside the group — loudly, and no request is created', async () => {
|
|
189
|
-
engine._tables['sys_organization'].push({ id: 'o_rival', slug: 'rival-co', parent_organization_id: null });
|
|
190
|
-
await expect(
|
|
191
|
-
svc.openNodeRequest(openInput([{ type: 'position', value: 'cfo', organization: 'rival-co' }]), CTX),
|
|
192
|
-
).rejects.toThrow(/VALIDATION_FAILED.*not in the same group/);
|
|
193
|
-
expect(engine._tables['sys_approval_request'] ?? []).toHaveLength(0);
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
it('refuses under a non-group posture — a posture migration cannot silently reroute', async () => {
|
|
197
|
-
const isolated = new ApprovalService({
|
|
198
|
-
engine: engine as any,
|
|
199
|
-
clock: { now: () => new Date(1767000000000) },
|
|
200
|
-
tenancyPosture: () => 'isolated',
|
|
201
|
-
});
|
|
202
|
-
await expect(
|
|
203
|
-
isolated.openNodeRequest(openInput([{ type: 'position', value: 'cfo', organization: '$root' }]), CTX),
|
|
204
|
-
).rejects.toThrow(/VALIDATION_FAILED.*'group' tenancy posture/);
|
|
205
|
-
});
|
|
206
|
-
});
|