@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.
- package/KIT_CONTEXT.md +2 -2
- package/dist/abac/index.d.cts +2 -2
- package/dist/abac/index.d.ts +2 -2
- package/dist/aggregate-version.cjs +14 -0
- package/dist/aggregate-version.cjs.map +1 -0
- package/dist/aggregate-version.js +9 -0
- package/dist/aggregate-version.js.map +1 -0
- package/dist/app-error.cjs +8 -1
- package/dist/app-error.cjs.map +1 -1
- package/dist/app-error.js +8 -1
- package/dist/app-error.js.map +1 -1
- package/dist/application/index.cjs +8 -4
- package/dist/application/index.d.cts +2 -2
- package/dist/application/index.d.ts +2 -2
- package/dist/application/index.js +1 -2
- package/dist/authorization-error.cjs +67 -17
- package/dist/authorization-error.cjs.map +1 -1
- package/dist/authorization-error.d.cts +6 -2
- package/dist/authorization-error.d.ts +6 -2
- package/dist/authorization-error.js +50 -18
- package/dist/authorization-error.js.map +1 -1
- package/dist/authorizer.port.d.cts +18 -3
- package/dist/authorizer.port.d.ts +18 -3
- package/dist/composite-authorizer.d.cts +63 -10
- package/dist/composite-authorizer.d.ts +63 -10
- package/dist/condition-evaluator.cjs +115 -184
- package/dist/condition-evaluator.cjs.map +1 -1
- package/dist/condition-evaluator.js +116 -179
- package/dist/condition-evaluator.js.map +1 -1
- package/dist/core-config.d.cts +53 -6
- package/dist/core-config.d.ts +53 -6
- package/dist/decorate.cjs +14 -0
- package/dist/decorate.js +9 -0
- package/dist/domain/index.cjs +107 -2
- package/dist/domain/index.cjs.map +1 -0
- package/dist/domain/index.d.cts +25 -1
- package/dist/domain/index.d.ts +25 -1
- package/dist/domain/index.js +99 -3
- package/dist/domain/index.js.map +1 -0
- package/dist/domain-event-contracts.cjs +12 -8
- package/dist/domain-event-contracts.cjs.map +1 -1
- package/dist/domain-event-contracts.d.cts +29 -4
- package/dist/domain-event-contracts.d.ts +29 -4
- package/dist/domain-event-contracts.js +11 -7
- package/dist/domain-event-contracts.js.map +1 -1
- package/dist/domain-exception.cjs +2 -1
- package/dist/domain-exception.cjs.map +1 -1
- package/dist/domain-exception.js +2 -1
- package/dist/domain-exception.js.map +1 -1
- package/dist/errors/index.cjs +1 -1
- package/dist/errors/index.d.cts +1 -1
- package/dist/errors/index.d.ts +1 -1
- package/dist/errors/index.js +2 -2
- package/dist/exceptions/index.cjs +2 -2
- package/dist/exceptions/index.d.cts +1 -2
- package/dist/exceptions/index.d.ts +1 -2
- package/dist/exceptions/index.js +2 -2
- package/dist/gate-engine-registry.cjs.map +1 -1
- package/dist/gate-engine-registry.d.cts +1 -1
- package/dist/gate-engine-registry.d.ts +1 -1
- package/dist/gate-engine-registry.js.map +1 -1
- package/dist/gate-v1-payload.schema.cjs +11 -6
- package/dist/gate-v1-payload.schema.cjs.map +1 -1
- package/dist/gate-v1-payload.schema.js +11 -6
- package/dist/gate-v1-payload.schema.js.map +1 -1
- package/dist/hashing.cjs +1 -1
- package/dist/hashing.cjs.map +1 -1
- package/dist/hashing.js +2 -2
- package/dist/hashing.js.map +1 -1
- package/dist/immutable.cjs +25 -12
- package/dist/immutable.cjs.map +1 -1
- package/dist/immutable.js +24 -11
- package/dist/immutable.js.map +1 -1
- package/dist/index.cjs +13 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +9 -10
- package/dist/index.d.ts +9 -10
- package/dist/index.js +7 -9
- package/dist/index.js.map +1 -1
- package/dist/invalid-state-transition-exception.cjs +6 -6
- package/dist/invalid-state-transition-exception.cjs.map +1 -1
- package/dist/invalid-state-transition-exception.js +6 -6
- package/dist/invalid-state-transition-exception.js.map +1 -1
- package/dist/invariant-violation-exception.cjs +2 -2
- package/dist/invariant-violation-exception.cjs.map +1 -1
- package/dist/invariant-violation-exception.js +2 -2
- package/dist/invariant-violation-exception.js.map +1 -1
- package/dist/not-found-error.cjs +6 -4
- package/dist/not-found-error.cjs.map +1 -1
- package/dist/not-found-error.js +6 -4
- package/dist/not-found-error.js.map +1 -1
- package/dist/outcome.cjs +5 -5
- package/dist/outcome.cjs.map +1 -1
- package/dist/outcome.js +5 -5
- package/dist/outcome.js.map +1 -1
- package/dist/parse-gate-payload.d.cts +1 -1
- package/dist/parse-gate-payload.d.ts +1 -1
- package/dist/path.d.cts +2 -2
- package/dist/path.d.ts +2 -2
- package/dist/plugin.cjs +23 -13
- package/dist/plugin.cjs.map +1 -1
- package/dist/plugin.d.cts +22 -8
- package/dist/plugin.d.ts +22 -8
- package/dist/plugin.js +23 -13
- package/dist/plugin.js.map +1 -1
- package/dist/policies/engines/index.d.cts +1 -1
- package/dist/policies/engines/index.d.ts +1 -1
- package/dist/policies/engines/v1/gate/index.d.cts +3 -15
- package/dist/policies/engines/v1/gate/index.d.ts +3 -15
- package/dist/policies/index.d.cts +1 -1
- package/dist/policies/index.d.ts +1 -1
- package/dist/policy-bridge.cjs +30 -2
- package/dist/policy-bridge.cjs.map +1 -1
- package/dist/policy-bridge.d.cts +18 -0
- package/dist/policy-bridge.d.ts +18 -0
- package/dist/policy-bridge.js +30 -2
- package/dist/policy-bridge.js.map +1 -1
- package/dist/policy-service.cjs +40 -46
- package/dist/policy-service.cjs.map +1 -1
- package/dist/policy-service.d.cts +72 -9
- package/dist/policy-service.d.ts +72 -9
- package/dist/policy-service.js +42 -48
- package/dist/policy-service.js.map +1 -1
- package/dist/requested-by.cjs +1 -1
- package/dist/requested-by.cjs.map +1 -1
- package/dist/requested-by.js +1 -1
- package/dist/requested-by.js.map +1 -1
- package/dist/result.cjs +29 -1
- package/dist/result.cjs.map +1 -1
- package/dist/result.d.cts +12 -0
- package/dist/result.d.ts +12 -0
- package/dist/result.js +29 -1
- package/dist/result.js.map +1 -1
- package/dist/rule.cjs +94 -24
- package/dist/rule.cjs.map +1 -1
- package/dist/rule.js +94 -24
- package/dist/rule.js.map +1 -1
- package/dist/ruleset-registry.cjs +19 -0
- package/dist/ruleset-registry.cjs.map +1 -1
- package/dist/ruleset-registry.js +19 -0
- package/dist/ruleset-registry.js.map +1 -1
- package/dist/stable-stringify.cjs +43 -3
- package/dist/stable-stringify.cjs.map +1 -1
- package/dist/stable-stringify.js +38 -4
- package/dist/stable-stringify.js.map +1 -1
- package/dist/temporal-snapshot.d.cts +87 -0
- package/dist/temporal-snapshot.d.ts +87 -0
- package/dist/temporal-use-case.cjs +169 -16
- package/dist/temporal-use-case.cjs.map +1 -1
- package/dist/temporal-use-case.d.cts +76 -43
- package/dist/temporal-use-case.d.ts +76 -43
- package/dist/temporal-use-case.js +162 -15
- package/dist/temporal-use-case.js.map +1 -1
- package/dist/unexpected-error.cjs +4 -2
- package/dist/unexpected-error.cjs.map +1 -1
- package/dist/unexpected-error.js +4 -2
- package/dist/unexpected-error.js.map +1 -1
- package/dist/uuid-identifier.cjs +140 -2
- package/dist/uuid-identifier.cjs.map +1 -1
- package/dist/uuid-identifier.d.cts +26 -7
- package/dist/uuid-identifier.d.ts +26 -7
- package/dist/uuid-identifier.js +135 -3
- package/dist/uuid-identifier.js.map +1 -1
- package/dist/uuid.cjs +11 -6
- package/dist/uuid.cjs.map +1 -1
- package/dist/uuid.js +11 -6
- package/dist/uuid.js.map +1 -1
- package/dist/validation-code.cjs +9 -0
- package/dist/validation-code.cjs.map +1 -1
- package/dist/validation-code.d.cts +3 -0
- package/dist/validation-code.d.ts +3 -0
- package/dist/validation-code.js +9 -0
- package/dist/validation-code.js.map +1 -1
- package/dist/validation-error.cjs +42 -67
- package/dist/validation-error.cjs.map +1 -1
- package/dist/validation-error.d.cts +24 -8
- package/dist/validation-error.d.ts +24 -8
- package/dist/validation-error.js +41 -66
- package/dist/validation-error.js.map +1 -1
- package/dist/validation-exception.cjs +17 -6
- package/dist/validation-exception.cjs.map +1 -1
- package/dist/validation-exception.d.cts +33 -9
- package/dist/validation-exception.d.ts +33 -9
- package/dist/validation-exception.js +17 -6
- package/dist/validation-exception.js.map +1 -1
- package/dist/validation-field.cjs +3 -0
- package/dist/validation-field.cjs.map +1 -1
- package/dist/validation-field.d.cts +1 -0
- package/dist/validation-field.d.ts +1 -0
- package/dist/validation-field.js +3 -0
- package/dist/validation-field.js.map +1 -1
- package/dist/value-object-ruleset.contracts.d.cts +10 -0
- package/dist/value-object-ruleset.contracts.d.ts +10 -0
- package/dist/value-object.cjs +12 -2
- package/dist/value-object.cjs.map +1 -1
- package/dist/value-object.d.cts +16 -0
- package/dist/value-object.d.ts +16 -0
- package/dist/value-object.js +13 -3
- package/dist/value-object.js.map +1 -1
- package/dist/version.d.cts +27 -0
- package/dist/version.d.ts +27 -0
- package/dist/versioning/index.d.cts +2 -2
- package/dist/versioning/index.d.ts +2 -2
- package/meta.json +3 -2
- package/package.json +1 -1
- package/src/core/abac/authorizer.ts +60 -10
- package/src/core/abac/domain/policy-set.ts +52 -5
- package/src/core/abac/domain/rule.ts +28 -16
- package/src/core/abac/index.ts +7 -1
- package/src/core/application/commands/command.ts +14 -1
- package/src/core/application/commands/requested-by.ts +12 -3
- package/src/core/application/index.ts +2 -1
- package/src/core/application/policy-error-mapper.ts +6 -0
- package/src/core/application/ports/index.ts +7 -0
- package/src/core/application/ports/temporal-repository.port.ts +35 -2
- package/src/core/application/queries/index.ts +1 -1
- package/src/core/application/queries/query.ts +19 -2
- package/src/core/application/temporal/temporal-use-case.ts +31 -4
- package/src/core/application/use-case.ts +44 -7
- package/src/core/config/core-config.ts +46 -25
- package/src/core/config/index.ts +1 -0
- package/src/core/config/policy-reporter.ts +32 -1
- package/src/core/domain/entity.ts +50 -7
- package/src/core/domain/rulesets/entity-ruleset.contracts.ts +0 -2
- package/src/core/domain/rulesets/ruleset-registry.ts +24 -0
- package/src/core/domain/rulesets/value-object-ruleset.contracts.ts +1 -7
- package/src/core/domain/temporal/half-open-interval.ts +34 -0
- package/src/core/domain/temporal/temporal-snapshot.ts +13 -2
- package/src/core/domain/temporal/transaction-time.ts +19 -15
- package/src/core/domain/temporal/valid-time.ts +20 -15
- package/src/core/domain/uuid-identifier.ts +5 -3
- package/src/core/domain/value-object.ts +17 -0
- package/src/core/errors/authentication-error.ts +5 -31
- package/src/core/errors/authorization-error.ts +12 -20
- package/src/core/errors/business-rule-violation-error.ts +4 -1
- package/src/core/errors/idempotency-error.ts +5 -2
- package/src/core/errors/index.ts +4 -0
- package/src/core/errors/integration-error.ts +4 -24
- package/src/core/errors/legacy-incompatible-error.ts +1 -0
- package/src/core/errors/not-found-error.ts +4 -1
- package/src/core/errors/temporal-error.ts +22 -20
- package/src/core/errors/unexpected-error.ts +5 -1
- package/src/core/errors/utils/factory-helpers.ts +70 -0
- package/src/core/errors/utils/index.ts +5 -0
- package/src/core/errors/utils/json-safe.ts +15 -1
- package/src/core/errors/validation-error.ts +4 -1
- package/src/core/exceptions/business-rule-violation-exception.ts +2 -1
- package/src/core/exceptions/domain-exception.ts +9 -1
- package/src/core/exceptions/entity-not-found-exception.ts +5 -1
- package/src/core/exceptions/invalid-state-transition-exception.ts +5 -2
- package/src/core/exceptions/invariant-violation-exception.ts +2 -2
- package/src/core/exceptions/validation-code.ts +14 -0
- package/src/core/exceptions/validation-exception.ts +44 -5
- package/src/core/exceptions/validation-field.ts +11 -1
- package/src/core/plugins/plugin.ts +25 -15
- package/src/core/plugins/types.ts +4 -2
- package/src/core/policies/asof/asof.ts +12 -0
- package/src/core/policies/catalog/policy-catalog-entry.ts +3 -1
- package/src/core/policies/catalog/policy-catalog.ts +5 -1
- package/src/core/policies/context/context-builder.ts +20 -0
- package/src/core/policies/context/context-resolver.ts +5 -0
- package/src/core/policies/context/context-seed.ts +11 -0
- package/src/core/policies/defs/in-memory-policy-definition-repo.ts +0 -2
- package/src/core/policies/engines/parse-gate-payload.ts +9 -0
- package/src/core/policies/engines/v1/condition-date-operands.ts +112 -0
- package/src/core/policies/engines/v1/condition-evaluator.ts +99 -325
- package/src/core/policies/engines/v1/condition-schema.ts +23 -19
- package/src/core/policies/engines/v1/condition-types.ts +18 -11
- package/src/core/policies/index.ts +4 -6
- package/src/core/policies/service/policy-service.ts +46 -35
- package/src/core/policies/utils/hash.ts +5 -69
- package/src/core/policies/utils/result.ts +1 -1
- package/src/core/rbac/access-request.ts +12 -2
- package/src/core/rbac/authorizer.ts +8 -1
- package/src/core/rbac/domain/role.ts +20 -0
- package/src/core/rbac/domain/scope.ts +6 -1
- package/src/core/result/index.ts +5 -0
- package/src/core/result/outcome.ts +5 -7
- package/src/core/result/result.ts +34 -1
- package/src/core/shared/hashing.ts +6 -3
- package/src/core/shared/immutable.ts +22 -4
- package/src/core/shared/stable-stringify.ts +91 -4
- package/src/core/shared/uuid.ts +11 -6
- package/src/core/versioning/domain-event-contracts.ts +43 -15
- package/src/core/versioning/index.ts +1 -0
- package/src/core/versioning/version.ts +30 -1
- package/src/domain/index.ts +5 -0
- package/src/examples/rulesets/entity/order-creation-rules-v1.ts +1 -1
- package/src/examples/rulesets/entity/order-invariants-v1.ts +2 -4
- package/src/examples/rulesets/entity/order-invariants-v2.ts +2 -4
- package/src/examples/rulesets/value-object/cpf-rules-v1.ts +2 -4
- package/src/examples/rulesets/value-object/cpf-rules-v2.ts +2 -4
- package/src/examples/rulesets/value-object/person-name-rules-v1.ts +2 -4
- package/src/examples/rulesets/value-object/person-name-rules-v2.ts +2 -4
- package/src/result/index.ts +7 -2
- package/src/version.ts +1 -1
- package/dist/domain-exception.d.cts +0 -7
- package/dist/domain-exception.d.ts +0 -7
- package/dist/entity.cjs +0 -122
- package/dist/entity.cjs.map +0 -1
- package/dist/entity.js +0 -111
- package/dist/entity.js.map +0 -1
- package/dist/use-case.cjs +0 -92
- package/dist/use-case.cjs.map +0 -1
- package/dist/use-case.js +0 -87
- 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
|
|
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}
|
|
40
|
-
*
|
|
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(
|
|
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
|
-
|
|
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: {
|
|
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:
|
|
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:
|
|
124
|
+
evaluatedAtIso: this.clock().toISOString(),
|
|
99
125
|
action: request.action,
|
|
100
126
|
resource: request.resource,
|
|
101
|
-
actor: {
|
|
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})
|
|
8
|
-
* ({@link defaultEffect})
|
|
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"
|
|
12
|
-
* the default effect to `"DENY"`,
|
|
13
|
-
*
|
|
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
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
!
|
|
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)}"`,
|
package/src/core/abac/index.ts
CHANGED
|
@@ -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–
|
|
9
|
-
// `UuidIdentifier` enforces
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
| {
|
|
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
|
}
|