@objectstack/plugin-approvals 17.0.0-rc.0 → 17.0.0-rc.2
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 +863 -0
- package/dist/index.d.mts +2236 -2688
- package/dist/index.d.ts +2236 -2688
- package/dist/index.js +591 -128
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +590 -127
- package/dist/index.mjs.map +1 -1
- package/package.json +17 -10
- 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
|
@@ -1,201 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* [ADR-0105 D9] Cross-organization approver targeting.
|
|
5
|
-
*
|
|
6
|
-
* The property that matters is that this is a route WITHIN one group, not a
|
|
7
|
-
* channel to an arbitrary tenant — so every test here is about a boundary
|
|
8
|
-
* holding or a failure being LOUD. The thing D9 exists to prevent is silence:
|
|
9
|
-
* an approval that reads as "escalate to group" but quietly resolves inside the
|
|
10
|
-
* plant, or one that routes to someone the D2 wall then hides the request from.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
14
|
-
import {
|
|
15
|
-
filterApproversWhoCanRead,
|
|
16
|
-
resolveApproverDirectoryOrg,
|
|
17
|
-
type ApproverOrgScopeDeps,
|
|
18
|
-
} from './approver-org-scope.js';
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* A three-tier group: group → division → plant, plus a shared-services SIBLING
|
|
22
|
-
* of the plant, plus an organization in a DIFFERENT group entirely.
|
|
23
|
-
*/
|
|
24
|
-
const ORGS: Record<string, { id: string; slug: string; parent_organization_id: string | null }> = {
|
|
25
|
-
o_group: { id: 'o_group', slug: 'acme-group', parent_organization_id: null },
|
|
26
|
-
o_div: { id: 'o_div', slug: 'acme-north', parent_organization_id: 'o_group' },
|
|
27
|
-
o_plant: { id: 'o_plant', slug: 'acme-plant-a', parent_organization_id: 'o_div' },
|
|
28
|
-
o_ssc: { id: 'o_ssc', slug: 'acme-ssc', parent_organization_id: 'o_group' },
|
|
29
|
-
o_other: { id: 'o_other', slug: 'rival-co', parent_organization_id: null },
|
|
30
|
-
o_lone: { id: 'o_lone', slug: 'lone-co', parent_organization_id: null },
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
function makeDeps(over: Partial<ApproverOrgScopeDeps> & { members?: Array<{ user_id: string; organization_id: string }> } = {}): ApproverOrgScopeDeps & { warn: any } {
|
|
34
|
-
const warn = vi.fn();
|
|
35
|
-
const members = over.members ?? [];
|
|
36
|
-
const engine = {
|
|
37
|
-
find: vi.fn(async (object: string, opts: any) => {
|
|
38
|
-
const f = opts?.filter ?? {};
|
|
39
|
-
if (object === 'sys_organization') {
|
|
40
|
-
const rows = Object.values(ORGS).filter((o) =>
|
|
41
|
-
(f.id === undefined || o.id === f.id) && (f.slug === undefined || o.slug === f.slug));
|
|
42
|
-
return rows;
|
|
43
|
-
}
|
|
44
|
-
if (object === 'sys_member') {
|
|
45
|
-
const wanted: string[] = f.user_id?.$in ?? [];
|
|
46
|
-
return members.filter((m) => m.organization_id === f.organization_id && wanted.includes(m.user_id));
|
|
47
|
-
}
|
|
48
|
-
return [];
|
|
49
|
-
}),
|
|
50
|
-
};
|
|
51
|
-
return { engine, logger: { warn }, posture: () => 'group', ...over, warn } as any;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const resolve = (
|
|
55
|
-
deps: ApproverOrgScopeDeps,
|
|
56
|
-
declaration: string | undefined,
|
|
57
|
-
requestOrg: string | null,
|
|
58
|
-
type = 'position',
|
|
59
|
-
orgScoped = true,
|
|
60
|
-
) => resolveApproverDirectoryOrg(deps, declaration, requestOrg, type, orgScoped);
|
|
61
|
-
|
|
62
|
-
describe('resolveApproverDirectoryOrg — the default path is untouched', () => {
|
|
63
|
-
it('returns the request org and reads NOTHING when no organization is declared', async () => {
|
|
64
|
-
const deps = makeDeps();
|
|
65
|
-
await expect(resolve(deps, undefined, 'o_plant')).resolves.toBe('o_plant');
|
|
66
|
-
// The overwhelmingly common case must not cost a query.
|
|
67
|
-
expect((deps.engine.find as any)).not.toHaveBeenCalled();
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it('passes a null request org straight through — a single-org deployment is unaffected', async () => {
|
|
71
|
-
const deps = makeDeps();
|
|
72
|
-
await expect(resolve(deps, undefined, null)).resolves.toBeNull();
|
|
73
|
-
});
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
describe('resolveApproverDirectoryOrg — symbols resolve against the D6 tree', () => {
|
|
77
|
-
it('$root climbs to the group organization, not just one level', async () => {
|
|
78
|
-
// o_plant → o_div → o_group. A one-level implementation would answer o_div.
|
|
79
|
-
await expect(resolve(makeDeps(), '$root', 'o_plant')).resolves.toBe('o_group');
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it('$parent stops at exactly one level — division sign-off in a three-tier group', async () => {
|
|
83
|
-
await expect(resolve(makeDeps(), '$parent', 'o_plant')).resolves.toBe('o_div');
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it('$root on an organization with no lineage FAILS instead of silently self-targeting', async () => {
|
|
87
|
-
// Returning o_lone would make "escalate to group" mean "approve in place" —
|
|
88
|
-
// the exact silent misrouting D9 exists to prevent.
|
|
89
|
-
await expect(resolve(makeDeps(), '$root', 'o_lone')).rejects.toThrow(
|
|
90
|
-
/VALIDATION_FAILED.*no 'parent_organization_id' lineage/,
|
|
91
|
-
);
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it('$parent at the root of a group fails rather than resolving to nothing', async () => {
|
|
95
|
-
await expect(resolve(makeDeps(), '$parent', 'o_group')).rejects.toThrow(/VALIDATION_FAILED.*no parent/);
|
|
96
|
-
});
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
describe('resolveApproverDirectoryOrg — a slug names one organization, bounded by the group', () => {
|
|
100
|
-
it('accepts a SIBLING in the same group — the shared-services-centre shape', async () => {
|
|
101
|
-
// o_ssc is not an ancestor of o_plant; it shares the root. This is why the
|
|
102
|
-
// rule is "shares a root", not "is an ancestor".
|
|
103
|
-
await expect(resolve(makeDeps(), 'acme-ssc', 'o_plant')).resolves.toBe('o_ssc');
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it('refuses an organization in a DIFFERENT group — this is not a channel to any tenant', async () => {
|
|
107
|
-
await expect(resolve(makeDeps(), 'rival-co', 'o_plant')).rejects.toThrow(
|
|
108
|
-
/VALIDATION_FAILED.*not in the same group/,
|
|
109
|
-
);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
it('refuses an unknown slug and says an id is not accepted', async () => {
|
|
113
|
-
// The most likely authoring mistake is pasting an organization id, which is
|
|
114
|
-
// per-deployment and would make the flow unportable.
|
|
115
|
-
await expect(resolve(makeDeps(), 'o_plant', 'o_plant')).rejects.toThrow(
|
|
116
|
-
/VALIDATION_FAILED.*never an id/,
|
|
117
|
-
);
|
|
118
|
-
});
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
describe('resolveApproverDirectoryOrg — the guards', () => {
|
|
122
|
-
it('refuses under a non-group posture instead of silently ignoring the declaration', async () => {
|
|
123
|
-
// A deployment migrating group → isolated must not quietly reroute its
|
|
124
|
-
// approvals; that is an audit event, not a config detail.
|
|
125
|
-
const deps = makeDeps({ posture: () => 'isolated' });
|
|
126
|
-
await expect(resolve(deps, '$root', 'o_plant')).rejects.toThrow(
|
|
127
|
-
/VALIDATION_FAILED.*requires the 'group' tenancy posture.*isolated/,
|
|
128
|
-
);
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
it('stands down when the posture is unknown — a minimal stack is not broken by a guard it cannot answer', async () => {
|
|
132
|
-
const deps = makeDeps({ posture: () => undefined });
|
|
133
|
-
await expect(resolve(deps, '$root', 'o_plant')).resolves.toBe('o_group');
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it('refuses the declaration on an approver type that has no organization directory', async () => {
|
|
137
|
-
// `user` names a person outright; an `organization` on it would have no
|
|
138
|
-
// effect. An author who wrote it believed it did something.
|
|
139
|
-
await expect(resolve(makeDeps(), '$root', 'o_plant', 'user', false)).rejects.toThrow(
|
|
140
|
-
/VALIDATION_FAILED.*would have no effect/,
|
|
141
|
-
);
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
it('refuses when the request carries no organization at all', async () => {
|
|
145
|
-
await expect(resolve(makeDeps(), '$root', null)).rejects.toThrow(
|
|
146
|
-
/VALIDATION_FAILED.*carries no organization/,
|
|
147
|
-
);
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
it('survives a cycle in the grouping metadata instead of looping forever', async () => {
|
|
151
|
-
const deps = makeDeps();
|
|
152
|
-
(deps.engine.find as any) = vi.fn(async (object: string, opts: any) => {
|
|
153
|
-
if (object !== 'sys_organization') return [];
|
|
154
|
-
const id = opts?.filter?.id;
|
|
155
|
-
// a → b → a
|
|
156
|
-
if (id === 'a') return [{ id: 'a', slug: 'a', parent_organization_id: 'b' }];
|
|
157
|
-
if (id === 'b') return [{ id: 'b', slug: 'b', parent_organization_id: 'a' }];
|
|
158
|
-
return [];
|
|
159
|
-
});
|
|
160
|
-
// Terminates; the cycle is broken and whatever it settles on is bounded.
|
|
161
|
-
await expect(resolve(deps, '$parent', 'a')).resolves.toBe('b');
|
|
162
|
-
});
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
describe('filterApproversWhoCanRead — routing to someone the wall hides is a silent failure', () => {
|
|
166
|
-
it('keeps approvers who hold a membership in the REQUEST org', async () => {
|
|
167
|
-
const deps = makeDeps({ members: [{ user_id: 'u_cfo', organization_id: 'o_plant' }] });
|
|
168
|
-
const kept = await filterApproversWhoCanRead(deps, ['u_cfo'], 'o_plant', {
|
|
169
|
-
approverType: 'position', value: 'cfo', directoryOrgId: 'o_group',
|
|
170
|
-
});
|
|
171
|
-
expect(kept).toEqual(['u_cfo']);
|
|
172
|
-
expect(deps.warn).not.toHaveBeenCalled();
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
it('drops an approver with no membership there, and says exactly why', async () => {
|
|
176
|
-
// She holds `cfo` in the group org but no membership in the plant, so D2's
|
|
177
|
-
// union would hide the request from her: routing succeeds, then she opens a
|
|
178
|
-
// task she cannot open. Convert that into the empty-slate path the node
|
|
179
|
-
// already has a policy for.
|
|
180
|
-
const deps = makeDeps({ members: [{ user_id: 'u_ok', organization_id: 'o_plant' }] });
|
|
181
|
-
const kept = await filterApproversWhoCanRead(deps, ['u_ok', 'u_no_membership'], 'o_plant', {
|
|
182
|
-
approverType: 'position', value: 'cfo', directoryOrgId: 'o_group',
|
|
183
|
-
});
|
|
184
|
-
expect(kept).toEqual(['u_ok']);
|
|
185
|
-
expect(deps.warn).toHaveBeenCalledTimes(1);
|
|
186
|
-
const msg = String(deps.warn.mock.calls[0][0]);
|
|
187
|
-
expect(msg).toMatch(/u_no_membership|cross-organization approver/);
|
|
188
|
-
expect(msg).toMatch(/Grant those users a membership|retarget/);
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
it('does NOT empty a live slate when membership is unreadable', async () => {
|
|
192
|
-
// An infrastructure hiccup must not silently unstaff a pending approval:
|
|
193
|
-
// routing to someone who may not see it is recoverable, emptying is not.
|
|
194
|
-
const deps = makeDeps();
|
|
195
|
-
(deps.engine.find as any) = vi.fn(async () => { throw new Error('driver hiccup'); });
|
|
196
|
-
const kept = await filterApproversWhoCanRead(deps, ['u1', 'u2'], 'o_plant', {
|
|
197
|
-
approverType: 'position', directoryOrgId: 'o_group',
|
|
198
|
-
});
|
|
199
|
-
expect(kept).toEqual(['u1', 'u2']);
|
|
200
|
-
});
|
|
201
|
-
});
|
|
@@ -1,261 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* [ADR-0105 D9] Cross-organization approver targeting — resolving WHICH
|
|
5
|
-
* organization's directory an approver is looked up in.
|
|
6
|
-
*
|
|
7
|
-
* One organization id used to do three jobs at once in `openNodeRequest`:
|
|
8
|
-
* where the request row lives, where its inbox index rows live, and where its
|
|
9
|
-
* approvers are looked up. The first two are the request's own organization by
|
|
10
|
-
* definition. The third is not: a group CFO holds her `cfo` position in the
|
|
11
|
-
* GROUP organization while the purchase order she signs off lives in the PLANT
|
|
12
|
-
* organization. Binding all three together meant
|
|
13
|
-
* `expandPositionUsers('cfo', <plant>)` matched nobody and the slot fell into
|
|
14
|
-
* `onEmptyApprovers` — a group escalation could not be expressed at all.
|
|
15
|
-
*
|
|
16
|
-
* This module resolves only the third job. The request keeps living in its own
|
|
17
|
-
* organization, and D2's membership union is what lets a group-side approver
|
|
18
|
-
* READ it — see `assertApproversCanRead` below for why that is a precondition
|
|
19
|
-
* worth checking rather than assuming.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
/** Cycle guard for the `parent_organization_id` walk (mirrors the BU subtree walk). */
|
|
23
|
-
const MAX_ORG_DEPTH = 32;
|
|
24
|
-
|
|
25
|
-
const SYSTEM_CTX = { isSystem: true } as const;
|
|
26
|
-
|
|
27
|
-
export interface ApproverOrgScopeEngine {
|
|
28
|
-
find(object: string, options: any): Promise<any[]>;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export interface ApproverOrgScopeDeps {
|
|
32
|
-
engine: ApproverOrgScopeEngine;
|
|
33
|
-
/**
|
|
34
|
-
* The tenancy posture in force, when the host could resolve one. `undefined`
|
|
35
|
-
* means "unknown" — a stack booted without the tenancy service — and is
|
|
36
|
-
* treated as permissive so a minimal test/embedded stack is not broken by a
|
|
37
|
-
* guard it cannot answer.
|
|
38
|
-
*/
|
|
39
|
-
posture?: () => string | undefined;
|
|
40
|
-
logger?: { warn?: (msg: any, ...rest: any[]) => void; debug?: (msg: any, ...rest: any[]) => void };
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Every failure here THROWS `VALIDATION_FAILED`, matching how `expression`
|
|
45
|
-
* approvers already fail (#3447 P2): an approver declaration that cannot be
|
|
46
|
-
* resolved is a ROUTING BUG, never "condition not met". Silently falling back
|
|
47
|
-
* to the request's own organization would be the worst option available — a
|
|
48
|
-
* flow that reads as "escalate to group" would quietly approve inside the
|
|
49
|
-
* plant, and the deployment that changed posture would reroute its approvals
|
|
50
|
-
* with no signal at all.
|
|
51
|
-
*
|
|
52
|
-
* Messages name the offending value because their primary reader is the AI
|
|
53
|
-
* author fixing the flow on the next validate pass.
|
|
54
|
-
*/
|
|
55
|
-
function fail(message: string): never {
|
|
56
|
-
throw new Error(`VALIDATION_FAILED: ${message}`);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
async function findOrg(
|
|
60
|
-
engine: ApproverOrgScopeEngine,
|
|
61
|
-
where: Record<string, unknown>,
|
|
62
|
-
): Promise<any | null> {
|
|
63
|
-
try {
|
|
64
|
-
const rows = await engine.find('sys_organization', {
|
|
65
|
-
filter: where,
|
|
66
|
-
fields: ['id', 'slug', 'parent_organization_id'],
|
|
67
|
-
limit: 1,
|
|
68
|
-
context: SYSTEM_CTX,
|
|
69
|
-
} as any);
|
|
70
|
-
return Array.isArray(rows) && rows[0] ? rows[0] : null;
|
|
71
|
-
} catch {
|
|
72
|
-
return null;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** The id chain from `orgId` up to its root, inclusive. Fail-closed on cycles. */
|
|
77
|
-
async function ancestorChain(
|
|
78
|
-
engine: ApproverOrgScopeEngine,
|
|
79
|
-
orgId: string,
|
|
80
|
-
): Promise<string[]> {
|
|
81
|
-
const chain: string[] = [];
|
|
82
|
-
const seen = new Set<string>();
|
|
83
|
-
let cursor: string | null = orgId;
|
|
84
|
-
for (let depth = 0; cursor && depth < MAX_ORG_DEPTH; depth++) {
|
|
85
|
-
if (seen.has(cursor)) break; // cycle in the grouping metadata — stop, do not loop
|
|
86
|
-
seen.add(cursor);
|
|
87
|
-
chain.push(cursor);
|
|
88
|
-
const row = await findOrg(engine, { id: cursor });
|
|
89
|
-
const parent = row?.parent_organization_id;
|
|
90
|
-
cursor = parent ? String(parent) : null;
|
|
91
|
-
}
|
|
92
|
-
return chain;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Resolve an approver's `organization` declaration to a concrete organization
|
|
97
|
-
* id for directory lookups. Returns the request's own organization when the
|
|
98
|
-
* declaration is absent — the unchanged default path, which does no reads.
|
|
99
|
-
*/
|
|
100
|
-
export async function resolveApproverDirectoryOrg(
|
|
101
|
-
deps: ApproverOrgScopeDeps,
|
|
102
|
-
declaration: string | null | undefined,
|
|
103
|
-
requestOrgId: string | null | undefined,
|
|
104
|
-
approverType: string,
|
|
105
|
-
isOrgScopedType: boolean,
|
|
106
|
-
): Promise<string | null | undefined> {
|
|
107
|
-
const declared = typeof declaration === 'string' ? declaration.trim() : '';
|
|
108
|
-
if (!declared) return requestOrgId;
|
|
109
|
-
|
|
110
|
-
// An `organization` on `user` / `field` / `manager` / `team` is not a
|
|
111
|
-
// narrower routing — those types never consult an org-scoped directory, so
|
|
112
|
-
// the declaration would have no effect. Refuse rather than ignore: a flow
|
|
113
|
-
// author who wrote it believed it did something.
|
|
114
|
-
if (!isOrgScopedType) {
|
|
115
|
-
fail(
|
|
116
|
-
`approver type '${approverType}' resolves people without an organization directory, `
|
|
117
|
-
+ `so 'organization: ${declared}' would have no effect — remove it `
|
|
118
|
-
+ `(ADR-0105 D9 applies to position / org_membership_level / department / expression)`,
|
|
119
|
-
);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// Posture guard. ADR-0105 D9 applies to `group` only: in `isolated`, cross-org
|
|
123
|
-
// approval remains system-context mirroring (cloud #2937 contract), and in
|
|
124
|
-
// `single` there is no second organization to target. Refusing here — rather
|
|
125
|
-
// than in a lint — is deliberate: posture is ENVIRONMENT configuration, so the
|
|
126
|
-
// same portable flow metadata may be deployed into any posture and no static
|
|
127
|
-
// check can see which. A deployment migrating group → isolated must fail
|
|
128
|
-
// loudly instead of silently rerouting its approvals.
|
|
129
|
-
const posture = deps.posture?.();
|
|
130
|
-
if (posture && posture !== 'group') {
|
|
131
|
-
fail(
|
|
132
|
-
`cross-organization approver targeting ('organization: ${declared}') requires the `
|
|
133
|
-
+ `'group' tenancy posture; this deployment resolves '${posture}' (ADR-0105 D9)`,
|
|
134
|
-
);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const requestOrg = requestOrgId ? String(requestOrgId) : '';
|
|
138
|
-
if (!requestOrg) {
|
|
139
|
-
fail(
|
|
140
|
-
`'organization: ${declared}' cannot be resolved for a request that carries no `
|
|
141
|
-
+ `organization — cross-organization targeting needs an organization to resolve from`,
|
|
142
|
-
);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
const chain = await ancestorChain(deps.engine, requestOrg);
|
|
146
|
-
|
|
147
|
-
if (declared === '$root') {
|
|
148
|
-
const root = chain[chain.length - 1];
|
|
149
|
-
if (root === requestOrg && chain.length === 1) {
|
|
150
|
-
fail(
|
|
151
|
-
`'organization: $root' resolved to the request's own organization — this organization `
|
|
152
|
-
+ `has no 'parent_organization_id' lineage. Declare the group hierarchy (ADR-0105 D6) `
|
|
153
|
-
+ `or drop the targeting`,
|
|
154
|
-
);
|
|
155
|
-
}
|
|
156
|
-
return root;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
if (declared === '$parent') {
|
|
160
|
-
const parent = chain[1];
|
|
161
|
-
if (!parent) {
|
|
162
|
-
fail(
|
|
163
|
-
`'organization: $parent' has no parent to resolve to — the request's organization is `
|
|
164
|
-
+ `already the root of its group (ADR-0105 D6 'parent_organization_id')`,
|
|
165
|
-
);
|
|
166
|
-
}
|
|
167
|
-
return parent;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// A slug names one specific organization — the shape the symbols cannot
|
|
171
|
-
// express (a SIBLING, e.g. a shared-services centre approving payables for
|
|
172
|
-
// every plant). Slugs rather than ids because flow metadata is portable
|
|
173
|
-
// across environments and organization ids are minted per deployment.
|
|
174
|
-
const target = await findOrg(deps.engine, { slug: declared });
|
|
175
|
-
if (!target?.id) {
|
|
176
|
-
fail(
|
|
177
|
-
`no organization with slug '${declared}' — 'organization' takes an organization SLUG `
|
|
178
|
-
+ `or a symbol ($root / $parent), never an id (ids are per-deployment, flows are portable)`,
|
|
179
|
-
);
|
|
180
|
-
}
|
|
181
|
-
const targetId = String(target.id);
|
|
182
|
-
if (targetId === requestOrg) return targetId;
|
|
183
|
-
|
|
184
|
-
// Legality: the target must share a grouping root with the request's
|
|
185
|
-
// organization. "Shares a root" rather than "is an ancestor" because the
|
|
186
|
-
// sibling case is a first-class use. The rule depends only on the
|
|
187
|
-
// organization tree — never on who submitted — so one flow routes identically
|
|
188
|
-
// for every submitter, which is what makes a routing bug reproducible.
|
|
189
|
-
const targetChain = await ancestorChain(deps.engine, targetId);
|
|
190
|
-
const targetRoot = targetChain[targetChain.length - 1];
|
|
191
|
-
const requestRoot = chain[chain.length - 1];
|
|
192
|
-
if (!targetRoot || !requestRoot || targetRoot !== requestRoot) {
|
|
193
|
-
fail(
|
|
194
|
-
`organization '${declared}' is not in the same group as the request's organization `
|
|
195
|
-
+ `— cross-organization approval routes WITHIN one group (they must share a `
|
|
196
|
-
+ `'parent_organization_id' root, ADR-0105 D6/D9)`,
|
|
197
|
-
);
|
|
198
|
-
}
|
|
199
|
-
return targetId;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/**
|
|
203
|
-
* Drop approvers who could not READ the request they were routed to, and say
|
|
204
|
-
* so.
|
|
205
|
-
*
|
|
206
|
-
* ADR-0105 D9 notes that reads by cross-org approvers "are covered by D2
|
|
207
|
-
* (membership union)". True — but D2's union is `organization_id IN
|
|
208
|
-
* accessible_org_ids`, and the request row is stamped with the REQUEST's
|
|
209
|
-
* organization. So a group-side approver reaches it only if she also holds a
|
|
210
|
-
* membership there. That is the intended group shape (group staff are members
|
|
211
|
-
* of every plant while holding their positions in the group org — the shape the
|
|
212
|
-
* ADR-0105 acceptance dogfood already models), but nothing enforces it.
|
|
213
|
-
*
|
|
214
|
-
* Left unchecked the failure is SILENT and expensive: routing succeeds, the
|
|
215
|
-
* request and inbox rows are written, and the approver then opens a task she
|
|
216
|
-
* cannot open — the wall hides the record. Filtering here converts that into
|
|
217
|
-
* the empty-slate path the node already has a policy for
|
|
218
|
-
* (`onEmptyApprovers`), and logs exactly who was dropped and why, so the fix
|
|
219
|
-
* ("grant the membership" or "retarget") is legible without a debugger.
|
|
220
|
-
*/
|
|
221
|
-
export async function filterApproversWhoCanRead(
|
|
222
|
-
deps: ApproverOrgScopeDeps,
|
|
223
|
-
userIds: string[],
|
|
224
|
-
requestOrgId: string | null | undefined,
|
|
225
|
-
context: { approverType: string; value?: string; directoryOrgId?: string | null },
|
|
226
|
-
): Promise<string[]> {
|
|
227
|
-
const requestOrg = requestOrgId ? String(requestOrgId) : '';
|
|
228
|
-
if (!requestOrg || userIds.length === 0) return userIds;
|
|
229
|
-
|
|
230
|
-
let members: any[] = [];
|
|
231
|
-
try {
|
|
232
|
-
members = await deps.engine.find('sys_member', {
|
|
233
|
-
filter: { organization_id: requestOrg, user_id: { $in: userIds } },
|
|
234
|
-
fields: ['user_id'],
|
|
235
|
-
limit: 10000,
|
|
236
|
-
context: SYSTEM_CTX,
|
|
237
|
-
} as any);
|
|
238
|
-
} catch {
|
|
239
|
-
// Membership unreadable — do NOT drop everyone on an infrastructure
|
|
240
|
-
// hiccup. Routing to someone who may not see the request is recoverable
|
|
241
|
-
// (an admin can grant the membership); silently emptying a live approval
|
|
242
|
-
// slate is not.
|
|
243
|
-
return userIds;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
const canRead = new Set(
|
|
247
|
-
(members ?? []).map((m: any) => String(m?.user_id ?? '')).filter(Boolean),
|
|
248
|
-
);
|
|
249
|
-
const dropped = userIds.filter((u) => !canRead.has(u));
|
|
250
|
-
if (dropped.length === 0) return userIds;
|
|
251
|
-
|
|
252
|
-
deps.logger?.warn?.(
|
|
253
|
-
`[approvals] ADR-0105 D9: ${dropped.length} cross-organization approver(s) dropped — `
|
|
254
|
-
+ `they hold '${context.value ?? context.approverType}' in organization `
|
|
255
|
-
+ `'${context.directoryOrgId}' but no membership in the request's organization `
|
|
256
|
-
+ `'${requestOrg}', so the D2 union wall would hide the request from them. `
|
|
257
|
-
+ `Grant those users a membership in the request's organization, or retarget the approver.`,
|
|
258
|
-
{ dropped, requestOrganizationId: requestOrg, directoryOrganizationId: context.directoryOrgId },
|
|
259
|
-
);
|
|
260
|
-
return userIds.filter((u) => canRead.has(u));
|
|
261
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* @objectstack/plugin-approvals
|
|
5
|
-
*
|
|
6
|
-
* Approval-as-flow-node runtime (ADR-0019). Persists sys_approval_request /
|
|
7
|
-
* sys_approval_action, resolves approvers, enforces the record lock, and
|
|
8
|
-
* records decisions that resume the owning flow run. Approval orchestration
|
|
9
|
-
* (when to pause, which branch to take) lives on the one automation engine via
|
|
10
|
-
* the `approval` node.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
export { SysApprovalRequest } from './sys-approval-request.object.js';
|
|
14
|
-
export { SysApprovalAction } from './sys-approval-action.object.js';
|
|
15
|
-
export { SysApprovalApprover } from './sys-approval-approver.object.js';
|
|
16
|
-
export { SysApprovalDelegation } from './sys-approval-delegation.object.js';
|
|
17
|
-
export {
|
|
18
|
-
ApprovalService,
|
|
19
|
-
type ApprovalEngine,
|
|
20
|
-
type ApprovalClock,
|
|
21
|
-
type ApprovalServiceOptions,
|
|
22
|
-
type ApprovalResumeSurface,
|
|
23
|
-
// #3447 P2 — expression approvers + empty-slate auto-approve outcome.
|
|
24
|
-
type ApproverExpressionContext,
|
|
25
|
-
type ApprovalNodeAutoOutcome,
|
|
26
|
-
} from './approval-service.js';
|
|
27
|
-
export {
|
|
28
|
-
ApprovalsServicePlugin,
|
|
29
|
-
type ApprovalsPluginOptions,
|
|
30
|
-
} from './approvals-plugin.js';
|
|
31
|
-
export {
|
|
32
|
-
registerApprovalNode,
|
|
33
|
-
type ApprovalAutomationSurface,
|
|
34
|
-
} from './approval-node.js';
|
|
35
|
-
export type {
|
|
36
|
-
IApprovalService,
|
|
37
|
-
ApprovalRequestRow,
|
|
38
|
-
ApprovalActionRow,
|
|
39
|
-
ApprovalDecisionInput,
|
|
40
|
-
ApprovalDecisionResult,
|
|
41
|
-
ApprovalStatus,
|
|
42
|
-
} from '@objectstack/spec/contracts';
|