@cullet/erp-core 1.5.0 → 2.0.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 (306) hide show
  1. package/KIT_CONTEXT.md +2 -2
  2. package/dist/abac/index.d.cts +2 -2
  3. package/dist/abac/index.d.ts +2 -2
  4. package/dist/aggregate-version.cjs +14 -0
  5. package/dist/aggregate-version.cjs.map +1 -0
  6. package/dist/aggregate-version.js +9 -0
  7. package/dist/aggregate-version.js.map +1 -0
  8. package/dist/app-error.cjs +8 -1
  9. package/dist/app-error.cjs.map +1 -1
  10. package/dist/app-error.js +8 -1
  11. package/dist/app-error.js.map +1 -1
  12. package/dist/application/index.cjs +8 -4
  13. package/dist/application/index.d.cts +2 -2
  14. package/dist/application/index.d.ts +2 -2
  15. package/dist/application/index.js +1 -2
  16. package/dist/authorization-error.cjs +67 -17
  17. package/dist/authorization-error.cjs.map +1 -1
  18. package/dist/authorization-error.d.cts +6 -2
  19. package/dist/authorization-error.d.ts +6 -2
  20. package/dist/authorization-error.js +50 -18
  21. package/dist/authorization-error.js.map +1 -1
  22. package/dist/authorizer.port.d.cts +18 -3
  23. package/dist/authorizer.port.d.ts +18 -3
  24. package/dist/composite-authorizer.d.cts +63 -10
  25. package/dist/composite-authorizer.d.ts +63 -10
  26. package/dist/condition-evaluator.cjs +115 -184
  27. package/dist/condition-evaluator.cjs.map +1 -1
  28. package/dist/condition-evaluator.js +116 -179
  29. package/dist/condition-evaluator.js.map +1 -1
  30. package/dist/core-config.d.cts +53 -6
  31. package/dist/core-config.d.ts +53 -6
  32. package/dist/decorate.cjs +14 -0
  33. package/dist/decorate.js +9 -0
  34. package/dist/domain/index.cjs +107 -2
  35. package/dist/domain/index.cjs.map +1 -0
  36. package/dist/domain/index.d.cts +25 -1
  37. package/dist/domain/index.d.ts +25 -1
  38. package/dist/domain/index.js +99 -3
  39. package/dist/domain/index.js.map +1 -0
  40. package/dist/domain-event-contracts.cjs +12 -8
  41. package/dist/domain-event-contracts.cjs.map +1 -1
  42. package/dist/domain-event-contracts.d.cts +29 -4
  43. package/dist/domain-event-contracts.d.ts +29 -4
  44. package/dist/domain-event-contracts.js +11 -7
  45. package/dist/domain-event-contracts.js.map +1 -1
  46. package/dist/domain-exception.cjs +2 -1
  47. package/dist/domain-exception.cjs.map +1 -1
  48. package/dist/domain-exception.js +2 -1
  49. package/dist/domain-exception.js.map +1 -1
  50. package/dist/errors/index.cjs +1 -1
  51. package/dist/errors/index.d.cts +1 -1
  52. package/dist/errors/index.d.ts +1 -1
  53. package/dist/errors/index.js +2 -2
  54. package/dist/exceptions/index.cjs +2 -2
  55. package/dist/exceptions/index.d.cts +1 -2
  56. package/dist/exceptions/index.d.ts +1 -2
  57. package/dist/exceptions/index.js +2 -2
  58. package/dist/gate-engine-registry.cjs.map +1 -1
  59. package/dist/gate-engine-registry.d.cts +1 -1
  60. package/dist/gate-engine-registry.d.ts +1 -1
  61. package/dist/gate-engine-registry.js.map +1 -1
  62. package/dist/gate-v1-payload.schema.cjs +11 -6
  63. package/dist/gate-v1-payload.schema.cjs.map +1 -1
  64. package/dist/gate-v1-payload.schema.js +11 -6
  65. package/dist/gate-v1-payload.schema.js.map +1 -1
  66. package/dist/hashing.cjs +1 -1
  67. package/dist/hashing.cjs.map +1 -1
  68. package/dist/hashing.js +2 -2
  69. package/dist/hashing.js.map +1 -1
  70. package/dist/immutable.cjs +25 -12
  71. package/dist/immutable.cjs.map +1 -1
  72. package/dist/immutable.js +24 -11
  73. package/dist/immutable.js.map +1 -1
  74. package/dist/index.cjs +13 -10
  75. package/dist/index.cjs.map +1 -1
  76. package/dist/index.d.cts +9 -10
  77. package/dist/index.d.ts +9 -10
  78. package/dist/index.js +7 -9
  79. package/dist/index.js.map +1 -1
  80. package/dist/invalid-state-transition-exception.cjs +6 -6
  81. package/dist/invalid-state-transition-exception.cjs.map +1 -1
  82. package/dist/invalid-state-transition-exception.js +6 -6
  83. package/dist/invalid-state-transition-exception.js.map +1 -1
  84. package/dist/invariant-violation-exception.cjs +2 -2
  85. package/dist/invariant-violation-exception.cjs.map +1 -1
  86. package/dist/invariant-violation-exception.js +2 -2
  87. package/dist/invariant-violation-exception.js.map +1 -1
  88. package/dist/not-found-error.cjs +6 -4
  89. package/dist/not-found-error.cjs.map +1 -1
  90. package/dist/not-found-error.js +6 -4
  91. package/dist/not-found-error.js.map +1 -1
  92. package/dist/outcome.cjs +5 -5
  93. package/dist/outcome.cjs.map +1 -1
  94. package/dist/outcome.js +5 -5
  95. package/dist/outcome.js.map +1 -1
  96. package/dist/parse-gate-payload.d.cts +1 -1
  97. package/dist/parse-gate-payload.d.ts +1 -1
  98. package/dist/path.d.cts +2 -2
  99. package/dist/path.d.ts +2 -2
  100. package/dist/plugin.cjs +23 -13
  101. package/dist/plugin.cjs.map +1 -1
  102. package/dist/plugin.d.cts +22 -8
  103. package/dist/plugin.d.ts +22 -8
  104. package/dist/plugin.js +23 -13
  105. package/dist/plugin.js.map +1 -1
  106. package/dist/policies/engines/index.d.cts +1 -1
  107. package/dist/policies/engines/index.d.ts +1 -1
  108. package/dist/policies/engines/v1/gate/index.d.cts +3 -15
  109. package/dist/policies/engines/v1/gate/index.d.ts +3 -15
  110. package/dist/policies/index.d.cts +1 -1
  111. package/dist/policies/index.d.ts +1 -1
  112. package/dist/policy-bridge.cjs +30 -2
  113. package/dist/policy-bridge.cjs.map +1 -1
  114. package/dist/policy-bridge.d.cts +18 -0
  115. package/dist/policy-bridge.d.ts +18 -0
  116. package/dist/policy-bridge.js +30 -2
  117. package/dist/policy-bridge.js.map +1 -1
  118. package/dist/policy-service.cjs +40 -46
  119. package/dist/policy-service.cjs.map +1 -1
  120. package/dist/policy-service.d.cts +72 -9
  121. package/dist/policy-service.d.ts +72 -9
  122. package/dist/policy-service.js +42 -48
  123. package/dist/policy-service.js.map +1 -1
  124. package/dist/requested-by.cjs +1 -1
  125. package/dist/requested-by.cjs.map +1 -1
  126. package/dist/requested-by.js +1 -1
  127. package/dist/requested-by.js.map +1 -1
  128. package/dist/result.cjs +29 -1
  129. package/dist/result.cjs.map +1 -1
  130. package/dist/result.d.cts +12 -0
  131. package/dist/result.d.ts +12 -0
  132. package/dist/result.js +29 -1
  133. package/dist/result.js.map +1 -1
  134. package/dist/rule.cjs +94 -24
  135. package/dist/rule.cjs.map +1 -1
  136. package/dist/rule.js +94 -24
  137. package/dist/rule.js.map +1 -1
  138. package/dist/ruleset-registry.cjs +19 -0
  139. package/dist/ruleset-registry.cjs.map +1 -1
  140. package/dist/ruleset-registry.js +19 -0
  141. package/dist/ruleset-registry.js.map +1 -1
  142. package/dist/stable-stringify.cjs +43 -3
  143. package/dist/stable-stringify.cjs.map +1 -1
  144. package/dist/stable-stringify.js +38 -4
  145. package/dist/stable-stringify.js.map +1 -1
  146. package/dist/temporal-snapshot.d.cts +87 -0
  147. package/dist/temporal-snapshot.d.ts +87 -0
  148. package/dist/temporal-use-case.cjs +169 -16
  149. package/dist/temporal-use-case.cjs.map +1 -1
  150. package/dist/temporal-use-case.d.cts +76 -43
  151. package/dist/temporal-use-case.d.ts +76 -43
  152. package/dist/temporal-use-case.js +162 -15
  153. package/dist/temporal-use-case.js.map +1 -1
  154. package/dist/unexpected-error.cjs +4 -2
  155. package/dist/unexpected-error.cjs.map +1 -1
  156. package/dist/unexpected-error.js +4 -2
  157. package/dist/unexpected-error.js.map +1 -1
  158. package/dist/uuid-identifier.cjs +140 -2
  159. package/dist/uuid-identifier.cjs.map +1 -1
  160. package/dist/uuid-identifier.d.cts +26 -7
  161. package/dist/uuid-identifier.d.ts +26 -7
  162. package/dist/uuid-identifier.js +135 -3
  163. package/dist/uuid-identifier.js.map +1 -1
  164. package/dist/uuid.cjs +11 -6
  165. package/dist/uuid.cjs.map +1 -1
  166. package/dist/uuid.js +11 -6
  167. package/dist/uuid.js.map +1 -1
  168. package/dist/validation-code.cjs +9 -0
  169. package/dist/validation-code.cjs.map +1 -1
  170. package/dist/validation-code.d.cts +3 -0
  171. package/dist/validation-code.d.ts +3 -0
  172. package/dist/validation-code.js +9 -0
  173. package/dist/validation-code.js.map +1 -1
  174. package/dist/validation-error.cjs +42 -67
  175. package/dist/validation-error.cjs.map +1 -1
  176. package/dist/validation-error.d.cts +24 -8
  177. package/dist/validation-error.d.ts +24 -8
  178. package/dist/validation-error.js +41 -66
  179. package/dist/validation-error.js.map +1 -1
  180. package/dist/validation-exception.cjs +17 -6
  181. package/dist/validation-exception.cjs.map +1 -1
  182. package/dist/validation-exception.d.cts +33 -9
  183. package/dist/validation-exception.d.ts +33 -9
  184. package/dist/validation-exception.js +17 -6
  185. package/dist/validation-exception.js.map +1 -1
  186. package/dist/validation-field.cjs +3 -0
  187. package/dist/validation-field.cjs.map +1 -1
  188. package/dist/validation-field.d.cts +1 -0
  189. package/dist/validation-field.d.ts +1 -0
  190. package/dist/validation-field.js +3 -0
  191. package/dist/validation-field.js.map +1 -1
  192. package/dist/value-object-ruleset.contracts.d.cts +10 -0
  193. package/dist/value-object-ruleset.contracts.d.ts +10 -0
  194. package/dist/value-object.cjs +12 -2
  195. package/dist/value-object.cjs.map +1 -1
  196. package/dist/value-object.d.cts +16 -0
  197. package/dist/value-object.d.ts +16 -0
  198. package/dist/value-object.js +13 -3
  199. package/dist/value-object.js.map +1 -1
  200. package/dist/version.d.cts +27 -0
  201. package/dist/version.d.ts +27 -0
  202. package/dist/versioning/index.d.cts +2 -2
  203. package/dist/versioning/index.d.ts +2 -2
  204. package/meta.json +3 -2
  205. package/package.json +1 -1
  206. package/src/core/abac/authorizer.ts +60 -10
  207. package/src/core/abac/domain/policy-set.ts +52 -5
  208. package/src/core/abac/domain/rule.ts +28 -16
  209. package/src/core/abac/index.ts +7 -1
  210. package/src/core/application/commands/command.ts +14 -1
  211. package/src/core/application/commands/requested-by.ts +12 -3
  212. package/src/core/application/index.ts +2 -1
  213. package/src/core/application/policy-error-mapper.ts +6 -0
  214. package/src/core/application/ports/index.ts +7 -0
  215. package/src/core/application/ports/temporal-repository.port.ts +35 -2
  216. package/src/core/application/queries/index.ts +1 -1
  217. package/src/core/application/queries/query.ts +19 -2
  218. package/src/core/application/temporal/temporal-use-case.ts +31 -4
  219. package/src/core/application/use-case.ts +44 -7
  220. package/src/core/config/core-config.ts +46 -25
  221. package/src/core/config/index.ts +1 -0
  222. package/src/core/config/policy-reporter.ts +32 -1
  223. package/src/core/domain/entity.ts +50 -7
  224. package/src/core/domain/rulesets/entity-ruleset.contracts.ts +0 -2
  225. package/src/core/domain/rulesets/ruleset-registry.ts +24 -0
  226. package/src/core/domain/rulesets/value-object-ruleset.contracts.ts +1 -7
  227. package/src/core/domain/temporal/half-open-interval.ts +34 -0
  228. package/src/core/domain/temporal/temporal-snapshot.ts +13 -2
  229. package/src/core/domain/temporal/transaction-time.ts +19 -15
  230. package/src/core/domain/temporal/valid-time.ts +20 -15
  231. package/src/core/domain/uuid-identifier.ts +5 -3
  232. package/src/core/domain/value-object.ts +17 -0
  233. package/src/core/errors/authentication-error.ts +5 -31
  234. package/src/core/errors/authorization-error.ts +12 -20
  235. package/src/core/errors/business-rule-violation-error.ts +4 -1
  236. package/src/core/errors/idempotency-error.ts +5 -2
  237. package/src/core/errors/index.ts +4 -0
  238. package/src/core/errors/integration-error.ts +4 -24
  239. package/src/core/errors/legacy-incompatible-error.ts +1 -0
  240. package/src/core/errors/not-found-error.ts +4 -1
  241. package/src/core/errors/temporal-error.ts +22 -20
  242. package/src/core/errors/unexpected-error.ts +5 -1
  243. package/src/core/errors/utils/factory-helpers.ts +70 -0
  244. package/src/core/errors/utils/index.ts +5 -0
  245. package/src/core/errors/utils/json-safe.ts +15 -1
  246. package/src/core/errors/validation-error.ts +4 -1
  247. package/src/core/exceptions/business-rule-violation-exception.ts +2 -1
  248. package/src/core/exceptions/domain-exception.ts +9 -1
  249. package/src/core/exceptions/entity-not-found-exception.ts +5 -1
  250. package/src/core/exceptions/invalid-state-transition-exception.ts +5 -2
  251. package/src/core/exceptions/invariant-violation-exception.ts +2 -2
  252. package/src/core/exceptions/validation-code.ts +14 -0
  253. package/src/core/exceptions/validation-exception.ts +44 -5
  254. package/src/core/exceptions/validation-field.ts +11 -1
  255. package/src/core/plugins/plugin.ts +25 -15
  256. package/src/core/plugins/types.ts +4 -2
  257. package/src/core/policies/asof/asof.ts +12 -0
  258. package/src/core/policies/catalog/policy-catalog-entry.ts +3 -1
  259. package/src/core/policies/catalog/policy-catalog.ts +5 -1
  260. package/src/core/policies/context/context-builder.ts +20 -0
  261. package/src/core/policies/context/context-resolver.ts +5 -0
  262. package/src/core/policies/context/context-seed.ts +11 -0
  263. package/src/core/policies/defs/in-memory-policy-definition-repo.ts +0 -2
  264. package/src/core/policies/engines/parse-gate-payload.ts +9 -0
  265. package/src/core/policies/engines/v1/condition-date-operands.ts +112 -0
  266. package/src/core/policies/engines/v1/condition-evaluator.ts +99 -325
  267. package/src/core/policies/engines/v1/condition-schema.ts +23 -19
  268. package/src/core/policies/engines/v1/condition-types.ts +18 -11
  269. package/src/core/policies/index.ts +4 -6
  270. package/src/core/policies/service/policy-service.ts +46 -35
  271. package/src/core/policies/utils/hash.ts +5 -69
  272. package/src/core/policies/utils/result.ts +1 -1
  273. package/src/core/rbac/access-request.ts +12 -2
  274. package/src/core/rbac/authorizer.ts +8 -1
  275. package/src/core/rbac/domain/role.ts +20 -0
  276. package/src/core/rbac/domain/scope.ts +6 -1
  277. package/src/core/result/index.ts +5 -0
  278. package/src/core/result/outcome.ts +5 -7
  279. package/src/core/result/result.ts +34 -1
  280. package/src/core/shared/hashing.ts +6 -3
  281. package/src/core/shared/immutable.ts +22 -4
  282. package/src/core/shared/stable-stringify.ts +91 -4
  283. package/src/core/shared/uuid.ts +11 -6
  284. package/src/core/versioning/domain-event-contracts.ts +43 -15
  285. package/src/core/versioning/index.ts +1 -0
  286. package/src/core/versioning/version.ts +30 -1
  287. package/src/domain/index.ts +5 -0
  288. package/src/examples/rulesets/entity/order-creation-rules-v1.ts +1 -1
  289. package/src/examples/rulesets/entity/order-invariants-v1.ts +2 -4
  290. package/src/examples/rulesets/entity/order-invariants-v2.ts +2 -4
  291. package/src/examples/rulesets/value-object/cpf-rules-v1.ts +2 -4
  292. package/src/examples/rulesets/value-object/cpf-rules-v2.ts +2 -4
  293. package/src/examples/rulesets/value-object/person-name-rules-v1.ts +2 -4
  294. package/src/examples/rulesets/value-object/person-name-rules-v2.ts +2 -4
  295. package/src/result/index.ts +7 -2
  296. package/src/version.ts +1 -1
  297. package/dist/domain-exception.d.cts +0 -7
  298. package/dist/domain-exception.d.ts +0 -7
  299. package/dist/entity.cjs +0 -122
  300. package/dist/entity.cjs.map +0 -1
  301. package/dist/entity.js +0 -111
  302. package/dist/entity.js.map +0 -1
  303. package/dist/use-case.cjs +0 -92
  304. package/dist/use-case.cjs.map +0 -1
  305. package/dist/use-case.js +0 -87
  306. package/dist/use-case.js.map +0 -1
@@ -7,7 +7,7 @@ import type { AbacRequest } from "./abac-request.js";
7
7
  import { abacContext } from "./attributes.js";
8
8
  import { combine, type CombiningAlgorithm } from "./combining.js";
9
9
  import type { AbacPolicySet } from "./domain/policy-set.js";
10
- import type { AbacRule } from "./domain/rule.js";
10
+ import type { AbacRule, RuleEffect } from "./domain/rule.js";
11
11
 
12
12
  // ABAC reuses the gate engine's v1 condition matcher; the version stamps the
13
13
  // evaluation reports the shared reporter emits.
@@ -34,16 +34,30 @@ interface AbacDecision {
34
34
  * "errors as values" contract of `RbacAuthorizer`/`GateEngineV1`.
35
35
  *
36
36
  * A denial maps to {@link AuthorizationError.policyDenied}, attributed to the
37
- * deciding rule's `id`/`version`; a condition-evaluation failure (a missing
37
+ * deciding rule's `id`/`version`. A condition-evaluation failure (a missing
38
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.
39
+ * {@link AuthorizationError.forbidden} also attributed to the culprit rule's
40
+ * `id`/`version` unless the set opts into `onEvaluationError: "skip-rule"`, in
41
+ * which case the unevaluable rule is skipped and the rest decide. Every resolved
42
+ * decision (PERMIT and DENY) is emitted best-effort through the configured
43
+ * reporter as an `abac-decision` event (silent by default). Timestamps come from
44
+ * an injectable `clock` (default `() => new Date()`), so the decisor is pure
45
+ * given one. Loading the dynamic attributes is the consumer's job (behind an
46
+ * `AbacAuthorizerPort` adapter); this class only decides.
41
47
  */
42
48
  class AbacAuthorizer {
43
49
  private readonly coreConfig: CoreConfig;
50
+ private readonly clock: () => Date;
44
51
 
45
- constructor(params: { readonly coreConfig?: CoreConfig } = {}) {
52
+ constructor(
53
+ params: {
54
+ readonly coreConfig?: CoreConfig;
55
+ /** Injectable clock for deterministic timestamps; defaults to `() => new Date()`. */
56
+ readonly clock?: () => Date;
57
+ } = {},
58
+ ) {
46
59
  this.coreConfig = params.coreConfig ?? coreConfig;
60
+ this.clock = params.clock ?? (() => new Date());
47
61
  }
48
62
 
49
63
  authorize(
@@ -60,12 +74,22 @@ class AbacAuthorizer {
60
74
  for (const rule of policies.rules) {
61
75
  const matched = evaluator.evaluate(rule.condition);
62
76
  if (matched.isErr()) {
63
- // Fail closed: a technical evaluation error is never a silent PERMIT.
77
+ if (policies.onEvaluationError === "skip-rule") {
78
+ // Escape valve for evolving rules: drop the unevaluable rule
79
+ // (the evaluator already reported the anomaly) and let the
80
+ // rest decide, instead of failing the whole set closed.
81
+ continue;
82
+ }
83
+ // Fail closed: a technical evaluation error is never a silent
84
+ // PERMIT. Attribute the deny to the culprit rule so the 403 is
85
+ // triageable without turning on the reporter.
64
86
  return Result.err(
65
87
  AuthorizationError.forbidden({
66
88
  action: request.action,
67
89
  resource: request.resource,
68
- actor: { userId: request.actor.raw },
90
+ actor: { actorId: request.actor.raw },
91
+ policyId: rule.id,
92
+ policyVersion: rule.version,
69
93
  details: matched.errorOrNull() ?? undefined,
70
94
  }),
71
95
  );
@@ -81,13 +105,15 @@ class AbacAuthorizer {
81
105
  policies.defaultEffect,
82
106
  );
83
107
 
108
+ this.reportDecision(request, policies, effect, decidingRule);
109
+
84
110
  if (effect === "PERMIT") {
85
111
  return Result.ok({
86
112
  action: request.action,
87
113
  effect: "PERMIT",
88
114
  matchedRule: decidingRule?.id ?? "<default>",
89
115
  algorithm: policies.algorithm,
90
- grantedAtIso: new Date().toISOString(),
116
+ grantedAtIso: this.clock().toISOString(),
91
117
  });
92
118
  }
93
119
 
@@ -95,14 +121,38 @@ class AbacAuthorizer {
95
121
  AuthorizationError.policyDenied({
96
122
  policyId: decidingRule?.id ?? "<default-deny>",
97
123
  policyVersion: decidingRule?.version ?? 0,
98
- evaluatedAtIso: new Date().toISOString(),
124
+ evaluatedAtIso: this.clock().toISOString(),
99
125
  action: request.action,
100
126
  resource: request.resource,
101
- actor: { userId: request.actor.raw },
127
+ actor: { actorId: request.actor.raw },
102
128
  reasonCode: decidingRule?.id,
103
129
  }),
104
130
  );
105
131
  }
132
+
133
+ // Best-effort audit trail: emits the resolved decision (PERMIT and DENY)
134
+ // through the configured reporter, silent by default.
135
+ private reportDecision(
136
+ request: AbacRequest,
137
+ policies: AbacPolicySet,
138
+ effect: RuleEffect,
139
+ decidingRule: AbacRule | undefined,
140
+ ): void {
141
+ this.coreConfig.reportSafely({
142
+ kind: "abac-decision",
143
+ level: "info",
144
+ action: request.action,
145
+ resource: request.resource,
146
+ actorId: request.actor.raw,
147
+ effect,
148
+ decidingRuleId:
149
+ decidingRule?.id ??
150
+ (effect === "PERMIT" ? "<default>" : "<default-deny>"),
151
+ decidingRuleVersion: decidingRule?.version,
152
+ algorithm: policies.algorithm,
153
+ occurredAt: this.clock(),
154
+ });
155
+ }
106
156
  }
107
157
 
108
158
  export { AbacAuthorizer, type AbacDecision };
@@ -1,41 +1,84 @@
1
+ import { ValidationCode } from "../../exceptions/validation-code.js";
2
+ import { InvalidValueException } from "../../exceptions/validation-exception.js";
3
+ import { ValidationField } from "../../exceptions/validation-field.js";
1
4
  import type { CombiningAlgorithm } from "../combining.js";
2
5
 
3
6
  import type { AbacRule, RuleEffect } from "./rule.js";
4
7
 
8
+ /**
9
+ * How the {@link AbacAuthorizer} reacts when a rule's condition cannot be
10
+ * evaluated (a referenced attribute is absent, an operand is wrong-typed).
11
+ *
12
+ * - `fail-closed` (default, secure): the whole decision fails closed as
13
+ * `forbidden`. Safe, but it means adding a rule that reads an attribute a call
14
+ * site does not populate makes that call site deny everything until it does.
15
+ * - `skip-rule`: the unevaluable rule is dropped from the applicable set and the
16
+ * remaining rules decide — the escape valve for evolving rules without
17
+ * trailing every call site. The evaluator still reports the anomaly.
18
+ */
19
+ export type OnEvaluationError = "fail-closed" | "skip-rule";
20
+
21
+ const POLICY_SET_FIELD = ValidationField.of("abacPolicySet");
22
+
5
23
  /**
6
24
  * 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
25
+ * ({@link CombiningAlgorithm}), what to decide when no rule applies
26
+ * ({@link defaultEffect}), and how to react to an unevaluable rule
27
+ * ({@link onEvaluationError}). This is where ABAC goes beyond a single gate
9
28
  * condition: several PERMIT/DENY rules resolved by an explicit algorithm.
10
29
  *
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}.
30
+ * Closed by default — the combining algorithm defaults to `"deny-overrides"`,
31
+ * the default effect to `"DENY"`, and evaluation errors to `"fail-closed"`, so a
32
+ * request matching nothing (or hitting a technical error) is denied. A plain
33
+ * holder (not a value object); build through {@link of}.
14
34
  */
15
35
  class AbacPolicySet {
16
36
  private constructor(
17
37
  private readonly _rules: readonly AbacRule[],
18
38
  private readonly _algorithm: CombiningAlgorithm,
19
39
  private readonly _defaultEffect: RuleEffect,
40
+ private readonly _onEvaluationError: OnEvaluationError,
20
41
  ) {
21
42
  Object.freeze(this._rules);
22
43
  Object.freeze(this);
23
44
  }
24
45
 
46
+ /**
47
+ * Builds a policy set. Rejects duplicate rule ids with
48
+ * {@link InvalidValueException} so a denial's `policyId` attribution is
49
+ * unambiguous.
50
+ */
25
51
  static of(
26
52
  rules: readonly AbacRule[],
27
53
  options: {
28
54
  readonly algorithm?: CombiningAlgorithm;
29
55
  readonly defaultEffect?: RuleEffect;
56
+ readonly onEvaluationError?: OnEvaluationError;
30
57
  } = {},
31
58
  ): AbacPolicySet {
59
+ AbacPolicySet.assertUniqueIds(rules);
32
60
  return new AbacPolicySet(
33
61
  [...rules],
34
62
  options.algorithm ?? "deny-overrides",
35
63
  options.defaultEffect ?? "DENY",
64
+ options.onEvaluationError ?? "fail-closed",
36
65
  );
37
66
  }
38
67
 
68
+ private static assertUniqueIds(rules: readonly AbacRule[]): void {
69
+ const seen = new Set<string>();
70
+ for (const rule of rules) {
71
+ if (seen.has(rule.id)) {
72
+ throw new InvalidValueException(
73
+ POLICY_SET_FIELD.nested("rules"),
74
+ ValidationCode.ALREADY_EXISTS,
75
+ `abac policy set has duplicate rule id "${rule.id}"; ids must be unique for unambiguous audit attribution`,
76
+ );
77
+ }
78
+ seen.add(rule.id);
79
+ }
80
+ }
81
+
39
82
  get rules(): readonly AbacRule[] {
40
83
  return this._rules;
41
84
  }
@@ -47,6 +90,10 @@ class AbacPolicySet {
47
90
  get defaultEffect(): RuleEffect {
48
91
  return this._defaultEffect;
49
92
  }
93
+
94
+ get onEvaluationError(): OnEvaluationError {
95
+ return this._onEvaluationError;
96
+ }
50
97
  }
51
98
 
52
99
  export { AbacPolicySet };
@@ -2,9 +2,10 @@ 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
4
  import { ValueObject } from "../../domain/value-object.js";
5
- import type {
6
- ConditionNode,
7
- ConditionOp,
5
+ import {
6
+ CONDITION_OPS,
7
+ type ConditionNode,
8
+ type ConditionOp,
8
9
  } from "../../policies/engines/v1/condition-types.js";
9
10
 
10
11
  /** Whether a matching {@link AbacRule} permits or denies the action. */
@@ -26,18 +27,9 @@ type AbacRuleProps = {
26
27
 
27
28
  const RULE_FIELD = ValidationField.of("abacRule");
28
29
 
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
- ]);
30
+ // Derived from the evaluator's own operator list (single source of truth) so a
31
+ // new operator becomes usable in ABAC rules without a parallel edit here.
32
+ const SUPPORTED_OPS = new Set<ConditionOp>(CONDITION_OPS);
41
33
 
42
34
  const RULE_EFFECTS = new Set<RuleEffect>(["PERMIT", "DENY"]);
43
35
 
@@ -57,6 +49,26 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
57
49
  * authored in TypeScript — trusted data, not untrusted JSON — the structural
58
50
  * check is a hand-rolled walk of the condition tree rather than a schema parse.
59
51
  * Build one through {@link of}; the constructor is private.
52
+ *
53
+ * **Attribute semantics a rule author must know (the condition is evaluated by
54
+ * the shared gate/compute engine, whose runtime rules apply here too):**
55
+ * - **A referenced attribute that is *absent* from the request is a technical
56
+ * evaluation error, not a non-match.** By default (`onEvaluationError:
57
+ * "fail-closed"` on the {@link AbacPolicySet}) that error fails the *entire*
58
+ * decision closed — so adding a rule that reads an attribute a given call site
59
+ * does not yet populate makes that call site start returning `forbidden`,
60
+ * *even for requests the older rules used to permit*. Choose
61
+ * `onEvaluationError: "skip-rule"` on the set to skip an unevaluable rule
62
+ * instead of failing the whole set.
63
+ * - **`isNull` / `isNotNull` test for an explicit `null`/`undefined` *value*, not
64
+ * for a missing key.** A key that is absent from the bag never reaches the
65
+ * operator — it short-circuits to the missing-attribute error above. To match
66
+ * "no deletedAt", the consumer must put `deletedAt: null` in the bag, not omit
67
+ * it. Pass `allowNull: true` on a relational leaf to treat a present `null` as
68
+ * a non-match instead of an error.
69
+ * - **Date comparison operands must be strict ISO-8601 UTC**
70
+ * (`YYYY-MM-DDTHH:mm:ss.sssZ`); a bare `"2026-07-09"` or an offset like
71
+ * `+00:00` is rejected as an invalid operand and fails closed.
60
72
  */
61
73
  class AbacRule extends ValueObject<AbacRuleProps, AbacRuleProps> {
62
74
  private constructor(props: AbacRuleProps) {
@@ -138,7 +150,7 @@ class AbacRule extends ValueObject<AbacRuleProps, AbacRuleProps> {
138
150
  }
139
151
  if (
140
152
  typeof node.op !== "string" ||
141
- !CONDITION_OPS.has(node.op as ConditionOp)
153
+ !SUPPORTED_OPS.has(node.op as ConditionOp)
142
154
  ) {
143
155
  AbacRule.rejectCondition(
144
156
  `abac rule condition leaf "op" is not a supported operator, got "${String(node.op)}"`,
@@ -2,6 +2,12 @@
2
2
  // combining algorithms, the attribute→context flattening, the pure decisor, the
3
3
  // optional RBAC+ABAC composite, and the AbacAuthorizerPort seam (which physically
4
4
  // lives under application/ports/ but is surfaced here as the ABAC public home).
5
+ //
6
+ // Rule-author footguns (documented in full on AbacRule): a referenced attribute
7
+ // absent from the request is a technical error that, by default, fails the whole
8
+ // decision closed — set `onEvaluationError: "skip-rule"` on the AbacPolicySet to
9
+ // skip the unevaluable rule instead. `isNull`/`isNotNull` match an explicit
10
+ // `null` value, never a missing key.
5
11
 
6
12
  export type { AbacAuthorizerPort } from "../application/ports/abac-authorizer.port.js";
7
13
 
@@ -13,7 +19,7 @@ export {
13
19
  type CompositeAccessRequest,
14
20
  CompositeAuthorizer,
15
21
  } from "./composite-authorizer.js";
16
- export { AbacPolicySet } from "./domain/policy-set.js";
22
+ export { AbacPolicySet, type OnEvaluationError } from "./domain/policy-set.js";
17
23
  export {
18
24
  AbacRule,
19
25
  type AbacRuleProps,
@@ -1,5 +1,6 @@
1
1
  import { UseCase } from "../use-case.js";
2
2
  import { version } from "../../versioning/version.js";
3
+ import type { TraceAttributeValue } from "../ports/tracer.port.js";
3
4
  import type { Result } from "../../result/result.js";
4
5
 
5
6
  import { RequestedBy } from "./requested-by.js";
@@ -22,7 +23,19 @@ interface CommandInput {
22
23
  abstract class Command<
23
24
  Input extends CommandInput,
24
25
  Output extends Result<unknown, unknown> = Result<void, never>,
25
- > extends UseCase<Input, Output> {}
26
+ > extends UseCase<Input, Output> {
27
+ /**
28
+ * Surfaces the actor's *kind* (`"user"` / `"system"`) on the span and
29
+ * metrics so a write can be sliced by origin. Only the low-cardinality
30
+ * `kind` is emitted — never the raw id, which would blow up label
31
+ * cardinality and could leak a user identifier into metrics.
32
+ */
33
+ protected override spanAttributes(
34
+ input: Input,
35
+ ): Readonly<Record<string, TraceAttributeValue>> {
36
+ return { "requested_by.kind": input.requestedBy.kind };
37
+ }
38
+ }
26
39
 
27
40
  export type { CommandInput };
28
41
  export { Command };
@@ -5,8 +5,11 @@ import { UUID_PATTERN } from "../../shared/uuid.js";
5
5
 
6
6
  // Identifies who triggered a Command.
7
7
  // Two valid formats:
8
- // - Human user: canonical RFC-4122 UUID, versions 1–5 the same pattern
9
- // `UuidIdentifier` enforces (e.g.: "550e8400-e29b-41d4-a716-446655440000")
8
+ // - Human user: canonical RFC-4122 UUID, versions 1–8 (including the
9
+ // time-ordered v7) — the same pattern `UuidIdentifier` enforces
10
+ // (e.g.: "550e8400-e29b-41d4-a716-446655440000"). The constraint is
11
+ // single-sourced in `shared/uuid.ts`, so `UuidIdentifier` and this value
12
+ // always accept the same set of versions.
10
13
  // - System identity: "system:<job>" where <job> is [a-z][a-z0-9]*(-[a-z0-9]+)*
11
14
  // (e.g.: "system:late-fee-job", "system:email-sender")
12
15
  //
@@ -26,7 +29,13 @@ class RequestedBy {
26
29
 
27
30
  private constructor(kind: RequestedByKind, raw: string) {
28
31
  this.kind = kind;
29
- this.raw = raw;
32
+ // UUIDs are matched case-insensitively (see `shared/uuid.ts`), so the
33
+ // same user id can arrive lowercased from Postgres and uppercased from a
34
+ // JWT. Canonicalize user ids to lowercase here — the single choke point
35
+ // every factory passes through — so exact-string comparators downstream
36
+ // (`Grant.appliesTo`, `rbacContextFields`) never miss the right identity
37
+ // on case alone. System identities are already lowercase by pattern.
38
+ this.raw = kind === "user" ? raw.toLowerCase() : raw;
30
39
  Object.freeze(this);
31
40
  }
32
41
 
@@ -15,6 +15,7 @@ export type {
15
15
  PolicyPortError,
16
16
  Repository,
17
17
  ResultRepository,
18
+ ResultTemporalRepository,
18
19
  TraceAttributeValue,
19
20
  TraceSpan,
20
21
  TracerPort,
@@ -22,7 +23,7 @@ export type {
22
23
  TemporalRepository,
23
24
  } from "./ports/index.js";
24
25
  export { mapPolicyEvaluationError } from "./policy-error-mapper.js";
25
- export type { CacheStrategy, Page } from "./queries/index.js";
26
+ export type { CacheStrategy, Page, QueryOutput } from "./queries/index.js";
26
27
  export { Query } from "./queries/index.js";
27
28
  export {
28
29
  assertTemporalContext,
@@ -49,7 +49,13 @@ export function mapPolicyEvaluationError(err: PolicyEvaluationError): AppError {
49
49
  { cause: err.cause },
50
50
  );
51
51
  case "ENGINE_FAILURE":
52
+ // `engine`/`engineVersion` are internal detail kept in `metadata`
53
+ // for logs. `publicMessage` gives the HTTP boundary a safe string
54
+ // to expose so it never has to serialize this metadata to a client
55
+ // on a 500. (Whether the boundary leaks `metadata` is still its
56
+ // call — this only hands it the non-leaking alternative.)
52
57
  return new UnexpectedError(err.message, err.cause, {
58
+ publicMessage: "Policy evaluation failed unexpectedly.",
53
59
  metadata: {
54
60
  policyKey: err.policyKey,
55
61
  engine: err.engine,
@@ -8,6 +8,7 @@ export type {
8
8
  } from "./policy-port.js";
9
9
  export type { Repository, ResultRepository } from "./repository.port.js";
10
10
  export type {
11
+ ResultTemporalRepository,
11
12
  TemporalHistory,
12
13
  TemporalRepository,
13
14
  } from "./temporal-repository.port.js";
@@ -16,3 +17,9 @@ export type {
16
17
  TraceSpan,
17
18
  TracerPort,
18
19
  } from "./tracer.port.js";
20
+
21
+ // Note: `AuthorizerPort` (RBAC) and `AbacAuthorizerPort` (ABAC) also live in
22
+ // this folder but are intentionally NOT re-exported here — they belong to the
23
+ // `rbac` / `abac` surfaces and are exported from those barrels instead (each
24
+ // port file documents why). Import them from `cullet/erp-core` rbac/abac
25
+ // entry points, not from this ports barrel.
@@ -1,9 +1,21 @@
1
1
  import type { TemporalSnapshot } from "../../domain/temporal/index.js";
2
+ import type { AppError } from "../../errors/index.js";
3
+ import type { Result } from "../../result/result.js";
2
4
 
3
- import type { Repository } from "./repository.port.js";
5
+ import type { Repository, ResultRepository } from "./repository.port.js";
4
6
 
5
7
  type TemporalHistory<TEntity> = readonly TemporalSnapshot<TEntity>[];
6
8
 
9
+ /**
10
+ * Bitemporal persistence port in the imperative (exception-raising) style.
11
+ *
12
+ * `delete(id)` is inherited from {@link Repository} and its bitemporal meaning
13
+ * is intentionally left to the implementation: it may close the current
14
+ * valid-time interval (a logical end-of-validity that preserves history) or
15
+ * physically purge the row's whole history. Pick one per adapter and document
16
+ * it — callers cannot tell which from this type. Prefer closing validity so
17
+ * `findAsOf`/`findHistory` stay meaningful for past instants.
18
+ */
7
19
  interface TemporalRepository<TEntity, TId> extends Repository<TEntity, TId> {
8
20
  findAsOf(id: TId, asOf: Date): Promise<TEntity | null>;
9
21
  findAtTransaction(id: TId, txTime: Date): Promise<TEntity | null>;
@@ -11,4 +23,25 @@ interface TemporalRepository<TEntity, TId> extends Repository<TEntity, TId> {
11
23
  save(entity: TEntity, validFrom?: Date): Promise<void>;
12
24
  }
13
25
 
14
- export type { TemporalHistory, TemporalRepository };
26
+ /**
27
+ * `Result`-returning counterpart of {@link TemporalRepository}, aligned with the
28
+ * "errors as values" philosophy (mirrors the {@link Repository} /
29
+ * {@link ResultRepository} pair). The `delete` semantics note above applies
30
+ * unchanged.
31
+ */
32
+ interface ResultTemporalRepository<TEntity, TId, TError = AppError>
33
+ extends ResultRepository<TEntity, TId, TError> {
34
+ findAsOf(id: TId, asOf: Date): Promise<Result<TEntity | null, TError>>;
35
+ findAtTransaction(
36
+ id: TId,
37
+ txTime: Date,
38
+ ): Promise<Result<TEntity | null, TError>>;
39
+ findHistory(id: TId): Promise<Result<TemporalHistory<TEntity>, TError>>;
40
+ save(entity: TEntity, validFrom?: Date): Promise<Result<void, TError>>;
41
+ }
42
+
43
+ export type {
44
+ ResultTemporalRepository,
45
+ TemporalHistory,
46
+ TemporalRepository,
47
+ };
@@ -1,2 +1,2 @@
1
- export type { CacheStrategy, Page } from "./query.js";
1
+ export type { CacheStrategy, Page, QueryOutput } from "./query.js";
2
2
  export { Query } from "./query.js";
@@ -4,12 +4,15 @@ import type { AppError } from "../../errors/index.js";
4
4
  import type { Result } from "../../result/result.js";
5
5
 
6
6
  /**
7
- * Paginated result of a list query.
7
+ * Paginated result of a list query (offset-based).
8
8
  * Use as `Output` when the query returns a collection with pagination metadata.
9
+ * For cursor/keyset pagination, model your own `Output` type instead.
9
10
  */
10
11
  interface Page<T> {
11
12
  readonly items: readonly T[];
13
+ /** Total rows across all pages, ignoring `page`/`pageSize`. */
12
14
  readonly total: number;
15
+ /** 1-based page number (the first page is `1`, not `0`). */
13
16
  readonly page: number;
14
17
  readonly pageSize: number;
15
18
  }
@@ -17,11 +20,25 @@ interface Page<T> {
17
20
  /**
18
21
  * Cache strategy declared by the query type.
19
22
  * Infrastructure reads this value to decide whether and how to cache the result.
23
+ *
24
+ * These are pure descriptors: the type cannot enforce that a duration is finite
25
+ * and positive, so the caching adapter must validate `ttlMs` /
26
+ * `staleWhileRevalidateMs` (`> 0`, finite) before honoring the strategy.
20
27
  */
21
28
  type CacheStrategy =
22
29
  | { readonly kind: "NO_CACHE" }
23
30
  | { readonly kind: "TIME_TO_LIVE"; readonly ttlMs: number }
24
- | { readonly kind: "STALE_WHILE_REVALIDATE"; readonly ttlMs: number };
31
+ | {
32
+ readonly kind: "STALE_WHILE_REVALIDATE";
33
+ /** How long the entry is served fresh. */
34
+ readonly ttlMs: number;
35
+ /**
36
+ * How long *after* `ttlMs` a stale entry may still be served while a
37
+ * background refresh runs. SWR needs both windows; a single TTL is
38
+ * ambiguous and each adapter would guess the second one.
39
+ */
40
+ readonly staleWhileRevalidateMs: number;
41
+ };
25
42
 
26
43
  type QueryOutput = object | string | number | boolean | bigint | symbol | null;
27
44
 
@@ -1,4 +1,5 @@
1
1
  import { UseCase } from "../use-case.js";
2
+ import { version } from "../../versioning/version.js";
2
3
  import type { ContextSeed } from "../../policies/index.js";
3
4
  import type { Result } from "../../result/result.js";
4
5
 
@@ -7,6 +8,14 @@ import {
7
8
  type TemporalContext,
8
9
  } from "./temporal-context.js";
9
10
 
11
+ /**
12
+ * Marker mixed into a temporal use case's `Input`.
13
+ *
14
+ * The consumer's `Input` must not re-declare `temporalContext` with an
15
+ * incompatible type: `TemporalUseCase` intersects the two, and a clashing
16
+ * declaration collapses the field to `never` (surfacing only as a confusing
17
+ * error at the call site, not here).
18
+ */
10
19
  interface TemporalUseCaseInput {
11
20
  readonly temporalContext?: TemporalContext;
12
21
  }
@@ -25,6 +34,7 @@ type TemporalizedContextSeed<TSeed extends ContextSeed> = Omit<
25
34
  readonly fields: TSeed["fields"] & { readonly now: Date };
26
35
  };
27
36
 
37
+ @version("1.0")
28
38
  abstract class TemporalUseCase<
29
39
  Input extends object,
30
40
  Output extends Result<unknown, unknown>,
@@ -35,16 +45,33 @@ abstract class TemporalUseCase<
35
45
  return createTemporalContext(input.temporalContext);
36
46
  }
37
47
 
48
+ /**
49
+ * Enriches a policy `ContextSeed` with `fields.now`, taken from the
50
+ * context's `requestedAt` (wall-clock time of the request).
51
+ *
52
+ * NOTE: this injects **only** `now` (from `requestedAt`) — it does *not*
53
+ * propagate `temporalContext.asOf`. Policy evaluation derives its own
54
+ * `asOf` from whichever seed field the policy's `asOfSource` points at
55
+ * (via `fields.now` by default). So a retroactive use case (an `asOf` in
56
+ * the past) will still evaluate policies at the *current* time unless the
57
+ * author maps `temporalContext.asOf` onto that seed field explicitly. Do
58
+ * that mapping in the use case when back-dated evaluation is intended.
59
+ */
38
60
  protected buildPolicySeed<TSeed extends ContextSeed>(
39
61
  seed: TSeed,
40
62
  temporalContext: TemporalContext,
41
63
  ): TemporalizedContextSeed<TSeed> {
64
+ // ponytail: shallow-freeze the two objects we create (outer + fields);
65
+ // nested `fields` values belong to the caller's seed — not ours to
66
+ // deep-freeze (would freeze shared refs) or structuredClone (would
67
+ // flatten any class instances the seed carries).
68
+ const fields = Object.freeze({
69
+ ...seed.fields,
70
+ now: new Date(temporalContext.requestedAt.getTime()),
71
+ });
42
72
  return Object.freeze({
43
73
  ...seed,
44
- fields: {
45
- ...seed.fields,
46
- now: new Date(temporalContext.requestedAt.getTime()),
47
- },
74
+ fields,
48
75
  }) as TemporalizedContextSeed<TSeed>;
49
76
  }
50
77
  }