@stigmer/server 3.15.2 → 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
@@ -18,6 +18,11 @@
18
18
  * provisioning slot fire for both); a first-login race resolves the
19
19
  * winner by subject whatever the failure's shape.
20
20
  *
21
+ * The answer says whether THIS call created the row (`created`; 20260913.01
22
+ * slice 4): the early return and the race loser answer false, so a rule
23
+ * keyed on first provisioning (the membership rules) runs exactly once per
24
+ * account and never on the idempotent path.
25
+ *
21
26
  * Step 4 of the cloud's flow (the personal organization) is not core: it
22
27
  * fires on `identity-account-provision:post-persist` in the composition.
23
28
  */
@@ -126,7 +131,9 @@ describe("provisionDirectAccount", () => {
126
131
  oidcCaller("auth0|alice"),
127
132
  );
128
133
 
129
- expect(second).toEqual(first);
134
+ expect(second.account).toEqual(first.account);
135
+ expect(first.created, "the first call created the row").toBe(true);
136
+ expect(second.created, "the second call found it").toBe(false);
130
137
  expect(path.calls).toHaveLength(1);
131
138
  expect(userInfo.tokens).toEqual(["token-for-auth0|alice"]);
132
139
  });
@@ -140,7 +147,7 @@ describe("provisionDirectAccount", () => {
140
147
  createAccount: path.createAccount,
141
148
  userInfo,
142
149
  });
143
- const account = await provisioner.provisionDirectAccount(
150
+ const { account } = await provisioner.provisionDirectAccount(
144
151
  "auth0|alice",
145
152
  oidcCaller("auth0|alice"),
146
153
  );
@@ -186,7 +193,7 @@ describe("provisionDirectAccount", () => {
186
193
  createAccount: path.createAccount,
187
194
  userInfo,
188
195
  });
189
- const account = await provisioner.provisionDirectAccount(
196
+ const { account } = await provisioner.provisionDirectAccount(
190
197
  "local|operator@example.com",
191
198
  operatorCaller,
192
199
  );
@@ -205,7 +212,7 @@ describe("provisionDirectAccount", () => {
205
212
  createAccount: path.createAccount,
206
213
  userInfo: fakeUserInfo({ ...PROFILE, email: "" }),
207
214
  });
208
- const account = await provisioner.provisionDirectAccount(
215
+ const { account } = await provisioner.provisionDirectAccount(
209
216
  "abc123@clients",
210
217
  oidcCaller("abc123@clients"),
211
218
  );
@@ -233,7 +240,8 @@ describe("provisionDirectAccount", () => {
233
240
  "auth0|carol",
234
241
  oidcCaller("auth0|carol"),
235
242
  );
236
- expect(resolved).toEqual(winner);
243
+ expect(resolved.account).toEqual(winner);
244
+ expect(resolved.created, "the loser did not create the row").toBe(false);
237
245
  expect(accounts.rows.size).toBe(1);
238
246
  });
239
247
 
@@ -274,8 +282,12 @@ describe("provisionDirectAccount", () => {
274
282
  ),
275
283
  ]);
276
284
 
277
- expect(a.metadata?.id).toBe(accountIdFor("auth0|erin"));
278
- expect(b.metadata?.id).toBe(a.metadata?.id);
285
+ expect(a.account.metadata?.id).toBe(accountIdFor("auth0|erin"));
286
+ expect(b.account.metadata?.id).toBe(a.account.metadata?.id);
279
287
  expect(accounts.rows.size).toBe(1);
288
+ expect(
289
+ [a.created, b.created].filter(Boolean),
290
+ "exactly one of the two created the row",
291
+ ).toHaveLength(1);
280
292
  });
281
293
  });
@@ -16,6 +16,20 @@
16
16
  * - a hit whose row carries no id is an infrastructure fault naming the
17
17
  * subject, never a `""` principal — the one deliberate divergence
18
18
  * from the cloud's inline `?? ""`.
19
+ *
20
+ * And the read in the other direction, `accountForCaller` (20260913.01
21
+ * slice 4, Q-S4-1): the account a CallerIdentity stands for, the two
22
+ * primary-key reads whoAmI has always made, stated once so the built-in
23
+ * role lifecycle and whoAmI cannot disagree:
24
+ *
25
+ * - an identityId that IS an account id resolves by that id (the
26
+ * verifier already re-stamped);
27
+ * - otherwise the caller's subject (idpIdOf) resolves through the direct
28
+ * lookup — the trusted-local operator's `local|<email>` and a
29
+ * composition verifier that stamps the raw subject both land here;
30
+ * - a caller whose credential names no subject (an opaque token) is
31
+ * `undefined` with NO second read — the store is never asked about "";
32
+ * - an idp-shaped caller with no account is `undefined`.
19
33
  */
20
34
  import { create } from "@bufbuild/protobuf";
21
35
  import { ConnectError } from "@connectrpc/connect";
@@ -24,8 +38,9 @@ import { describe, expect, it, vi } from "vitest";
24
38
  import { IdentityAccountSchema } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
25
39
  import { IdentityAccountProvisioningMode } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/enum_pb";
26
40
 
27
- import { accountIdFor } from "../constants.js";
28
- import { identityIdForSubject } from "../resolve.js";
41
+ import type { CallerIdentity } from "../../../extensions/identity.js";
42
+ import { accountIdFor, localIdpIdFor } from "../constants.js";
43
+ import { accountForCaller, identityIdForSubject } from "../resolve.js";
29
44
  import type { AccountsBySubject } from "../resolve.js";
30
45
  import { fakeIdentityAccountStore } from "./support.js";
31
46
 
@@ -112,3 +127,99 @@ describe("identityIdForSubject", () => {
112
127
  expect(String(error)).toContain("auth0|corrupt");
113
128
  });
114
129
  });
130
+
131
+ /** An unsigned JWT-shaped token whose payload carries `sub` — what idpIdOf reads. */
132
+ function tokenFor(sub: string): string {
133
+ const segment = (value: unknown): string =>
134
+ Buffer.from(JSON.stringify(value)).toString("base64url");
135
+ return `${segment({ alg: "none" })}.${segment({ sub })}.unsigned`;
136
+ }
137
+
138
+ function caller(overrides: Partial<CallerIdentity>): CallerIdentity {
139
+ return {
140
+ identityId: "",
141
+ callerClass: "user",
142
+ issuer: "",
143
+ rawToken: "",
144
+ ...overrides,
145
+ };
146
+ }
147
+
148
+ describe("accountForCaller — the account a caller stands for", () => {
149
+ it("an identityId that is an account id resolves by that id, with no subject read", async () => {
150
+ const accounts = seeded("auth0|known");
151
+ const bySubject = vi.spyOn(accounts, "findDirectByIdpId");
152
+
153
+ const account = await accountForCaller(
154
+ accounts,
155
+ caller({
156
+ identityId: accountIdFor("auth0|known"),
157
+ issuer: "https://issuer.test",
158
+ rawToken: tokenFor("auth0|known"),
159
+ }),
160
+ );
161
+
162
+ expect(account?.metadata?.id).toBe(accountIdFor("auth0|known"));
163
+ expect(bySubject).not.toHaveBeenCalled();
164
+ });
165
+
166
+ it("a raw-subject identityId resolves through the token's sub — the composition-verifier shape", async () => {
167
+ const accounts = seeded("auth0|raw");
168
+
169
+ const account = await accountForCaller(
170
+ accounts,
171
+ caller({
172
+ identityId: "auth0|raw",
173
+ issuer: "",
174
+ rawToken: tokenFor("auth0|raw"),
175
+ }),
176
+ );
177
+
178
+ expect(account?.metadata?.id).toBe(accountIdFor("auth0|raw"));
179
+ });
180
+
181
+ it("the trusted-local operator resolves through local|<identityId>", async () => {
182
+ const accounts = seeded(localIdpIdFor("operator@example.com"));
183
+
184
+ const account = await accountForCaller(
185
+ accounts,
186
+ caller({ identityId: "operator@example.com" }),
187
+ );
188
+
189
+ expect(account?.metadata?.id).toBe(
190
+ accountIdFor(localIdpIdFor("operator@example.com")),
191
+ );
192
+ });
193
+
194
+ it("a credential naming no subject is undefined and the store is never asked about ''", async () => {
195
+ const accounts = seeded("auth0|someone");
196
+ const bySubject = vi.spyOn(accounts, "findDirectByIdpId");
197
+
198
+ const account = await accountForCaller(
199
+ accounts,
200
+ caller({
201
+ identityId: "opaque",
202
+ issuer: "https://issuer.test",
203
+ rawToken: "not-a-jwt",
204
+ }),
205
+ );
206
+
207
+ expect(account).toBeUndefined();
208
+ expect(bySubject).not.toHaveBeenCalled();
209
+ });
210
+
211
+ it("an idp-shaped caller with no account is undefined", async () => {
212
+ const accounts = seeded("auth0|someone-else");
213
+
214
+ const account = await accountForCaller(
215
+ accounts,
216
+ caller({
217
+ identityId: "auth0|stranger",
218
+ issuer: "https://issuer.test",
219
+ rawToken: tokenFor("auth0|stranger"),
220
+ }),
221
+ );
222
+
223
+ expect(account).toBeUndefined();
224
+ });
225
+ });
@@ -5,17 +5,20 @@
5
5
  *
6
6
  * The derived id (A1). A direct account's id is a pure function of its
7
7
  * subject: `ida_` + the top 130 bits of sha256(idp_id) as 26 lowercase
8
- * Crockford-base32 characters. The shape is indistinguishable from a
9
- * minted `{prefix}_{ulid}`, so nothing downstream learns a second id
10
- * grammar, and the PRIMARY KEY becomes the one home of "one account per
11
- * subject": the generic store has no secondary uniqueness and its save is
12
- * an upsert (`store/sqlite/store.ts`), so a lookup-then-insert on a scan
13
- * could never close the first-login race. Two replicas deriving the same
14
- * id converge by primary key with no migration and no second copy of any
15
- * field. The hash is one-way, so an issuer subject never leaks into the
16
- * `created_by.id` stamps that ride on every resource. The golden vectors in
17
- * __tests__/constants.test.ts are wire-adjacent contract: a change here
18
- * re-addresses every direct account open source ever created.
8
+ * Crockford-base32 characters, through `derivedId` in
9
+ * pipeline/steps/defaults.ts (the one encoder every derived kind shares
10
+ * since 20260913.01 slice 2; the IamPolicy id is the other). The shape is
11
+ * indistinguishable from a minted `{prefix}_{ulid}`, so nothing downstream
12
+ * learns a second id grammar, and the PRIMARY KEY becomes the one home of
13
+ * "one account per subject": the generic store has no secondary uniqueness
14
+ * and its save is an upsert (`store/sqlite/store.ts`), so a
15
+ * lookup-then-insert on a scan could never close the first-login race. Two
16
+ * replicas deriving the same id converge by primary key with no migration
17
+ * and no second copy of any field. The hash is one-way, so an issuer
18
+ * subject never leaks into the `created_by.id` stamps that ride on every
19
+ * resource. The golden vectors in __tests__/constants.test.ts are
20
+ * wire-adjacent contract: a change here re-addresses every direct account
21
+ * open source ever created.
19
22
  *
20
23
  * `idpIdOf` (A2) is the ONE place a CallerIdentity becomes a subject. An
21
24
  * issuer-vouched token is read for its `sub` (already verified at
@@ -28,19 +31,12 @@
28
31
  * The copy moved from the cloud's iam/account/handlers.ts as-is: the CLI,
29
32
  * console and SDK show these sentences verbatim, so they are contract.
30
33
  */
31
- import { createHash } from "node:crypto";
32
-
33
34
  import type { CallerIdentity } from "../../extensions/identity.js";
35
+ import { derivedId } from "../../pipeline/steps/defaults.js";
34
36
 
35
37
  /** The kind's id prefix (kind_meta `identity_account.id_prefix`). */
36
38
  export const ACCOUNT_ID_PREFIX = "ida";
37
39
 
38
- /** Crockford base32, lowercased — the alphabet every minted ULID id uses. */
39
- const CROCKFORD_ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
40
- const DERIVED_ID_CHARS = 26;
41
- const DERIVED_ID_BITS = BigInt(DERIVED_ID_CHARS * 5);
42
- const SHA256_BITS = 256n;
43
-
44
40
  /**
45
41
  * The derived account id of a direct account with subject `idpId`. Pure,
46
42
  * total over non-empty input; an empty subject has no principal and so
@@ -50,18 +46,7 @@ export function accountIdFor(idpId: string): string {
50
46
  if (idpId === "") {
51
47
  throw new Error("idp_id must not be empty");
52
48
  }
53
- const digest = createHash("sha256").update(idpId, "utf8").digest();
54
- let bits = 0n;
55
- for (const byte of digest) {
56
- bits = (bits << 8n) | BigInt(byte);
57
- }
58
- let top = bits >> (SHA256_BITS - DERIVED_ID_BITS);
59
- let encoded = "";
60
- for (let i = 0; i < DERIVED_ID_CHARS; i++) {
61
- encoded = CROCKFORD_ALPHABET[Number(top & 31n)] + encoded;
62
- top >>= 5n;
63
- }
64
- return `${ACCOUNT_ID_PREFIX}_${encoded}`;
49
+ return derivedId(ACCOUNT_ID_PREFIX, idpId);
65
50
  }
66
51
 
67
52
  /**
@@ -27,13 +27,16 @@
27
27
  * ensure runs it as the trusted-local user.
28
28
  *
29
29
  * Direct handlers (no pipeline): whoAmI (the cloud's two primary-key
30
- * reads through idpIdOf; NOT_FOUND for the unprovisioned), getByEmail and
31
- * getByIdpId (lookup FIRST, then authorizeDirect on the FOUND id — the
32
- * cloud's order; the annotation's `field_path = "value"` would otherwise
33
- * hand the Authorizer an email as a resource id), getActorInfo, and
34
- * provisionMyAccount (the provisioner, then the composed post-persist
35
- * gates; UNAUTHENTICATED with the cloud's copy for a credential naming no
36
- * subject; UNAVAILABLE with the cloud's copy when userinfo fails).
30
+ * reads, stated once as resolve.ts accountForCaller; NOT_FOUND for the
31
+ * unprovisioned), getByEmail and getByIdpId (lookup FIRST, then
32
+ * authorizeDirect on the FOUND id — the cloud's order; the annotation's
33
+ * `field_path = "value"` would otherwise hand the Authorizer an email as a
34
+ * resource id), getActorInfo, and provisionMyAccount (the provisioner,
35
+ * then — on the call that CREATED the row — the core AccountCreatedHook
36
+ * (open source's membership rules, 20260913.01 slice 4), then the composed
37
+ * post-persist gates; UNAUTHENTICATED with the cloud's copy for a
38
+ * credential naming no subject; UNAVAILABLE with the cloud's copy when
39
+ * userinfo fails).
37
40
  *
38
41
  * The four federation RPCs refuse UNIMPLEMENTED with the edition reason
39
42
  * when no unit composes the capability (never INTERNAL; the organization
@@ -69,6 +72,7 @@ import type { Logger } from "../../boot/logger.js";
69
72
  import type { Authorizer } from "../../extensions/authorizer.js";
70
73
  import { stepsForSlot } from "../../extensions/gate-slots.js";
71
74
  import type { ResolvedGateSteps } from "../../extensions/gate-slots.js";
75
+ import { isPlatformPipelineCaller } from "../../extensions/identity.js";
72
76
  import type { CallerIdentity } from "../../extensions/identity.js";
73
77
  import type { IdentityFederation } from "../../extensions/identity-federation.js";
74
78
  import type { ResourceAuthorizationLifecycle } from "../../extensions/resource-authorization.js";
@@ -102,11 +106,13 @@ import {
102
106
  idpIdOf,
103
107
  } from "./constants.js";
104
108
  import type {
109
+ AccountCreatedHook,
105
110
  CreateAccount,
106
111
  CreateAccountInput,
107
112
  DirectAccountProvisioner,
108
113
  } from "./provisioning.js";
109
114
  import { UserInfoFetchError } from "./provisioning.js";
115
+ import { accountForCaller } from "./resolve.js";
110
116
  import {
111
117
  accountNotFoundError,
112
118
  newAssignBackendFieldsStep,
@@ -140,6 +146,13 @@ export interface IdentityAccountControllerDeps extends CreateAccountPathDeps {
140
146
  readonly federation: IdentityFederation | undefined;
141
147
  /** The composed slot registrations — this domain's provision slot (Q-IA-9, A10). */
142
148
  readonly gateSteps: ResolvedGateSteps;
149
+ /**
150
+ * The core first-provisioning rule — open source's membership rules
151
+ * under the built-in authorization posture; undefined when a
152
+ * composition brings its own Authorizer and so its own onboarding
153
+ * (20260913.01, Q-OR-6).
154
+ */
155
+ readonly membership: AccountCreatedHook | undefined;
143
156
  }
144
157
 
145
158
  /** Registers both identityaccount services on the router (routes stage). */
@@ -294,11 +307,9 @@ function createRpc(
294
307
  }
295
308
 
296
309
  function guardInternalRpc(caller: CallerIdentity): void {
297
- if (
298
- caller.callerClass === "machine" ||
299
- caller.callerClass === "internal" ||
300
- caller.origin === "in-process"
301
- ) {
310
+ // The one definition of "the platform's own pipelines"
311
+ // (extensions/identity.ts), shared with the IamPolicy system RPCs.
312
+ if (isPlatformPipelineCaller(caller)) {
302
313
  return;
303
314
  }
304
315
  throw new ConnectError(CREATE_IS_INTERNAL_MESSAGE, Code.PermissionDenied);
@@ -458,16 +469,12 @@ async function whoAmI(
458
469
  ctx: HandlerContext,
459
470
  ): Promise<IdentityAccount> {
460
471
  const caller = callerIdentityOf(ctx);
461
- let account = await read(
462
- () => deps.accounts.findById(caller.identityId),
472
+ // The domain's one statement of "the caller's account" (resolve.ts):
473
+ // by id, then by subject — shared with the built-in role lifecycle.
474
+ const account = await read(
475
+ () => accountForCaller(deps.accounts, caller),
463
476
  "failed to load identity account",
464
477
  );
465
- if (account === undefined) {
466
- account = await read(
467
- () => deps.accounts.findDirectByIdpId(idpIdOf(caller)),
468
- "failed to load identity account",
469
- );
470
- }
471
478
  if (account === undefined) {
472
479
  throw new ConnectError(ACCOUNT_NOT_FOUND_FOR_CALLER_MESSAGE, Code.NotFound);
473
480
  }
@@ -530,16 +537,28 @@ async function lookupThenAuthorize(
530
537
 
531
538
  /**
532
539
  * provisionMyAccount — the subject from the caller's credential, the
533
- * provisioner's flow, then the composed post-persist gates (the cloud's
534
- * personal organization) with the caller RE-STAMPED as the account: the
535
- * position-1 identity was idp-shaped because no row existed when the
536
- * verifier ran, and a gate that attributes ownership must name the
537
- * account, never a principal no later request carries. The gates run on
538
- * EVERY call, the idempotent early return included — the cloud's step
539
- * backfills accounts that predate personal organizations there, which is
540
- * why Q-IA-9 chose a slot over onResourceCreated. Non-transactional in
541
- * the `org-create:post-persist` sense: a gate failure fails the request,
542
- * the row survives, the next call heals it.
540
+ * provisioner's flow, then two post-persist consumers with the caller
541
+ * RE-STAMPED as the account: the position-1 identity was idp-shaped
542
+ * because no row existed when the verifier ran, and anything that
543
+ * attributes ownership must name the account, never a principal no later
544
+ * request carries.
545
+ *
546
+ * 1. The core AccountCreatedHook (open source's membership rules), on
547
+ * the call that CREATED the row only (`created`): the slot below
548
+ * fires on the idempotent path by design, and a rule keyed on "holds
549
+ * no row" there would re-admit a member an administrator had just
550
+ * revoked (20260913.01 plan finding 2). A throw fails the request
551
+ * INTERNAL with the row in place and the rule does not run again for
552
+ * that account — the ratified store-fault mapping, and the one loud
553
+ * record of the window slice 4's Q-S4-4 accepted: the rule's writes
554
+ * are idempotent, so a partial run is harmless, and the recovery is
555
+ * an administrator's grant.
556
+ * 2. The composed post-persist gates (the cloud's personal
557
+ * organization), on EVERY call — the cloud's step backfills accounts
558
+ * that predate personal organizations there, which is why Q-IA-9
559
+ * chose a slot over onResourceCreated. Non-transactional in the
560
+ * `org-create:post-persist` sense: a gate failure fails the request,
561
+ * the row survives, the next call heals it.
543
562
  */
544
563
  async function provisionMyAccount(
545
564
  deps: IdentityAccountControllerDeps,
@@ -552,8 +571,12 @@ async function provisionMyAccount(
552
571
  throw new ConnectError(NO_IDP_ID_MESSAGE, Code.Unauthenticated);
553
572
  }
554
573
  let account: IdentityAccount;
574
+ let created: boolean;
555
575
  try {
556
- account = await deps.provisioner.provisionDirectAccount(idpId, caller);
576
+ ({ account, created } = await deps.provisioner.provisionDirectAccount(
577
+ idpId,
578
+ caller,
579
+ ));
557
580
  } catch (error) {
558
581
  if (error instanceof UserInfoFetchError) {
559
582
  throw new ConnectError(
@@ -563,6 +586,17 @@ async function provisionMyAccount(
563
586
  }
564
587
  throw error;
565
588
  }
589
+ const asAccount: CallerIdentity = {
590
+ ...caller,
591
+ identityId: account.metadata?.id ?? "",
592
+ };
593
+ if (created && deps.membership !== undefined) {
594
+ try {
595
+ await deps.membership.onAccountCreated(account, asAccount);
596
+ } catch (error) {
597
+ throw internalError(error, "failed to record organization membership");
598
+ }
599
+ }
566
600
  // The ratified provision slot (Q-IA-9; the gate-slots.ts header carries
567
601
  // its semantics). Empty in OSS — no pipeline is built for zero steps.
568
602
  const gates = stepsForSlot<typeof IdentityAccountSchema>(
@@ -570,10 +604,6 @@ async function provisionMyAccount(
570
604
  "identity-account-provision:post-persist",
571
605
  );
572
606
  if (gates.length > 0) {
573
- const asAccount: CallerIdentity = {
574
- ...caller,
575
- identityId: account.metadata?.id ?? "",
576
- };
577
607
  const reqCtx = new RequestContext(
578
608
  IdentityAccountSchema,
579
609
  account,
@@ -34,7 +34,10 @@ import { create } from "@bufbuild/protobuf";
34
34
  import type { IdentityAccount } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
35
35
  import { IdentityAccountSpecSchema } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/spec_pb";
36
36
 
37
- import { trustedLocalIdentityFor } from "../../pipeline/interceptors/auth.js";
37
+ import {
38
+ SYSTEM_OPERATOR_IDENTITY_ID,
39
+ trustedLocalIdentityFor,
40
+ } from "../../pipeline/interceptors/auth.js";
38
41
  import { accountIdFor, localIdpIdFor } from "./constants.js";
39
42
  import type { CreateAccount } from "./provisioning.js";
40
43
  import { resolveCreateRace } from "./provisioning.js";
@@ -52,9 +55,6 @@ export interface OperatorAccountDeps {
52
55
  readonly createAccount: CreateAccount;
53
56
  }
54
57
 
55
- /** The unconfigured laptop's operator: what the interceptor stamps as identityId. */
56
- const SYSTEM_OPERATOR = "system";
57
-
58
58
  export async function ensureOperatorAccount(
59
59
  deps: OperatorAccountDeps,
60
60
  operator: OperatorIdentity,
@@ -72,7 +72,7 @@ export async function ensureOperatorAccount(
72
72
  ? operator.displayName
73
73
  : operator.email !== ""
74
74
  ? operator.email
75
- : SYSTEM_OPERATOR;
75
+ : SYSTEM_OPERATOR_IDENTITY_ID;
76
76
  try {
77
77
  return await deps.createAccount(
78
78
  {
@@ -28,6 +28,16 @@
28
28
  * fires on the `identity-account-provision:post-persist` slot in the
29
29
  * composition (A10), wired by the controller.
30
30
  *
31
+ * The answer says whether THIS call created the row (`ProvisionedAccount.
32
+ * created`; 20260913.01 slice 4): the idempotent early return and the
33
+ * race loser answer false. The slot above fires on every call by design
34
+ * (the cloud's backfill needs that), so a CORE rule that must run exactly
35
+ * once per person — the open-source membership rules, which hand out
36
+ * organization roles at first sign-in — keys on `created` instead, through
37
+ * the AccountCreatedHook this domain offers and the controller runs. The
38
+ * hook is declared here, in the domain that fires it, so the domain that
39
+ * implements it (iampolicy) depends on this one and never the reverse.
40
+ *
31
41
  * This module holds the domain flow over two PORTS — the store and the
32
42
  * userinfo client — and no network code; identity/oidc-userinfo.ts is the
33
43
  * OIDC implementation the composition root wires in.
@@ -88,22 +98,46 @@ export interface DirectAccountProvisionerDeps {
88
98
  readonly userInfo: UserInfoClient;
89
99
  }
90
100
 
101
+ /** The provisioner's answer: the account, and whether this call created its row. */
102
+ export interface ProvisionedAccount {
103
+ readonly account: IdentityAccount;
104
+ /** False on the idempotent early return and for a first-login race's loser. */
105
+ readonly created: boolean;
106
+ }
107
+
108
+ /**
109
+ * What the identity-account domain offers a core rule keyed on a person's
110
+ * FIRST provisioning (the open-source membership rules,
111
+ * domain/iampolicy/membership.ts). Runs once per account, on the call
112
+ * whose `created` is true, with the caller RE-STAMPED as the account, so
113
+ * whatever the hook writes attributes to the account and never to the
114
+ * idp-shaped principal the verifier admitted before the row existed. Its
115
+ * failure semantics are the controller's: a throw fails the request with
116
+ * the account in place, and the hook does not run again for that account.
117
+ */
118
+ export interface AccountCreatedHook {
119
+ onAccountCreated(
120
+ account: IdentityAccount,
121
+ caller: CallerIdentity,
122
+ ): Promise<void>;
123
+ }
124
+
91
125
  export interface DirectAccountProvisioner {
92
126
  /** provisionMyAccount's domain flow; `idpId` is the caller's verified subject. */
93
127
  provisionDirectAccount(
94
128
  idpId: string,
95
129
  caller: CallerIdentity,
96
- ): Promise<IdentityAccount>;
130
+ ): Promise<ProvisionedAccount>;
97
131
  }
98
132
 
99
133
  export function newDirectAccountProvisioner(
100
134
  deps: DirectAccountProvisionerDeps,
101
135
  ): DirectAccountProvisioner {
102
136
  return {
103
- async provisionDirectAccount(idpId, caller): Promise<IdentityAccount> {
137
+ async provisionDirectAccount(idpId, caller): Promise<ProvisionedAccount> {
104
138
  const existing = await deps.accounts.findDirectByIdpId(idpId);
105
139
  if (existing !== undefined) {
106
- return existing;
140
+ return { account: existing, created: false };
107
141
  }
108
142
 
109
143
  const profile = await profileOf(deps.userInfo, caller);
@@ -121,11 +155,15 @@ export function newDirectAccountProvisioner(
121
155
  };
122
156
 
123
157
  try {
124
- return await deps.createAccount(input, caller);
158
+ return {
159
+ account: await deps.createAccount(input, caller),
160
+ created: true,
161
+ };
125
162
  } catch (error) {
126
- return resolveCreateRace(error, () =>
163
+ const winner = await resolveCreateRace(error, () =>
127
164
  deps.accounts.findDirectByIdpId(idpId),
128
165
  );
166
+ return { account: winner, created: false };
129
167
  }
130
168
  },
131
169
  };
@@ -28,12 +28,35 @@
28
28
  * no id is the one deliberate divergence from the cloud's inline
29
29
  * `metadata?.id ?? ""`: a `""` principal is the silent-junk failure the
30
30
  * oss#405 doctrine forbids, so it is a loud fault naming the subject.
31
+ *
32
+ * The same knowledge read in the caller's direction is `accountForCaller`
33
+ * (20260913.01 slice 4, Q-S4-1): the account a stamped CallerIdentity
34
+ * stands for. Two primary-key reads, the cloud's whoAmI order — the
35
+ * identityId AS an account id (a verifier that resolved), then the
36
+ * caller's subject through the direct lookup (a verifier that did not:
37
+ * the trusted-local interceptor, which stamps the operator's email and
38
+ * never consults the store; a composition verifier that runs before any
39
+ * row exists). It lives here so whoAmI and the built-in role lifecycle,
40
+ * which asks "whose organization is this" from the creating caller,
41
+ * cannot answer the same question two ways. A credential naming no
42
+ * subject is `undefined` with no second read: the store is never asked
43
+ * about "".
31
44
  */
45
+ import type { IdentityAccount } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
46
+
47
+ import type { CallerIdentity } from "../../extensions/identity.js";
48
+ import { idpIdOf } from "./constants.js";
32
49
  import type { IdentityAccountStore } from "./store.js";
33
50
 
34
51
  /** The one read a verifier needs from the domain: the direct account for a subject. */
35
52
  export type AccountsBySubject = Pick<IdentityAccountStore, "findDirectByIdpId">;
36
53
 
54
+ /** The two reads the caller-direction resolution needs: by id, then by subject. */
55
+ export type AccountsByCaller = Pick<
56
+ IdentityAccountStore,
57
+ "findById" | "findDirectByIdpId"
58
+ >;
59
+
37
60
  export async function identityIdForSubject(
38
61
  accounts: AccountsBySubject,
39
62
  subject: string,
@@ -50,3 +73,23 @@ export async function identityIdForSubject(
50
73
  }
51
74
  return id;
52
75
  }
76
+
77
+ /**
78
+ * The account `caller` stands for, or `undefined` when there is none (an
79
+ * idp-shaped caller before provisioning; a credential naming no subject).
80
+ * Faults propagate as they are — the caller decides the wire shape.
81
+ */
82
+ export async function accountForCaller(
83
+ accounts: AccountsByCaller,
84
+ caller: CallerIdentity,
85
+ ): Promise<IdentityAccount | undefined> {
86
+ const byId = await accounts.findById(caller.identityId);
87
+ if (byId !== undefined) {
88
+ return byId;
89
+ }
90
+ const subject = idpIdOf(caller);
91
+ if (subject === "") {
92
+ return undefined;
93
+ }
94
+ return accounts.findDirectByIdpId(subject);
95
+ }