@classytic/arc-approval 0.1.0

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.
@@ -0,0 +1,137 @@
1
+ import { ApprovalChain, DecisionInput } from "@classytic/primitives/approval";
2
+ import { PermissionCheck } from "@classytic/arc/permissions";
3
+ import { ActionDefinition, RequestWithExtras } from "@classytic/arc/types";
4
+
5
+ //#region src/index.d.ts
6
+ /** Minimum surface every approval-bearing subject doc exposes (P7). */
7
+ interface ApprovableDoc {
8
+ readonly _id: unknown;
9
+ readonly approvals?: ApprovalChain | null;
10
+ }
11
+ /**
12
+ * Structural repository port — satisfied by any kit repository. The preset
13
+ * only reads one doc and patches one doc; it takes no dependency on how.
14
+ * `organizationId` is absent when the preset runs with `tenancy: 'off'`
15
+ * (single-tenant hosts) — kits without a tenant plugin simply ignore it.
16
+ */
17
+ interface ApprovalSubjectRepository<TDoc extends ApprovableDoc> {
18
+ getById(id: string, options: {
19
+ organizationId?: string;
20
+ throwOnNotFound: false;
21
+ lean: true;
22
+ }): Promise<TDoc | null>;
23
+ update(id: string, patch: Record<string, unknown>, options: {
24
+ organizationId?: string;
25
+ lean: true;
26
+ }): Promise<TDoc | null>;
27
+ }
28
+ /**
29
+ * Context the policy resolver matches against — host-extensible.
30
+ * `branchId` is the tenant dimension when the host is multi-tenant; optional
31
+ * so single-tenant hosts (`tenancy: 'off'`) can resolve policies without one.
32
+ * (Name kept for API stability with existing consumers; the generic rename
33
+ * ships with the preset's extraction to its own package.)
34
+ */
35
+ interface ApprovalEvaluationContext {
36
+ readonly branchId?: string;
37
+ readonly amount?: number;
38
+ readonly category?: string;
39
+ readonly [extra: string]: unknown;
40
+ }
41
+ /** A chain resolved from an approval-policy matrix, with provenance. */
42
+ interface ResolvedApprovalChain {
43
+ readonly chain: ApprovalChain;
44
+ readonly policyId: string | null;
45
+ readonly policyVersion: number | null;
46
+ }
47
+ interface PersistContext {
48
+ /** Absent under `tenancy: 'off'` (single-tenant hosts). */
49
+ readonly organizationId?: string;
50
+ readonly actorId: string | null;
51
+ readonly requestId: string | undefined;
52
+ }
53
+ interface WithApprovalChainConfig<TDoc extends ApprovableDoc, TSubject extends string = string> {
54
+ /** Stable subject identifier — must match the host's ApprovalPolicy.subjectType. */
55
+ readonly subjectType: TSubject;
56
+ readonly repository: ApprovalSubjectRepository<TDoc>;
57
+ /** Statuses in which `submit_for_approval` is accepted. Omit → any state. */
58
+ readonly allowedSubmitStatus?: readonly string[];
59
+ /** Reads the lifecycle status off the doc. Default reads `status`. */
60
+ readonly getStatus?: (doc: TDoc) => string | undefined;
61
+ readonly permissions: {
62
+ readonly submit: PermissionCheck;
63
+ readonly decide: PermissionCheck;
64
+ };
65
+ /**
66
+ * Delegation / identity-mapping hook for `decide`.
67
+ *
68
+ * By default a decision is recorded as the AUTHENTICATED actor:
69
+ * `approverId` defaults to the actor id, and a caller-supplied
70
+ * `approverId` that differs from it is rejected with 403
71
+ * `approval.approver_mismatch` — otherwise anyone holding the broad
72
+ * `decide` permission could spoof `{ approverId: 'boss' }` and decide as
73
+ * any approver listed in the chain.
74
+ *
75
+ * Provide this hook to authorize the mismatch explicitly: delegation
76
+ * ("decide on behalf of"), or hosts whose chain approver ids are NOT auth
77
+ * user ids (employee-profile ids, role ids) and need an identity mapping.
78
+ * Return `true` to allow `actorId` to decide as `approverId`.
79
+ */
80
+ readonly canActAsApprover?: (actorId: string | null, approverId: string, req: RequestWithExtras) => boolean | Promise<boolean>;
81
+ /**
82
+ * Tenancy mode. `'required'` (default) rejects requests without an org
83
+ * context with 400 `approval.no_organization_context` and threads
84
+ * `organizationId` into every repository call. `'off'` skips the org
85
+ * requirement entirely — for single-tenant hosts.
86
+ */
87
+ readonly tenancy?: "required" | "off";
88
+ /**
89
+ * Policy-provenance persistence. `'persist'` (default) writes
90
+ * `approvalPolicyId` / `approvalPolicyVersion` onto the subject patch;
91
+ * subjects whose schema omits those fields rely on the storage kit's
92
+ * unknown-field policy (Mongoose strict mode DROPS them; a kit that throws
93
+ * on unknown fields must use `'omit'`). `'omit'` never writes them.
94
+ */
95
+ readonly provenance?: "persist" | "omit";
96
+ /** Required for `useMatrix: true` — builds the policy evaluation context. */
97
+ readonly toEvaluationContext?: (doc: TDoc) => ApprovalEvaluationContext;
98
+ /** Pluggable matrix resolver (host policy). Absent → literal chains only. */
99
+ readonly resolveChain?: (subjectType: TSubject, evalCtx: ApprovalEvaluationContext) => Promise<ResolvedApprovalChain | null>;
100
+ /** Coarse-status side effects — return a doc to make it the response. */
101
+ readonly onSubmitted?: (doc: TDoc, ctx: PersistContext) => Promise<TDoc | void>;
102
+ readonly onApproved?: (doc: TDoc, ctx: PersistContext) => Promise<TDoc | void>;
103
+ readonly onRejected?: (doc: TDoc, input: DecisionInput, ctx: PersistContext) => Promise<TDoc | void>;
104
+ }
105
+ /**
106
+ * Build the `{ submit_for_approval, decide }` slice of a resource's
107
+ * `actions:` map. Spread into the resource's actions block:
108
+ *
109
+ * ```ts
110
+ * actions: {
111
+ * ...withApprovalChain({ subjectType: 'budget', repository, permissions, … }),
112
+ * post: { … }, // subject-specific verbs stay here
113
+ * }
114
+ * ```
115
+ */
116
+ declare function withApprovalChain<TDoc extends ApprovableDoc, TSubject extends string = string>(config: WithApprovalChainConfig<TDoc, TSubject>): Record<"submit_for_approval" | "decide", ActionDefinition>;
117
+ /** Standalone name for the `canActAsApprover` hook shape. */
118
+ type CanActAsApprover = (actorId: string | null, approverId: string, req: RequestWithExtras) => boolean | Promise<boolean>;
119
+ /**
120
+ * `canActAsApprover` policy: callers holding one of the given PLATFORM roles
121
+ * (`req.user.role`, string or array) may decide as any chain approver.
122
+ *
123
+ * The operational escape hatch for role-expanded matrix chains — upstreamed
124
+ * from be-prod's `platformAdminMayDelegate` shim. The audit trail still
125
+ * records the real actor (`PersistContext.actorId`); this only authorizes the
126
+ * decision to be RECORDED against the named approver:
127
+ *
128
+ * ```ts
129
+ * withApprovalChain({
130
+ * // …
131
+ * canActAsApprover: delegateForRoles(["admin", "superadmin"]),
132
+ * });
133
+ * ```
134
+ */
135
+ declare function delegateForRoles(roles: readonly string[]): CanActAsApprover;
136
+ //#endregion
137
+ export { ApprovableDoc, ApprovalEvaluationContext, ApprovalSubjectRepository, CanActAsApprover, PersistContext, ResolvedApprovalChain, WithApprovalChainConfig, delegateForRoles, withApprovalChain };
package/dist/index.mjs ADDED
@@ -0,0 +1,249 @@
1
+ import { getOrgId, getUserId } from "@classytic/arc/scope";
2
+ import { NotFoundError, ValidationError, createDomainError } from "@classytic/arc/utils";
3
+ import { ApprovalError, applyDecision, createChain, isApproved, isRejected } from "@classytic/primitives/approval";
4
+ //#region src/index.ts
5
+ const STATUS_BY_CODE = {
6
+ EMPTY_STEPS: 400,
7
+ DUPLICATE_STEP_ID: 400,
8
+ EMPTY_APPROVERS: 400,
9
+ DUPLICATE_APPROVER_ID: 400,
10
+ INVALID_QUORUM: 400,
11
+ UNKNOWN_STEP: 422,
12
+ UNAUTHORIZED_APPROVER: 403,
13
+ STEP_NOT_ACTIVE: 422,
14
+ STEP_ALREADY_DECIDED_BY_APPROVER: 409
15
+ };
16
+ function rethrowApprovalError(fn) {
17
+ try {
18
+ return fn();
19
+ } catch (err) {
20
+ if (err instanceof ApprovalError) {
21
+ const status = STATUS_BY_CODE[err.code] ?? 422;
22
+ throw createDomainError(`approval.${err.code.toLowerCase()}`, err.message, status);
23
+ }
24
+ throw err;
25
+ }
26
+ }
27
+ /**
28
+ * Build the `{ submit_for_approval, decide }` slice of a resource's
29
+ * `actions:` map. Spread into the resource's actions block:
30
+ *
31
+ * ```ts
32
+ * actions: {
33
+ * ...withApprovalChain({ subjectType: 'budget', repository, permissions, … }),
34
+ * post: { … }, // subject-specific verbs stay here
35
+ * }
36
+ * ```
37
+ */
38
+ function withApprovalChain(config) {
39
+ return {
40
+ submit_for_approval: {
41
+ handler: async (id, data, req) => submitHandler(config, id, data, req),
42
+ permissions: config.permissions.submit,
43
+ description: `Attach an approval chain to a ${config.subjectType}. Pass either a literal \`chain\` or \`useMatrix: true\` to resolve from policy.`,
44
+ schema: {
45
+ type: "object",
46
+ properties: {
47
+ useMatrix: {
48
+ type: "boolean",
49
+ description: "Resolve chain from ApprovalPolicy matrix."
50
+ },
51
+ chain: {
52
+ type: "object",
53
+ properties: {
54
+ order: {
55
+ type: "string",
56
+ enum: ["sequential", "parallel"]
57
+ },
58
+ steps: {
59
+ type: "array",
60
+ minItems: 1,
61
+ items: {
62
+ type: "object",
63
+ required: ["id", "approvers"],
64
+ properties: {
65
+ id: {
66
+ type: "string",
67
+ minLength: 1
68
+ },
69
+ name: { type: "string" },
70
+ requiredApprovals: {
71
+ type: "integer",
72
+ minimum: 1
73
+ },
74
+ approvers: {
75
+ type: "array",
76
+ minItems: 1,
77
+ items: {
78
+ type: "object",
79
+ required: ["id"],
80
+ properties: {
81
+ id: {
82
+ type: "string",
83
+ minLength: 1
84
+ },
85
+ name: { type: "string" },
86
+ role: { type: "string" }
87
+ }
88
+ }
89
+ }
90
+ }
91
+ }
92
+ }
93
+ }
94
+ }
95
+ },
96
+ additionalProperties: false
97
+ }
98
+ },
99
+ decide: {
100
+ handler: async (id, data, req) => decideHandler(config, id, data, req),
101
+ permissions: config.permissions.decide,
102
+ description: `Apply a single approver decision to the ${config.subjectType}'s active chain step.`,
103
+ schema: {
104
+ type: "object",
105
+ required: ["stepId", "decision"],
106
+ properties: {
107
+ stepId: {
108
+ type: "string",
109
+ minLength: 1
110
+ },
111
+ approverId: {
112
+ type: "string",
113
+ minLength: 1,
114
+ description: "Defaults to the authenticated actor. A different id requires the host's canActAsApprover hook to authorize it (403 otherwise)."
115
+ },
116
+ decision: {
117
+ type: "string",
118
+ enum: ["approved", "rejected"]
119
+ },
120
+ note: {
121
+ type: "string",
122
+ maxLength: 1e3
123
+ }
124
+ },
125
+ additionalProperties: false
126
+ }
127
+ }
128
+ };
129
+ }
130
+ async function submitHandler(config, id, body, req) {
131
+ const ctx = persistContext(req, config.tenancy ?? "required");
132
+ const doc = await loadSubject(config, id, ctx);
133
+ if (config.allowedSubmitStatus) {
134
+ const current = readStatus(config, doc);
135
+ if (!current || !config.allowedSubmitStatus.includes(current)) throw createDomainError("approval.invalid_status_for_submit", `Cannot submit ${config.subjectType} for approval in status '${current ?? "unknown"}'. Must be ${config.allowedSubmitStatus.join(" or ")}.`, 422);
136
+ }
137
+ const existingChain = doc.approvals;
138
+ if (existingChain && existingChain.status !== "rejected") throw createDomainError("approval.chain_already_attached", `Approval chain is already attached and ${existingChain.status}. Cancel or wait for the current workflow first.`, 409);
139
+ let chain;
140
+ let policyId = null;
141
+ let policyVersion = null;
142
+ if (body.useMatrix) {
143
+ if (!config.resolveChain || !config.toEvaluationContext) throw createDomainError("approval.matrix_unavailable", `Matrix-driven submit not configured for ${config.subjectType}. Provide a literal 'chain' instead.`, 422);
144
+ const resolved = await config.resolveChain(config.subjectType, config.toEvaluationContext(doc));
145
+ if (!resolved) throw createDomainError("approval.no_matching_policy", `No approval policy matched for ${config.subjectType}. Configure one or pass a literal 'chain'.`, 422);
146
+ chain = resolved.chain;
147
+ policyId = resolved.policyId;
148
+ policyVersion = resolved.policyVersion;
149
+ } else {
150
+ const literal = body.chain;
151
+ if (!literal || typeof literal !== "object") throw new ValidationError("Either `chain` (literal) or `useMatrix: true` is required.");
152
+ chain = rethrowApprovalError(() => createChain(literal));
153
+ }
154
+ const patch = { approvals: chain };
155
+ if (config.provenance !== "omit") {
156
+ if (policyId) patch.approvalPolicyId = policyId;
157
+ if (policyVersion !== null) patch.approvalPolicyVersion = policyVersion;
158
+ }
159
+ const updated = await config.repository.update(id, patch, {
160
+ ...ctx.organizationId ? { organizationId: ctx.organizationId } : {},
161
+ lean: true
162
+ });
163
+ if (!updated) throw new NotFoundError(config.subjectType);
164
+ if (config.onSubmitted) {
165
+ const refined = await config.onSubmitted(updated, ctx);
166
+ if (refined) return refined;
167
+ }
168
+ return updated;
169
+ }
170
+ async function decideHandler(config, id, body, req) {
171
+ const ctx = persistContext(req, config.tenancy ?? "required");
172
+ if (!body.stepId || !body.decision) throw new ValidationError("stepId and decision are required.");
173
+ const approverId = body.approverId ?? ctx.actorId;
174
+ if (!approverId) throw new ValidationError("approverId is required when the request has no authenticated actor.");
175
+ if (approverId !== ctx.actorId) {
176
+ if (!(config.canActAsApprover ? await config.canActAsApprover(ctx.actorId, approverId, req) : false)) throw createDomainError("approval.approver_mismatch", `Cannot decide as approver '${approverId}': decisions are recorded as the authenticated actor unless canActAsApprover authorizes the delegation.`, 403);
177
+ }
178
+ const existing = (await loadSubject(config, id, ctx)).approvals;
179
+ if (!existing) throw createDomainError("approval.no_chain_attached", `No approval chain attached to this ${config.subjectType}. Submit one first.`, 422);
180
+ const decisionInput = {
181
+ stepId: body.stepId,
182
+ approverId,
183
+ decision: body.decision,
184
+ ...body.note !== void 0 ? { note: body.note } : {}
185
+ };
186
+ const updatedChain = rethrowApprovalError(() => applyDecision(existing, decisionInput));
187
+ const updated = await config.repository.update(id, { approvals: updatedChain }, {
188
+ ...ctx.organizationId ? { organizationId: ctx.organizationId } : {},
189
+ lean: true
190
+ });
191
+ if (!updated) throw new NotFoundError(config.subjectType);
192
+ if (isApproved(updatedChain) && !isApproved(existing) && config.onApproved) {
193
+ const refined = await config.onApproved(updated, ctx);
194
+ if (refined) return refined;
195
+ } else if (isRejected(updatedChain) && !isRejected(existing) && config.onRejected) {
196
+ const refined = await config.onRejected(updated, decisionInput, ctx);
197
+ if (refined) return refined;
198
+ }
199
+ return updated;
200
+ }
201
+ function persistContext(req, tenancy) {
202
+ const organizationId = getOrgId(req.scope) ?? "";
203
+ if (!organizationId && tenancy === "required") throw createDomainError("approval.no_organization_context", "Organization context is required for this action.", 400);
204
+ const actorId = getUserId(req.scope) ?? req.user?._id ?? req.user?.id ?? null;
205
+ return {
206
+ ...organizationId ? { organizationId } : {},
207
+ actorId,
208
+ requestId: req.id
209
+ };
210
+ }
211
+ function readStatus(config, doc) {
212
+ if (config.getStatus) return config.getStatus(doc);
213
+ const maybe = doc.status;
214
+ return typeof maybe === "string" ? maybe : void 0;
215
+ }
216
+ async function loadSubject(config, id, ctx) {
217
+ const doc = await config.repository.getById(id, {
218
+ ...ctx.organizationId ? { organizationId: ctx.organizationId } : {},
219
+ throwOnNotFound: false,
220
+ lean: true
221
+ });
222
+ if (!doc) throw new NotFoundError(`${config.subjectType} not found`);
223
+ return doc;
224
+ }
225
+ /**
226
+ * `canActAsApprover` policy: callers holding one of the given PLATFORM roles
227
+ * (`req.user.role`, string or array) may decide as any chain approver.
228
+ *
229
+ * The operational escape hatch for role-expanded matrix chains — upstreamed
230
+ * from be-prod's `platformAdminMayDelegate` shim. The audit trail still
231
+ * records the real actor (`PersistContext.actorId`); this only authorizes the
232
+ * decision to be RECORDED against the named approver:
233
+ *
234
+ * ```ts
235
+ * withApprovalChain({
236
+ * // …
237
+ * canActAsApprover: delegateForRoles(["admin", "superadmin"]),
238
+ * });
239
+ * ```
240
+ */
241
+ function delegateForRoles(roles) {
242
+ const allowed = new Set(roles);
243
+ return (_actorId, _approverId, req) => {
244
+ const raw = req.user?.role;
245
+ return (Array.isArray(raw) ? raw : typeof raw === "string" ? [raw] : []).some((role) => typeof role === "string" && allowed.has(role));
246
+ };
247
+ }
248
+ //#endregion
249
+ export { delegateForRoles, withApprovalChain };
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@classytic/arc-approval",
3
+ "version": "0.1.0",
4
+ "description": "Approval-chain action preset for @classytic/arc — submit_for_approval + decide with policy-matrix resolution, actor-derived decision identity, and delegation hooks",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.mts",
9
+ "default": "./dist/index.mjs"
10
+ }
11
+ },
12
+ "main": "./dist/index.mjs",
13
+ "types": "./dist/index.d.mts",
14
+ "sideEffects": false,
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "engines": {
22
+ "node": ">=22"
23
+ },
24
+ "scripts": {
25
+ "build": "tsdown",
26
+ "typecheck": "tsc --noEmit",
27
+ "lint": "biome check src/",
28
+ "prepublishOnly": "node ../../scripts/prepublish-gate.mjs"
29
+ },
30
+ "peerDependencies": {
31
+ "@classytic/arc": ">=2.20.0",
32
+ "@classytic/primitives": ">=0.9.0"
33
+ },
34
+ "keywords": [
35
+ "arc",
36
+ "fastify",
37
+ "approval",
38
+ "approval-chain",
39
+ "workflow",
40
+ "classytic"
41
+ ],
42
+ "license": "MIT",
43
+ "author": "Classytic",
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "https://github.com/classytic/arc-ecosystem.git",
47
+ "directory": "packages/arc-approval"
48
+ }
49
+ }