@stigmer/server 3.14.0 → 3.15.0

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 (178) hide show
  1. package/dist/boot/compose.d.ts +8 -1
  2. package/dist/boot/compose.d.ts.map +1 -1
  3. package/dist/boot/compose.js +98 -12
  4. package/dist/boot/compose.js.map +1 -1
  5. package/dist/domain/agentexecution/constants.d.ts +16 -0
  6. package/dist/domain/agentexecution/constants.d.ts.map +1 -1
  7. package/dist/domain/agentexecution/constants.js +22 -0
  8. package/dist/domain/agentexecution/constants.js.map +1 -1
  9. package/dist/domain/agentexecution/controller.d.ts.map +1 -1
  10. package/dist/domain/agentexecution/controller.js +10 -3
  11. package/dist/domain/agentexecution/controller.js.map +1 -1
  12. package/dist/domain/agentexecution/run-target.d.ts +26 -0
  13. package/dist/domain/agentexecution/run-target.d.ts.map +1 -0
  14. package/dist/domain/agentexecution/run-target.js +31 -0
  15. package/dist/domain/agentexecution/run-target.js.map +1 -0
  16. package/dist/domain/apikey/verifier.d.ts +8 -1
  17. package/dist/domain/apikey/verifier.d.ts.map +1 -1
  18. package/dist/domain/apikey/verifier.js +14 -2
  19. package/dist/domain/apikey/verifier.js.map +1 -1
  20. package/dist/domain/identityaccount/constants.d.ts +66 -0
  21. package/dist/domain/identityaccount/constants.d.ts.map +1 -0
  22. package/dist/domain/identityaccount/constants.js +153 -0
  23. package/dist/domain/identityaccount/constants.js.map +1 -0
  24. package/dist/domain/identityaccount/controller.d.ts +34 -0
  25. package/dist/domain/identityaccount/controller.d.ts.map +1 -0
  26. package/dist/domain/identityaccount/controller.js +342 -0
  27. package/dist/domain/identityaccount/controller.js.map +1 -0
  28. package/dist/domain/identityaccount/operator.d.ts +14 -0
  29. package/dist/domain/identityaccount/operator.d.ts.map +1 -0
  30. package/dist/domain/identityaccount/operator.js +67 -0
  31. package/dist/domain/identityaccount/operator.js.map +1 -0
  32. package/dist/domain/identityaccount/provisioning.d.ts +52 -0
  33. package/dist/domain/identityaccount/provisioning.d.ts.map +1 -0
  34. package/dist/domain/identityaccount/provisioning.js +114 -0
  35. package/dist/domain/identityaccount/provisioning.js.map +1 -0
  36. package/dist/domain/identityaccount/resolve.d.ts +36 -0
  37. package/dist/domain/identityaccount/resolve.d.ts.map +1 -0
  38. package/dist/domain/identityaccount/resolve.js +12 -0
  39. package/dist/domain/identityaccount/resolve.js.map +1 -0
  40. package/dist/domain/identityaccount/resource-store.d.ts +4 -0
  41. package/dist/domain/identityaccount/resource-store.d.ts.map +1 -0
  42. package/dist/domain/identityaccount/resource-store.js +131 -0
  43. package/dist/domain/identityaccount/resource-store.js.map +1 -0
  44. package/dist/domain/identityaccount/steps.d.ts +79 -0
  45. package/dist/domain/identityaccount/steps.d.ts.map +1 -0
  46. package/dist/domain/identityaccount/steps.js +291 -0
  47. package/dist/domain/identityaccount/steps.js.map +1 -0
  48. package/dist/domain/identityaccount/store-contract.d.ts +22 -0
  49. package/dist/domain/identityaccount/store-contract.d.ts.map +1 -0
  50. package/dist/domain/identityaccount/store-contract.js +307 -0
  51. package/dist/domain/identityaccount/store-contract.js.map +1 -0
  52. package/dist/domain/identityaccount/store.d.ts +66 -0
  53. package/dist/domain/identityaccount/store.d.ts.map +1 -0
  54. package/dist/domain/identityaccount/store.js +13 -0
  55. package/dist/domain/identityaccount/store.js.map +1 -0
  56. package/dist/domain/session/constants.d.ts +15 -0
  57. package/dist/domain/session/constants.d.ts.map +1 -0
  58. package/dist/domain/session/constants.js +17 -0
  59. package/dist/domain/session/constants.js.map +1 -0
  60. package/dist/domain/session/controller.d.ts.map +1 -1
  61. package/dist/domain/session/controller.js +10 -0
  62. package/dist/domain/session/controller.js.map +1 -1
  63. package/dist/domain/session/run-target.d.ts +15 -0
  64. package/dist/domain/session/run-target.d.ts.map +1 -0
  65. package/dist/domain/session/run-target.js +14 -0
  66. package/dist/domain/session/run-target.js.map +1 -0
  67. package/dist/domain/workflowexecution/constants.d.ts +9 -0
  68. package/dist/domain/workflowexecution/constants.d.ts.map +1 -1
  69. package/dist/domain/workflowexecution/constants.js +13 -0
  70. package/dist/domain/workflowexecution/constants.js.map +1 -1
  71. package/dist/domain/workflowexecution/controller.d.ts.map +1 -1
  72. package/dist/domain/workflowexecution/controller.js +8 -0
  73. package/dist/domain/workflowexecution/controller.js.map +1 -1
  74. package/dist/domain/workflowexecution/run-target.d.ts +18 -0
  75. package/dist/domain/workflowexecution/run-target.d.ts.map +1 -0
  76. package/dist/domain/workflowexecution/run-target.js +23 -0
  77. package/dist/domain/workflowexecution/run-target.js.map +1 -0
  78. package/dist/extensions/drivers.d.ts +26 -0
  79. package/dist/extensions/drivers.d.ts.map +1 -1
  80. package/dist/extensions/gate-slots.d.ts +16 -1
  81. package/dist/extensions/gate-slots.d.ts.map +1 -1
  82. package/dist/extensions/gate-slots.js +1 -0
  83. package/dist/extensions/gate-slots.js.map +1 -1
  84. package/dist/extensions/identity-federation.d.ts +36 -0
  85. package/dist/extensions/identity-federation.d.ts.map +1 -0
  86. package/dist/extensions/identity-federation.js +2 -0
  87. package/dist/extensions/identity-federation.js.map +1 -0
  88. package/dist/extensions/registry.d.ts +18 -1
  89. package/dist/extensions/registry.d.ts.map +1 -1
  90. package/dist/extensions/registry.js +20 -0
  91. package/dist/extensions/registry.js.map +1 -1
  92. package/dist/identity/oidc-discovery.d.ts +36 -0
  93. package/dist/identity/oidc-discovery.d.ts.map +1 -0
  94. package/dist/identity/oidc-discovery.js +60 -0
  95. package/dist/identity/oidc-discovery.js.map +1 -0
  96. package/dist/identity/oidc-userinfo.d.ts +30 -0
  97. package/dist/identity/oidc-userinfo.d.ts.map +1 -0
  98. package/dist/identity/oidc-userinfo.js +28 -0
  99. package/dist/identity/oidc-userinfo.js.map +1 -0
  100. package/dist/identity/oidc-verifier.d.ts +6 -0
  101. package/dist/identity/oidc-verifier.d.ts.map +1 -1
  102. package/dist/identity/oidc-verifier.js +35 -34
  103. package/dist/identity/oidc-verifier.js.map +1 -1
  104. package/dist/index.d.ts +8 -0
  105. package/dist/index.d.ts.map +1 -1
  106. package/dist/index.js +10 -0
  107. package/dist/index.js.map +1 -1
  108. package/dist/pipeline/interceptors/auth.d.ts +10 -0
  109. package/dist/pipeline/interceptors/auth.d.ts.map +1 -1
  110. package/dist/pipeline/interceptors/auth.js +9 -1
  111. package/dist/pipeline/interceptors/auth.js.map +1 -1
  112. package/dist/pipeline/steps/authorize-run-target.d.ts +105 -0
  113. package/dist/pipeline/steps/authorize-run-target.d.ts.map +1 -0
  114. package/dist/pipeline/steps/authorize-run-target.js +61 -0
  115. package/dist/pipeline/steps/authorize-run-target.js.map +1 -0
  116. package/dist/query/search/criteria.d.ts +10 -3
  117. package/dist/query/search/criteria.d.ts.map +1 -1
  118. package/dist/query/search/criteria.js +11 -4
  119. package/dist/query/search/criteria.js.map +1 -1
  120. package/package.json +4 -4
  121. package/src/boot/__tests__/auth-enabled-composition.test.ts +226 -2
  122. package/src/boot/compose.ts +118 -13
  123. package/src/domain/agentexecution/__tests__/run-gate.test.ts +245 -0
  124. package/src/domain/agentexecution/__tests__/run-target.test.ts +117 -0
  125. package/src/domain/agentexecution/constants.ts +25 -0
  126. package/src/domain/agentexecution/controller.ts +10 -3
  127. package/src/domain/agentexecution/run-target.ts +64 -0
  128. package/src/domain/apikey/__tests__/verifier.test.ts +146 -22
  129. package/src/domain/apikey/verifier.ts +24 -2
  130. package/src/domain/identityaccount/__tests__/constants.test.ts +155 -0
  131. package/src/domain/identityaccount/__tests__/identityaccount.test.ts +484 -0
  132. package/src/domain/identityaccount/__tests__/operator.test.ts +180 -0
  133. package/src/domain/identityaccount/__tests__/provisioning.test.ts +281 -0
  134. package/src/domain/identityaccount/__tests__/resolve.test.ts +114 -0
  135. package/src/domain/identityaccount/__tests__/resource-store.test.ts +216 -0
  136. package/src/domain/identityaccount/__tests__/support.ts +58 -0
  137. package/src/domain/identityaccount/constants.ts +178 -0
  138. package/src/domain/identityaccount/controller.ts +667 -0
  139. package/src/domain/identityaccount/operator.ts +94 -0
  140. package/src/domain/identityaccount/provisioning.ts +182 -0
  141. package/src/domain/identityaccount/resolve.ts +52 -0
  142. package/src/domain/identityaccount/resource-store.ts +157 -0
  143. package/src/domain/identityaccount/steps.ts +377 -0
  144. package/src/domain/identityaccount/store-contract.ts +471 -0
  145. package/src/domain/identityaccount/store.ts +70 -0
  146. package/src/domain/session/__tests__/run-gate.test.ts +219 -0
  147. package/src/domain/session/__tests__/run-target.test.ts +43 -0
  148. package/src/domain/session/constants.ts +17 -0
  149. package/src/domain/session/controller.ts +10 -0
  150. package/src/domain/session/run-target.ts +30 -0
  151. package/src/domain/workflowexecution/__tests__/run-gate.test.ts +200 -0
  152. package/src/domain/workflowexecution/__tests__/run-target.test.ts +80 -0
  153. package/src/domain/workflowexecution/constants.ts +17 -0
  154. package/src/domain/workflowexecution/controller.ts +10 -0
  155. package/src/domain/workflowexecution/run-target.ts +47 -0
  156. package/src/extensions/__tests__/extension-composition.test.ts +49 -0
  157. package/src/extensions/__tests__/identity-account-composed.test.ts +803 -0
  158. package/src/extensions/__tests__/identity-account-points.test.ts +129 -0
  159. package/src/extensions/__tests__/registry.test.ts +4 -0
  160. package/src/extensions/__tests__/tier-truthfulness.test.ts +299 -0
  161. package/src/extensions/drivers.ts +26 -0
  162. package/src/extensions/gate-slots.ts +16 -0
  163. package/src/extensions/identity-federation.ts +58 -0
  164. package/src/extensions/registry.ts +44 -1
  165. package/src/identity/__tests__/oidc-discovery.test.ts +122 -0
  166. package/src/identity/__tests__/oidc-userinfo.test.ts +103 -0
  167. package/src/identity/__tests__/oidc-verifier.test.ts +173 -3
  168. package/src/identity/oidc-discovery.ts +84 -0
  169. package/src/identity/oidc-userinfo.ts +64 -0
  170. package/src/identity/oidc-verifier.ts +42 -45
  171. package/src/index.ts +26 -0
  172. package/src/pipeline/__tests__/support.ts +34 -0
  173. package/src/pipeline/interceptors/auth.ts +13 -1
  174. package/src/pipeline/steps/__tests__/authorize-annotation-completeness.test.ts +184 -0
  175. package/src/pipeline/steps/__tests__/authorize-run-target.test.ts +261 -0
  176. package/src/pipeline/steps/authorize-run-target.ts +148 -0
  177. package/src/query/search/__tests__/criteria.test.ts +18 -1
  178. package/src/query/search/criteria.ts +11 -4
@@ -6,6 +6,15 @@
6
6
  * authenticates-as-owner identity mapping (created_by actor → identityId
7
7
  * + display fields), and instant revocation (delete visible on the very
8
8
  * next verify — the no-cache posture).
9
+ *
10
+ * Since 20260911.11 (T01_1_review.md A6) the creator stamp is resolved
11
+ * the way the OIDC lane resolves `sub`: a key minted before its owner
12
+ * was provisioned carries the raw subject and answers the owner's
13
+ * ACCOUNT id once one exists; a stamp that is already an account id is
14
+ * kept; one primary-key read either way, no cache. The account store is
15
+ * the REAL open-source adapter over the same SQLite store the keys live
16
+ * in, so what is pinned is the production pair; only the fault arm
17
+ * substitutes a one-method lookup that rejects.
9
18
  */
10
19
  import { mkdtempSync, rmSync } from "node:fs";
11
20
  import { tmpdir } from "node:os";
@@ -18,9 +27,15 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
18
27
 
19
28
  import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
20
29
  import { ApiKeySchema } from "@stigmer/protos/ai/stigmer/iam/apikey/v1/api_pb";
30
+ import { IdentityAccountSchema } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
31
+ import { IdentityAccountProvisioningMode } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/enum_pb";
21
32
 
22
33
  import { createLogger } from "../../../boot/logger.js";
23
34
  import { SqliteStore } from "../../../store/sqlite/store.js";
35
+ import { accountIdFor } from "../../identityaccount/constants.js";
36
+ import type { AccountsBySubject } from "../../identityaccount/resolve.js";
37
+ import { newResourceIdentityAccountStore } from "../../identityaccount/resource-store.js";
38
+ import type { IdentityAccountStore } from "../../identityaccount/store.js";
24
39
  import { generateApiKeyPlaintext, hashApiKey } from "../keymaterial.js";
25
40
  import {
26
41
  INVALID_TOKEN_MESSAGE,
@@ -30,6 +45,7 @@ import {
30
45
 
31
46
  let dir: string;
32
47
  let store: SqliteStore;
48
+ let accounts: IdentityAccountStore;
33
49
 
34
50
  beforeAll(() => {
35
51
  dir = mkdtempSync(path.join(tmpdir(), "apikey-verifier-test-"));
@@ -37,8 +53,32 @@ beforeAll(() => {
37
53
  path.join(dir, "stigmer.db"),
38
54
  createLogger({ level: "error", pretty: false, write: () => {} }),
39
55
  );
56
+ accounts = newResourceIdentityAccountStore(store);
40
57
  });
41
58
 
59
+ /** The verifier as compose builds it: the key store and the account port over ONE store. */
60
+ function verifier(lookup: AccountsBySubject = accounts) {
61
+ return newApiKeyIdentityVerifier({ store, accounts: lookup });
62
+ }
63
+
64
+ /** A direct account for `sub` through the real adapter — derived id, as provisioning writes it. */
65
+ async function provisionAccount(sub: string, email: string): Promise<string> {
66
+ const id = accountIdFor(sub);
67
+ await accounts.save(
68
+ create(IdentityAccountSchema, {
69
+ apiVersion: "iam.stigmer.ai/v1",
70
+ kind: "IdentityAccount",
71
+ metadata: { id, name: email },
72
+ spec: {
73
+ idpId: sub,
74
+ email,
75
+ provisioningMode: IdentityAccountProvisioningMode.direct,
76
+ },
77
+ }),
78
+ );
79
+ return id;
80
+ }
81
+
42
82
  afterAll(async () => {
43
83
  await store.close();
44
84
  rmSync(dir, { recursive: true, force: true });
@@ -75,7 +115,10 @@ async function seedKey(options?: {
75
115
  : {}),
76
116
  },
77
117
  status: {
78
- audit: { specAudit: { createdBy: owner }, statusAudit: { createdBy: owner } },
118
+ audit: {
119
+ specAudit: { createdBy: owner },
120
+ statusAudit: { createdBy: owner },
121
+ },
79
122
  },
80
123
  }),
81
124
  );
@@ -84,16 +127,16 @@ async function seedKey(options?: {
84
127
 
85
128
  describe("claim-or-pass", () => {
86
129
  it("passes (null) on anything without the stk_ prefix", async () => {
87
- const verifier = newApiKeyIdentityVerifier(store);
88
- expect(await verifier.verify("eyJhbGciOiJSUzI1NiJ9.x.y")).toBeNull();
89
- expect(await verifier.verify("Basic-ish-token")).toBeNull();
130
+ const apikey = verifier();
131
+ expect(await apikey.verify("eyJhbGciOiJSUzI1NiJ9.x.y")).toBeNull();
132
+ expect(await apikey.verify("Basic-ish-token")).toBeNull();
90
133
  });
91
134
 
92
135
  it("claims stk_ tokens case-insensitively (Java startsWithIgnoreCase)", async () => {
93
- const verifier = newApiKeyIdentityVerifier(store);
136
+ const apikey = verifier();
94
137
  // Recognized but unknown — must THROW, never pass (a forged key must
95
138
  // not fall through to a laxer verifier).
96
- await expect(verifier.verify("STK_unknown")).rejects.toSatisfy(
139
+ await expect(apikey.verify("STK_unknown")).rejects.toSatisfy(
97
140
  (error: unknown) => {
98
141
  expect(ConnectError.from(error).code).toBe(Code.Unauthenticated);
99
142
  expect(ConnectError.from(error).rawMessage).toBe(INVALID_TOKEN_MESSAGE);
@@ -110,7 +153,7 @@ describe("verification arms", () => {
110
153
  email: "owner@example.com",
111
154
  displayName: "Key Owner",
112
155
  });
113
- const identity = await newApiKeyIdentityVerifier(store).verify(plaintext);
156
+ const identity = await verifier().verify(plaintext);
114
157
  expect(identity).toEqual({
115
158
  identityId: "ida_owner1",
116
159
  callerClass: "user",
@@ -125,29 +168,29 @@ describe("verification arms", () => {
125
168
  const { plaintext } = await seedKey({
126
169
  expiresAt: new Date(Date.now() - 60_000),
127
170
  });
128
- await expect(
129
- newApiKeyIdentityVerifier(store).verify(plaintext),
130
- ).rejects.toSatisfy((error: unknown) => {
131
- expect(ConnectError.from(error).rawMessage).toBe(TOKEN_EXPIRED_MESSAGE);
132
- return true;
133
- });
171
+ await expect(verifier().verify(plaintext)).rejects.toSatisfy(
172
+ (error: unknown) => {
173
+ expect(ConnectError.from(error).rawMessage).toBe(TOKEN_EXPIRED_MESSAGE);
174
+ return true;
175
+ },
176
+ );
134
177
  });
135
178
 
136
179
  it("a future expiry verifies fine (expires_at set and NOT past)", async () => {
137
180
  const { plaintext } = await seedKey({
138
181
  expiresAt: new Date(Date.now() + 60_000),
139
182
  });
140
- const identity = await newApiKeyIdentityVerifier(store).verify(plaintext);
183
+ const identity = await verifier().verify(plaintext);
141
184
  expect(identity?.callerClass).toBe("user");
142
185
  });
143
186
 
144
187
  it("a deleted key is rejected on the very next verify (no cache)", async () => {
145
188
  const { plaintext, id } = await seedKey();
146
- const verifier = newApiKeyIdentityVerifier(store);
147
- expect(await verifier.verify(plaintext)).not.toBeNull();
189
+ const apikey = verifier();
190
+ expect(await apikey.verify(plaintext)).not.toBeNull();
148
191
 
149
192
  await store.deleteResource(ApiResourceKind.api_key, id);
150
- await expect(verifier.verify(plaintext)).rejects.toSatisfy(
193
+ await expect(apikey.verify(plaintext)).rejects.toSatisfy(
151
194
  (error: unknown) => {
152
195
  expect(ConnectError.from(error).rawMessage).toBe(INVALID_TOKEN_MESSAGE);
153
196
  return true;
@@ -170,11 +213,92 @@ describe("verification arms", () => {
170
213
  spec: { keyHash: hashApiKey(plaintext) },
171
214
  }),
172
215
  );
173
- await expect(
174
- newApiKeyIdentityVerifier(store).verify(plaintext),
175
- ).rejects.toSatisfy((error: unknown) => {
176
- expect(ConnectError.from(error).rawMessage).toBe(INVALID_TOKEN_MESSAGE);
177
- return true;
216
+ await expect(verifier().verify(plaintext)).rejects.toSatisfy(
217
+ (error: unknown) => {
218
+ expect(ConnectError.from(error).rawMessage).toBe(INVALID_TOKEN_MESSAGE);
219
+ return true;
220
+ },
221
+ );
222
+ });
223
+ });
224
+
225
+ describe("creator stamp → account resolution (20260911.11 A6)", () => {
226
+ it("a key minted BEFORE its owner was provisioned resolves to the owner's account once one exists; the display fields stay the stamp's", async () => {
227
+ const { plaintext } = await seedKey({
228
+ ownerId: "auth0|early-adopter",
229
+ email: "early@example.com",
230
+ displayName: "Early Adopter",
231
+ });
232
+ const accountId = await provisionAccount(
233
+ "auth0|early-adopter",
234
+ "early@example.com",
235
+ );
236
+ expect(await verifier().verify(plaintext)).toEqual({
237
+ identityId: accountId,
238
+ callerClass: "user",
239
+ issuer: "",
240
+ rawToken: plaintext,
241
+ email: "early@example.com",
242
+ displayName: "Early Adopter",
178
243
  });
179
244
  });
245
+
246
+ it("a stamp that is already an account id is kept — no account carries it as a subject", async () => {
247
+ const accountId = await provisionAccount(
248
+ "auth0|provisioned-first",
249
+ "first@example.com",
250
+ );
251
+ const { plaintext } = await seedKey({ ownerId: accountId });
252
+ expect((await verifier().verify(plaintext))?.identityId).toBe(accountId);
253
+ });
254
+
255
+ it("resolution is one read per verify with no cache — an account provisioned between two verifies is seen by the second", async () => {
256
+ const { plaintext } = await seedKey({ ownerId: "auth0|late-bloomer" });
257
+ const apikey = verifier();
258
+ expect((await apikey.verify(plaintext))?.identityId).toBe(
259
+ "auth0|late-bloomer",
260
+ );
261
+ const accountId = await provisionAccount(
262
+ "auth0|late-bloomer",
263
+ "late@example.com",
264
+ );
265
+ expect((await apikey.verify(plaintext))?.identityId).toBe(accountId);
266
+ });
267
+
268
+ it("an account-store fault is an infrastructure fault (plain error), never a credential rejection", async () => {
269
+ const { plaintext } = await seedKey({ ownerId: "auth0|anyone" });
270
+ const broken: AccountsBySubject = {
271
+ findDirectByIdpId: () => Promise.reject(new Error("store is on fire")),
272
+ };
273
+ const error = await verifier(broken)
274
+ .verify(plaintext)
275
+ .then(
276
+ () => {
277
+ throw new Error("expected rejection");
278
+ },
279
+ (e: unknown) => e,
280
+ );
281
+ expect(error).not.toBeInstanceOf(ConnectError);
282
+ expect(String(error)).toContain("store is on fire");
283
+ });
284
+
285
+ it("a revoked or expired key never reaches the account store — credential checks come first", async () => {
286
+ const reads: string[] = [];
287
+ const counting: AccountsBySubject = {
288
+ findDirectByIdpId: async (idpId) => {
289
+ reads.push(idpId);
290
+ return undefined;
291
+ },
292
+ };
293
+ const { plaintext: expired } = await seedKey({
294
+ expiresAt: new Date(Date.now() - 60_000),
295
+ });
296
+ await expect(verifier(counting).verify(expired)).rejects.toBeInstanceOf(
297
+ ConnectError,
298
+ );
299
+ await expect(
300
+ verifier(counting).verify("stk_never_minted"),
301
+ ).rejects.toBeInstanceOf(ConnectError);
302
+ expect(reads).toEqual([]);
303
+ });
180
304
  });
@@ -23,6 +23,16 @@
23
23
  * seam wants. Expiry is "expires_at set and past"; never_expires is
24
24
  * deliberately not read — Java parity (never-expiring keys leave
25
25
  * expires_at unset).
26
+ *
27
+ * The stamp is then resolved the way the OIDC lane resolves `sub`
28
+ * (20260911.11, T01_1_review.md A6; domain/identityaccount/resolve.ts):
29
+ * a key minted before its owner was provisioned carries the raw subject
30
+ * and answers the owner's ACCOUNT id once one exists, so no write over a
31
+ * legacy key mints a raw-subject stamp again; a stamp that is already an
32
+ * account id is kept — one primary-key read either way, no cache. Only
33
+ * identityId changes on a hit: email/displayName stay the stamp's, the
34
+ * actor the key recorded at minting. The credential checks run first, so
35
+ * a revoked or expired key never reaches the account store.
26
36
  */
27
37
  import { Code, ConnectError } from "@connectrpc/connect";
28
38
  import { timestampDate } from "@bufbuild/protobuf/wkt";
@@ -32,6 +42,8 @@ import type {
32
42
  IdentityVerifier,
33
43
  } from "../../extensions/identity.js";
34
44
  import type { Store } from "../../store/interface.js";
45
+ import { identityIdForSubject } from "../identityaccount/resolve.js";
46
+ import type { AccountsBySubject } from "../identityaccount/resolve.js";
35
47
  import { hashApiKey, isApiKeyToken } from "./keymaterial.js";
36
48
  import { findApiKeyByHash } from "./lookup.js";
37
49
 
@@ -41,7 +53,17 @@ export const INVALID_TOKEN_MESSAGE = "invalid token";
41
53
  /** Java classifyAuthError's "expired" arm. */
42
54
  export const TOKEN_EXPIRED_MESSAGE = "token has expired";
43
55
 
44
- export function newApiKeyIdentityVerifier(store: Store): IdentityVerifier {
56
+ export interface ApiKeyVerifierDeps {
57
+ /** Where the keys live — the generic Store, by hash (lookup.ts). */
58
+ readonly store: Store;
59
+ /** The identity-account domain's subject lookup the creator stamp resolves through (A6). */
60
+ readonly accounts: AccountsBySubject;
61
+ }
62
+
63
+ export function newApiKeyIdentityVerifier(
64
+ deps: ApiKeyVerifierDeps,
65
+ ): IdentityVerifier {
66
+ const { store, accounts } = deps;
45
67
  return {
46
68
  name: "apikey",
47
69
  async verify(token: string): Promise<CallerIdentity | null> {
@@ -64,7 +86,7 @@ export function newApiKeyIdentityVerifier(store: Store): IdentityVerifier {
64
86
  throw new ConnectError(INVALID_TOKEN_MESSAGE, Code.Unauthenticated);
65
87
  }
66
88
  return {
67
- identityId: owner.id,
89
+ identityId: await identityIdForSubject(accounts, owner.id),
68
90
  callerClass: "user",
69
91
  issuer: "",
70
92
  rawToken: token,
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Pins the identity-account domain's constants (project 20260911.11,
3
+ * T01_1_review.md A1 and A2):
4
+ *
5
+ * - the account-id derivation — `ida_` + the top 130 bits of
6
+ * sha256(idp_id) as 26 lowercase Crockford-base32 characters, so a
7
+ * derived id is indistinguishable in shape from a minted `{prefix}_{ulid}`
8
+ * and the primary key is the one home of "one account per subject".
9
+ * The golden vectors are wire-adjacent constants: a change here
10
+ * re-addresses every direct account open source ever created;
11
+ * - `idpIdOf(caller)` — the ONE place a caller becomes an issuer subject:
12
+ * a token-bearing caller's JWT `sub`; `local|<identityId>` for a caller
13
+ * with no issuer and no token (the trusted-local posture);
14
+ * - the byte-pinned copy moved from the cloud's handlers as-is, plus the
15
+ * two new sentences (the federation refusal, the immutable subject).
16
+ */
17
+ import { describe, expect, it } from "vitest";
18
+
19
+ import type { CallerIdentity } from "../../../extensions/identity.js";
20
+ import {
21
+ ACCOUNT_NOT_FOUND_FOR_CALLER_MESSAGE,
22
+ FEDERATION_UNIMPLEMENTED_REASON,
23
+ LOCAL_IDP_ID_PREFIX,
24
+ NO_IDP_ID_MESSAGE,
25
+ USERINFO_UNAVAILABLE_PREFIX,
26
+ accountIdFor,
27
+ accountNotFoundMessage,
28
+ federationUnimplementedMessage,
29
+ idpIdImmutableMessage,
30
+ idpIdOf,
31
+ localIdpIdFor,
32
+ } from "../constants.js";
33
+
34
+ const CROCKFORD_ID = /^ida_[0-9abcdefghjkmnpqrstvwxyz]{26}$/;
35
+
36
+ describe("accountIdFor — the derived account id", () => {
37
+ it("pins the golden vectors (a change re-addresses every OSS account)", () => {
38
+ expect(accountIdFor("auth0|user123")).toBe(
39
+ "ida_wtr3jcf281yfk9xx61kj59fsme",
40
+ );
41
+ expect(accountIdFor("local|system")).toBe("ida_fn0zdvkkkhhrb4wry43zba8gnn");
42
+ expect(accountIdFor("local|operator@example.com")).toBe(
43
+ "ida_0byc5k14t1e7b7kdxft7hwz1f7",
44
+ );
45
+ });
46
+
47
+ it("has the shape of every minted id: prefix, underscore, 26 Crockford chars", () => {
48
+ for (const subject of [
49
+ "auth0|user123",
50
+ "google-oauth2|109876543210",
51
+ "x",
52
+ ]) {
53
+ expect(accountIdFor(subject)).toMatch(CROCKFORD_ID);
54
+ }
55
+ });
56
+
57
+ it("is a pure function of the subject", () => {
58
+ expect(accountIdFor("auth0|user123")).toBe(accountIdFor("auth0|user123"));
59
+ });
60
+
61
+ it("separates subjects that differ by one character", () => {
62
+ expect(accountIdFor("auth0|user123")).not.toBe(
63
+ accountIdFor("auth0|user124"),
64
+ );
65
+ expect(accountIdFor("auth0|user123")).not.toBe(
66
+ accountIdFor("auth0|User123"),
67
+ );
68
+ });
69
+
70
+ it("hashes the UTF-8 bytes — unicode and long subjects derive stably", () => {
71
+ expect(accountIdFor("ünïcödé|日本語")).toBe(
72
+ "ida_sk0tfhnzv2mshyh5cvpb2c84fd",
73
+ );
74
+ expect(accountIdFor("a".repeat(4096))).toMatch(CROCKFORD_ID);
75
+ });
76
+
77
+ it("refuses an empty subject — no principal, no address", () => {
78
+ expect(() => accountIdFor("")).toThrow("idp_id must not be empty");
79
+ });
80
+ });
81
+
82
+ describe("idpIdOf — the one place a caller becomes a subject", () => {
83
+ const base = { callerClass: "user", rawToken: "" } as const;
84
+
85
+ it("a caller with no issuer and no token is the local operator: local|<identityId>", () => {
86
+ const operator: CallerIdentity = {
87
+ ...base,
88
+ identityId: "operator@example.com",
89
+ issuer: "",
90
+ };
91
+ expect(idpIdOf(operator)).toBe("local|operator@example.com");
92
+ expect(localIdpIdFor("system")).toBe(`${LOCAL_IDP_ID_PREFIX}system`);
93
+ });
94
+
95
+ it("a token-bearing caller is its JWT subject, whatever identityId was resolved to", async () => {
96
+ // A three-segment JWT whose payload carries sub — the verifier already
97
+ // proved the signature; this function only reads the claim.
98
+ const payload = Buffer.from(
99
+ JSON.stringify({ sub: "auth0|user123" }),
100
+ ).toString("base64url");
101
+ const resolved: CallerIdentity = {
102
+ ...base,
103
+ identityId: accountIdFor("auth0|user123"),
104
+ issuer: "https://issuer.test",
105
+ rawToken: `eyJhbGciOiJSUzI1NiJ9.${payload}.sig`,
106
+ };
107
+ expect(idpIdOf(resolved)).toBe("auth0|user123");
108
+ });
109
+
110
+ it("a token without a readable subject yields the empty string (the UNAUTHENTICATED arm's input)", () => {
111
+ const opaque: CallerIdentity = {
112
+ ...base,
113
+ identityId: "ida_whatever",
114
+ issuer: "https://issuer.test",
115
+ rawToken: "stk_not_a_jwt",
116
+ };
117
+ expect(idpIdOf(opaque)).toBe("");
118
+ });
119
+ });
120
+
121
+ describe("byte-pinned copy", () => {
122
+ it("moves the cloud handlers' sentences as-is", () => {
123
+ expect(ACCOUNT_NOT_FOUND_FOR_CALLER_MESSAGE).toBe(
124
+ "Identity account not found for the authenticated user",
125
+ );
126
+ expect(NO_IDP_ID_MESSAGE).toBe(
127
+ "Cannot provision account: no IDP ID in authentication context",
128
+ );
129
+ expect(USERINFO_UNAVAILABLE_PREFIX).toBe(
130
+ "Failed to fetch user profile from identity provider: ",
131
+ );
132
+ expect(accountNotFoundMessage("ida_x")).toBe(
133
+ "Identity account not found: ida_x",
134
+ );
135
+ });
136
+
137
+ it("the two new sentences name the edition and the subject", () => {
138
+ // The organization directory's absent-method shape ("<method> is not
139
+ // implemented", controller.ts refusesEnumeration) so clients that match
140
+ // on it keep working, followed by the reason a person can act on.
141
+ expect(FEDERATION_UNIMPLEMENTED_REASON).toBe(
142
+ "federated identity accounts are served by the Enterprise and Cloud editions",
143
+ );
144
+ expect(
145
+ federationUnimplementedMessage(
146
+ "ai.stigmer.iam.identityaccount.v1.IdentityAccountCommandController.createFederatedAccount",
147
+ ),
148
+ ).toBe(
149
+ "ai.stigmer.iam.identityaccount.v1.IdentityAccountCommandController.createFederatedAccount is not implemented: federated identity accounts are served by the Enterprise and Cloud editions",
150
+ );
151
+ expect(idpIdImmutableMessage("auth0|user123")).toBe(
152
+ "spec.idp_id is immutable (account subject is 'auth0|user123') — create a new account for a different subject",
153
+ );
154
+ });
155
+ });