@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
@@ -0,0 +1,450 @@
1
+ /**
2
+ * The IamPolicyStore PORT-CONTRACT KIT (20260913.01, T01_0_plan.md §3a;
3
+ * the 2a A11/A12 discipline): every behavior an implementation of store.ts
4
+ * must satisfy identically, as cases a driver's test iterates. Open source
5
+ * runs them over its own adapter (resource-store.ts) on sqlite and Postgres
6
+ * in __tests__/resource-store.test.ts; a composition runs the SAME cases
7
+ * over the store it registers as `drivers.iamPolicyStore` (the cloud's
8
+ * `cloud.iam_policy` store, S3), so "the port holds" is one statement
9
+ * proven per driver, never restated per repository.
10
+ *
11
+ * Shape. A list of declarations over the port-contract runner
12
+ * (store/port-contract.ts): the runner owns the fresh fixture per case,
13
+ * the cleanup, the rule that a failing assertion wins over a failing
14
+ * cleanup, and the reason a kit returns cases instead of calling vitest's
15
+ * `describe`. `disconnect` is the one escape hatch: the port has no
16
+ * lifecycle by design, yet "an outage never reads as no grant" is the line
17
+ * that matters most.
18
+ *
19
+ * The corners A12 taught are cases from the start, not later additions:
20
+ * every relation on a pair; distinct principals, not rows; the scope-tuple
21
+ * exclusions verbatim; unknown-id delete a no-op; a held id refused.
22
+ *
23
+ * What the kit deliberately does not carry: open source's derived-id
24
+ * refusal and its primary-key read, the cloud's column layout and SQLSTATE
25
+ * 23505 mapping — driver-physical, each driver's own tests. Every fixture
26
+ * row carries its triple's DERIVED id, so "a held triple" and "a held id"
27
+ * are one case here; a composition's legacy random ids are the grant
28
+ * path's by-triple read to converge (store.ts header), not a store promise.
29
+ *
30
+ * The duplicate arm checks `instanceof DuplicatePolicyError` on the
31
+ * exported class, because that is how the grant path's race arm catches
32
+ * it: a driver throwing a lookalike fails here for the reason it would
33
+ * fail a grant.
34
+ *
35
+ * Case names are the contract lines, in the port's words. The OSS test pins
36
+ * the full list, so a case cannot drop out of the kit unnoticed.
37
+ */
38
+ import assert from "node:assert/strict";
39
+
40
+ import { create, equals } from "@bufbuild/protobuf";
41
+
42
+ import { ApiResourceMetadataSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/metadata_pb";
43
+ import type { IamPolicy } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/api_pb";
44
+ import { IamPolicySchema } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/api_pb";
45
+ import type { IamPolicySpec } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/spec_pb";
46
+ import { IamPolicySpecSchema } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/spec_pb";
47
+
48
+ import { portContractCases } from "../../store/port-contract.js";
49
+ import type {
50
+ PortContractCase,
51
+ PortContractDeclaration,
52
+ PortContractFixture,
53
+ } from "../../store/port-contract.js";
54
+ import {
55
+ IAM_POLICY_API_VERSION,
56
+ IAM_POLICY_KIND,
57
+ policyIdFor,
58
+ } from "./constants.js";
59
+ import { DuplicatePolicyError } from "./store.js";
60
+ import type { IamPolicyStore } from "./store.js";
61
+
62
+ /** The runner's fixture over this port — the name a driver's test and the barrel know it by. */
63
+ export type IamPolicyStoreContractFixture = PortContractFixture<IamPolicyStore>;
64
+
65
+ export type IamPolicyStoreContractCase = PortContractCase;
66
+
67
+ const ALICE = "ida_wtr3jcf281yfk9xx61kj59fsme";
68
+ const BOB = "ida_0byc5k14t1e7b7kdxft7hwz1f7";
69
+ const AGENT = "agt_01hzzzzzzzzzzzzzzzzzzzzzzz";
70
+
71
+ function spec(
72
+ principal: { kind: string; id: string; relation?: string },
73
+ relation: string,
74
+ resource: { kind: string; id: string },
75
+ ): IamPolicySpec {
76
+ return create(IamPolicySpecSchema, {
77
+ principal: {
78
+ kind: principal.kind,
79
+ id: principal.id,
80
+ relation: principal.relation ?? "",
81
+ },
82
+ relation,
83
+ resource: { kind: resource.kind, id: resource.id },
84
+ });
85
+ }
86
+
87
+ /** `identity_account:<id>` holds `<relation>` on `organization:<org>`. */
88
+ function orgRole(
89
+ accountId: string,
90
+ relation: string,
91
+ orgId: string,
92
+ ): IamPolicySpec {
93
+ return spec({ kind: "identity_account", id: accountId }, relation, {
94
+ kind: "organization",
95
+ id: orgId,
96
+ });
97
+ }
98
+
99
+ /**
100
+ * A row as the domain writes it: the contract's apiVersion and kind, the
101
+ * triple's derived id, and a creator stamp so two rows for one triple can
102
+ * be told apart ("the first row stands").
103
+ */
104
+ function policyRow(policySpec: IamPolicySpec, createdBy = ALICE): IamPolicy {
105
+ return create(IamPolicySchema, {
106
+ apiVersion: IAM_POLICY_API_VERSION,
107
+ kind: IAM_POLICY_KIND,
108
+ metadata: create(ApiResourceMetadataSchema, {
109
+ id: policyIdFor(policySpec),
110
+ }),
111
+ spec: policySpec,
112
+ status: { audit: { specAudit: { createdBy: { id: createdBy } } } },
113
+ });
114
+ }
115
+
116
+ function idOf(policy: IamPolicy): string {
117
+ const id = policy.metadata?.id ?? "";
118
+ assert.notEqual(id, "", "a fixture policy must carry an id");
119
+ return id;
120
+ }
121
+
122
+ function relationsOf(policies: ReadonlyArray<IamPolicy>): string[] {
123
+ return policies.map((policy) => policy.spec?.relation ?? "").sort();
124
+ }
125
+
126
+ async function saveAll(
127
+ store: IamPolicyStore,
128
+ policies: ReadonlyArray<IamPolicy>,
129
+ ): Promise<void> {
130
+ for (const policy of policies) {
131
+ await store.save(policy);
132
+ }
133
+ }
134
+
135
+ const CASES: ReadonlyArray<PortContractDeclaration<IamPolicyStore>> = [
136
+ [
137
+ "save then findById round-trips the policy",
138
+ async ({ store }) => {
139
+ const policy = policyRow(orgRole(ALICE, "admin", "acme"));
140
+ await store.save(policy);
141
+ const found = await store.findById(idOf(policy));
142
+ assert.ok(found !== undefined, "a saved policy must be readable by id");
143
+ assert.ok(
144
+ equals(IamPolicySchema, found, policy),
145
+ "findById must return the policy exactly as it was saved",
146
+ );
147
+ assert.equal(
148
+ await store.findById("iamp_00000000000000000000000000"),
149
+ undefined,
150
+ "an unknown id must read as undefined, not as an error",
151
+ );
152
+ },
153
+ ],
154
+ [
155
+ "a second save under a held triple is DuplicatePolicyError, and the first row stands",
156
+ async ({ store }) => {
157
+ const triple = orgRole(ALICE, "admin", "acme");
158
+ await store.save(policyRow(triple, ALICE));
159
+ await assert.rejects(
160
+ store.save(policyRow(triple, BOB)),
161
+ DuplicatePolicyError,
162
+ "save under a held id must raise the exported DuplicatePolicyError, never overwrite",
163
+ );
164
+ const standing = await store.findById(policyIdFor(triple));
165
+ assert.equal(
166
+ standing?.status?.audit?.specAudit?.createdBy?.id,
167
+ ALICE,
168
+ "the first writer's row must stand after a refused duplicate",
169
+ );
170
+ },
171
+ ],
172
+ [
173
+ "findByPrincipalAndResource answers every relation on the pair and nothing on another",
174
+ async ({ store }) => {
175
+ await saveAll(store, [
176
+ policyRow(orgRole(ALICE, "admin", "acme")),
177
+ policyRow(orgRole(ALICE, "viewer", "acme")),
178
+ policyRow(orgRole(ALICE, "member", "globex")),
179
+ policyRow(orgRole(BOB, "member", "acme")),
180
+ ]);
181
+ assert.deepEqual(
182
+ relationsOf(
183
+ await store.findByPrincipalAndResource(
184
+ "identity_account",
185
+ ALICE,
186
+ "organization",
187
+ "acme",
188
+ ),
189
+ ),
190
+ ["admin", "viewer"],
191
+ "the pair's rows are every relation the principal holds on the resource",
192
+ );
193
+ assert.deepEqual(
194
+ await store.findByPrincipalAndResource(
195
+ "identity_account",
196
+ BOB,
197
+ "organization",
198
+ "globex",
199
+ ),
200
+ [],
201
+ "a pair with no rows must answer an empty list",
202
+ );
203
+ },
204
+ ],
205
+ [
206
+ "findByPrincipal and findByResource answer each side of a row",
207
+ async ({ store }) => {
208
+ const structural = spec(
209
+ { kind: "organization", id: "acme" },
210
+ "organization",
211
+ { kind: "agent", id: AGENT },
212
+ );
213
+ await saveAll(store, [
214
+ policyRow(orgRole(ALICE, "owner", "acme")),
215
+ policyRow(structural),
216
+ policyRow(orgRole(BOB, "member", "globex")),
217
+ ]);
218
+ assert.deepEqual(
219
+ (await store.findByPrincipal("organization", "acme")).map(idOf),
220
+ [policyIdFor(structural)],
221
+ "findByPrincipal answers the rows where the ref is the principal",
222
+ );
223
+ assert.deepEqual(
224
+ (await store.findByResource("organization", "acme")).map(idOf),
225
+ [policyIdFor(orgRole(ALICE, "owner", "acme"))],
226
+ "findByResource answers the rows where the ref is the resource",
227
+ );
228
+ assert.deepEqual(
229
+ await store.findByResource("organization", "nowhere"),
230
+ [],
231
+ "a resource with no rows must answer an empty list",
232
+ );
233
+ },
234
+ ],
235
+ [
236
+ "findByResourceWithRelations filters to the allowlist; an empty allowlist matches nothing",
237
+ async ({ store }) => {
238
+ await saveAll(store, [
239
+ policyRow(orgRole(ALICE, "admin", "acme")),
240
+ policyRow(orgRole(BOB, "member", "acme")),
241
+ policyRow(
242
+ spec({ kind: "organization", id: "acme" }, "organization", {
243
+ kind: "organization",
244
+ id: "acme",
245
+ }),
246
+ ),
247
+ ]);
248
+ assert.deepEqual(
249
+ relationsOf(
250
+ await store.findByResourceWithRelations("organization", "acme", [
251
+ "admin",
252
+ "member",
253
+ "viewer",
254
+ ]),
255
+ ),
256
+ ["admin", "member"],
257
+ "only rows whose relation is in the allowlist are answered",
258
+ );
259
+ assert.deepEqual(
260
+ await store.findByResourceWithRelations("organization", "acme", []),
261
+ [],
262
+ "an empty allowlist must match nothing on every edition",
263
+ );
264
+ },
265
+ ],
266
+ [
267
+ "countDistinctPrincipalsByResource counts (kind, id) pairs, not rows, optionally by principal kind",
268
+ async ({ store }) => {
269
+ const roles = ["owner", "admin", "member", "viewer"];
270
+ await saveAll(store, [
271
+ policyRow(orgRole(ALICE, "admin", "acme")),
272
+ policyRow(orgRole(ALICE, "viewer", "acme")),
273
+ policyRow(orgRole(BOB, "member", "acme")),
274
+ policyRow(
275
+ spec({ kind: "organization", id: "globex" }, "member", {
276
+ kind: "organization",
277
+ id: "acme",
278
+ }),
279
+ ),
280
+ policyRow(orgRole(BOB, "member", "globex")),
281
+ ]);
282
+ assert.equal(
283
+ await store.countDistinctPrincipalsByResource(
284
+ "organization",
285
+ "acme",
286
+ undefined,
287
+ roles,
288
+ ),
289
+ 3,
290
+ "alice (two rows), bob and the organization principal are three principals, not four rows",
291
+ );
292
+ assert.equal(
293
+ await store.countDistinctPrincipalsByResource(
294
+ "organization",
295
+ "acme",
296
+ "identity_account",
297
+ roles,
298
+ ),
299
+ 2,
300
+ "the principal-kind filter narrows to identity accounts",
301
+ );
302
+ assert.equal(
303
+ await store.countDistinctPrincipalsByResource(
304
+ "organization",
305
+ "acme",
306
+ undefined,
307
+ ["owner"],
308
+ ),
309
+ 0,
310
+ "no row in the allowlist is a count of zero",
311
+ );
312
+ },
313
+ ],
314
+ [
315
+ "findScopeTuple skips identity_account and team principals and owner and creator relations",
316
+ async ({ store }) => {
317
+ const scope = spec({ kind: "organization", id: "acme" }, "organization", {
318
+ kind: "agent",
319
+ id: AGENT,
320
+ });
321
+ await saveAll(store, [
322
+ policyRow(
323
+ spec({ kind: "identity_account", id: ALICE }, "owner", {
324
+ kind: "agent",
325
+ id: AGENT,
326
+ }),
327
+ ),
328
+ policyRow(
329
+ spec({ kind: "team", id: "tm_1", relation: "member" }, "viewer", {
330
+ kind: "agent",
331
+ id: AGENT,
332
+ }),
333
+ ),
334
+ policyRow(
335
+ spec({ kind: "organization", id: "acme" }, "creator", {
336
+ kind: "agent",
337
+ id: AGENT,
338
+ }),
339
+ ),
340
+ policyRow(scope),
341
+ ]);
342
+ assert.equal(
343
+ (await store.findScopeTuple("agent", AGENT))?.metadata?.id,
344
+ policyIdFor(scope),
345
+ "the one structural link is the row whose principal and relation are both structural",
346
+ );
347
+ assert.equal(
348
+ await store.findScopeTuple("agent", "agt_nowhere"),
349
+ undefined,
350
+ "a resource with no structural link must read as undefined",
351
+ );
352
+ },
353
+ ],
354
+ [
355
+ "deleteById removes the row; the triple is free to be granted again",
356
+ async ({ store }) => {
357
+ const policy = policyRow(orgRole(ALICE, "admin", "acme"));
358
+ await store.save(policy);
359
+ await store.deleteById(idOf(policy));
360
+ assert.equal(
361
+ await store.findById(idOf(policy)),
362
+ undefined,
363
+ "a deleted policy must not be readable by id",
364
+ );
365
+ assert.deepEqual(
366
+ await store.findByPrincipalAndResource(
367
+ "identity_account",
368
+ ALICE,
369
+ "organization",
370
+ "acme",
371
+ ),
372
+ [],
373
+ "a deleted policy must not be readable by its pair",
374
+ );
375
+ await assert.doesNotReject(
376
+ store.save(policy),
377
+ "the triple of a deleted policy must be savable again",
378
+ );
379
+ },
380
+ ],
381
+ [
382
+ "deleteById of an unknown id resolves",
383
+ async ({ store }) => {
384
+ await assert.doesNotReject(
385
+ store.deleteById("iamp_00000000000000000000000000"),
386
+ "deleting an id that is not held must resolve, not reject",
387
+ );
388
+ },
389
+ ],
390
+ [
391
+ "a disconnected store is an infrastructure fault, never 'not found'",
392
+ async (fixture) => {
393
+ await fixture.disconnect();
394
+ await assert.rejects(
395
+ fixture.store.findById(policyIdFor(orgRole(ALICE, "admin", "acme"))),
396
+ "an id read on a disconnected store must reject, never read as undefined",
397
+ );
398
+ await assert.rejects(
399
+ fixture.store.findByPrincipalAndResource(
400
+ "identity_account",
401
+ ALICE,
402
+ "organization",
403
+ "acme",
404
+ ),
405
+ "a pair read on a disconnected store must reject, never read as an empty list",
406
+ );
407
+ },
408
+ ],
409
+ [
410
+ "two concurrent saves of one triple end in one row and DuplicatePolicyErrors only",
411
+ async ({ store }) => {
412
+ const triple = orgRole(ALICE, "admin", "acme");
413
+ const results = await Promise.allSettled([
414
+ store.save(policyRow(triple, ALICE)),
415
+ store.save(policyRow(triple, BOB)),
416
+ ]);
417
+ for (const result of results) {
418
+ if (result.status === "rejected") {
419
+ assert.ok(
420
+ result.reason instanceof DuplicatePolicyError,
421
+ `a losing concurrent save must fail as DuplicatePolicyError, got ${String(result.reason)}`,
422
+ );
423
+ }
424
+ }
425
+ assert.ok(
426
+ results.some((result) => result.status === "fulfilled"),
427
+ "at least one of two concurrent saves must win",
428
+ );
429
+ const rows = await store.findByPrincipalAndResource(
430
+ "identity_account",
431
+ ALICE,
432
+ "organization",
433
+ "acme",
434
+ );
435
+ assert.equal(rows.length, 1, "one triple must end as exactly one row");
436
+ assert.equal(
437
+ rows[0]?.metadata?.id,
438
+ policyIdFor(triple),
439
+ "the surviving row carries the triple's derived id",
440
+ );
441
+ },
442
+ ],
443
+ ];
444
+
445
+ /** The port's cases over `makeFixture`, one fresh fixture per case. */
446
+ export function iamPolicyStoreContract(
447
+ makeFixture: () => Promise<IamPolicyStoreContractFixture>,
448
+ ): ReadonlyArray<IamPolicyStoreContractCase> {
449
+ return portContractCases(CASES, makeFixture);
450
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * The IamPolicyStore PORT (20260913.01, T01_0_plan.md §3a; Q-OR-1): the
3
+ * storage contract the IamPolicy domain writes and reads through, cut from
4
+ * the cloud's row store (iam/policy/store.ts) to what BOTH editions call.
5
+ * The second OSS domain whose persistence is a port rather than the
6
+ * generic Store (the identity account was the first): the cloud serves the
7
+ * same controller over its own `cloud.iam_policy` table by registering an
8
+ * implementation as `drivers.iamPolicyStore`, so the domain's grant path
9
+ * and handlers take this interface, never `Store`. Open source's own
10
+ * implementation is resource-store.ts over the generic Store.
11
+ *
12
+ * What the port deliberately does not carry: `markSynced` and
13
+ * `findUnsynced` (the cloud's tuple-mirror bookkeeping and boot backfill;
14
+ * its store keeps them as extras) and `findByIdentityAccountInOrg` (the
15
+ * cloud's org-column arm of revocation — the column was never stamped by
16
+ * any writer, so it matched nothing; dropped, Q-OR-9). A port does not
17
+ * carry methods only one edition calls or no edition needs.
18
+ *
19
+ * Contract every implementation must satisfy — proven by the port-contract
20
+ * kit (store-contract.ts, exported), which the OSS adapter's test iterates
21
+ * on both drivers and a composition's driver test iterates too:
22
+ * - `save` is create-only in effect: a save under a held ID raises
23
+ * DuplicatePolicyError, never a silent overwrite. Under derived ids a
24
+ * held triple IS a held id, which is what the kit exercises; where a
25
+ * composition still holds legacy random ids, "one row per triple" is
26
+ * the grant path's by-triple read (grant-path.ts), not this promise;
27
+ * - `deleteById` of an unknown id resolves;
28
+ * - the finds answer rows by the axes named, exactly:
29
+ * `findByPrincipalAndResource` every relation on the pair;
30
+ * `findByResourceWithRelations` the allowlist only, an empty allowlist
31
+ * matching nothing; `countDistinctPrincipalsByResource` distinct
32
+ * (kind, id) principals — not rows — over the allowlist, `undefined`
33
+ * principal kind meaning any (callers normalize the wire's `""`);
34
+ * `findScopeTuple` the resource's one structural link — a principal
35
+ * that is not `identity_account` or `team` and a relation that is not
36
+ * `owner` or `creator`, the cloud SQL's exclusion arms verbatim (`team`
37
+ * is a Java-era name no ApiResourceKind carries; kept so the two
38
+ * editions' filters read alike);
39
+ * - a typed not-found reads as `undefined`; any other storage failure
40
+ * propagates as the infrastructure fault it is (the ratified
41
+ * store-fault mapping — an outage must never read as "no grant").
42
+ */
43
+ import type { IamPolicy } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/api_pb";
44
+
45
+ /**
46
+ * Raised by `save` when the id is already held, so the grant path runs its
47
+ * race arm (re-read the winner by triple; answer it as the duplicate).
48
+ * `cause` carries a driver's own error (the cloud's SQLSTATE 23505) for
49
+ * the logs; the message is the port's.
50
+ */
51
+ export class DuplicatePolicyError extends Error {
52
+ constructor(message: string, options?: { cause?: unknown }) {
53
+ super(message, options);
54
+ this.name = "DuplicatePolicyError";
55
+ }
56
+ }
57
+
58
+ export interface IamPolicyStore {
59
+ /** Insert; a held id raises DuplicatePolicyError. */
60
+ save(policy: IamPolicy): Promise<void>;
61
+ /** Removes the row; no error when it does not exist. */
62
+ deleteById(id: string): Promise<void>;
63
+ findById(id: string): Promise<IamPolicy | undefined>;
64
+ /** Every row naming the principal — the cleanup's principal-side arm. */
65
+ findByPrincipal(
66
+ principalKind: string,
67
+ principalId: string,
68
+ ): Promise<ReadonlyArray<IamPolicy>>;
69
+ /** Every row on the resource — the cleanup's resource-side arm. */
70
+ findByResource(
71
+ resourceKind: string,
72
+ resourceId: string,
73
+ ): Promise<ReadonlyArray<IamPolicy>>;
74
+ /** Every relation the principal holds on the resource — the by-triple read's candidates. */
75
+ findByPrincipalAndResource(
76
+ principalKind: string,
77
+ principalId: string,
78
+ resourceKind: string,
79
+ resourceId: string,
80
+ ): Promise<ReadonlyArray<IamPolicy>>;
81
+ /** Rows on the resource whose relation is in the allowlist (the assignable roles); an empty allowlist matches nothing. */
82
+ findByResourceWithRelations(
83
+ resourceKind: string,
84
+ resourceId: string,
85
+ relations: ReadonlyArray<string>,
86
+ ): Promise<ReadonlyArray<IamPolicy>>;
87
+ /** Distinct (kind, id) principals holding an allowlisted relation on the resource; `undefined` kind = any. */
88
+ countDistinctPrincipalsByResource(
89
+ resourceKind: string,
90
+ resourceId: string,
91
+ principalKind: string | undefined,
92
+ relations: ReadonlyArray<string>,
93
+ ): Promise<number>;
94
+ /**
95
+ * The resource's scope tuple (the hierarchy walk's one step): a policy
96
+ * on the resource whose principal is structural (not identity_account
97
+ * or team) and whose relation is structural (not owner or creator).
98
+ */
99
+ findScopeTuple(
100
+ resourceKind: string,
101
+ resourceId: string,
102
+ ): Promise<IamPolicy | undefined>;
103
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The IamPolicy domain's wire refusals (20260913.01; slice 2 ruling
3
+ * Q-S2-1, slice 5 ruling Q-S5-2): the ONE place a kind string that came
4
+ * off the wire, or a whole policy triple, is turned into INVALID_ARGUMENT
5
+ * with the domain's byte-pinned copy (constants.ts). Three callers share
6
+ * it so the rule cannot drift between them — the grant path (before any
7
+ * read, write or lifecycle hook), the create chain's ValidateGrantableRole
8
+ * step (before its role arms, so an unknown kind is never answered with a
9
+ * role sentence) and the query controller (checkMyPermission's resource,
10
+ * checkAuthorization's policy, the two listings' kind strings, the three
11
+ * row reads' refs).
12
+ *
13
+ * Why the match is exact (the `kindByEnumName` doctrine,
14
+ * pipeline/apiresource-meta.ts): an `ApiResourceRef.kind` is the enum
15
+ * MEMBER name and the derived policy id hashes the spec's text (Q-OR-9), so
16
+ * a lenient match would let "Organization" and "organization" mint two
17
+ * rows for one grant. Where the refusal runs (Q-S6-1, 2026-09-14): the
18
+ * controller calls it BEFORE position 1 on every lane, so no Authorizer in
19
+ * any edition is ever asked about a kind that is not a kind — a kind
20
+ * string that names no kind names no authorization target (the cloud's
21
+ * handlers kept this order; the resolver's unknown-kind deny stays as the
22
+ * backstop for a stored row on `get`). The grant path and the
23
+ * ValidateGrantableRole step keep their own calls for the callers that
24
+ * reach them without the controller (the lifecycle, the membership rules,
25
+ * the unit suites), so a garbage row never enters the store from any door.
26
+ *
27
+ * A spec missing either reference is a plain Error, not a refusal: the
28
+ * protovalidate boundary marks both `required`, so absence is a contract
29
+ * bug in a caller that bypassed it, never a client's mistake.
30
+ */
31
+ import { Code, ConnectError } from "@connectrpc/connect";
32
+
33
+ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
34
+ import type {
35
+ ApiResourceRef,
36
+ IamPolicySpec,
37
+ } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/spec_pb";
38
+
39
+ import { kindByEnumName } from "../../pipeline/apiresource-meta.js";
40
+ import {
41
+ malformedTripleField,
42
+ malformedTripleMessage,
43
+ unknownPrincipalKindMessage,
44
+ unknownResourceKindMessage,
45
+ } from "./constants.js";
46
+
47
+ /** A triple the wire may say: both refs present, both kinds resolved. */
48
+ export interface AdmittedTriple {
49
+ readonly principal: ApiResourceRef;
50
+ readonly resource: ApiResourceRef;
51
+ readonly principalKind: ApiResourceKind;
52
+ readonly resourceKind: ApiResourceKind;
53
+ }
54
+
55
+ /** The kind a `resource.kind` string names, or INVALID_ARGUMENT with the cloud's sentence. */
56
+ export function requireKnownResourceKind(kind: string): ApiResourceKind {
57
+ const resolved = kindByEnumName(kind);
58
+ if (resolved === ApiResourceKind.api_resource_kind_unknown) {
59
+ throw new ConnectError(
60
+ unknownResourceKindMessage(kind),
61
+ Code.InvalidArgument,
62
+ );
63
+ }
64
+ return resolved;
65
+ }
66
+
67
+ /** The kind a `principal.kind` string names, or INVALID_ARGUMENT with the principal sentence. */
68
+ export function requireKnownPrincipalKind(kind: string): ApiResourceKind {
69
+ const resolved = kindByEnumName(kind);
70
+ if (resolved === ApiResourceKind.api_resource_kind_unknown) {
71
+ throw new ConnectError(
72
+ unknownPrincipalKindMessage(kind),
73
+ Code.InvalidArgument,
74
+ );
75
+ }
76
+ return resolved;
77
+ }
78
+
79
+ /**
80
+ * The whole triple, in the order the cloud refused it: no delimiter in any
81
+ * field (the canonical text would be ambiguous, Q-S2-1), then the resource
82
+ * kind, then the principal kind.
83
+ */
84
+ export function requireWellFormedTriple(spec: IamPolicySpec): AdmittedTriple {
85
+ const { principal, resource } = refsOf(spec);
86
+ const malformed = malformedTripleField(spec);
87
+ if (malformed !== undefined) {
88
+ throw new ConnectError(
89
+ malformedTripleMessage(malformed),
90
+ Code.InvalidArgument,
91
+ );
92
+ }
93
+ return {
94
+ principal,
95
+ resource,
96
+ resourceKind: requireKnownResourceKind(resource.kind),
97
+ principalKind: requireKnownPrincipalKind(principal.kind),
98
+ };
99
+ }
100
+
101
+ /** Both refs, present — absence is a caller that bypassed the boundary, not wire input. */
102
+ export function refsOf(spec: IamPolicySpec): {
103
+ principal: ApiResourceRef;
104
+ resource: ApiResourceRef;
105
+ } {
106
+ if (spec.principal === undefined || spec.resource === undefined) {
107
+ throw new Error("policy spec must carry principal and resource");
108
+ }
109
+ return { principal: spec.principal, resource: spec.resource };
110
+ }