@stigmer/server 3.15.2-dev.20260913223433 → 3.15.3-dev.20260914100347
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/boot/compose.d.ts.map +1 -1
- package/dist/boot/compose.js +103 -9
- package/dist/boot/compose.js.map +1 -1
- package/dist/domain/iampolicy/access-lists.d.ts +27 -0
- package/dist/domain/iampolicy/access-lists.d.ts.map +1 -0
- package/dist/domain/iampolicy/access-lists.js +124 -0
- package/dist/domain/iampolicy/access-lists.js.map +1 -0
- package/dist/domain/iampolicy/constants.d.ts +135 -0
- package/dist/domain/iampolicy/constants.d.ts.map +1 -0
- package/dist/domain/iampolicy/constants.js +203 -0
- package/dist/domain/iampolicy/constants.js.map +1 -0
- package/dist/domain/iampolicy/controller.d.ts +29 -0
- package/dist/domain/iampolicy/controller.d.ts.map +1 -0
- package/dist/domain/iampolicy/controller.js +451 -0
- package/dist/domain/iampolicy/controller.js.map +1 -0
- package/dist/domain/iampolicy/display-resolver.d.ts +6 -0
- package/dist/domain/iampolicy/display-resolver.d.ts.map +1 -0
- package/dist/domain/iampolicy/display-resolver.js +90 -0
- package/dist/domain/iampolicy/display-resolver.js.map +1 -0
- package/dist/domain/iampolicy/grant-path.d.ts +42 -0
- package/dist/domain/iampolicy/grant-path.d.ts.map +1 -0
- package/dist/domain/iampolicy/grant-path.js +183 -0
- package/dist/domain/iampolicy/grant-path.js.map +1 -0
- package/dist/domain/iampolicy/grant-scope.d.ts +4 -0
- package/dist/domain/iampolicy/grant-scope.d.ts.map +1 -0
- package/dist/domain/iampolicy/grant-scope.js +34 -0
- package/dist/domain/iampolicy/grant-scope.js.map +1 -0
- package/dist/domain/iampolicy/membership.d.ts +111 -0
- package/dist/domain/iampolicy/membership.d.ts.map +1 -0
- package/dist/domain/iampolicy/membership.js +152 -0
- package/dist/domain/iampolicy/membership.js.map +1 -0
- package/dist/domain/iampolicy/permissions.d.ts +19 -0
- package/dist/domain/iampolicy/permissions.d.ts.map +1 -0
- package/dist/domain/iampolicy/permissions.js +27 -0
- package/dist/domain/iampolicy/permissions.js.map +1 -0
- package/dist/domain/iampolicy/resource-store.d.ts +4 -0
- package/dist/domain/iampolicy/resource-store.d.ts.map +1 -0
- package/dist/domain/iampolicy/resource-store.js +135 -0
- package/dist/domain/iampolicy/resource-store.js.map +1 -0
- package/dist/domain/iampolicy/role-lifecycle.d.ts +9 -0
- package/dist/domain/iampolicy/role-lifecycle.d.ts.map +1 -0
- package/dist/domain/iampolicy/role-lifecycle.js +106 -0
- package/dist/domain/iampolicy/role-lifecycle.js.map +1 -0
- package/dist/domain/iampolicy/roles.d.ts +11 -0
- package/dist/domain/iampolicy/roles.d.ts.map +1 -0
- package/dist/domain/iampolicy/roles.js +78 -0
- package/dist/domain/iampolicy/roles.js.map +1 -0
- package/dist/domain/iampolicy/specs.d.ts +7 -0
- package/dist/domain/iampolicy/specs.d.ts.map +1 -0
- package/dist/domain/iampolicy/specs.js +45 -0
- package/dist/domain/iampolicy/specs.js.map +1 -0
- package/dist/domain/iampolicy/steps.d.ts +18 -0
- package/dist/domain/iampolicy/steps.d.ts.map +1 -0
- package/dist/domain/iampolicy/steps.js +117 -0
- package/dist/domain/iampolicy/steps.js.map +1 -0
- package/dist/domain/iampolicy/store-contract.d.ts +8 -0
- package/dist/domain/iampolicy/store-contract.d.ts.map +1 -0
- package/dist/domain/iampolicy/store-contract.js +258 -0
- package/dist/domain/iampolicy/store-contract.js.map +1 -0
- package/dist/domain/iampolicy/store.d.ts +78 -0
- package/dist/domain/iampolicy/store.d.ts.map +1 -0
- package/dist/domain/iampolicy/store.js +13 -0
- package/dist/domain/iampolicy/store.js.map +1 -0
- package/dist/domain/iampolicy/wire-refusals.d.ts +25 -0
- package/dist/domain/iampolicy/wire-refusals.d.ts.map +1 -0
- package/dist/domain/iampolicy/wire-refusals.js +76 -0
- package/dist/domain/iampolicy/wire-refusals.js.map +1 -0
- package/dist/domain/identityaccount/constants.d.ts +33 -0
- package/dist/domain/identityaccount/constants.d.ts.map +1 -1
- package/dist/domain/identityaccount/constants.js +2 -48
- package/dist/domain/identityaccount/constants.js.map +1 -1
- package/dist/domain/identityaccount/controller.d.ts +8 -1
- package/dist/domain/identityaccount/controller.d.ts.map +1 -1
- package/dist/domain/identityaccount/controller.js +54 -29
- package/dist/domain/identityaccount/controller.js.map +1 -1
- package/dist/domain/identityaccount/operator.d.ts.map +1 -1
- package/dist/domain/identityaccount/operator.js +2 -4
- package/dist/domain/identityaccount/operator.js.map +1 -1
- package/dist/domain/identityaccount/provisioning.d.ts +20 -1
- package/dist/domain/identityaccount/provisioning.d.ts.map +1 -1
- package/dist/domain/identityaccount/provisioning.js +17 -3
- package/dist/domain/identityaccount/provisioning.js.map +1 -1
- package/dist/domain/identityaccount/resolve.d.ts +23 -0
- package/dist/domain/identityaccount/resolve.d.ts.map +1 -1
- package/dist/domain/identityaccount/resolve.js +17 -0
- package/dist/domain/identityaccount/resolve.js.map +1 -1
- package/dist/domain/identityaccount/store-contract.d.ts +4 -18
- package/dist/domain/identityaccount/store-contract.d.ts.map +1 -1
- package/dist/domain/identityaccount/store-contract.js +12 -47
- package/dist/domain/identityaccount/store-contract.js.map +1 -1
- package/dist/extensions/authorization-queries.d.ts +81 -0
- package/dist/extensions/authorization-queries.d.ts.map +1 -0
- package/dist/extensions/authorization-queries.js +2 -0
- package/dist/extensions/authorization-queries.js.map +1 -0
- package/dist/extensions/drivers.d.ts +49 -2
- package/dist/extensions/drivers.d.ts.map +1 -1
- package/dist/extensions/identity.d.ts +14 -0
- package/dist/extensions/identity.d.ts.map +1 -1
- package/dist/extensions/identity.js +18 -1
- package/dist/extensions/identity.js.map +1 -1
- package/dist/extensions/policy-grant-scope.d.ts +50 -0
- package/dist/extensions/policy-grant-scope.d.ts.map +1 -0
- package/dist/extensions/policy-grant-scope.js +2 -0
- package/dist/extensions/policy-grant-scope.js.map +1 -0
- package/dist/extensions/registry.d.ts +23 -1
- package/dist/extensions/registry.d.ts.map +1 -1
- package/dist/extensions/registry.js +30 -0
- package/dist/extensions/registry.js.map +1 -1
- package/dist/extensions/resource-authorization.d.ts +63 -0
- package/dist/extensions/resource-authorization.d.ts.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/pipeline/apiresource-meta.d.ts +56 -1
- package/dist/pipeline/apiresource-meta.d.ts.map +1 -1
- package/dist/pipeline/apiresource-meta.js +155 -1
- package/dist/pipeline/apiresource-meta.js.map +1 -1
- package/dist/pipeline/interceptors/auth.d.ts +12 -0
- package/dist/pipeline/interceptors/auth.d.ts.map +1 -1
- package/dist/pipeline/interceptors/auth.js +13 -1
- package/dist/pipeline/interceptors/auth.js.map +1 -1
- package/dist/pipeline/steps/authorize.d.ts +20 -5
- package/dist/pipeline/steps/authorize.d.ts.map +1 -1
- package/dist/pipeline/steps/authorize.js +37 -17
- package/dist/pipeline/steps/authorize.js.map +1 -1
- package/dist/pipeline/steps/defaults.d.ts +10 -0
- package/dist/pipeline/steps/defaults.d.ts.map +1 -1
- package/dist/pipeline/steps/defaults.js +39 -0
- package/dist/pipeline/steps/defaults.js.map +1 -1
- package/dist/store/port-contract.d.ts +61 -0
- package/dist/store/port-contract.d.ts.map +1 -0
- package/dist/store/port-contract.js +67 -0
- package/dist/store/port-contract.js.map +1 -0
- package/package.json +4 -4
- package/src/boot/compose.ts +107 -10
- package/src/domain/iampolicy/__tests__/access-lists.test.ts +238 -0
- package/src/domain/iampolicy/__tests__/constants.test.ts +388 -0
- package/src/domain/iampolicy/__tests__/controller.test.ts +960 -0
- package/src/domain/iampolicy/__tests__/display-resolver.test.ts +138 -0
- package/src/domain/iampolicy/__tests__/grant-path.test.ts +474 -0
- package/src/domain/iampolicy/__tests__/grant-scope.test.ts +61 -0
- package/src/domain/iampolicy/__tests__/iampolicy.test.ts +532 -0
- package/src/domain/iampolicy/__tests__/membership.test.ts +447 -0
- package/src/domain/iampolicy/__tests__/permissions.test.ts +41 -0
- package/src/domain/iampolicy/__tests__/resource-store.test.ts +165 -0
- package/src/domain/iampolicy/__tests__/role-lifecycle.test.ts +361 -0
- package/src/domain/iampolicy/__tests__/roles.test.ts +53 -0
- package/src/domain/iampolicy/__tests__/specs.test.ts +41 -0
- package/src/domain/iampolicy/__tests__/steps.test.ts +278 -0
- package/src/domain/iampolicy/__tests__/support.ts +195 -0
- package/src/domain/iampolicy/__tests__/wire-refusals.test.ts +117 -0
- package/src/domain/iampolicy/access-lists.ts +191 -0
- package/src/domain/iampolicy/constants.ts +243 -0
- package/src/domain/iampolicy/controller.ts +798 -0
- package/src/domain/iampolicy/display-resolver.ts +123 -0
- package/src/domain/iampolicy/grant-path.ts +270 -0
- package/src/domain/iampolicy/grant-scope.ts +36 -0
- package/src/domain/iampolicy/membership.ts +298 -0
- package/src/domain/iampolicy/permissions.ts +35 -0
- package/src/domain/iampolicy/resource-store.ts +186 -0
- package/src/domain/iampolicy/role-lifecycle.ts +132 -0
- package/src/domain/iampolicy/roles.ts +95 -0
- package/src/domain/iampolicy/specs.ts +53 -0
- package/src/domain/iampolicy/steps.ts +178 -0
- package/src/domain/iampolicy/store-contract.ts +450 -0
- package/src/domain/iampolicy/store.ts +103 -0
- package/src/domain/iampolicy/wire-refusals.ts +110 -0
- package/src/domain/identityaccount/__tests__/provisioning.test.ts +19 -7
- package/src/domain/identityaccount/__tests__/resolve.test.ts +113 -2
- package/src/domain/identityaccount/constants.ts +16 -31
- package/src/domain/identityaccount/controller.ts +65 -35
- package/src/domain/identityaccount/operator.ts +5 -5
- package/src/domain/identityaccount/provisioning.ts +43 -5
- package/src/domain/identityaccount/resolve.ts +43 -0
- package/src/domain/identityaccount/store-contract.ts +22 -70
- package/src/extensions/__tests__/composed-support.ts +87 -0
- package/src/extensions/__tests__/iam-policy-composed.test.ts +373 -0
- package/src/extensions/__tests__/iam-policy-points.test.ts +169 -0
- package/src/extensions/__tests__/identity-account-composed.test.ts +12 -78
- package/src/extensions/__tests__/identity.test.ts +62 -0
- package/src/extensions/__tests__/tier-truthfulness.test.ts +8 -0
- package/src/extensions/authorization-queries.ts +97 -0
- package/src/extensions/drivers.ts +49 -2
- package/src/extensions/identity.ts +21 -0
- package/src/extensions/policy-grant-scope.ts +50 -0
- package/src/extensions/registry.ts +62 -1
- package/src/extensions/resource-authorization.ts +65 -0
- package/src/index.ts +20 -0
- package/src/pipeline/__tests__/grantable-roles-for.test.ts +87 -0
- package/src/pipeline/__tests__/kind-by-enum-name.test.ts +107 -0
- package/src/pipeline/__tests__/kind-served-by-edition.test.ts +107 -0
- package/src/pipeline/apiresource-meta.ts +181 -1
- package/src/pipeline/interceptors/auth.ts +14 -1
- package/src/pipeline/steps/__tests__/authorize-annotation-completeness.test.ts +73 -3
- package/src/pipeline/steps/__tests__/authorize.test.ts +134 -9
- package/src/pipeline/steps/__tests__/derived-id.test.ts +49 -0
- package/src/pipeline/steps/authorize.ts +46 -21
- package/src/pipeline/steps/defaults.ts +42 -0
- package/src/store/__tests__/port-contract.test.ts +123 -0
- package/src/store/port-contract.ts +102 -0
|
@@ -31,9 +31,13 @@
|
|
|
31
31
|
* Resolution never throws: an unresolvable `field_path` yields an empty
|
|
32
32
|
* resource id and an unresolvable `resource_kind_path` yields the unknown
|
|
33
33
|
* kind — the check still reaches the Authorizer, which owns the decision.
|
|
34
|
-
* A
|
|
35
|
-
*
|
|
36
|
-
*
|
|
34
|
+
* A `resource_kind_path` may point at an `ApiResourceKind` field or at a
|
|
35
|
+
* string carrying a kind's enum member name (an `ApiResourceRef.kind`, the
|
|
36
|
+
* IamPolicy RPCs' spec-named target; 20260913.01 Q-OR-2); a string that is
|
|
37
|
+
* not exactly a member name is the unknown kind, which an enforcing
|
|
38
|
+
* authorizer denies. A thrown resolution would be a NEW wire behavior on
|
|
39
|
+
* requests that are legal today (byte-identity forbids it); an
|
|
40
|
+
* implementation that wants to refuse empty ids does so as a deny, visibly.
|
|
37
41
|
*
|
|
38
42
|
* `authorizeDirect` is the SAME evaluation exported for the direct
|
|
39
43
|
* handlers — the config-annotated methods that deliberately run no
|
|
@@ -42,11 +46,16 @@
|
|
|
42
46
|
* two entry shapes; a direct handler calls it after its own input
|
|
43
47
|
* validation and before any load or side effect, mirroring the Java
|
|
44
48
|
* edition's validate → authorize handler order (C2 Stage 4 ruling,
|
|
45
|
-
* 20260827.10). The optional target override serves the
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* McpServerCompleteOAuthConnectHandler
|
|
49
|
+
* 20260827.10). The optional target override serves the lanes whose true
|
|
50
|
+
* target is server-side state rather than caller input, in two shapes:
|
|
51
|
+
* a server-side ID under the annotation's static kind (completeOAuthConnect
|
|
52
|
+
* authorizes the PENDING RECORD's server id — a caller-supplied id would
|
|
53
|
+
* be a confused-deputy hole, the Java McpServerCompleteOAuthConnectHandler
|
|
54
|
+
* discipline; getByEmail/getByIdpId authorize the account they looked up),
|
|
55
|
+
* and a server-side kind AND id (the IamPolicy `get`, whose annotation
|
|
56
|
+
* names no kind because the target is the loaded row's resource). The
|
|
57
|
+
* annotation keeps owning the permission, the copy and the skip arms in
|
|
58
|
+
* both shapes; only the target is the handler's.
|
|
50
59
|
*/
|
|
51
60
|
import { Code, ConnectError } from "@connectrpc/connect";
|
|
52
61
|
import type { DescMethod, DescMessage, Message } from "@bufbuild/protobuf";
|
|
@@ -66,7 +75,7 @@ import type {
|
|
|
66
75
|
AuthzDecision,
|
|
67
76
|
} from "../../extensions/authorizer.js";
|
|
68
77
|
import type { CallerIdentity } from "../../extensions/identity.js";
|
|
69
|
-
import { getKindName } from "../apiresource-meta.js";
|
|
78
|
+
import { getKindName, kindByEnumName } from "../apiresource-meta.js";
|
|
70
79
|
import { internalError, notFoundError } from "../errors.js";
|
|
71
80
|
import type { PipelineStep } from "../pipeline.js";
|
|
72
81
|
import type { RequestContext } from "../request-context.js";
|
|
@@ -115,12 +124,16 @@ export function newAuthorizeStep<Desc extends DescMessage>(
|
|
|
115
124
|
}
|
|
116
125
|
|
|
117
126
|
/**
|
|
118
|
-
* The
|
|
119
|
-
*
|
|
120
|
-
* annotation's `field_path`/`resource_id` resolution;
|
|
121
|
-
*
|
|
127
|
+
* The lanes whose authorization target is server-side state rather than a
|
|
128
|
+
* request field (see the module header). `resourceId` replaces the
|
|
129
|
+
* annotation's `field_path`/`resource_id` resolution; `resourceKind`, when
|
|
130
|
+
* given, replaces its `resource_kind`/`resource_kind_path` resolution — the
|
|
131
|
+
* lane whose annotation names no kind because the kind is inside the row
|
|
132
|
+
* it loaded. Everything else — skip arms, permission, copy — still comes
|
|
133
|
+
* from the annotation.
|
|
122
134
|
*/
|
|
123
135
|
export interface AuthorizeTargetOverride {
|
|
136
|
+
readonly resourceKind?: ApiResourceKind;
|
|
124
137
|
readonly resourceId: string;
|
|
125
138
|
}
|
|
126
139
|
|
|
@@ -156,7 +169,8 @@ export async function authorizeDirect(
|
|
|
156
169
|
identity,
|
|
157
170
|
{
|
|
158
171
|
permission: config.permission,
|
|
159
|
-
resourceKind:
|
|
172
|
+
resourceKind:
|
|
173
|
+
override?.resourceKind ?? resolveResourceKind(input, config),
|
|
160
174
|
resourceId: override?.resourceId ?? resolveResourceId(input, config),
|
|
161
175
|
},
|
|
162
176
|
config.errorMsg,
|
|
@@ -189,7 +203,7 @@ export async function authorizeResolvedResource(
|
|
|
189
203
|
if (identity.callerClass === "internal") {
|
|
190
204
|
return;
|
|
191
205
|
}
|
|
192
|
-
const decision = await
|
|
206
|
+
const decision = await evaluateAuthorizer(authorizer, identity, check);
|
|
193
207
|
switch (decision.kind) {
|
|
194
208
|
case "allow":
|
|
195
209
|
return;
|
|
@@ -234,9 +248,13 @@ export async function authorizeResolvedResource(
|
|
|
234
248
|
/**
|
|
235
249
|
* An Authorizer that THROWS is an evaluation failure by definition —
|
|
236
250
|
* normalized into the unavailable arm so a buggy implementation can never
|
|
237
|
-
* soften an outage into a denial by accident.
|
|
251
|
+
* soften an outage into a denial by accident. Exported for the one lane
|
|
252
|
+
* that needs the DECISION rather than the wire mapping: `checkMyPermission`
|
|
253
|
+
* answers a boolean (allow → true, deny and not-found → false) and maps
|
|
254
|
+
* only `unavailable` to the wire, through the same INTERNAL copy
|
|
255
|
+
* (20260913.01, Q-S5-3).
|
|
238
256
|
*/
|
|
239
|
-
async function
|
|
257
|
+
export async function evaluateAuthorizer(
|
|
240
258
|
authorizer: Authorizer,
|
|
241
259
|
identity: CallerIdentity,
|
|
242
260
|
check: AuthzCheck,
|
|
@@ -251,7 +269,10 @@ async function runAuthorizer(
|
|
|
251
269
|
}
|
|
252
270
|
}
|
|
253
271
|
|
|
254
|
-
/**
|
|
272
|
+
/**
|
|
273
|
+
* Static kind, or the resource_kind_path read — an enum field as its
|
|
274
|
+
* number, a string as an enum member name — or unknown. Never a throw.
|
|
275
|
+
*/
|
|
255
276
|
function resolveResourceKind(
|
|
256
277
|
input: Message,
|
|
257
278
|
config: RpcAuthorizationConfig,
|
|
@@ -260,9 +281,13 @@ function resolveResourceKind(
|
|
|
260
281
|
return config.resourceKind;
|
|
261
282
|
}
|
|
262
283
|
const value = resolveDotPath(input, config.resourceKindPath);
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
284
|
+
if (typeof value === "number") {
|
|
285
|
+
return value as ApiResourceKind;
|
|
286
|
+
}
|
|
287
|
+
if (typeof value === "string") {
|
|
288
|
+
return kindByEnumName(value);
|
|
289
|
+
}
|
|
290
|
+
return ApiResourceKind.api_resource_kind_unknown;
|
|
266
291
|
}
|
|
267
292
|
|
|
268
293
|
/** Static resource_id, or the field_path read, or "" — never a throw. */
|
|
@@ -14,7 +14,19 @@
|
|
|
14
14
|
* setAuditFieldsForUpdate call site declares which slot it owns —
|
|
15
15
|
* SpecAudit for definition changes (search recency, version "pushed at"),
|
|
16
16
|
* StatusAudit for operational changes (Recents, lifecycle metadata).
|
|
17
|
+
*
|
|
18
|
+
* This module is also the one home of how an id is SPELLED. `generateId`
|
|
19
|
+
* mints `{prefix}_{ulid}`; `derivedId` is its sibling for the kinds
|
|
20
|
+
* metadata.proto names as deriving their id from a natural key instead
|
|
21
|
+
* (a direct IdentityAccount from its issuer subject, an IamPolicy from its
|
|
22
|
+
* triple): `{prefix}_` + the top 130 bits of sha256 over the key's
|
|
23
|
+
* canonical text as 26 lowercase Crockford-base32 characters. The two
|
|
24
|
+
* shapes are indistinguishable on the wire, so nothing downstream learns
|
|
25
|
+
* a second grammar, and the primary key becomes the one home of "one row
|
|
26
|
+
* per natural key" without a secondary index or a scan.
|
|
17
27
|
*/
|
|
28
|
+
import { createHash } from "node:crypto";
|
|
29
|
+
|
|
18
30
|
import { create, clone } from "@bufbuild/protobuf";
|
|
19
31
|
import type { DescMessage, Message } from "@bufbuild/protobuf";
|
|
20
32
|
import { reflect } from "@bufbuild/protobuf/reflect";
|
|
@@ -353,3 +365,33 @@ function setAuditSlotReflect(
|
|
|
353
365
|
export function generateId(prefix: string): string {
|
|
354
366
|
return `${prefix}_${ulid().toLowerCase()}`;
|
|
355
367
|
}
|
|
368
|
+
|
|
369
|
+
/** Crockford base32, lowercased — the alphabet every minted ULID id uses. */
|
|
370
|
+
const CROCKFORD_ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
|
|
371
|
+
const DERIVED_ID_CHARS = 26;
|
|
372
|
+
const DERIVED_ID_BITS = BigInt(DERIVED_ID_CHARS * 5);
|
|
373
|
+
const SHA256_BITS = 256n;
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* The derived id of a kind whose id is a function of its natural key
|
|
377
|
+
* (metadata.proto `id`): `{prefix}_` + the top 130 bits of
|
|
378
|
+
* sha256(canonicalText) as 26 lowercase Crockford-base32 characters. Pure
|
|
379
|
+
* and total; the CALLER owns what `canonicalText` may be (the domains
|
|
380
|
+
* refuse an empty subject or an ambiguous triple before hashing), and the
|
|
381
|
+
* domains' golden vectors are the wire-adjacent pin — a change here
|
|
382
|
+
* re-addresses every derived row open source ever wrote.
|
|
383
|
+
*/
|
|
384
|
+
export function derivedId(prefix: string, canonicalText: string): string {
|
|
385
|
+
const digest = createHash("sha256").update(canonicalText, "utf8").digest();
|
|
386
|
+
let bits = 0n;
|
|
387
|
+
for (const byte of digest) {
|
|
388
|
+
bits = (bits << 8n) | BigInt(byte);
|
|
389
|
+
}
|
|
390
|
+
let top = bits >> (SHA256_BITS - DERIVED_ID_BITS);
|
|
391
|
+
let encoded = "";
|
|
392
|
+
for (let i = 0; i < DERIVED_ID_CHARS; i++) {
|
|
393
|
+
encoded = CROCKFORD_ALPHABET[Number(top & 31n)] + encoded;
|
|
394
|
+
top >>= 5n;
|
|
395
|
+
}
|
|
396
|
+
return `${prefix}_${encoded}`;
|
|
397
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins the port-contract runner (../port-contract.ts), the scaffolding every
|
|
3
|
+
* domain store port's kit is built on (identity-account since 20260911.11;
|
|
4
|
+
* IamPolicy since 20260913.01 slice 2, when the scaffolding was lifted out
|
|
5
|
+
* of the first kit so the second did not copy it):
|
|
6
|
+
*
|
|
7
|
+
* - every case makes a FRESH fixture, runs its body over it, and cleans
|
|
8
|
+
* up — in that order, once each;
|
|
9
|
+
* - a failing body is the failure reported: a cleanup that fails after
|
|
10
|
+
* a failed body must not replace the assertion that matters with a
|
|
11
|
+
* teardown detail;
|
|
12
|
+
* - a cleanup that fails after a PASSING body is a real failure and
|
|
13
|
+
* propagates;
|
|
14
|
+
* - the case list keeps the declared names in the declared order, so a
|
|
15
|
+
* consumer's pinned name list is a faithful diff of the contract.
|
|
16
|
+
*/
|
|
17
|
+
import { describe, expect, it } from "vitest";
|
|
18
|
+
|
|
19
|
+
import { portContractCases } from "../port-contract.js";
|
|
20
|
+
import type { PortContractFixture } from "../port-contract.js";
|
|
21
|
+
|
|
22
|
+
interface Probe {
|
|
23
|
+
readonly label: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type ProbeFixture = PortContractFixture<Probe>;
|
|
27
|
+
|
|
28
|
+
function fixtureFactory(
|
|
29
|
+
log: string[],
|
|
30
|
+
options: { readonly failCleanup?: boolean } = {},
|
|
31
|
+
): () => Promise<ProbeFixture> {
|
|
32
|
+
let made = 0;
|
|
33
|
+
return async () => {
|
|
34
|
+
made += 1;
|
|
35
|
+
const label = `fixture-${made}`;
|
|
36
|
+
log.push(`make ${label}`);
|
|
37
|
+
return {
|
|
38
|
+
store: { label },
|
|
39
|
+
disconnect: async () => {
|
|
40
|
+
log.push(`disconnect ${label}`);
|
|
41
|
+
},
|
|
42
|
+
cleanup: async () => {
|
|
43
|
+
log.push(`cleanup ${label}`);
|
|
44
|
+
if (options.failCleanup === true) {
|
|
45
|
+
throw new Error(`cleanup of ${label} failed`);
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe("portContractCases", () => {
|
|
53
|
+
it("keeps the declared names in the declared order", () => {
|
|
54
|
+
const cases = portContractCases<Probe>(
|
|
55
|
+
[
|
|
56
|
+
["first line", async () => {}],
|
|
57
|
+
["second line", async () => {}],
|
|
58
|
+
],
|
|
59
|
+
fixtureFactory([]),
|
|
60
|
+
);
|
|
61
|
+
expect(cases.map((contractCase) => contractCase.name)).toEqual([
|
|
62
|
+
"first line",
|
|
63
|
+
"second line",
|
|
64
|
+
]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("makes a fresh fixture per case, runs the body over it, then cleans up — once each", async () => {
|
|
68
|
+
const log: string[] = [];
|
|
69
|
+
const cases = portContractCases<Probe>(
|
|
70
|
+
[
|
|
71
|
+
[
|
|
72
|
+
"a",
|
|
73
|
+
async ({ store }) => {
|
|
74
|
+
log.push(`body over ${store.label}`);
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
[
|
|
78
|
+
"b",
|
|
79
|
+
async ({ store }) => {
|
|
80
|
+
log.push(`body over ${store.label}`);
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
],
|
|
84
|
+
fixtureFactory(log),
|
|
85
|
+
);
|
|
86
|
+
for (const contractCase of cases) {
|
|
87
|
+
await contractCase.run();
|
|
88
|
+
}
|
|
89
|
+
expect(log).toEqual([
|
|
90
|
+
"make fixture-1",
|
|
91
|
+
"body over fixture-1",
|
|
92
|
+
"cleanup fixture-1",
|
|
93
|
+
"make fixture-2",
|
|
94
|
+
"body over fixture-2",
|
|
95
|
+
"cleanup fixture-2",
|
|
96
|
+
]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("reports the body's failure, not the cleanup's, when both fail", async () => {
|
|
100
|
+
const log: string[] = [];
|
|
101
|
+
const [only] = portContractCases<Probe>(
|
|
102
|
+
[
|
|
103
|
+
[
|
|
104
|
+
"assertion loses to nothing",
|
|
105
|
+
async () => {
|
|
106
|
+
throw new Error("the assertion that matters");
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
],
|
|
110
|
+
fixtureFactory(log, { failCleanup: true }),
|
|
111
|
+
);
|
|
112
|
+
await expect(only?.run()).rejects.toThrow("the assertion that matters");
|
|
113
|
+
expect(log).toEqual(["make fixture-1", "cleanup fixture-1"]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("a cleanup failure after a passing body is a real failure", async () => {
|
|
117
|
+
const [only] = portContractCases<Probe>(
|
|
118
|
+
[["passes", async () => {}]],
|
|
119
|
+
fixtureFactory([], { failCleanup: true }),
|
|
120
|
+
);
|
|
121
|
+
await expect(only?.run()).rejects.toThrow("cleanup of fixture-1 failed");
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The port-contract runner — the scaffolding every domain store PORT's
|
|
3
|
+
* contract kit is built on (identity-account's store-contract.ts since
|
|
4
|
+
* 20260911.11 A11; IamPolicy's since 20260913.01 slice 2, when this was
|
|
5
|
+
* lifted out of the first kit so the second did not carry a copy).
|
|
6
|
+
*
|
|
7
|
+
* A port kit is a list of named cases a DRIVER's test iterates: open
|
|
8
|
+
* source runs them over its adapter on sqlite and Postgres, a composition
|
|
9
|
+
* runs the same list over the store it registers as a driver, so "the port
|
|
10
|
+
* holds" is one statement proven per driver, never restated per
|
|
11
|
+
* repository. This module owns the part every kit shares and no kit should
|
|
12
|
+
* restate: a FRESH fixture per case, the body run over it, cleanup after,
|
|
13
|
+
* and the rule that a failing body is the failure reported (a cleanup that
|
|
14
|
+
* fails after a failed body must not replace the assertion that matters
|
|
15
|
+
* with a teardown detail; a cleanup that fails after a passing body is a
|
|
16
|
+
* real failure).
|
|
17
|
+
*
|
|
18
|
+
* Shape. The package's precedent is store/__tests__/store-contract.ts —
|
|
19
|
+
* `describeStoreContract(makeFixture)` over the generic Store, a vitest
|
|
20
|
+
* `describe`. A port kit is consumed by ANOTHER package's tests through the
|
|
21
|
+
* published barrel, so it ships in dist/ and must not import vitest (a
|
|
22
|
+
* devDependency that would enter the root barrel's runtime import graph).
|
|
23
|
+
* It therefore returns cases instead of calling `describe`, asserts through
|
|
24
|
+
* node:assert/strict, and the consumer's framework does
|
|
25
|
+
* `for (const c of cases) it(c.name, c.run)`. Do not "fix" it back to the
|
|
26
|
+
* precedent's vitest shape.
|
|
27
|
+
*
|
|
28
|
+
* `disconnect` is the one escape hatch a port cannot express: a port has
|
|
29
|
+
* no lifecycle by design (a composition owns its store's), yet "an outage
|
|
30
|
+
* never reads as not-found" is the line that matters most, so a fixture
|
|
31
|
+
* cuts its store from the database and the kit asserts that the fault
|
|
32
|
+
* propagates. `cleanup` must tolerate a disconnected store.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** One fresh, isolated store per case, plus the one escape hatch the port cannot express. */
|
|
36
|
+
export interface PortContractFixture<Port> {
|
|
37
|
+
readonly store: Port;
|
|
38
|
+
/**
|
|
39
|
+
* Cuts the store from its database so every later call is an
|
|
40
|
+
* infrastructure fault. `cleanup` still runs afterwards and must tolerate
|
|
41
|
+
* a disconnected store (both OSS drivers' `close` is idempotent; a pool
|
|
42
|
+
* fixture ends a per-case pool here and skips it in cleanup).
|
|
43
|
+
*/
|
|
44
|
+
disconnect(): Promise<void>;
|
|
45
|
+
cleanup(): Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface PortContractCase {
|
|
49
|
+
/** The contract line, in the port's words; the framework prints it as the test name. */
|
|
50
|
+
readonly name: string;
|
|
51
|
+
/** Makes a FRESH fixture, runs the case, cleans up. A failing assertion wins over a failing cleanup. */
|
|
52
|
+
run(): Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A case body over a live fixture; the runner owns the fixture's lifecycle around it. */
|
|
56
|
+
export type PortContractBody<Port> = (
|
|
57
|
+
fixture: PortContractFixture<Port>,
|
|
58
|
+
) => Promise<void>;
|
|
59
|
+
|
|
60
|
+
/** A kit's declaration: the contract line and the body that proves it. */
|
|
61
|
+
export type PortContractDeclaration<Port> = readonly [
|
|
62
|
+
name: string,
|
|
63
|
+
body: PortContractBody<Port>,
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Runs `body` over a fresh fixture. The body's failure is the one reported:
|
|
68
|
+
* a cleanup failure after a failed body would otherwise replace the
|
|
69
|
+
* assertion that matters with a teardown detail.
|
|
70
|
+
*/
|
|
71
|
+
async function withFixture<Port>(
|
|
72
|
+
makeFixture: () => Promise<PortContractFixture<Port>>,
|
|
73
|
+
body: PortContractBody<Port>,
|
|
74
|
+
): Promise<void> {
|
|
75
|
+
const fixture = await makeFixture();
|
|
76
|
+
let failed = false;
|
|
77
|
+
try {
|
|
78
|
+
await body(fixture);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
failed = true;
|
|
81
|
+
throw error;
|
|
82
|
+
} finally {
|
|
83
|
+
try {
|
|
84
|
+
await fixture.cleanup();
|
|
85
|
+
} catch (cleanupError) {
|
|
86
|
+
if (!failed) {
|
|
87
|
+
throw cleanupError;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The kit's declarations as runnable cases over `makeFixture`, one fresh fixture per case, in declared order. */
|
|
94
|
+
export function portContractCases<Port>(
|
|
95
|
+
declarations: ReadonlyArray<PortContractDeclaration<Port>>,
|
|
96
|
+
makeFixture: () => Promise<PortContractFixture<Port>>,
|
|
97
|
+
): ReadonlyArray<PortContractCase> {
|
|
98
|
+
return declarations.map(([name, body]) => ({
|
|
99
|
+
name,
|
|
100
|
+
run: () => withFixture(makeFixture, body),
|
|
101
|
+
}));
|
|
102
|
+
}
|