@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.
Files changed (201) hide show
  1. package/dist/boot/compose.d.ts.map +1 -1
  2. package/dist/boot/compose.js +103 -9
  3. package/dist/boot/compose.js.map +1 -1
  4. package/dist/domain/iampolicy/access-lists.d.ts +27 -0
  5. package/dist/domain/iampolicy/access-lists.d.ts.map +1 -0
  6. package/dist/domain/iampolicy/access-lists.js +124 -0
  7. package/dist/domain/iampolicy/access-lists.js.map +1 -0
  8. package/dist/domain/iampolicy/constants.d.ts +135 -0
  9. package/dist/domain/iampolicy/constants.d.ts.map +1 -0
  10. package/dist/domain/iampolicy/constants.js +203 -0
  11. package/dist/domain/iampolicy/constants.js.map +1 -0
  12. package/dist/domain/iampolicy/controller.d.ts +29 -0
  13. package/dist/domain/iampolicy/controller.d.ts.map +1 -0
  14. package/dist/domain/iampolicy/controller.js +451 -0
  15. package/dist/domain/iampolicy/controller.js.map +1 -0
  16. package/dist/domain/iampolicy/display-resolver.d.ts +6 -0
  17. package/dist/domain/iampolicy/display-resolver.d.ts.map +1 -0
  18. package/dist/domain/iampolicy/display-resolver.js +90 -0
  19. package/dist/domain/iampolicy/display-resolver.js.map +1 -0
  20. package/dist/domain/iampolicy/grant-path.d.ts +42 -0
  21. package/dist/domain/iampolicy/grant-path.d.ts.map +1 -0
  22. package/dist/domain/iampolicy/grant-path.js +183 -0
  23. package/dist/domain/iampolicy/grant-path.js.map +1 -0
  24. package/dist/domain/iampolicy/grant-scope.d.ts +4 -0
  25. package/dist/domain/iampolicy/grant-scope.d.ts.map +1 -0
  26. package/dist/domain/iampolicy/grant-scope.js +34 -0
  27. package/dist/domain/iampolicy/grant-scope.js.map +1 -0
  28. package/dist/domain/iampolicy/membership.d.ts +111 -0
  29. package/dist/domain/iampolicy/membership.d.ts.map +1 -0
  30. package/dist/domain/iampolicy/membership.js +152 -0
  31. package/dist/domain/iampolicy/membership.js.map +1 -0
  32. package/dist/domain/iampolicy/permissions.d.ts +19 -0
  33. package/dist/domain/iampolicy/permissions.d.ts.map +1 -0
  34. package/dist/domain/iampolicy/permissions.js +27 -0
  35. package/dist/domain/iampolicy/permissions.js.map +1 -0
  36. package/dist/domain/iampolicy/resource-store.d.ts +4 -0
  37. package/dist/domain/iampolicy/resource-store.d.ts.map +1 -0
  38. package/dist/domain/iampolicy/resource-store.js +135 -0
  39. package/dist/domain/iampolicy/resource-store.js.map +1 -0
  40. package/dist/domain/iampolicy/role-lifecycle.d.ts +9 -0
  41. package/dist/domain/iampolicy/role-lifecycle.d.ts.map +1 -0
  42. package/dist/domain/iampolicy/role-lifecycle.js +106 -0
  43. package/dist/domain/iampolicy/role-lifecycle.js.map +1 -0
  44. package/dist/domain/iampolicy/roles.d.ts +11 -0
  45. package/dist/domain/iampolicy/roles.d.ts.map +1 -0
  46. package/dist/domain/iampolicy/roles.js +78 -0
  47. package/dist/domain/iampolicy/roles.js.map +1 -0
  48. package/dist/domain/iampolicy/specs.d.ts +7 -0
  49. package/dist/domain/iampolicy/specs.d.ts.map +1 -0
  50. package/dist/domain/iampolicy/specs.js +45 -0
  51. package/dist/domain/iampolicy/specs.js.map +1 -0
  52. package/dist/domain/iampolicy/steps.d.ts +18 -0
  53. package/dist/domain/iampolicy/steps.d.ts.map +1 -0
  54. package/dist/domain/iampolicy/steps.js +117 -0
  55. package/dist/domain/iampolicy/steps.js.map +1 -0
  56. package/dist/domain/iampolicy/store-contract.d.ts +8 -0
  57. package/dist/domain/iampolicy/store-contract.d.ts.map +1 -0
  58. package/dist/domain/iampolicy/store-contract.js +258 -0
  59. package/dist/domain/iampolicy/store-contract.js.map +1 -0
  60. package/dist/domain/iampolicy/store.d.ts +78 -0
  61. package/dist/domain/iampolicy/store.d.ts.map +1 -0
  62. package/dist/domain/iampolicy/store.js +13 -0
  63. package/dist/domain/iampolicy/store.js.map +1 -0
  64. package/dist/domain/iampolicy/wire-refusals.d.ts +25 -0
  65. package/dist/domain/iampolicy/wire-refusals.d.ts.map +1 -0
  66. package/dist/domain/iampolicy/wire-refusals.js +76 -0
  67. package/dist/domain/iampolicy/wire-refusals.js.map +1 -0
  68. package/dist/domain/identityaccount/constants.d.ts +33 -0
  69. package/dist/domain/identityaccount/constants.d.ts.map +1 -1
  70. package/dist/domain/identityaccount/constants.js +2 -48
  71. package/dist/domain/identityaccount/constants.js.map +1 -1
  72. package/dist/domain/identityaccount/controller.d.ts +8 -1
  73. package/dist/domain/identityaccount/controller.d.ts.map +1 -1
  74. package/dist/domain/identityaccount/controller.js +54 -29
  75. package/dist/domain/identityaccount/controller.js.map +1 -1
  76. package/dist/domain/identityaccount/operator.d.ts.map +1 -1
  77. package/dist/domain/identityaccount/operator.js +2 -4
  78. package/dist/domain/identityaccount/operator.js.map +1 -1
  79. package/dist/domain/identityaccount/provisioning.d.ts +20 -1
  80. package/dist/domain/identityaccount/provisioning.d.ts.map +1 -1
  81. package/dist/domain/identityaccount/provisioning.js +17 -3
  82. package/dist/domain/identityaccount/provisioning.js.map +1 -1
  83. package/dist/domain/identityaccount/resolve.d.ts +23 -0
  84. package/dist/domain/identityaccount/resolve.d.ts.map +1 -1
  85. package/dist/domain/identityaccount/resolve.js +17 -0
  86. package/dist/domain/identityaccount/resolve.js.map +1 -1
  87. package/dist/domain/identityaccount/store-contract.d.ts +4 -18
  88. package/dist/domain/identityaccount/store-contract.d.ts.map +1 -1
  89. package/dist/domain/identityaccount/store-contract.js +12 -47
  90. package/dist/domain/identityaccount/store-contract.js.map +1 -1
  91. package/dist/extensions/authorization-queries.d.ts +81 -0
  92. package/dist/extensions/authorization-queries.d.ts.map +1 -0
  93. package/dist/extensions/authorization-queries.js +2 -0
  94. package/dist/extensions/authorization-queries.js.map +1 -0
  95. package/dist/extensions/drivers.d.ts +49 -2
  96. package/dist/extensions/drivers.d.ts.map +1 -1
  97. package/dist/extensions/identity.d.ts +14 -0
  98. package/dist/extensions/identity.d.ts.map +1 -1
  99. package/dist/extensions/identity.js +18 -1
  100. package/dist/extensions/identity.js.map +1 -1
  101. package/dist/extensions/policy-grant-scope.d.ts +50 -0
  102. package/dist/extensions/policy-grant-scope.d.ts.map +1 -0
  103. package/dist/extensions/policy-grant-scope.js +2 -0
  104. package/dist/extensions/policy-grant-scope.js.map +1 -0
  105. package/dist/extensions/registry.d.ts +23 -1
  106. package/dist/extensions/registry.d.ts.map +1 -1
  107. package/dist/extensions/registry.js +30 -0
  108. package/dist/extensions/registry.js.map +1 -1
  109. package/dist/extensions/resource-authorization.d.ts +63 -0
  110. package/dist/extensions/resource-authorization.d.ts.map +1 -1
  111. package/dist/index.d.ts +7 -1
  112. package/dist/index.d.ts.map +1 -1
  113. package/dist/index.js +2 -0
  114. package/dist/index.js.map +1 -1
  115. package/dist/pipeline/apiresource-meta.d.ts +56 -1
  116. package/dist/pipeline/apiresource-meta.d.ts.map +1 -1
  117. package/dist/pipeline/apiresource-meta.js +155 -1
  118. package/dist/pipeline/apiresource-meta.js.map +1 -1
  119. package/dist/pipeline/interceptors/auth.d.ts +12 -0
  120. package/dist/pipeline/interceptors/auth.d.ts.map +1 -1
  121. package/dist/pipeline/interceptors/auth.js +13 -1
  122. package/dist/pipeline/interceptors/auth.js.map +1 -1
  123. package/dist/pipeline/steps/authorize.d.ts +20 -5
  124. package/dist/pipeline/steps/authorize.d.ts.map +1 -1
  125. package/dist/pipeline/steps/authorize.js +37 -17
  126. package/dist/pipeline/steps/authorize.js.map +1 -1
  127. package/dist/pipeline/steps/defaults.d.ts +10 -0
  128. package/dist/pipeline/steps/defaults.d.ts.map +1 -1
  129. package/dist/pipeline/steps/defaults.js +39 -0
  130. package/dist/pipeline/steps/defaults.js.map +1 -1
  131. package/dist/store/port-contract.d.ts +61 -0
  132. package/dist/store/port-contract.d.ts.map +1 -0
  133. package/dist/store/port-contract.js +67 -0
  134. package/dist/store/port-contract.js.map +1 -0
  135. package/package.json +4 -4
  136. package/src/boot/compose.ts +107 -10
  137. package/src/domain/iampolicy/__tests__/access-lists.test.ts +238 -0
  138. package/src/domain/iampolicy/__tests__/constants.test.ts +388 -0
  139. package/src/domain/iampolicy/__tests__/controller.test.ts +960 -0
  140. package/src/domain/iampolicy/__tests__/display-resolver.test.ts +138 -0
  141. package/src/domain/iampolicy/__tests__/grant-path.test.ts +474 -0
  142. package/src/domain/iampolicy/__tests__/grant-scope.test.ts +61 -0
  143. package/src/domain/iampolicy/__tests__/iampolicy.test.ts +532 -0
  144. package/src/domain/iampolicy/__tests__/membership.test.ts +447 -0
  145. package/src/domain/iampolicy/__tests__/permissions.test.ts +41 -0
  146. package/src/domain/iampolicy/__tests__/resource-store.test.ts +165 -0
  147. package/src/domain/iampolicy/__tests__/role-lifecycle.test.ts +361 -0
  148. package/src/domain/iampolicy/__tests__/roles.test.ts +53 -0
  149. package/src/domain/iampolicy/__tests__/specs.test.ts +41 -0
  150. package/src/domain/iampolicy/__tests__/steps.test.ts +278 -0
  151. package/src/domain/iampolicy/__tests__/support.ts +195 -0
  152. package/src/domain/iampolicy/__tests__/wire-refusals.test.ts +117 -0
  153. package/src/domain/iampolicy/access-lists.ts +191 -0
  154. package/src/domain/iampolicy/constants.ts +243 -0
  155. package/src/domain/iampolicy/controller.ts +798 -0
  156. package/src/domain/iampolicy/display-resolver.ts +123 -0
  157. package/src/domain/iampolicy/grant-path.ts +270 -0
  158. package/src/domain/iampolicy/grant-scope.ts +36 -0
  159. package/src/domain/iampolicy/membership.ts +298 -0
  160. package/src/domain/iampolicy/permissions.ts +35 -0
  161. package/src/domain/iampolicy/resource-store.ts +186 -0
  162. package/src/domain/iampolicy/role-lifecycle.ts +132 -0
  163. package/src/domain/iampolicy/roles.ts +95 -0
  164. package/src/domain/iampolicy/specs.ts +53 -0
  165. package/src/domain/iampolicy/steps.ts +178 -0
  166. package/src/domain/iampolicy/store-contract.ts +450 -0
  167. package/src/domain/iampolicy/store.ts +103 -0
  168. package/src/domain/iampolicy/wire-refusals.ts +110 -0
  169. package/src/domain/identityaccount/__tests__/provisioning.test.ts +19 -7
  170. package/src/domain/identityaccount/__tests__/resolve.test.ts +113 -2
  171. package/src/domain/identityaccount/constants.ts +16 -31
  172. package/src/domain/identityaccount/controller.ts +65 -35
  173. package/src/domain/identityaccount/operator.ts +5 -5
  174. package/src/domain/identityaccount/provisioning.ts +43 -5
  175. package/src/domain/identityaccount/resolve.ts +43 -0
  176. package/src/domain/identityaccount/store-contract.ts +22 -70
  177. package/src/extensions/__tests__/composed-support.ts +87 -0
  178. package/src/extensions/__tests__/iam-policy-composed.test.ts +373 -0
  179. package/src/extensions/__tests__/iam-policy-points.test.ts +169 -0
  180. package/src/extensions/__tests__/identity-account-composed.test.ts +12 -78
  181. package/src/extensions/__tests__/identity.test.ts +62 -0
  182. package/src/extensions/__tests__/tier-truthfulness.test.ts +8 -0
  183. package/src/extensions/authorization-queries.ts +97 -0
  184. package/src/extensions/drivers.ts +49 -2
  185. package/src/extensions/identity.ts +21 -0
  186. package/src/extensions/policy-grant-scope.ts +50 -0
  187. package/src/extensions/registry.ts +62 -1
  188. package/src/extensions/resource-authorization.ts +65 -0
  189. package/src/index.ts +20 -0
  190. package/src/pipeline/__tests__/grantable-roles-for.test.ts +87 -0
  191. package/src/pipeline/__tests__/kind-by-enum-name.test.ts +107 -0
  192. package/src/pipeline/__tests__/kind-served-by-edition.test.ts +107 -0
  193. package/src/pipeline/apiresource-meta.ts +181 -1
  194. package/src/pipeline/interceptors/auth.ts +14 -1
  195. package/src/pipeline/steps/__tests__/authorize-annotation-completeness.test.ts +73 -3
  196. package/src/pipeline/steps/__tests__/authorize.test.ts +134 -9
  197. package/src/pipeline/steps/__tests__/derived-id.test.ts +49 -0
  198. package/src/pipeline/steps/authorize.ts +46 -21
  199. package/src/pipeline/steps/defaults.ts +42 -0
  200. package/src/store/__tests__/port-contract.test.ts +123 -0
  201. 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 thrown resolution would be a NEW wire behavior on requests that are
35
- * legal today (byte-identity forbids it); an implementation that wants to
36
- * refuse empty ids does so as a deny, visibly.
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 one lane whose
46
- * true target is server-side state rather than caller input
47
- * (completeOAuthConnect authorizes the PENDING RECORD's server id — a
48
- * caller-supplied id would be a confused-deputy hole, the Java
49
- * McpServerCompleteOAuthConnectHandler discipline).
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 one lane whose authorization target is server-side state rather
119
- * than a request field (see the module header). `resourceId` replaces the
120
- * annotation's `field_path`/`resource_id` resolution; everything else —
121
- * skip arms, kind, permission, copy — still comes from the annotation.
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: resolveResourceKind(input, config),
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 runAuthorizer(authorizer, identity, check);
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 runAuthorizer(
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
- /** Static kind, or the resource_kind_path read, or unknown — never a throw. */
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
- return typeof value === "number"
264
- ? (value as ApiResourceKind)
265
- : ApiResourceKind.api_resource_kind_unknown;
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
+ }