@stigmer/server 3.15.2 → 3.15.3-dev.20260914121148

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 (217) 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/list-read-scope.d.ts +41 -4
  102. package/dist/extensions/list-read-scope.d.ts.map +1 -1
  103. package/dist/extensions/list-read-scope.js +44 -7
  104. package/dist/extensions/list-read-scope.js.map +1 -1
  105. package/dist/extensions/policy-grant-scope.d.ts +50 -0
  106. package/dist/extensions/policy-grant-scope.d.ts.map +1 -0
  107. package/dist/extensions/policy-grant-scope.js +2 -0
  108. package/dist/extensions/policy-grant-scope.js.map +1 -0
  109. package/dist/extensions/registry.d.ts +23 -1
  110. package/dist/extensions/registry.d.ts.map +1 -1
  111. package/dist/extensions/registry.js +30 -0
  112. package/dist/extensions/registry.js.map +1 -1
  113. package/dist/extensions/resource-authorization.d.ts +63 -0
  114. package/dist/extensions/resource-authorization.d.ts.map +1 -1
  115. package/dist/index.d.ts +7 -1
  116. package/dist/index.d.ts.map +1 -1
  117. package/dist/index.js +2 -0
  118. package/dist/index.js.map +1 -1
  119. package/dist/pipeline/apiresource-meta.d.ts +77 -1
  120. package/dist/pipeline/apiresource-meta.d.ts.map +1 -1
  121. package/dist/pipeline/apiresource-meta.js +183 -1
  122. package/dist/pipeline/apiresource-meta.js.map +1 -1
  123. package/dist/pipeline/interceptors/auth.d.ts +12 -0
  124. package/dist/pipeline/interceptors/auth.d.ts.map +1 -1
  125. package/dist/pipeline/interceptors/auth.js +13 -1
  126. package/dist/pipeline/interceptors/auth.js.map +1 -1
  127. package/dist/pipeline/steps/authorization-tuples.d.ts.map +1 -1
  128. package/dist/pipeline/steps/authorization-tuples.js +7 -26
  129. package/dist/pipeline/steps/authorization-tuples.js.map +1 -1
  130. package/dist/pipeline/steps/authorize.d.ts +20 -5
  131. package/dist/pipeline/steps/authorize.d.ts.map +1 -1
  132. package/dist/pipeline/steps/authorize.js +37 -17
  133. package/dist/pipeline/steps/authorize.js.map +1 -1
  134. package/dist/pipeline/steps/defaults.d.ts +10 -0
  135. package/dist/pipeline/steps/defaults.d.ts.map +1 -1
  136. package/dist/pipeline/steps/defaults.js +39 -0
  137. package/dist/pipeline/steps/defaults.js.map +1 -1
  138. package/dist/pipeline/steps/shapes.d.ts +18 -0
  139. package/dist/pipeline/steps/shapes.d.ts.map +1 -1
  140. package/dist/pipeline/steps/shapes.js +29 -0
  141. package/dist/pipeline/steps/shapes.js.map +1 -1
  142. package/dist/store/port-contract.d.ts +61 -0
  143. package/dist/store/port-contract.d.ts.map +1 -0
  144. package/dist/store/port-contract.js +67 -0
  145. package/dist/store/port-contract.js.map +1 -0
  146. package/package.json +4 -4
  147. package/src/boot/compose.ts +107 -10
  148. package/src/domain/iampolicy/__tests__/access-lists.test.ts +238 -0
  149. package/src/domain/iampolicy/__tests__/constants.test.ts +388 -0
  150. package/src/domain/iampolicy/__tests__/controller.test.ts +960 -0
  151. package/src/domain/iampolicy/__tests__/display-resolver.test.ts +138 -0
  152. package/src/domain/iampolicy/__tests__/grant-path.test.ts +474 -0
  153. package/src/domain/iampolicy/__tests__/grant-scope.test.ts +61 -0
  154. package/src/domain/iampolicy/__tests__/iampolicy.test.ts +532 -0
  155. package/src/domain/iampolicy/__tests__/membership.test.ts +447 -0
  156. package/src/domain/iampolicy/__tests__/permissions.test.ts +41 -0
  157. package/src/domain/iampolicy/__tests__/resource-store.test.ts +165 -0
  158. package/src/domain/iampolicy/__tests__/role-lifecycle.test.ts +361 -0
  159. package/src/domain/iampolicy/__tests__/roles.test.ts +53 -0
  160. package/src/domain/iampolicy/__tests__/specs.test.ts +41 -0
  161. package/src/domain/iampolicy/__tests__/steps.test.ts +278 -0
  162. package/src/domain/iampolicy/__tests__/support.ts +195 -0
  163. package/src/domain/iampolicy/__tests__/wire-refusals.test.ts +117 -0
  164. package/src/domain/iampolicy/access-lists.ts +191 -0
  165. package/src/domain/iampolicy/constants.ts +243 -0
  166. package/src/domain/iampolicy/controller.ts +798 -0
  167. package/src/domain/iampolicy/display-resolver.ts +123 -0
  168. package/src/domain/iampolicy/grant-path.ts +270 -0
  169. package/src/domain/iampolicy/grant-scope.ts +36 -0
  170. package/src/domain/iampolicy/membership.ts +298 -0
  171. package/src/domain/iampolicy/permissions.ts +35 -0
  172. package/src/domain/iampolicy/resource-store.ts +186 -0
  173. package/src/domain/iampolicy/role-lifecycle.ts +132 -0
  174. package/src/domain/iampolicy/roles.ts +95 -0
  175. package/src/domain/iampolicy/specs.ts +53 -0
  176. package/src/domain/iampolicy/steps.ts +178 -0
  177. package/src/domain/iampolicy/store-contract.ts +450 -0
  178. package/src/domain/iampolicy/store.ts +103 -0
  179. package/src/domain/iampolicy/wire-refusals.ts +110 -0
  180. package/src/domain/identityaccount/__tests__/provisioning.test.ts +19 -7
  181. package/src/domain/identityaccount/__tests__/resolve.test.ts +113 -2
  182. package/src/domain/identityaccount/constants.ts +16 -31
  183. package/src/domain/identityaccount/controller.ts +65 -35
  184. package/src/domain/identityaccount/operator.ts +5 -5
  185. package/src/domain/identityaccount/provisioning.ts +43 -5
  186. package/src/domain/identityaccount/resolve.ts +43 -0
  187. package/src/domain/identityaccount/store-contract.ts +22 -70
  188. package/src/extensions/__tests__/composed-support.ts +87 -0
  189. package/src/extensions/__tests__/iam-policy-composed.test.ts +373 -0
  190. package/src/extensions/__tests__/iam-policy-points.test.ts +169 -0
  191. package/src/extensions/__tests__/identity-account-composed.test.ts +12 -78
  192. package/src/extensions/__tests__/identity.test.ts +62 -0
  193. package/src/extensions/__tests__/list-read-scope.test.ts +138 -0
  194. package/src/extensions/__tests__/tier-truthfulness.test.ts +8 -0
  195. package/src/extensions/authorization-queries.ts +97 -0
  196. package/src/extensions/drivers.ts +49 -2
  197. package/src/extensions/identity.ts +21 -0
  198. package/src/extensions/list-read-scope.ts +87 -9
  199. package/src/extensions/policy-grant-scope.ts +50 -0
  200. package/src/extensions/registry.ts +62 -1
  201. package/src/extensions/resource-authorization.ts +65 -0
  202. package/src/index.ts +20 -0
  203. package/src/pipeline/__tests__/grantable-roles-for.test.ts +87 -0
  204. package/src/pipeline/__tests__/inherited-authorization-parent.test.ts +51 -0
  205. package/src/pipeline/__tests__/kind-by-enum-name.test.ts +107 -0
  206. package/src/pipeline/__tests__/kind-served-by-edition.test.ts +107 -0
  207. package/src/pipeline/apiresource-meta.ts +218 -1
  208. package/src/pipeline/interceptors/auth.ts +14 -1
  209. package/src/pipeline/steps/__tests__/authorize-annotation-completeness.test.ts +73 -3
  210. package/src/pipeline/steps/__tests__/authorize.test.ts +134 -9
  211. package/src/pipeline/steps/__tests__/derived-id.test.ts +49 -0
  212. package/src/pipeline/steps/authorization-tuples.ts +7 -28
  213. package/src/pipeline/steps/authorize.ts +46 -21
  214. package/src/pipeline/steps/defaults.ts +42 -0
  215. package/src/pipeline/steps/shapes.ts +31 -0
  216. package/src/store/__tests__/port-contract.test.ts +123 -0
  217. package/src/store/port-contract.ts +102 -0
@@ -4,9 +4,11 @@
4
4
  * annotation's byte-pinned error_msg; unavailable → INTERNAL, never a
5
5
  * softened denial), the skip arms (internal caller class, is_public,
6
6
  * is_skip_authorization, no-config methods), check-target resolution
7
- * (field_path, static resource_id, absent field = empty id — never a
8
- * throw), and the mid-chain resolved-id pattern the traced ListVersions
9
- * handlers port onto.
7
+ * (field_path, static resource_id, absent field = empty id, a string
8
+ * resource_kind_path resolved by enum name with an unknown name = the
9
+ * unknown kind — never a throw), the direct-handler override in both its
10
+ * shapes (a server-side id; a server-side kind AND id), and the mid-chain
11
+ * resolved-id pattern the traced ListVersions handlers port onto.
10
12
  */
11
13
  import { describe, expect, it } from "vitest";
12
14
  import { create } from "@bufbuild/protobuf";
@@ -17,6 +19,7 @@ import { AgentSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb"
17
19
  import { AgentExecutionCommandController } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/command_pb";
18
20
  import { AgentExecutionSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
19
21
  import { IamPolicyCommandController } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/command_pb";
22
+ import { IamPolicyQueryController } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/query_pb";
20
23
  import { IamPermission } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
21
24
  import { PlatformQueryController } from "@stigmer/protos/ai/stigmer/platform/v1/server_info_pb";
22
25
  import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
@@ -37,9 +40,7 @@ import {
37
40
  } from "../authorize.js";
38
41
 
39
42
  /** Awaits the rejection and returns it as a ConnectError. */
40
- async function captureError(
41
- run: () => Promise<void>,
42
- ): Promise<ConnectError> {
43
+ async function captureError(run: () => Promise<void>): Promise<ConnectError> {
43
44
  try {
44
45
  await run();
45
46
  } catch (error) {
@@ -290,6 +291,75 @@ describe("check-target resolution (never a throw — byte-identity)", () => {
290
291
  },
291
292
  ]);
292
293
  });
294
+
295
+ // 20260913.01 (Q-OR-2): the IamPolicy RPCs name their target inside the
296
+ // request as a STRING kind (`ApiResourceRef.kind`, "organization"), so
297
+ // `resource_kind_path` resolves a string through the kind enum's names.
298
+ // The option had no user before this entry; a numeric field still works.
299
+ it("resource_kind_path over a string field resolves through the kind enum's names (IamPolicy create)", async () => {
300
+ const { authorizer, checks } = fakeAuthorizer({ kind: "allow" });
301
+ const method = IamPolicyCommandController.method.create;
302
+ const step = newAuthorizeStep(method, authorizer);
303
+ const ctx = new RequestContext(
304
+ method.input,
305
+ create(method.input, {
306
+ principal: { kind: "identity_account", id: "ida_alice" },
307
+ relation: "member",
308
+ resource: { kind: "organization", id: "acme" },
309
+ }),
310
+ testCallerIdentity(),
311
+ );
312
+ await step.execute(ctx);
313
+ expect(checks).toEqual([
314
+ {
315
+ permission: IamPermission.can_grant_access,
316
+ resourceKind: ApiResourceKind.organization,
317
+ resourceId: "acme",
318
+ },
319
+ ]);
320
+ });
321
+
322
+ it("an unknown kind name resolves to the unknown kind — never a throw; the Authorizer owns the decision", async () => {
323
+ const { authorizer, checks } = fakeAuthorizer({ kind: "allow" });
324
+ const method = IamPolicyCommandController.method.create;
325
+ const step = newAuthorizeStep(method, authorizer);
326
+ const ctx = new RequestContext(
327
+ method.input,
328
+ create(method.input, {
329
+ principal: { kind: "identity_account", id: "ida_alice" },
330
+ relation: "member",
331
+ resource: { kind: "spaceship", id: "acme" },
332
+ }),
333
+ testCallerIdentity(),
334
+ );
335
+ await expect(step.execute(ctx)).resolves.toBeUndefined();
336
+ expect(checks[0]?.resourceKind).toBe(
337
+ ApiResourceKind.api_resource_kind_unknown,
338
+ );
339
+ expect(checks[0]?.resourceId).toBe("acme");
340
+ });
341
+
342
+ it("revokeOrgAccess names the organization statically and its id by field_path (organization_id)", async () => {
343
+ const { authorizer, checks } = fakeAuthorizer({ kind: "allow" });
344
+ const method = IamPolicyCommandController.method.revokeOrgAccess;
345
+ const step = newAuthorizeStep(method, authorizer);
346
+ const ctx = new RequestContext(
347
+ method.input,
348
+ create(method.input, {
349
+ identityAccountId: "ida_alice",
350
+ organizationId: "acme",
351
+ }),
352
+ testCallerIdentity(),
353
+ );
354
+ await step.execute(ctx);
355
+ expect(checks).toEqual([
356
+ {
357
+ permission: IamPermission.can_grant_access,
358
+ resourceKind: ApiResourceKind.organization,
359
+ resourceId: "acme",
360
+ },
361
+ ]);
362
+ });
293
363
  });
294
364
 
295
365
  describe("authorizeDirect (the direct-handler arm, C2 Stage 4)", () => {
@@ -348,6 +418,49 @@ describe("authorizeDirect (the direct-handler arm, C2 Stage 4)", () => {
348
418
  ]);
349
419
  });
350
420
 
421
+ // 20260913.01 (Q-OR-2): the IamPolicy `get(IamPolicyId)` lane. Its
422
+ // annotation names a permission and NO kind, because the target is the
423
+ // loaded row's resource — kind AND id are server-side state. The
424
+ // override carries both; the annotation still owns the permission and
425
+ // the copy.
426
+ it("the target override may carry the resource KIND too — the IamPolicy get lane", async () => {
427
+ const { authorizer, checks } = fakeAuthorizer({ kind: "allow" });
428
+ const method = IamPolicyQueryController.method.get;
429
+ await authorizeDirect(
430
+ method,
431
+ authorizer,
432
+ testCallerIdentity(),
433
+ create(method.input, { value: "iamp_01row" }),
434
+ { resourceKind: ApiResourceKind.organization, resourceId: "acme" },
435
+ );
436
+ expect(checks).toEqual([
437
+ {
438
+ permission: IamPermission.can_view_access,
439
+ resourceKind: ApiResourceKind.organization,
440
+ resourceId: "acme",
441
+ },
442
+ ]);
443
+ });
444
+
445
+ it("an override with the id alone leaves the annotation's kind in place — the completeOAuthConnect lane is unchanged", async () => {
446
+ const { authorizer, checks } = fakeAuthorizer({ kind: "allow" });
447
+ const method = IamPolicyQueryController.method.get;
448
+ // A kind-less annotation plus an id-only override: the kind stays
449
+ // unknown, exactly what the annotation says. The lane that needs a
450
+ // kind must say so; nothing is inferred.
451
+ await authorizeDirect(
452
+ method,
453
+ authorizer,
454
+ testCallerIdentity(),
455
+ create(method.input, { value: "iamp_01row" }),
456
+ { resourceId: "acme" },
457
+ );
458
+ expect(checks[0]?.resourceKind).toBe(
459
+ ApiResourceKind.api_resource_kind_unknown,
460
+ );
461
+ expect(checks[0]?.resourceId).toBe("acme");
462
+ });
463
+
351
464
  it("the ruling-Q1 not-found arm maps identically from the direct entry", async () => {
352
465
  const { authorizer } = fakeAuthorizer({ kind: "not-found" });
353
466
  const error = await authorizeDirect(
@@ -438,18 +551,30 @@ describe("authorizeResolvedResource (the mid-chain resolved-id pattern, 20260830
438
551
  ),
439
552
  );
440
553
  expect(err.code).toBe(Code.PermissionDenied);
441
- expect(err.rawMessage).toBe("unauthorized to view workflow version history");
554
+ expect(err.rawMessage).toBe(
555
+ "unauthorized to view workflow version history",
556
+ );
442
557
  });
443
558
 
444
559
  it("deny without lane copy falls back to the reason, then the shared fallback", async () => {
445
560
  const reasoned = fakeAuthorizer({ kind: "deny", reason: "because" });
446
561
  const err1 = await captureError(() =>
447
- authorizeResolvedResource(reasoned.authorizer, testCallerIdentity(), check, ""),
562
+ authorizeResolvedResource(
563
+ reasoned.authorizer,
564
+ testCallerIdentity(),
565
+ check,
566
+ "",
567
+ ),
448
568
  );
449
569
  expect(err1.rawMessage).toBe("because");
450
570
  const bare = fakeAuthorizer({ kind: "deny", reason: "" });
451
571
  const err2 = await captureError(() =>
452
- authorizeResolvedResource(bare.authorizer, testCallerIdentity(), check, ""),
572
+ authorizeResolvedResource(
573
+ bare.authorizer,
574
+ testCallerIdentity(),
575
+ check,
576
+ "",
577
+ ),
453
578
  );
454
579
  expect(err2.rawMessage).toBe("permission denied");
455
580
  });
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Pins `derivedId`, the one encoder behind every id a kind derives from its
3
+ * natural key instead of minting (metadata.proto's `id` comment: a direct
4
+ * IdentityAccount from its subject, an IamPolicy from its triple; the
5
+ * Organization's slug needs no encoding). Lifted from the identity-account
6
+ * domain in 20260913.01 slice 2 so the second derived id did not carry a
7
+ * second copy of the grammar.
8
+ *
9
+ * The vectors here are the two domains' golden vectors restated through
10
+ * the shared function: `accountIdFor` and `policyIdFor` must equal
11
+ * `derivedId(prefix, text)` over their canonical text, and those domain
12
+ * tests pin the wire-adjacent constants. A change to the encoding
13
+ * re-addresses every derived row open source ever wrote.
14
+ */
15
+ import { describe, expect, it } from "vitest";
16
+
17
+ import { derivedId } from "../defaults.js";
18
+
19
+ const CROCKFORD_26 = /^[0-9abcdefghjkmnpqrstvwxyz]{26}$/;
20
+
21
+ describe("derivedId — prefix, underscore, the top 130 bits of sha256 as 26 Crockford chars", () => {
22
+ it("matches the identity-account golden vector", () => {
23
+ // accountIdFor("auth0|user123") as pinned in identityaccount/__tests__/constants.test.ts.
24
+ expect(derivedId("ida", "auth0|user123")).toBe(
25
+ "ida_wtr3jcf281yfk9xx61kj59fsme",
26
+ );
27
+ });
28
+
29
+ it("matches the IamPolicy golden vector over the canonical triple text", () => {
30
+ expect(
31
+ derivedId(
32
+ "iamp",
33
+ "identity_account:ida_wtr3jcf281yfk9xx61kj59fsme#@organization:acme#admin",
34
+ ),
35
+ ).toBe("iamp_vkbjb6nvqm77vtwnr2bj0x95f9");
36
+ });
37
+
38
+ it("has the minted id's shape whatever the prefix, so nothing downstream learns a second grammar", () => {
39
+ const id = derivedId("xyz", "anything");
40
+ expect(id.startsWith("xyz_")).toBe(true);
41
+ expect(id.slice("xyz_".length)).toMatch(CROCKFORD_26);
42
+ });
43
+
44
+ it("is a pure function of prefix and text: equal inputs, equal ids; a changed byte, a changed id", () => {
45
+ expect(derivedId("ida", "same")).toBe(derivedId("ida", "same"));
46
+ expect(derivedId("ida", "same")).not.toBe(derivedId("ida", "same "));
47
+ expect(derivedId("ida", "same")).not.toBe(derivedId("iamp", "same"));
48
+ });
49
+ });
@@ -78,7 +78,7 @@ import type { PipelineStep } from "../pipeline.js";
78
78
  import type { RequestContext } from "../request-context.js";
79
79
  import { EXISTING_RESOURCE_KEY } from "./load-existing.js";
80
80
  import type { HasMetadataShape } from "./shapes.js";
81
- import { metadataOf } from "./shapes.js";
81
+ import { metadataOf, parentIdOf } from "./shapes.js";
82
82
 
83
83
  // ---------------------------------------------------------------------------
84
84
  // Visibility shape policy — the reconciler's level→shape mapping, ported
@@ -156,40 +156,19 @@ export function diffVisibilityShapes(
156
156
  }
157
157
 
158
158
  // ---------------------------------------------------------------------------
159
- // Parent-id resolution — the ParentIdExtractorRegistry port. Zero
160
- // hardcoded kind knowledge: the proto config names the spec field, this
161
- // module reads it structurally (protobuf-es spec messages are plain
162
- // objects whose properties are the camelCase proto field names).
159
+ // Parent-id resolution — the ParentIdExtractorRegistry port. The spec-field
160
+ // read itself is shapes.ts's `parentIdOf`, shared with the list read scope
161
+ // (20260913.04 T04) so the id a tuple is written with and the id the scope
162
+ // asks about are one read. This module adds the create-time rule: a
163
+ // missing parent fails the request.
163
164
  // ---------------------------------------------------------------------------
164
165
 
165
- /** proto snake_case field name → the generated property name. */
166
- function camelCaseFieldName(specField: string): string {
167
- return specField.replace(/_([a-z0-9])/g, (_, ch: string) => ch.toUpperCase());
168
- }
169
-
170
- /**
171
- * Extracts the parent id named by the config from the resource's spec.
172
- * Returns "" for every miss (no spec, no field, non-string value) — the
173
- * caller decides whether that is fatal, exactly as Java's registry
174
- * returns null and the service throws.
175
- */
176
- function extractParentId(resource: Message, specField: string): string {
177
- // Structural access is the shapes.ts idiom: spec messages are plain
178
- // objects; the property name is the camelCase form of the proto field.
179
- const spec = (resource as unknown as { spec?: Record<string, unknown> }).spec;
180
- if (spec === undefined) {
181
- return "";
182
- }
183
- const value = spec[camelCaseFieldName(specField)];
184
- return typeof value === "string" ? value : "";
185
- }
186
-
187
166
  function resolveParentLink(
188
167
  kind: ApiResourceKind,
189
168
  resource: Message,
190
169
  parentConfig: ParentRelationConfig,
191
170
  ): ResolvedParentLink {
192
- const parentId = extractParentId(resource, parentConfig.specField);
171
+ const parentId = parentIdOf(resource, parentConfig.specField);
193
172
  if (parentId === "") {
194
173
  // Java: "Parent ID required for X but not found" → the request fails.
195
174
  throw internalError(
@@ -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
+ }
@@ -39,6 +39,37 @@ export function idValueOf(msg: Message): string {
39
39
  return typeof value === "string" ? value : "";
40
40
  }
41
41
 
42
+ /** proto snake_case field name → the generated property name. */
43
+ function camelCaseFieldName(specField: string): string {
44
+ return specField.replace(/_([a-z0-9])/g, (_, ch: string) => ch.toUpperCase());
45
+ }
46
+
47
+ /**
48
+ * The parent id a `kind_meta` ParentRelationConfig names by `spec_field`,
49
+ * read from the resource's spec — the ParentIdExtractorRegistry port, with
50
+ * zero hardcoded kind knowledge: the proto config names the field, this
51
+ * reads it structurally (spec messages are plain objects whose properties
52
+ * are the camelCase proto field names). Returns "" for every miss (no
53
+ * spec, no field, non-string value); the caller decides whether that is
54
+ * fatal — the tuple lifecycle throws at create (Java's registry returned
55
+ * null and the service threw), the list scope carries no parent.
56
+ *
57
+ * ONE reader for both: the id the authorization tuple was written with is
58
+ * the id the list scope later asks the authorization backend about, so
59
+ * the two cannot drift (20260913.04 T04). Takes `object` rather than
60
+ * `Message` because the list lanes hand the scope structurally-typed rows
61
+ * and their unit suite hands it plain objects — the same posture
62
+ * `metadataOf` takes behind its cast.
63
+ */
64
+ export function parentIdOf(resource: object, specField: string): string {
65
+ const spec = (resource as { spec?: Record<string, unknown> }).spec;
66
+ if (spec === undefined) {
67
+ return "";
68
+ }
69
+ const value = spec[camelCaseFieldName(specField)];
70
+ return typeof value === "string" ? value : "";
71
+ }
72
+
42
73
  /** A message-typed field, statically narrowed so get()/set() type-check. */
43
74
  export type MessageDescField = Extract<DescField, { fieldKind: "message" }>;
44
75
 
@@ -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
+ });