@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +863 -0
  2. package/dist/index.d.mts +2236 -2688
  3. package/dist/index.d.ts +2236 -2688
  4. package/dist/index.js +591 -128
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +590 -127
  7. package/dist/index.mjs.map +1 -1
  8. package/package.json +17 -10
  9. package/.turbo/turbo-build.log +0 -22
  10. package/scripts/i18n-extract.config.ts +0 -38
  11. package/src/action-link-pages.ts +0 -102
  12. package/src/approval-actor-impersonation.test.ts +0 -330
  13. package/src/approval-node.test.ts +0 -356
  14. package/src/approval-node.ts +0 -196
  15. package/src/approval-revise.test.ts +0 -418
  16. package/src/approval-service.test.ts +0 -2858
  17. package/src/approval-service.ts +0 -3617
  18. package/src/approvals-plugin.ts +0 -294
  19. package/src/approver-cross-org.integration.test.ts +0 -206
  20. package/src/approver-org-scope.test.ts +0 -201
  21. package/src/approver-org-scope.ts +0 -261
  22. package/src/index.ts +0 -42
  23. package/src/lifecycle-hooks.ts +0 -201
  24. package/src/nav-contribution.test.ts +0 -50
  25. package/src/record-lock-schedule-run.integration.test.ts +0 -206
  26. package/src/status-mirror-cascade.integration.test.ts +0 -224
  27. package/src/sys-approval-action.object.ts +0 -149
  28. package/src/sys-approval-approver.object.ts +0 -85
  29. package/src/sys-approval-delegation.object.test.ts +0 -42
  30. package/src/sys-approval-delegation.object.ts +0 -142
  31. package/src/sys-approval-request.object.test.ts +0 -116
  32. package/src/sys-approval-request.object.ts +0 -413
  33. package/src/sys-approval-token.object.ts +0 -101
  34. package/src/translations/bundle-ownership.test.ts +0 -48
  35. package/src/translations/en.objects.generated.ts +0 -311
  36. package/src/translations/es-ES.objects.generated.ts +0 -311
  37. package/src/translations/index.ts +0 -23
  38. package/src/translations/ja-JP.objects.generated.ts +0 -311
  39. package/src/translations/zh-CN.objects.generated.ts +0 -311
  40. package/tsconfig.json +0 -10
@@ -1,201 +0,0 @@
1
- // Copyright (c) 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';