@cullet/erp-core 1.0.9 → 1.0.11
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/dist/index.d.ts +123 -2
- package/dist/index.js +103 -1
- package/dist/index.js.map +1 -1
- package/dist/policy-service.d.ts +99 -0
- package/dist/policy-service.js +73 -0
- package/dist/policy-service.js.map +1 -1
- package/dist/validation-code.js +33 -0
- package/dist/validation-code.js.map +1 -1
- package/dist/validation-error.d.ts +202 -0
- package/dist/validation-error.js +164 -0
- package/dist/validation-error.js.map +1 -1
- package/meta.json +12 -8
- package/package.json +1 -1
- package/src/core/domain/entity.ts +71 -0
- package/src/core/domain/value-object.ts +51 -0
- package/src/core/errors/app-error.ts +33 -0
- package/src/core/errors/authorization-error.ts +43 -0
- package/src/core/errors/conflict-error.ts +69 -0
- package/src/core/errors/integration-error.ts +25 -0
- package/src/core/errors/not-found-error.ts +14 -0
- package/src/core/errors/validation-error.ts +18 -0
- package/src/core/policies/catalog/policy-catalog.ts +36 -0
- package/src/core/policies/resolver/policy-resolver.ts +10 -0
- package/src/core/policies/service/policy-service.ts +53 -0
- package/src/version.ts +1 -1
|
@@ -12,6 +12,22 @@ import { assertJsonSafeMetadata } from "./utils/index.js";
|
|
|
12
12
|
// AppError (abstract base class)
|
|
13
13
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Root of the application/domain error hierarchy. Every error the system raises
|
|
17
|
+
* on purpose extends `AppError`, which gives callers a single `instanceof` to
|
|
18
|
+
* catch and a uniform, serializable shape to log and transport.
|
|
19
|
+
*
|
|
20
|
+
* Beyond the native `Error` message it carries a stable `code` (the contract the
|
|
21
|
+
* outside world matches on), an optional non-leaking `publicMessage`, a
|
|
22
|
+
* severity, JSON-safe `metadata`, and the correlation/request/command ids that
|
|
23
|
+
* stitch an error back to the request that produced it. The metadata is
|
|
24
|
+
* validated as JSON-safe on construction, so a logger can serialize any
|
|
25
|
+
* `AppError` without hitting a circular reference or a non-serializable value.
|
|
26
|
+
*
|
|
27
|
+
* Abstract on purpose: callers should throw a specific subclass (e.g.
|
|
28
|
+
* {@link ValidationError}, {@link NotFoundError}) so the `code` and shape are
|
|
29
|
+
* meaningful, never a bare `AppError`.
|
|
30
|
+
*/
|
|
15
31
|
abstract class AppError extends Error {
|
|
16
32
|
public readonly code: string;
|
|
17
33
|
public readonly cause?: unknown;
|
|
@@ -36,6 +52,23 @@ abstract class AppError extends Error {
|
|
|
36
52
|
*/
|
|
37
53
|
public readonly commandId?: string;
|
|
38
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Builds the common error envelope shared by every subclass.
|
|
57
|
+
*
|
|
58
|
+
* `name` is taken from `new.target` so the thrown instance reports its
|
|
59
|
+
* concrete subclass name (not `"AppError"`), and the prototype is re-pinned
|
|
60
|
+
* via `setPrototypeOf` so `instanceof` keeps working after transpilation to
|
|
61
|
+
* older targets where extending built-ins breaks the chain. `createdAtIso`
|
|
62
|
+
* defaults to now, and `metadata` is validated as JSON-safe so the error is
|
|
63
|
+
* always serializable.
|
|
64
|
+
*
|
|
65
|
+
* Declared `protected`: instantiate a concrete subclass, never `AppError`.
|
|
66
|
+
*
|
|
67
|
+
* @param message - The internal, developer-facing message.
|
|
68
|
+
* @param code - The stable machine-readable code callers match on.
|
|
69
|
+
* @param options - Optional cause, metadata, severity, and correlation ids.
|
|
70
|
+
* @throws When `options.metadata` contains a value that is not JSON-safe.
|
|
71
|
+
*/
|
|
39
72
|
protected constructor(
|
|
40
73
|
message: string,
|
|
41
74
|
code: string,
|
|
@@ -61,6 +61,19 @@ type AuthorizationErrorFactoryOptions = Omit<
|
|
|
61
61
|
// AuthorizationError
|
|
62
62
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Raised when an authenticated actor is not allowed to perform a business
|
|
66
|
+
* action — the "you may not" error, distinct from authentication ("who are
|
|
67
|
+
* you"). Maps to an HTTP 403 at the edge.
|
|
68
|
+
*
|
|
69
|
+
* The discriminating {@link reason} records *why* access was denied (a flat
|
|
70
|
+
* forbid, a missing role/capability, an out-of-scope target, or a policy
|
|
71
|
+
* decision) so the boundary can shape the response without re-deriving it. The
|
|
72
|
+
* metadata is deliberately built around a stable business `action` and a
|
|
73
|
+
* type/id resource reference rather than an HTTP route or full payload, keeping
|
|
74
|
+
* sensitive data out of logs. Instances are frozen. Construct through the
|
|
75
|
+
* static factories, never directly.
|
|
76
|
+
*/
|
|
64
77
|
class AuthorizationError extends AppError {
|
|
65
78
|
public readonly reason: AuthorizationErrorReason;
|
|
66
79
|
|
|
@@ -98,6 +111,12 @@ class AuthorizationError extends AppError {
|
|
|
98
111
|
// Factories
|
|
99
112
|
// ─────────────────────────────────────────────────────────────────────────
|
|
100
113
|
|
|
114
|
+
/**
|
|
115
|
+
* A flat denial with no more specific reason — the actor simply may not
|
|
116
|
+
* perform this action. Reach for a more precise factory
|
|
117
|
+
* ({@link AuthorizationError.missingCapability}, {@link AuthorizationError.outOfScope},
|
|
118
|
+
* {@link AuthorizationError.policyDenied}) when the cause is known.
|
|
119
|
+
*/
|
|
101
120
|
static forbidden(
|
|
102
121
|
input: AuthorizationErrorFactoryOptions,
|
|
103
122
|
): AuthorizationError {
|
|
@@ -110,6 +129,15 @@ class AuthorizationError extends AppError {
|
|
|
110
129
|
});
|
|
111
130
|
}
|
|
112
131
|
|
|
132
|
+
/**
|
|
133
|
+
* The action was denied by an evaluated policy. Captures the deciding
|
|
134
|
+
* policy's id, version, and evaluation instant into the metadata (with
|
|
135
|
+
* `decision: "deny"`) so the denial is auditable back to the exact policy
|
|
136
|
+
* that produced it.
|
|
137
|
+
*
|
|
138
|
+
* @param input - Factory options plus the required `policyId`,
|
|
139
|
+
* `policyVersion`, and `evaluatedAtIso` of the deciding policy.
|
|
140
|
+
*/
|
|
113
141
|
static policyDenied(
|
|
114
142
|
input: AuthorizationErrorFactoryOptions & {
|
|
115
143
|
policyId: string;
|
|
@@ -130,6 +158,13 @@ class AuthorizationError extends AppError {
|
|
|
130
158
|
});
|
|
131
159
|
}
|
|
132
160
|
|
|
161
|
+
/**
|
|
162
|
+
* The actor lacks a required role or capability. The expected
|
|
163
|
+
* {@link AuthorizationRequirement} is recorded so the boundary can tell the
|
|
164
|
+
* caller precisely what grant is missing.
|
|
165
|
+
*
|
|
166
|
+
* @param input - Factory options plus the `required` role/capability/scope.
|
|
167
|
+
*/
|
|
133
168
|
static missingCapability(
|
|
134
169
|
input: AuthorizationErrorFactoryOptions & {
|
|
135
170
|
required: AuthorizationRequirement;
|
|
@@ -147,6 +182,14 @@ class AuthorizationError extends AppError {
|
|
|
147
182
|
});
|
|
148
183
|
}
|
|
149
184
|
|
|
185
|
+
/**
|
|
186
|
+
* The actor may perform the action in general, but not on *this* target —
|
|
187
|
+
* the resource falls outside the actor's permitted scope (e.g. a different
|
|
188
|
+
* school or tenant than the one they are bound to).
|
|
189
|
+
*
|
|
190
|
+
* @param input - Factory options plus the optional `required` scope that the
|
|
191
|
+
* target failed to satisfy.
|
|
192
|
+
*/
|
|
150
193
|
static outOfScope(
|
|
151
194
|
input: AuthorizationErrorFactoryOptions & {
|
|
152
195
|
required?: AuthorizationRequirement;
|
|
@@ -26,6 +26,11 @@ type ConflictErrorMetadata = {
|
|
|
26
26
|
hint?: string;
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Storage-level description of a unique-constraint breach, as reported by a
|
|
31
|
+
* database driver. Used to translate a raw DB error into a domain
|
|
32
|
+
* {@link DuplicateError} via {@link translateUniqueViolationToDuplicate}.
|
|
33
|
+
*/
|
|
29
34
|
type UniqueConstraintViolation = {
|
|
30
35
|
kind: "unique_violation";
|
|
31
36
|
constraintName?: string;
|
|
@@ -33,6 +38,17 @@ type UniqueConstraintViolation = {
|
|
|
33
38
|
columns?: string[];
|
|
34
39
|
};
|
|
35
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Base for errors that signal a state conflict — the request collides with data
|
|
43
|
+
* that already exists. Common at an HTTP 409 boundary.
|
|
44
|
+
*
|
|
45
|
+
* The discriminating {@link kind} lets a handler branch on *why* it conflicted
|
|
46
|
+
* (an already-existing record, a domain duplicate, or a raw storage uniqueness
|
|
47
|
+
* breach) without `instanceof` chains. Abstract: construct one of the concrete
|
|
48
|
+
* subclasses through its `detected(...)` factory, which fills in the right code,
|
|
49
|
+
* message, and metadata. Instances are frozen, so a conflict error can be shared
|
|
50
|
+
* and rethrown without risk of tampering.
|
|
51
|
+
*/
|
|
36
52
|
abstract class ConflictError extends AppError {
|
|
37
53
|
public readonly kind: ConflictKind;
|
|
38
54
|
|
|
@@ -58,6 +74,11 @@ abstract class ConflictError extends AppError {
|
|
|
58
74
|
}
|
|
59
75
|
}
|
|
60
76
|
|
|
77
|
+
/**
|
|
78
|
+
* The operation cannot proceed because a matching record already exists —
|
|
79
|
+
* e.g. creating something whose natural key is already taken. Construct via
|
|
80
|
+
* {@link AlreadyExistsError.detected}.
|
|
81
|
+
*/
|
|
61
82
|
class AlreadyExistsError extends ConflictError {
|
|
62
83
|
private constructor(
|
|
63
84
|
input: {
|
|
@@ -74,6 +95,14 @@ class AlreadyExistsError extends ConflictError {
|
|
|
74
95
|
});
|
|
75
96
|
}
|
|
76
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Builds an {@link AlreadyExistsError} for a detected collision. `operation`
|
|
100
|
+
* defaults to `"create"` and a generic retry `hint` is supplied when none is
|
|
101
|
+
* given, so the error is actionable even from a minimal call site.
|
|
102
|
+
*
|
|
103
|
+
* @param input - The conflicting entity plus optional non-sensitive context
|
|
104
|
+
* (operation, field, existing id, value hash/preview, correlation ids).
|
|
105
|
+
*/
|
|
77
106
|
static detected(input: {
|
|
78
107
|
entity: string;
|
|
79
108
|
operation?: string;
|
|
@@ -107,6 +136,13 @@ class AlreadyExistsError extends ConflictError {
|
|
|
107
136
|
}
|
|
108
137
|
}
|
|
109
138
|
|
|
139
|
+
/**
|
|
140
|
+
* A domain-level duplicate: the data being written would duplicate an existing
|
|
141
|
+
* record under the model's own uniqueness rules. Use this when the duplication
|
|
142
|
+
* is recognized in the domain, as opposed to a raw DB constraint
|
|
143
|
+
* ({@link UniqueConstraintViolationError}). Construct via
|
|
144
|
+
* {@link DuplicateError.detected}.
|
|
145
|
+
*/
|
|
110
146
|
class DuplicateError extends ConflictError {
|
|
111
147
|
private constructor(
|
|
112
148
|
input: {
|
|
@@ -122,6 +158,13 @@ class DuplicateError extends ConflictError {
|
|
|
122
158
|
});
|
|
123
159
|
}
|
|
124
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Builds a {@link DuplicateError} for a detected duplicate, defaulting to a
|
|
163
|
+
* "adjust the data so it does not duplicate" hint when none is provided.
|
|
164
|
+
*
|
|
165
|
+
* @param input - The conflicting entity plus optional non-sensitive context
|
|
166
|
+
* (field, constraint name, existing id, value hash/preview, correlation ids).
|
|
167
|
+
*/
|
|
125
168
|
static detected(input: {
|
|
126
169
|
entity: string;
|
|
127
170
|
operation?: string;
|
|
@@ -159,6 +202,14 @@ class DuplicateError extends ConflictError {
|
|
|
159
202
|
}
|
|
160
203
|
}
|
|
161
204
|
|
|
205
|
+
/**
|
|
206
|
+
* A uniqueness breach surfaced by the storage layer itself (a database unique
|
|
207
|
+
* index), carrying the raw {@link UniqueConstraintViolation} (constraint, table,
|
|
208
|
+
* columns). Keep this distinct from {@link DuplicateError}: this one is the
|
|
209
|
+
* infrastructure signal; translate it into a domain duplicate at the boundary
|
|
210
|
+
* with {@link translateUniqueViolationToDuplicate} when the model should own the
|
|
211
|
+
* message. Construct via {@link UniqueConstraintViolationError.detected}.
|
|
212
|
+
*/
|
|
162
213
|
class UniqueConstraintViolationError extends ConflictError {
|
|
163
214
|
private constructor(
|
|
164
215
|
input: {
|
|
@@ -176,6 +227,13 @@ class UniqueConstraintViolationError extends ConflictError {
|
|
|
176
227
|
});
|
|
177
228
|
}
|
|
178
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Builds a {@link UniqueConstraintViolationError} from the constraint details
|
|
232
|
+
* a driver reports, packaging them into a nested `violation` payload.
|
|
233
|
+
*
|
|
234
|
+
* @param input - The entity plus the offending constraint name, table, and
|
|
235
|
+
* columns, and optional correlation ids.
|
|
236
|
+
*/
|
|
179
237
|
static detected(input: {
|
|
180
238
|
entity: string;
|
|
181
239
|
operation?: string;
|
|
@@ -210,6 +268,17 @@ class UniqueConstraintViolationError extends ConflictError {
|
|
|
210
268
|
}
|
|
211
269
|
}
|
|
212
270
|
|
|
271
|
+
/**
|
|
272
|
+
* Translates a raw storage {@link UniqueConstraintViolation} into a domain-level
|
|
273
|
+
* {@link DuplicateError}. This is the seam where an infrastructure concern (a DB
|
|
274
|
+
* unique index firing) is re-expressed in the language of the model, so callers
|
|
275
|
+
* upstream catch a `DuplicateError` and never have to know a database was
|
|
276
|
+
* involved.
|
|
277
|
+
*
|
|
278
|
+
* @param input - The entity, the driver-reported `violation`, and optional
|
|
279
|
+
* request context (`ctx`) carrying correlation ids and a clock instant.
|
|
280
|
+
* @returns A {@link DuplicateError} carrying the constraint name as its field hint.
|
|
281
|
+
*/
|
|
213
282
|
function translateUniqueViolationToDuplicate(input: {
|
|
214
283
|
entity: string;
|
|
215
284
|
operation?: string;
|
|
@@ -42,6 +42,18 @@ type IntegrationErrorConstructorParams = {
|
|
|
42
42
|
metadata: IntegrationErrorMetadata;
|
|
43
43
|
} & IntegrationErrorAppOptions;
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Raised when a call to an external provider fails — a timeout, an unreachable
|
|
47
|
+
* endpoint, or a malformed response. This is the boundary error that separates
|
|
48
|
+
* "our code is fine, the outside world misbehaved" from internal faults, which
|
|
49
|
+
* matters for retry and alerting decisions.
|
|
50
|
+
*
|
|
51
|
+
* Every instance records the `provider`, the `operation`, and timing
|
|
52
|
+
* (`startedAtIso` / `durationMs`) so failures are correlatable across services
|
|
53
|
+
* and a slow dependency is visible in the metadata. The discriminating
|
|
54
|
+
* {@link reason} lets callers decide whether a failure is worth retrying.
|
|
55
|
+
* Instances are frozen; construct through the static factories.
|
|
56
|
+
*/
|
|
45
57
|
class IntegrationError extends AppError {
|
|
46
58
|
public readonly reason: IntegrationErrorReason;
|
|
47
59
|
|
|
@@ -56,6 +68,10 @@ class IntegrationError extends AppError {
|
|
|
56
68
|
Object.freeze(this);
|
|
57
69
|
}
|
|
58
70
|
|
|
71
|
+
/**
|
|
72
|
+
* The provider did not respond within the allotted time. Usually retryable,
|
|
73
|
+
* since a timeout leaves the outcome unknown rather than known-failed.
|
|
74
|
+
*/
|
|
59
75
|
static timeout(options: IntegrationErrorTimeoutOptions): IntegrationError {
|
|
60
76
|
return new IntegrationError({
|
|
61
77
|
message: "Timeout while integrating with external provider",
|
|
@@ -66,6 +82,10 @@ class IntegrationError extends AppError {
|
|
|
66
82
|
});
|
|
67
83
|
}
|
|
68
84
|
|
|
85
|
+
/**
|
|
86
|
+
* The provider could not be reached at all (connection refused, DNS
|
|
87
|
+
* failure, network partition) — the request never landed.
|
|
88
|
+
*/
|
|
69
89
|
static unreachable(
|
|
70
90
|
options: IntegrationErrorEndpointOptions,
|
|
71
91
|
): IntegrationError {
|
|
@@ -78,6 +98,11 @@ class IntegrationError extends AppError {
|
|
|
78
98
|
});
|
|
79
99
|
}
|
|
80
100
|
|
|
101
|
+
/**
|
|
102
|
+
* The provider answered, but with something the system cannot use — an
|
|
103
|
+
* unexpected status, an unparsable body, or a contract mismatch. Unlike a
|
|
104
|
+
* timeout this is a definite failure, so blind retries rarely help.
|
|
105
|
+
*/
|
|
81
106
|
static badResponse(
|
|
82
107
|
options: IntegrationErrorEndpointOptions,
|
|
83
108
|
): IntegrationError {
|
|
@@ -6,7 +6,21 @@ import type { AppErrorOptions } from "./types.js";
|
|
|
6
6
|
// NotFoundError
|
|
7
7
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Raised when a requested resource does not exist. Maps naturally onto an HTTP
|
|
11
|
+
* 404 at the edge, but stays transport-agnostic here.
|
|
12
|
+
*
|
|
13
|
+
* The `resource` name builds the message (`"<resource> not found"`) and, with
|
|
14
|
+
* the optional lookup `criteria`, lands in the metadata so logs show *what* was
|
|
15
|
+
* searched for without the caller having to restate it in the message.
|
|
16
|
+
*/
|
|
9
17
|
class NotFoundError extends AppError {
|
|
18
|
+
/**
|
|
19
|
+
* @param resource - Human-readable name of the missing resource (e.g. `"Order"`).
|
|
20
|
+
* @param criteria - Optional lookup keys used in the search; recorded in
|
|
21
|
+
* metadata to aid debugging. Avoid putting sensitive values here.
|
|
22
|
+
* @param options - Optional cause, correlation ids, and extra metadata.
|
|
23
|
+
*/
|
|
10
24
|
constructor(
|
|
11
25
|
resource: string,
|
|
12
26
|
criteria?: Record<string, unknown>,
|
|
@@ -9,7 +9,25 @@ import type { AppErrorOptions } from "./types.js";
|
|
|
9
9
|
// ValidationError
|
|
10
10
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Raised when a single input fails a validation rule — the canonical
|
|
14
|
+
* "this field is wrong, and here is why" error.
|
|
15
|
+
*
|
|
16
|
+
* It pins the offending `field` and a machine-readable `validationCode` into
|
|
17
|
+
* the metadata (merged ahead of any caller-supplied metadata) so an API layer
|
|
18
|
+
* can map the failure straight onto a form field without parsing the message.
|
|
19
|
+
* The fixed {@link ErrorCodes.validation} code lets callers catch all
|
|
20
|
+
* validation failures uniformly while still distinguishing the specific rule
|
|
21
|
+
* through `validationCode`.
|
|
22
|
+
*/
|
|
12
23
|
class ValidationError extends AppError {
|
|
24
|
+
/**
|
|
25
|
+
* @param field - The input that failed validation; surfaced as `metadata.field`.
|
|
26
|
+
* @param code - The specific rule that was violated; surfaced as `metadata.validationCode`.
|
|
27
|
+
* @param message - The developer-facing description of the failure.
|
|
28
|
+
* @param options - Optional cause, correlation ids, and extra metadata
|
|
29
|
+
* (merged over the field/code metadata, never overwriting it by accident).
|
|
30
|
+
*/
|
|
13
31
|
constructor(
|
|
14
32
|
field: ValidationField,
|
|
15
33
|
code: ValidationCode,
|
|
@@ -14,6 +14,15 @@ export class PolicyCatalog {
|
|
|
14
14
|
readonly PolicyCatalogEntry[]
|
|
15
15
|
>;
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Indexes the given entries up front into two lookups — one per exact
|
|
19
|
+
* variant, one per policy key (the "family") — and validates them eagerly:
|
|
20
|
+
* a duplicate variant or an internally inconsistent family throws here, at
|
|
21
|
+
* wiring time, so a malformed catalog fails fast at startup rather than on
|
|
22
|
+
* the first evaluation.
|
|
23
|
+
*
|
|
24
|
+
* @throws {UnexpectedError} When two entries resolve to the same variant key.
|
|
25
|
+
*/
|
|
17
26
|
constructor(entries: readonly PolicyCatalogEntry[]) {
|
|
18
27
|
const families = new Map<string, PolicyCatalogEntry[]>();
|
|
19
28
|
const versionedMap = new Map<string, PolicyCatalogEntry>();
|
|
@@ -43,6 +52,14 @@ export class PolicyCatalog {
|
|
|
43
52
|
this.entriesByKey = families;
|
|
44
53
|
}
|
|
45
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Returns the single entry for a key. Deliberately strict: if the key has
|
|
57
|
+
* more than one variant it errs rather than guessing, steering the caller to
|
|
58
|
+
* {@link getVersioned} or {@link getFamily}. Use this only when a key is
|
|
59
|
+
* known to be unambiguous.
|
|
60
|
+
*
|
|
61
|
+
* @returns `ok` with the entry, or `err` when the key is unknown or ambiguous.
|
|
62
|
+
*/
|
|
46
63
|
get(key: string): Result<PolicyCatalogEntry, string> {
|
|
47
64
|
const familyResult = this.getFamily(key);
|
|
48
65
|
if (familyResult.isErr()) {
|
|
@@ -64,6 +81,13 @@ export class PolicyCatalog {
|
|
|
64
81
|
return Result.ok(entry);
|
|
65
82
|
}
|
|
66
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Returns every variant registered under a key — the whole "family". This is
|
|
86
|
+
* the lookup the evaluation pipeline uses, since a single key may host
|
|
87
|
+
* several engine/schema variants that the resolver then chooses between.
|
|
88
|
+
*
|
|
89
|
+
* @returns `ok` with the family (always non-empty), or `err` when the key is unknown.
|
|
90
|
+
*/
|
|
67
91
|
getFamily(key: string): Result<readonly PolicyCatalogEntry[], string> {
|
|
68
92
|
const family = this.entriesByKey.get(key);
|
|
69
93
|
if (!family) {
|
|
@@ -73,6 +97,16 @@ export class PolicyCatalog {
|
|
|
73
97
|
return Result.ok(family);
|
|
74
98
|
}
|
|
75
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Resolves the one entry matching an exact variant — kind plus engine and
|
|
102
|
+
* payload-schema versions. Falls back to the sole family member when a key
|
|
103
|
+
* has exactly one entry and that entry declares no explicit version
|
|
104
|
+
* selector, so unversioned single-variant policies "just work" without the
|
|
105
|
+
* caller spelling out versions.
|
|
106
|
+
*
|
|
107
|
+
* @param params - The key and the variant coordinates to match on.
|
|
108
|
+
* @returns `ok` with the matching entry, or `err` when no variant matches.
|
|
109
|
+
*/
|
|
76
110
|
getVersioned(params: {
|
|
77
111
|
readonly key: string;
|
|
78
112
|
readonly kind: "GATE" | "COMPUTE";
|
|
@@ -114,10 +148,12 @@ export class PolicyCatalog {
|
|
|
114
148
|
);
|
|
115
149
|
}
|
|
116
150
|
|
|
151
|
+
/** Lists every registered variant across all keys — useful for introspection and diagnostics. */
|
|
117
152
|
list(): readonly PolicyCatalogEntry[] {
|
|
118
153
|
return Array.from(this.versionedEntries.values());
|
|
119
154
|
}
|
|
120
155
|
|
|
156
|
+
/** Returns whether any variant is registered under the given key. */
|
|
121
157
|
has(key: string): boolean {
|
|
122
158
|
return this.entriesByKey.has(key);
|
|
123
159
|
}
|
|
@@ -14,6 +14,16 @@ import { Result } from "../../result/result.js";
|
|
|
14
14
|
* 5. policyVersion descending (semver — final deterministic tiebreaker)
|
|
15
15
|
*/
|
|
16
16
|
export class PolicyResolver {
|
|
17
|
+
/**
|
|
18
|
+
* Picks the single winning definition from an already-filtered candidate
|
|
19
|
+
* list by applying the class's ordering criteria in priority order. The sort
|
|
20
|
+
* runs over a copy, so the caller's array is left untouched, and the final
|
|
21
|
+
* semver tiebreaker guarantees the winner is fully deterministic — it never
|
|
22
|
+
* depends on the order the repository returned the candidates in.
|
|
23
|
+
*
|
|
24
|
+
* @param candidates - Definitions already filtered to match key, scope, and as-of.
|
|
25
|
+
* @returns `ok` with the highest-ranked definition, or `err` when the list is empty.
|
|
26
|
+
*/
|
|
17
27
|
resolveBest(
|
|
18
28
|
candidates: readonly PolicyDefinition[],
|
|
19
29
|
): Result<PolicyDefinition, string> {
|
|
@@ -36,6 +36,13 @@ import {
|
|
|
36
36
|
|
|
37
37
|
// ─── Input ──────────────────────────────────────────────────────────────────
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Everything a single policy evaluation needs. `policyKey` names the policy,
|
|
41
|
+
* `scopeChain` narrows the search from most-specific to least-specific scope,
|
|
42
|
+
* `contextVersion` pins which context schema applies, and `seed` carries the
|
|
43
|
+
* raw facts the context builder expands. `decisionId` is the caller's
|
|
44
|
+
* idempotency/audit handle, echoed back on the result.
|
|
45
|
+
*/
|
|
39
46
|
export interface EvaluateInput {
|
|
40
47
|
readonly decisionId: PolicyDecisionId;
|
|
41
48
|
readonly policyKey: string;
|
|
@@ -44,11 +51,23 @@ export interface EvaluateInput {
|
|
|
44
51
|
readonly seed: ContextSeed;
|
|
45
52
|
}
|
|
46
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Cross-cutting knobs for a {@link PolicyService}: how the as-of instant is
|
|
56
|
+
* derived ({@link asOf}) and where evaluation telemetry is sent
|
|
57
|
+
* ({@link reporter}). Both are optional; the service defaults to a silent
|
|
58
|
+
* reporter so telemetry is opt-in.
|
|
59
|
+
*/
|
|
47
60
|
export interface PolicyServiceOptions {
|
|
48
61
|
readonly asOf?: DeriveAsOfOptions;
|
|
49
62
|
readonly reporter?: PolicyReporter;
|
|
50
63
|
}
|
|
51
64
|
|
|
65
|
+
/**
|
|
66
|
+
* The collaborators a {@link PolicyService} is wired with. Each is an injected
|
|
67
|
+
* port so the service stays storage- and engine-agnostic: the catalog defines
|
|
68
|
+
* the universe of policies, `defRepo` supplies their versioned definitions, the
|
|
69
|
+
* `resolver` picks the winning definition, and the engine registries execute it.
|
|
70
|
+
*/
|
|
52
71
|
export interface PolicyServiceParams {
|
|
53
72
|
readonly catalog: PolicyCatalog;
|
|
54
73
|
readonly contextBuilder: PolicyContextBuilder;
|
|
@@ -66,6 +85,13 @@ export type PolicyDecision = GateOutcome | ComputeOutcome;
|
|
|
66
85
|
|
|
67
86
|
export type { PolicyEvaluationError } from "./policy-evaluation-error.js";
|
|
68
87
|
|
|
88
|
+
/**
|
|
89
|
+
* The full record of one successful evaluation. Beyond the business
|
|
90
|
+
* {@link decision}, it pins the exact definition that produced it — id, key,
|
|
91
|
+
* version, payload hash, and engine version — together with the `asOf` instant
|
|
92
|
+
* the policy was selected for and the wall-clock `evaluatedAt`. That provenance
|
|
93
|
+
* is what makes a decision reproducible and auditable after the fact.
|
|
94
|
+
*/
|
|
69
95
|
export interface PolicyEvaluationResult {
|
|
70
96
|
readonly decisionId: PolicyDecisionId;
|
|
71
97
|
readonly ref: {
|
|
@@ -91,6 +117,18 @@ interface ResolvedPolicyCandidate {
|
|
|
91
117
|
|
|
92
118
|
// ─── Service ────────────────────────────────────────────────────────────────
|
|
93
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Orchestrates the end-to-end evaluation of a policy: it parses and validates
|
|
122
|
+
* the context seed, derives the as-of instant, resolves the best matching
|
|
123
|
+
* definition for the requested key/scope/version, builds the context the policy
|
|
124
|
+
* needs, and runs it through the right engine (gate or compute).
|
|
125
|
+
*
|
|
126
|
+
* The service never throws for an expected failure: every step returns a
|
|
127
|
+
* {@link Result}, and the failures are folded into a typed
|
|
128
|
+
* {@link PolicyEvaluationError} so callers branch on `isErr()` rather than
|
|
129
|
+
* catching. Telemetry is best-effort — a throwing reporter can never abort an
|
|
130
|
+
* evaluation — which keeps observability strictly side-band.
|
|
131
|
+
*/
|
|
94
132
|
export class PolicyService {
|
|
95
133
|
readonly #reporter: PolicyReporter;
|
|
96
134
|
private readonly catalog: PolicyCatalog;
|
|
@@ -136,6 +174,21 @@ export class PolicyService {
|
|
|
136
174
|
return Result.ok(candidate);
|
|
137
175
|
}
|
|
138
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Evaluates a policy and returns its decision.
|
|
179
|
+
*
|
|
180
|
+
* This is the single public entry point. It delegates the actual work to the
|
|
181
|
+
* private pipeline and wraps it with telemetry: a completed or failed event
|
|
182
|
+
* is reported either way, but reporting is guarded so it can never change the
|
|
183
|
+
* outcome. The returned {@link Result} is `ok` with a
|
|
184
|
+
* {@link PolicyEvaluationResult} on success, or `err` with a typed
|
|
185
|
+
* {@link PolicyEvaluationError} describing which stage rejected the input —
|
|
186
|
+
* the method itself does not throw for expected failures.
|
|
187
|
+
*
|
|
188
|
+
* @param input - The policy key, scope chain, context version, and seed to
|
|
189
|
+
* evaluate, plus the caller's `decisionId`.
|
|
190
|
+
* @returns A `Result` carrying the decision or the evaluation error.
|
|
191
|
+
*/
|
|
139
192
|
async evaluate(
|
|
140
193
|
input: EvaluateInput,
|
|
141
194
|
): Promise<Result<PolicyEvaluationResult, PolicyEvaluationError>> {
|
package/src/version.ts
CHANGED
|
@@ -4,4 +4,4 @@
|
|
|
4
4
|
// Mantemos a versao aqui, dentro de src/, para que a copia full-control seja
|
|
5
5
|
// auto-contida: ao copiar so o conteudo de src/, o entry nao depende de um
|
|
6
6
|
// `../package.json` que deixaria de existir no projeto consumidor.
|
|
7
|
-
export const version = "1.0.
|
|
7
|
+
export const version = "1.0.11";
|