@cullet/erp-core 1.3.0 → 1.5.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.
Files changed (197) hide show
  1. package/KIT_CONTEXT.md +11 -3
  2. package/README.md +160 -0
  3. package/dist/abac/index.cjs +7 -0
  4. package/dist/abac/index.d.cts +2 -0
  5. package/dist/abac/index.d.ts +2 -0
  6. package/dist/abac/index.js +2 -0
  7. package/dist/app-error.cjs +201 -0
  8. package/dist/app-error.cjs.map +1 -0
  9. package/dist/app-error.js +178 -0
  10. package/dist/app-error.js.map +1 -0
  11. package/dist/application/index.cjs +2 -1
  12. package/dist/application/index.d.cts +2 -1
  13. package/dist/application/index.d.ts +2 -1
  14. package/dist/application/index.js +2 -1
  15. package/dist/authorization-error.cjs +157 -0
  16. package/dist/authorization-error.cjs.map +1 -0
  17. package/dist/authorization-error.d.cts +113 -0
  18. package/dist/authorization-error.d.ts +113 -0
  19. package/dist/authorization-error.js +152 -0
  20. package/dist/authorization-error.js.map +1 -0
  21. package/dist/authorizer.port.d.cts +130 -0
  22. package/dist/authorizer.port.d.ts +130 -0
  23. package/dist/composite-authorizer.d.cts +216 -0
  24. package/dist/composite-authorizer.d.ts +216 -0
  25. package/dist/condition-evaluator.cjs +579 -0
  26. package/dist/condition-evaluator.cjs.map +1 -0
  27. package/dist/condition-evaluator.js +550 -0
  28. package/dist/condition-evaluator.js.map +1 -0
  29. package/dist/{gate-types.d.ts → core-config.d.cts} +78 -77
  30. package/dist/{gate-types.d.cts → core-config.d.ts} +78 -77
  31. package/dist/domain/index.cjs +2 -1
  32. package/dist/domain/index.d.cts +2 -1
  33. package/dist/domain/index.d.ts +2 -1
  34. package/dist/domain/index.js +2 -1
  35. package/dist/domain-event-contracts.cjs +3 -2
  36. package/dist/domain-event-contracts.cjs.map +1 -1
  37. package/dist/domain-event-contracts.js +2 -1
  38. package/dist/domain-event-contracts.js.map +1 -1
  39. package/dist/entity.cjs +122 -0
  40. package/dist/entity.cjs.map +1 -0
  41. package/dist/entity.js +111 -0
  42. package/dist/entity.js.map +1 -0
  43. package/dist/errors/index.cjs +10 -7
  44. package/dist/errors/index.d.cts +2 -1
  45. package/dist/errors/index.d.ts +2 -1
  46. package/dist/errors/index.js +6 -3
  47. package/dist/gate-engine-registry.cjs +5 -4
  48. package/dist/gate-engine-registry.cjs.map +1 -1
  49. package/dist/gate-engine-registry.d.cts +3 -2
  50. package/dist/gate-engine-registry.d.ts +3 -2
  51. package/dist/gate-engine-registry.js +2 -1
  52. package/dist/gate-engine-registry.js.map +1 -1
  53. package/dist/gate-v1-payload.schema.cjs +0 -562
  54. package/dist/gate-v1-payload.schema.cjs.map +1 -1
  55. package/dist/gate-v1-payload.schema.js +1 -533
  56. package/dist/gate-v1-payload.schema.js.map +1 -1
  57. package/dist/hashing.cjs +2 -44
  58. package/dist/hashing.cjs.map +1 -1
  59. package/dist/hashing.js +2 -38
  60. package/dist/hashing.js.map +1 -1
  61. package/dist/index.cjs +36 -17
  62. package/dist/index.cjs.map +1 -1
  63. package/dist/index.d.cts +19 -12
  64. package/dist/index.d.ts +19 -12
  65. package/dist/index.js +17 -10
  66. package/dist/index.js.map +1 -1
  67. package/dist/not-found-error.cjs +5 -5
  68. package/dist/not-found-error.cjs.map +1 -1
  69. package/dist/not-found-error.js +1 -1
  70. package/dist/outcome.d.cts +1 -83
  71. package/dist/outcome.d.ts +1 -83
  72. package/dist/parse-gate-payload.d.cts +2 -2
  73. package/dist/parse-gate-payload.d.ts +2 -2
  74. package/dist/path.d.cts +2 -2
  75. package/dist/path.d.ts +2 -2
  76. package/dist/policies/engines/index.d.cts +1 -1
  77. package/dist/policies/engines/index.d.ts +1 -1
  78. package/dist/policies/engines/v1/gate/index.cjs +5 -4
  79. package/dist/policies/engines/v1/gate/index.cjs.map +1 -1
  80. package/dist/policies/engines/v1/gate/index.d.cts +4 -2
  81. package/dist/policies/engines/v1/gate/index.d.ts +4 -2
  82. package/dist/policies/engines/v1/gate/index.js +2 -1
  83. package/dist/policies/engines/v1/gate/index.js.map +1 -1
  84. package/dist/policies/index.cjs +5 -5
  85. package/dist/policies/index.d.cts +3 -2
  86. package/dist/policies/index.d.ts +3 -2
  87. package/dist/policies/index.js +2 -2
  88. package/dist/policy-bridge.cjs +437 -0
  89. package/dist/policy-bridge.cjs.map +1 -0
  90. package/dist/policy-bridge.d.cts +185 -0
  91. package/dist/policy-bridge.d.ts +185 -0
  92. package/dist/policy-bridge.js +396 -0
  93. package/dist/policy-bridge.js.map +1 -0
  94. package/dist/policy-service.cjs +241 -240
  95. package/dist/policy-service.cjs.map +1 -1
  96. package/dist/policy-service.d.cts +2 -2
  97. package/dist/policy-service.d.ts +2 -2
  98. package/dist/policy-service.js +240 -239
  99. package/dist/policy-service.js.map +1 -1
  100. package/dist/rbac/index.cjs +9 -0
  101. package/dist/rbac/index.d.cts +3 -0
  102. package/dist/rbac/index.d.ts +3 -0
  103. package/dist/rbac/index.js +2 -0
  104. package/dist/requested-by.cjs +54 -0
  105. package/dist/requested-by.cjs.map +1 -0
  106. package/dist/requested-by.d.cts +25 -0
  107. package/dist/requested-by.d.ts +25 -0
  108. package/dist/requested-by.js +49 -0
  109. package/dist/requested-by.js.map +1 -0
  110. package/dist/result/index.d.cts +2 -1
  111. package/dist/result/index.d.ts +2 -1
  112. package/dist/result.d.cts +84 -0
  113. package/dist/result.d.ts +84 -0
  114. package/dist/rule.cjs +327 -0
  115. package/dist/rule.cjs.map +1 -0
  116. package/dist/rule.js +298 -0
  117. package/dist/rule.js.map +1 -0
  118. package/dist/stable-stringify.cjs +47 -0
  119. package/dist/stable-stringify.cjs.map +1 -0
  120. package/dist/stable-stringify.js +42 -0
  121. package/dist/stable-stringify.js.map +1 -0
  122. package/dist/temporal-use-case.cjs +6 -53
  123. package/dist/temporal-use-case.cjs.map +1 -1
  124. package/dist/temporal-use-case.d.cts +11 -28
  125. package/dist/temporal-use-case.d.ts +11 -28
  126. package/dist/temporal-use-case.js +7 -48
  127. package/dist/temporal-use-case.js.map +1 -1
  128. package/dist/unexpected-error.cjs +3 -192
  129. package/dist/unexpected-error.cjs.map +1 -1
  130. package/dist/unexpected-error.js +2 -167
  131. package/dist/unexpected-error.js.map +1 -1
  132. package/dist/use-case.cjs +2 -6
  133. package/dist/use-case.cjs.map +1 -1
  134. package/dist/use-case.js +2 -6
  135. package/dist/use-case.js.map +1 -1
  136. package/dist/uuid-identifier.cjs +2 -7
  137. package/dist/uuid-identifier.cjs.map +1 -1
  138. package/dist/uuid-identifier.d.cts +2 -85
  139. package/dist/uuid-identifier.d.ts +2 -85
  140. package/dist/uuid-identifier.js +1 -6
  141. package/dist/uuid-identifier.js.map +1 -1
  142. package/dist/uuid.cjs +18 -0
  143. package/dist/uuid.cjs.map +1 -0
  144. package/dist/uuid.js +13 -0
  145. package/dist/uuid.js.map +1 -0
  146. package/dist/validation-error.cjs +33 -166
  147. package/dist/validation-error.cjs.map +1 -1
  148. package/dist/validation-error.d.cts +12 -103
  149. package/dist/validation-error.d.ts +12 -103
  150. package/dist/validation-error.js +7 -134
  151. package/dist/validation-error.js.map +1 -1
  152. package/dist/value-object.cjs +12 -126
  153. package/dist/value-object.cjs.map +1 -1
  154. package/dist/value-object.d.cts +97 -0
  155. package/dist/value-object.d.ts +97 -0
  156. package/dist/value-object.js +13 -115
  157. package/dist/value-object.js.map +1 -1
  158. package/meta.json +20 -4
  159. package/package.json +27 -1
  160. package/src/abac/index.ts +1 -0
  161. package/src/core/abac/abac-request.ts +29 -0
  162. package/src/core/abac/attributes.ts +39 -0
  163. package/src/core/abac/authorizer.ts +108 -0
  164. package/src/core/abac/combining.ts +63 -0
  165. package/src/core/abac/composite-authorizer.ts +45 -0
  166. package/src/core/abac/domain/policy-set.ts +52 -0
  167. package/src/core/abac/domain/rule.ts +197 -0
  168. package/src/core/abac/index.ts +21 -0
  169. package/src/core/application/commands/requested-by.ts +3 -4
  170. package/src/core/application/ports/abac-authorizer.port.ts +20 -0
  171. package/src/core/application/ports/authorizer.port.ts +22 -0
  172. package/src/core/application/queries/query.ts +6 -1
  173. package/src/core/application/use-case.ts +1 -1
  174. package/src/core/domain/entity.ts +1 -1
  175. package/src/core/domain/uuid-identifier.ts +1 -8
  176. package/src/core/domain/value-object.ts +12 -3
  177. package/src/core/errors/authorization-error.ts +26 -0
  178. package/src/core/errors/error-codes.ts +1 -0
  179. package/src/core/errors/utils/json-safe.ts +20 -11
  180. package/src/core/index.ts +7 -0
  181. package/src/core/policies/engines/v1/condition-evaluator.ts +56 -1
  182. package/src/core/rbac/access-request.ts +32 -0
  183. package/src/core/rbac/authorizer.ts +91 -0
  184. package/src/core/rbac/domain/grant.ts +96 -0
  185. package/src/core/rbac/domain/permission-set.ts +67 -0
  186. package/src/core/rbac/domain/permission.ts +119 -0
  187. package/src/core/rbac/domain/role.ts +101 -0
  188. package/src/core/rbac/domain/scope.ts +84 -0
  189. package/src/core/rbac/index.ts +15 -0
  190. package/src/core/rbac/policy-bridge.ts +44 -0
  191. package/src/core/shared/hashing.ts +1 -55
  192. package/src/core/shared/stable-stringify.ts +57 -0
  193. package/src/core/shared/uuid.ts +11 -0
  194. package/src/examples/application/authorize-cancel-order-abac.example.ts +107 -0
  195. package/src/examples/application/authorize-cancel-order.example.ts +101 -0
  196. package/src/rbac/index.ts +1 -0
  197. package/src/version.ts +1 -1
@@ -0,0 +1,108 @@
1
+ import { type CoreConfig, coreConfig } from "../config/index.js";
2
+ import { AuthorizationError } from "../errors/authorization-error.js";
3
+ import { ConditionEvaluatorV1 } from "../policies/engines/v1/condition-evaluator.js";
4
+ import { Result } from "../result/result.js";
5
+
6
+ import type { AbacRequest } from "./abac-request.js";
7
+ import { abacContext } from "./attributes.js";
8
+ import { combine, type CombiningAlgorithm } from "./combining.js";
9
+ import type { AbacPolicySet } from "./domain/policy-set.js";
10
+ import type { AbacRule } from "./domain/rule.js";
11
+
12
+ // ABAC reuses the gate engine's v1 condition matcher; the version stamps the
13
+ // evaluation reports the shared reporter emits.
14
+ const ENGINE_VERSION = 1;
15
+
16
+ /** The successful decision returned by {@link AbacAuthorizer.authorize}. */
17
+ interface AbacDecision {
18
+ /** The audited business action that was allowed. */
19
+ readonly action: string;
20
+ readonly effect: "PERMIT";
21
+ /** The id of the rule that permitted, or `"<default>"` when the set's default did. */
22
+ readonly matchedRule: string;
23
+ readonly algorithm: CombiningAlgorithm;
24
+ /** When the decision was made, ISO-8601. */
25
+ readonly grantedAtIso: string;
26
+ }
27
+
28
+ /**
29
+ * The pure ABAC decisor. Given an {@link AbacRequest} and an
30
+ * {@link AbacPolicySet}, it flattens the request's attributes into a context,
31
+ * matches each rule's condition with the gate engine's pure condition evaluator
32
+ * (reused, not reimplemented), resolves the effects through the set's combining
33
+ * algorithm, and returns a {@link Result} — never throwing, mirroring the
34
+ * "errors as values" contract of `RbacAuthorizer`/`GateEngineV1`.
35
+ *
36
+ * A denial maps to {@link AuthorizationError.policyDenied}, attributed to the
37
+ * deciding rule's `id`/`version`; a condition-evaluation failure (a missing
38
+ * attribute, a wrong-typed operand) fails closed as
39
+ * {@link AuthorizationError.forbidden}. Loading the dynamic attributes is the
40
+ * consumer's job (behind an `AbacAuthorizerPort` adapter); this class only decides.
41
+ */
42
+ class AbacAuthorizer {
43
+ private readonly coreConfig: CoreConfig;
44
+
45
+ constructor(params: { readonly coreConfig?: CoreConfig } = {}) {
46
+ this.coreConfig = params.coreConfig ?? coreConfig;
47
+ }
48
+
49
+ authorize(
50
+ request: AbacRequest,
51
+ policies: AbacPolicySet,
52
+ ): Result<AbacDecision, AuthorizationError> {
53
+ const context = abacContext(request);
54
+ const evaluator = new ConditionEvaluatorV1(
55
+ context,
56
+ this.coreConfig.getConditionEvaluationOptions(ENGINE_VERSION),
57
+ );
58
+
59
+ const applicable: AbacRule[] = [];
60
+ for (const rule of policies.rules) {
61
+ const matched = evaluator.evaluate(rule.condition);
62
+ if (matched.isErr()) {
63
+ // Fail closed: a technical evaluation error is never a silent PERMIT.
64
+ return Result.err(
65
+ AuthorizationError.forbidden({
66
+ action: request.action,
67
+ resource: request.resource,
68
+ actor: { userId: request.actor.raw },
69
+ details: matched.errorOrNull() ?? undefined,
70
+ }),
71
+ );
72
+ }
73
+ if (matched.getOrThrow()) {
74
+ applicable.push(rule);
75
+ }
76
+ }
77
+
78
+ const { effect, decidingRule } = combine(
79
+ policies.algorithm,
80
+ applicable,
81
+ policies.defaultEffect,
82
+ );
83
+
84
+ if (effect === "PERMIT") {
85
+ return Result.ok({
86
+ action: request.action,
87
+ effect: "PERMIT",
88
+ matchedRule: decidingRule?.id ?? "<default>",
89
+ algorithm: policies.algorithm,
90
+ grantedAtIso: new Date().toISOString(),
91
+ });
92
+ }
93
+
94
+ return Result.err(
95
+ AuthorizationError.policyDenied({
96
+ policyId: decidingRule?.id ?? "<default-deny>",
97
+ policyVersion: decidingRule?.version ?? 0,
98
+ evaluatedAtIso: new Date().toISOString(),
99
+ action: request.action,
100
+ resource: request.resource,
101
+ actor: { userId: request.actor.raw },
102
+ reasonCode: decidingRule?.id,
103
+ }),
104
+ );
105
+ }
106
+ }
107
+
108
+ export { AbacAuthorizer, type AbacDecision };
@@ -0,0 +1,63 @@
1
+ import type { AbacRule, RuleEffect } from "./domain/rule.js";
2
+
3
+ /**
4
+ * How an {@link AbacPolicySet} resolves several applicable rules into one effect.
5
+ *
6
+ * - `deny-overrides` (default, secure): any applicable DENY wins; otherwise the
7
+ * first PERMIT; otherwise the set's default effect.
8
+ * - `permit-overrides`: any applicable PERMIT wins; otherwise the first DENY;
9
+ * otherwise the default effect.
10
+ * - `first-applicable`: the effect of the first rule that matches; otherwise the
11
+ * default effect.
12
+ */
13
+ export type CombiningAlgorithm =
14
+ | "deny-overrides"
15
+ | "permit-overrides"
16
+ | "first-applicable";
17
+
18
+ /** The resolved decision plus the rule (if any) that produced it. */
19
+ export interface CombineResult {
20
+ readonly effect: RuleEffect;
21
+ readonly decidingRule?: AbacRule;
22
+ }
23
+
24
+ function firstWithEffect(
25
+ rules: readonly AbacRule[],
26
+ effect: RuleEffect,
27
+ ): AbacRule | undefined {
28
+ return rules.find((rule) => rule.effect === effect);
29
+ }
30
+
31
+ /**
32
+ * Applies `algorithm` to the already-matched `applicable` rules, falling back to
33
+ * `defaultEffect` when the algorithm reaches no rule-backed decision. Pure and
34
+ * total; the returned `decidingRule` is absent exactly when `defaultEffect` was
35
+ * used.
36
+ */
37
+ export function combine(
38
+ algorithm: CombiningAlgorithm,
39
+ applicable: readonly AbacRule[],
40
+ defaultEffect: RuleEffect,
41
+ ): CombineResult {
42
+ switch (algorithm) {
43
+ case "deny-overrides": {
44
+ const deny = firstWithEffect(applicable, "DENY");
45
+ if (deny) return { effect: "DENY", decidingRule: deny };
46
+ const permit = firstWithEffect(applicable, "PERMIT");
47
+ if (permit) return { effect: "PERMIT", decidingRule: permit };
48
+ return { effect: defaultEffect };
49
+ }
50
+ case "permit-overrides": {
51
+ const permit = firstWithEffect(applicable, "PERMIT");
52
+ if (permit) return { effect: "PERMIT", decidingRule: permit };
53
+ const deny = firstWithEffect(applicable, "DENY");
54
+ if (deny) return { effect: "DENY", decidingRule: deny };
55
+ return { effect: defaultEffect };
56
+ }
57
+ case "first-applicable": {
58
+ const first = applicable[0];
59
+ if (first) return { effect: first.effect, decidingRule: first };
60
+ return { effect: defaultEffect };
61
+ }
62
+ }
63
+ }
@@ -0,0 +1,45 @@
1
+ import { Result } from "../result/result.js";
2
+ import type { AbacAuthorizerPort } from "../application/ports/abac-authorizer.port.js";
3
+ import type { AuthorizerPort } from "../application/ports/authorizer.port.js";
4
+ import type { AuthorizationError } from "../errors/authorization-error.js";
5
+ import type { AccessRequest } from "../rbac/access-request.js";
6
+
7
+ import type { AbacAttributes } from "./abac-request.js";
8
+
9
+ /**
10
+ * The request a {@link CompositeAuthorizer} accepts — one object that satisfies
11
+ * both the RBAC {@link AccessRequest} (its `required`/`scope`) and the ABAC
12
+ * request (its `attributes`).
13
+ */
14
+ type CompositeAccessRequest = AccessRequest & {
15
+ readonly attributes: AbacAttributes;
16
+ };
17
+
18
+ /**
19
+ * Runs a coarse RBAC check first, then refines with ABAC — the standard hybrid
20
+ * "does the actor hold the capability, *and* do the attributes allow it here?"
21
+ * flow. Short-circuits with the RBAC denial when the actor is not even capable,
22
+ * so the boundary sees the most specific 403.
23
+ *
24
+ * Only the two port interfaces are referenced (both erased at runtime), so this
25
+ * pulls in no RBAC/ABAC engine code of its own — it just sequences the seams the
26
+ * consumer wires up.
27
+ */
28
+ class CompositeAuthorizer {
29
+ constructor(
30
+ private readonly rbac: AuthorizerPort,
31
+ private readonly abac: AbacAuthorizerPort,
32
+ ) {}
33
+
34
+ async authorize(
35
+ request: CompositeAccessRequest,
36
+ ): Promise<Result<void, AuthorizationError>> {
37
+ const coarse = await this.rbac.authorize(request);
38
+ if (coarse.isErr()) {
39
+ return coarse;
40
+ }
41
+ return this.abac.authorize(request);
42
+ }
43
+ }
44
+
45
+ export { CompositeAuthorizer, type CompositeAccessRequest };
@@ -0,0 +1,52 @@
1
+ import type { CombiningAlgorithm } from "../combining.js";
2
+
3
+ import type { AbacRule, RuleEffect } from "./rule.js";
4
+
5
+ /**
6
+ * An ordered collection of {@link AbacRule}s plus how to combine them
7
+ * ({@link CombiningAlgorithm}) and what to decide when no rule applies
8
+ * ({@link defaultEffect}). This is where ABAC goes beyond a single gate
9
+ * condition: several PERMIT/DENY rules resolved by an explicit algorithm.
10
+ *
11
+ * Closed by default — the combining algorithm defaults to `"deny-overrides"` and
12
+ * the default effect to `"DENY"`, so a request matching nothing is denied. A
13
+ * plain holder (not a value object); build through {@link of}.
14
+ */
15
+ class AbacPolicySet {
16
+ private constructor(
17
+ private readonly _rules: readonly AbacRule[],
18
+ private readonly _algorithm: CombiningAlgorithm,
19
+ private readonly _defaultEffect: RuleEffect,
20
+ ) {
21
+ Object.freeze(this._rules);
22
+ Object.freeze(this);
23
+ }
24
+
25
+ static of(
26
+ rules: readonly AbacRule[],
27
+ options: {
28
+ readonly algorithm?: CombiningAlgorithm;
29
+ readonly defaultEffect?: RuleEffect;
30
+ } = {},
31
+ ): AbacPolicySet {
32
+ return new AbacPolicySet(
33
+ [...rules],
34
+ options.algorithm ?? "deny-overrides",
35
+ options.defaultEffect ?? "DENY",
36
+ );
37
+ }
38
+
39
+ get rules(): readonly AbacRule[] {
40
+ return this._rules;
41
+ }
42
+
43
+ get algorithm(): CombiningAlgorithm {
44
+ return this._algorithm;
45
+ }
46
+
47
+ get defaultEffect(): RuleEffect {
48
+ return this._defaultEffect;
49
+ }
50
+ }
51
+
52
+ export { AbacPolicySet };
@@ -0,0 +1,197 @@
1
+ import { ValidationCode } from "../../exceptions/validation-code.js";
2
+ import { InvalidValueException } from "../../exceptions/validation-exception.js";
3
+ import { ValidationField } from "../../exceptions/validation-field.js";
4
+ import { ValueObject } from "../../domain/value-object.js";
5
+ import type {
6
+ ConditionNode,
7
+ ConditionOp,
8
+ } from "../../policies/engines/v1/condition-types.js";
9
+
10
+ /** Whether a matching {@link AbacRule} permits or denies the action. */
11
+ type RuleEffect = "PERMIT" | "DENY";
12
+
13
+ /**
14
+ * The serializable shape of a rule: a stable `id`/`version` (surfaced on a
15
+ * denial as `policyId`/`policyVersion`), the {@link RuleEffect}, and the
16
+ * {@link ConditionNode} that decides whether the rule applies to a request's
17
+ * flattened attributes.
18
+ */
19
+ type AbacRuleProps = {
20
+ readonly id: string;
21
+ readonly version: number;
22
+ readonly effect: RuleEffect;
23
+ readonly condition: ConditionNode;
24
+ readonly description?: string;
25
+ };
26
+
27
+ const RULE_FIELD = ValidationField.of("abacRule");
28
+
29
+ const CONDITION_OPS = new Set<ConditionOp>([
30
+ "eq",
31
+ "neq",
32
+ "gt",
33
+ "gte",
34
+ "lt",
35
+ "lte",
36
+ "in",
37
+ "notIn",
38
+ "isNull",
39
+ "isNotNull",
40
+ ]);
41
+
42
+ const RULE_EFFECTS = new Set<RuleEffect>(["PERMIT", "DENY"]);
43
+
44
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
45
+ return typeof value === "object" && value !== null && !Array.isArray(value);
46
+ }
47
+
48
+ /**
49
+ * A single attribute-based rule: an {@link RuleEffect} that takes hold when its
50
+ * {@link ConditionNode} matches the request's flattened attributes. The
51
+ * condition reuses the gate/compute condition DSL (`{ field, op, value }` leaves
52
+ * combined with `and`/`or`/`not`), so a consumer learns one condition language
53
+ * across the whole kit.
54
+ *
55
+ * A zod-free value object: it validates its own shape on construction (throwing
56
+ * {@link InvalidValueException}) and is deep-frozen thereafter. Because rules are
57
+ * authored in TypeScript — trusted data, not untrusted JSON — the structural
58
+ * check is a hand-rolled walk of the condition tree rather than a schema parse.
59
+ * Build one through {@link of}; the constructor is private.
60
+ */
61
+ class AbacRule extends ValueObject<AbacRuleProps, AbacRuleProps> {
62
+ private constructor(props: AbacRuleProps) {
63
+ super(props);
64
+ this.finalize();
65
+ }
66
+
67
+ /**
68
+ * Builds a rule, validating that `id` is non-blank, `version` is an integer
69
+ * ≥ 1, `effect` is `"PERMIT"`/`"DENY"`, and `condition` is a well-formed
70
+ * condition node. Throws {@link InvalidValueException} otherwise.
71
+ */
72
+ static of(props: AbacRuleProps): AbacRule {
73
+ if (typeof props.id !== "string" || props.id.trim().length === 0) {
74
+ throw new InvalidValueException(
75
+ RULE_FIELD.nested("id"),
76
+ ValidationCode.BLANK,
77
+ `abac rule id must be a non-empty string, got "${props.id}"`,
78
+ );
79
+ }
80
+
81
+ if (!Number.isInteger(props.version) || props.version < 1) {
82
+ throw new InvalidValueException(
83
+ RULE_FIELD.nested("version"),
84
+ ValidationCode.OUT_OF_RANGE,
85
+ `abac rule version must be an integer >= 1, got "${props.version}"`,
86
+ );
87
+ }
88
+
89
+ if (!RULE_EFFECTS.has(props.effect)) {
90
+ throw new InvalidValueException(
91
+ RULE_FIELD.nested("effect"),
92
+ ValidationCode.INVALID_FORMAT,
93
+ `abac rule effect must be "PERMIT" or "DENY", got "${String(props.effect)}"`,
94
+ );
95
+ }
96
+
97
+ AbacRule.assertConditionShape(props.condition);
98
+
99
+ return new AbacRule(props);
100
+ }
101
+
102
+ // Mirrors what the shared condition evaluator would reject at eval time
103
+ // (empty and/or nodes, malformed leaves) but fails fast at construction.
104
+ private static assertConditionShape(node: unknown): void {
105
+ if (!isPlainObject(node)) {
106
+ AbacRule.rejectCondition(
107
+ "abac rule condition must be a leaf { field, op } or an { and | or | not } node",
108
+ );
109
+ }
110
+
111
+ if ("and" in node || "or" in node) {
112
+ const key = "and" in node ? "and" : "or";
113
+ const children = node[key];
114
+ if (!Array.isArray(children) || children.length === 0) {
115
+ AbacRule.rejectCondition(
116
+ `abac rule "${key}" node must hold a non-empty array of child conditions`,
117
+ );
118
+ }
119
+ for (const child of children) {
120
+ AbacRule.assertConditionShape(child);
121
+ }
122
+ return;
123
+ }
124
+
125
+ if ("not" in node) {
126
+ AbacRule.assertConditionShape(node.not);
127
+ return;
128
+ }
129
+
130
+ if ("field" in node && "op" in node) {
131
+ if (
132
+ typeof node.field !== "string" ||
133
+ node.field.trim().length === 0
134
+ ) {
135
+ AbacRule.rejectCondition(
136
+ `abac rule condition leaf "field" must be a non-empty string, got "${String(node.field)}"`,
137
+ );
138
+ }
139
+ if (
140
+ typeof node.op !== "string" ||
141
+ !CONDITION_OPS.has(node.op as ConditionOp)
142
+ ) {
143
+ AbacRule.rejectCondition(
144
+ `abac rule condition leaf "op" is not a supported operator, got "${String(node.op)}"`,
145
+ );
146
+ }
147
+ return;
148
+ }
149
+
150
+ AbacRule.rejectCondition(
151
+ "abac rule condition must be a leaf { field, op } or an { and | or | not } node",
152
+ );
153
+ }
154
+
155
+ private static rejectCondition(message: string): never {
156
+ throw new InvalidValueException(
157
+ RULE_FIELD.nested("condition"),
158
+ ValidationCode.INVALID_FORMAT,
159
+ message,
160
+ );
161
+ }
162
+
163
+ get id(): string {
164
+ return this.value.id;
165
+ }
166
+
167
+ get version(): number {
168
+ return this.value.version;
169
+ }
170
+
171
+ get effect(): RuleEffect {
172
+ return this.value.effect;
173
+ }
174
+
175
+ /** The condition tree evaluated against a request's flattened attributes. */
176
+ get condition(): ConditionNode {
177
+ return this.value.condition as ConditionNode;
178
+ }
179
+
180
+ get description(): string | undefined {
181
+ return this.value.description;
182
+ }
183
+
184
+ toPrimitive(): AbacRuleProps {
185
+ return {
186
+ id: this.value.id,
187
+ version: this.value.version,
188
+ effect: this.value.effect,
189
+ condition: this.value.condition as ConditionNode,
190
+ ...(this.value.description !== undefined
191
+ ? { description: this.value.description }
192
+ : {}),
193
+ };
194
+ }
195
+ }
196
+
197
+ export { AbacRule, type AbacRuleProps, type RuleEffect };
@@ -0,0 +1,21 @@
1
+ // Barrel for the zod-free ABAC module: the pure rule/policy-set primitives, the
2
+ // combining algorithms, the attribute→context flattening, the pure decisor, the
3
+ // optional RBAC+ABAC composite, and the AbacAuthorizerPort seam (which physically
4
+ // lives under application/ports/ but is surfaced here as the ABAC public home).
5
+
6
+ export type { AbacAuthorizerPort } from "../application/ports/abac-authorizer.port.js";
7
+
8
+ export type { AbacAttributes, AbacRequest } from "./abac-request.js";
9
+ export { abacContext } from "./attributes.js";
10
+ export { AbacAuthorizer, type AbacDecision } from "./authorizer.js";
11
+ export type { CombiningAlgorithm } from "./combining.js";
12
+ export {
13
+ type CompositeAccessRequest,
14
+ CompositeAuthorizer,
15
+ } from "./composite-authorizer.js";
16
+ export { AbacPolicySet } from "./domain/policy-set.js";
17
+ export {
18
+ AbacRule,
19
+ type AbacRuleProps,
20
+ type RuleEffect,
21
+ } from "./domain/rule.js";
@@ -1,10 +1,12 @@
1
1
  import { ValidationCode } from "../../exceptions/validation-code.js";
2
2
  import { InvalidValueException } from "../../exceptions/validation-exception.js";
3
3
  import { ValidationField } from "../../exceptions/validation-field.js";
4
+ import { UUID_PATTERN } from "../../shared/uuid.js";
4
5
 
5
6
  // Identifies who triggered a Command.
6
7
  // Two valid formats:
7
- // - Human user: UUID v4 (e.g.: "550e8400-e29b-41d4-a716-446655440000")
8
+ // - Human user: canonical RFC-4122 UUID, versions 1–5 — the same pattern
9
+ // `UuidIdentifier` enforces (e.g.: "550e8400-e29b-41d4-a716-446655440000")
8
10
  // - System identity: "system:<job>" where <job> is [a-z][a-z0-9]*(-[a-z0-9]+)*
9
11
  // (e.g.: "system:late-fee-job", "system:email-sender")
10
12
  //
@@ -15,9 +17,6 @@ type RequestedByKind = "user" | "system";
15
17
 
16
18
  const REQUESTED_BY_FIELD = ValidationField.of("requestedBy");
17
19
 
18
- const UUID_PATTERN =
19
- /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
20
-
21
20
  // system:<job> where <job> starts with a lowercase letter and may have hyphens between segments
22
21
  const SYSTEM_IDENTITY_PATTERN = /^system:[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
23
22
 
@@ -0,0 +1,20 @@
1
+ import type { AbacRequest } from "../../abac/abac-request.js";
2
+ import type { AuthorizationError } from "../../errors/authorization-error.js";
3
+ import type { Result } from "../../result/result.js";
4
+
5
+ /**
6
+ * The seam an application layer injects to authorize an action by attributes —
7
+ * symmetric to {@link AuthorizerPort} (RBAC). The use case depends only on this
8
+ * contract; an adapter in the consumer's stack resolves the dynamic attributes
9
+ * (resource state, environment, RBAC facts) and delegates the decision to the
10
+ * pure `AbacAuthorizer`.
11
+ *
12
+ * Returns the decision as a {@link Result}: `ok(void)` when permitted, or
13
+ * `err(AuthorizationError)` carrying `reason: "policy_denied"` (or `"forbidden"`
14
+ * on a fail-closed evaluation error) that the HTTP boundary maps to a 403.
15
+ */
16
+ interface AbacAuthorizerPort {
17
+ authorize(request: AbacRequest): Promise<Result<void, AuthorizationError>>;
18
+ }
19
+
20
+ export type { AbacAuthorizerPort };
@@ -0,0 +1,22 @@
1
+ import type { AuthorizationError } from "../../errors/authorization-error.js";
2
+ import type { AccessRequest } from "../../rbac/access-request.js";
3
+ import type { Result } from "../../result/result.js";
4
+
5
+ /**
6
+ * The seam an application layer injects to authorize an action — symmetric to
7
+ * {@link PolicyPort}. The use case depends only on this contract; an adapter in
8
+ * the consumer's stack loads the actor's grants (from its own store) and
9
+ * delegates the decision to the pure `RbacAuthorizer`.
10
+ *
11
+ * Returns the decision as a {@link Result}: `ok(void)` when allowed, or
12
+ * `err(AuthorizationError)` carrying the discriminated `reason` the HTTP
13
+ * boundary maps to a 403. Promised here under `[extension-points]` in
14
+ * KIT_CONTEXT.md.
15
+ */
16
+ interface AuthorizerPort {
17
+ authorize(
18
+ request: AccessRequest,
19
+ ): Promise<Result<void, AuthorizationError>>;
20
+ }
21
+
22
+ export type { AuthorizerPort };
@@ -44,7 +44,12 @@ abstract class Query<
44
44
  Data extends QueryOutput = never,
45
45
  Failure = AppError,
46
46
  > extends UseCase<Input, Result<Data, Failure>> {
47
- protected cacheStrategy(): CacheStrategy {
47
+ /**
48
+ * Declares how infrastructure should cache this query's result. Public so
49
+ * a caching adapter can actually read it off the instance; overrides must
50
+ * stay public.
51
+ */
52
+ public cacheStrategy(): CacheStrategy {
48
53
  return { kind: "NO_CACHE" };
49
54
  }
50
55
  }
@@ -34,7 +34,7 @@ abstract class UseCase<
34
34
  declare public static readonly CONTRACT_VERSION: ContractVersion;
35
35
 
36
36
  public get contractVersion(): ContractVersion {
37
- return UseCase.CONTRACT_VERSION;
37
+ return (this.constructor as typeof UseCase).CONTRACT_VERSION;
38
38
  }
39
39
 
40
40
  /**
@@ -112,7 +112,7 @@ abstract class Entity<TIdentifier> {
112
112
  * an older shape.
113
113
  */
114
114
  public get contractVersion(): ContractVersion {
115
- return Entity.CONTRACT_VERSION;
115
+ return (this.constructor as typeof Entity).CONTRACT_VERSION;
116
116
  }
117
117
 
118
118
  /**
@@ -1,13 +1,6 @@
1
+ import { UUID_PATTERN } from "../shared/uuid.js";
1
2
  import { ValueObject } from "./value-object.js";
2
3
 
3
- /**
4
- * Canonical RFC-4122 UUID (versions 1–5), matched case-insensitively. Declared
5
- * once here so the dozens of typed identifiers a domain accumulates never have
6
- * to re-state the pattern.
7
- */
8
- const UUID_PATTERN =
9
- /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
10
-
11
4
  /**
12
5
  * Base class for UUID-backed identity value objects.
13
6
  *
@@ -1,5 +1,6 @@
1
1
  import { PluginManager } from "../plugins/index.js";
2
2
  import { type DeepReadonly, makeImmutable } from "../shared/immutable.js";
3
+ import { stableStringify } from "../shared/stable-stringify.js";
3
4
  import { type ContractVersion, version } from "../versioning/version.js";
4
5
 
5
6
  /**
@@ -28,6 +29,12 @@ type ValueObjectPluginContract = {
28
29
  * any risk of a consumer mutating shared state. Subclasses seal the instance
29
30
  * itself with {@link finalize} once their own fields are set.
30
31
  *
32
+ * One exception: nested `Date` instances are cloned but NOT frozen —
33
+ * `Object.freeze` does not block `setTime`/`setFullYear`, so a consumer of
34
+ * `value` can still mutate an inner `Date` in place. Store instants as ISO
35
+ * strings or epoch timestamps in the wrapped state (converting to `Date` only
36
+ * in an accessor) when that guarantee matters.
37
+ *
31
38
  * @typeParam T - The shape of the wrapped data.
32
39
  * @typeParam P - The primitive form produced by {@link toPrimitive} / {@link toJSON}.
33
40
  */
@@ -61,7 +68,7 @@ abstract class ValueObject<T, P> {
61
68
  * `@version` decorator — used to detect state persisted under an older shape.
62
69
  */
63
70
  public get contractVersion(): ContractVersion {
64
- return ValueObject.CONTRACT_VERSION;
71
+ return (this.constructor as typeof ValueObject).CONTRACT_VERSION;
65
72
  }
66
73
 
67
74
  /**
@@ -93,13 +100,15 @@ abstract class ValueObject<T, P> {
93
100
  * same data are considered equal.
94
101
  *
95
102
  * Delegates to the registered equality {@link plugins}; with no plugin
96
- * registered it falls back to comparing the serialized wrapped `value`.
103
+ * registered it falls back to comparing the serialized wrapped `value`,
104
+ * with object keys sorted so insertion order (e.g. code-built vs
105
+ * JSON-rehydrated) never affects the result.
97
106
  * Subclasses may still override for a faster or domain-specific comparison.
98
107
  */
99
108
  public equals(other: this): boolean {
100
109
  return ValueObject.plugins.invoke("equals", [this, other], {
101
110
  fallback: (a, b) =>
102
- JSON.stringify(a.value) === JSON.stringify(b.value),
111
+ stableStringify(a.value) === stableStringify(b.value),
103
112
  });
104
113
  }
105
114