@stigmer/server 3.14.1 → 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 (116) 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/apikey/verifier.d.ts +8 -1
  6. package/dist/domain/apikey/verifier.d.ts.map +1 -1
  7. package/dist/domain/apikey/verifier.js +14 -2
  8. package/dist/domain/apikey/verifier.js.map +1 -1
  9. package/dist/domain/identityaccount/constants.d.ts +66 -0
  10. package/dist/domain/identityaccount/constants.d.ts.map +1 -0
  11. package/dist/domain/identityaccount/constants.js +153 -0
  12. package/dist/domain/identityaccount/constants.js.map +1 -0
  13. package/dist/domain/identityaccount/controller.d.ts +34 -0
  14. package/dist/domain/identityaccount/controller.d.ts.map +1 -0
  15. package/dist/domain/identityaccount/controller.js +342 -0
  16. package/dist/domain/identityaccount/controller.js.map +1 -0
  17. package/dist/domain/identityaccount/operator.d.ts +14 -0
  18. package/dist/domain/identityaccount/operator.d.ts.map +1 -0
  19. package/dist/domain/identityaccount/operator.js +67 -0
  20. package/dist/domain/identityaccount/operator.js.map +1 -0
  21. package/dist/domain/identityaccount/provisioning.d.ts +52 -0
  22. package/dist/domain/identityaccount/provisioning.d.ts.map +1 -0
  23. package/dist/domain/identityaccount/provisioning.js +114 -0
  24. package/dist/domain/identityaccount/provisioning.js.map +1 -0
  25. package/dist/domain/identityaccount/resolve.d.ts +36 -0
  26. package/dist/domain/identityaccount/resolve.d.ts.map +1 -0
  27. package/dist/domain/identityaccount/resolve.js +12 -0
  28. package/dist/domain/identityaccount/resolve.js.map +1 -0
  29. package/dist/domain/identityaccount/resource-store.d.ts +4 -0
  30. package/dist/domain/identityaccount/resource-store.d.ts.map +1 -0
  31. package/dist/domain/identityaccount/resource-store.js +131 -0
  32. package/dist/domain/identityaccount/resource-store.js.map +1 -0
  33. package/dist/domain/identityaccount/steps.d.ts +79 -0
  34. package/dist/domain/identityaccount/steps.d.ts.map +1 -0
  35. package/dist/domain/identityaccount/steps.js +291 -0
  36. package/dist/domain/identityaccount/steps.js.map +1 -0
  37. package/dist/domain/identityaccount/store-contract.d.ts +22 -0
  38. package/dist/domain/identityaccount/store-contract.d.ts.map +1 -0
  39. package/dist/domain/identityaccount/store-contract.js +307 -0
  40. package/dist/domain/identityaccount/store-contract.js.map +1 -0
  41. package/dist/domain/identityaccount/store.d.ts +66 -0
  42. package/dist/domain/identityaccount/store.d.ts.map +1 -0
  43. package/dist/domain/identityaccount/store.js +13 -0
  44. package/dist/domain/identityaccount/store.js.map +1 -0
  45. package/dist/extensions/drivers.d.ts +26 -0
  46. package/dist/extensions/drivers.d.ts.map +1 -1
  47. package/dist/extensions/gate-slots.d.ts +16 -1
  48. package/dist/extensions/gate-slots.d.ts.map +1 -1
  49. package/dist/extensions/gate-slots.js +1 -0
  50. package/dist/extensions/gate-slots.js.map +1 -1
  51. package/dist/extensions/identity-federation.d.ts +36 -0
  52. package/dist/extensions/identity-federation.d.ts.map +1 -0
  53. package/dist/extensions/identity-federation.js +2 -0
  54. package/dist/extensions/identity-federation.js.map +1 -0
  55. package/dist/extensions/registry.d.ts +18 -1
  56. package/dist/extensions/registry.d.ts.map +1 -1
  57. package/dist/extensions/registry.js +20 -0
  58. package/dist/extensions/registry.js.map +1 -1
  59. package/dist/identity/oidc-discovery.d.ts +36 -0
  60. package/dist/identity/oidc-discovery.d.ts.map +1 -0
  61. package/dist/identity/oidc-discovery.js +60 -0
  62. package/dist/identity/oidc-discovery.js.map +1 -0
  63. package/dist/identity/oidc-userinfo.d.ts +30 -0
  64. package/dist/identity/oidc-userinfo.d.ts.map +1 -0
  65. package/dist/identity/oidc-userinfo.js +28 -0
  66. package/dist/identity/oidc-userinfo.js.map +1 -0
  67. package/dist/identity/oidc-verifier.d.ts +6 -0
  68. package/dist/identity/oidc-verifier.d.ts.map +1 -1
  69. package/dist/identity/oidc-verifier.js +35 -34
  70. package/dist/identity/oidc-verifier.js.map +1 -1
  71. package/dist/index.d.ts +7 -0
  72. package/dist/index.d.ts.map +1 -1
  73. package/dist/index.js +3 -0
  74. package/dist/index.js.map +1 -1
  75. package/dist/pipeline/interceptors/auth.d.ts +10 -0
  76. package/dist/pipeline/interceptors/auth.d.ts.map +1 -1
  77. package/dist/pipeline/interceptors/auth.js +9 -1
  78. package/dist/pipeline/interceptors/auth.js.map +1 -1
  79. package/package.json +4 -4
  80. package/src/boot/__tests__/auth-enabled-composition.test.ts +226 -2
  81. package/src/boot/compose.ts +118 -13
  82. package/src/domain/apikey/__tests__/verifier.test.ts +146 -22
  83. package/src/domain/apikey/verifier.ts +24 -2
  84. package/src/domain/identityaccount/__tests__/constants.test.ts +155 -0
  85. package/src/domain/identityaccount/__tests__/identityaccount.test.ts +484 -0
  86. package/src/domain/identityaccount/__tests__/operator.test.ts +180 -0
  87. package/src/domain/identityaccount/__tests__/provisioning.test.ts +281 -0
  88. package/src/domain/identityaccount/__tests__/resolve.test.ts +114 -0
  89. package/src/domain/identityaccount/__tests__/resource-store.test.ts +216 -0
  90. package/src/domain/identityaccount/__tests__/support.ts +58 -0
  91. package/src/domain/identityaccount/constants.ts +178 -0
  92. package/src/domain/identityaccount/controller.ts +667 -0
  93. package/src/domain/identityaccount/operator.ts +94 -0
  94. package/src/domain/identityaccount/provisioning.ts +182 -0
  95. package/src/domain/identityaccount/resolve.ts +52 -0
  96. package/src/domain/identityaccount/resource-store.ts +157 -0
  97. package/src/domain/identityaccount/steps.ts +377 -0
  98. package/src/domain/identityaccount/store-contract.ts +471 -0
  99. package/src/domain/identityaccount/store.ts +70 -0
  100. package/src/extensions/__tests__/identity-account-composed.test.ts +803 -0
  101. package/src/extensions/__tests__/identity-account-points.test.ts +129 -0
  102. package/src/extensions/__tests__/registry.test.ts +4 -0
  103. package/src/extensions/__tests__/tier-truthfulness.test.ts +299 -0
  104. package/src/extensions/drivers.ts +26 -0
  105. package/src/extensions/gate-slots.ts +16 -0
  106. package/src/extensions/identity-federation.ts +58 -0
  107. package/src/extensions/registry.ts +44 -1
  108. package/src/identity/__tests__/oidc-discovery.test.ts +122 -0
  109. package/src/identity/__tests__/oidc-userinfo.test.ts +103 -0
  110. package/src/identity/__tests__/oidc-verifier.test.ts +173 -3
  111. package/src/identity/oidc-discovery.ts +84 -0
  112. package/src/identity/oidc-userinfo.ts +64 -0
  113. package/src/identity/oidc-verifier.ts +42 -45
  114. package/src/index.ts +19 -0
  115. package/src/pipeline/interceptors/auth.ts +13 -1
  116. package/src/pipeline/steps/__tests__/authorize-annotation-completeness.test.ts +184 -0
@@ -28,6 +28,7 @@ import path from "node:path";
28
28
 
29
29
  import type { ConnectRouter, Transport } from "@connectrpc/connect";
30
30
 
31
+ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
31
32
  import { HealthCheckResponse_ServingStatus as ServingStatus } from "@stigmer/protos/grpc/health/v1/health_pb";
32
33
 
33
34
  import { registerAgentServices } from "../domain/agent/controller.js";
@@ -64,6 +65,15 @@ import { registerChannelConversationServices } from "../domain/agentchannel/conv
64
65
  import { registerChannelMessageServices } from "../domain/agentchannel/message.js";
65
66
  import { registerAgentShareServices } from "../domain/agentshare/controller.js";
66
67
  import { registerApiKeyServices } from "../domain/apikey/controller.js";
68
+ import {
69
+ newCreateAccountPath,
70
+ registerIdentityAccountServices,
71
+ } from "../domain/identityaccount/controller.js";
72
+ import { ensureOperatorAccount } from "../domain/identityaccount/operator.js";
73
+ import { newDirectAccountProvisioner } from "../domain/identityaccount/provisioning.js";
74
+ import { newResourceIdentityAccountStore } from "../domain/identityaccount/resource-store.js";
75
+ import { newIssuerDiscovery } from "../identity/oidc-discovery.js";
76
+ import { newOidcUserInfoClient } from "../identity/oidc-userinfo.js";
67
77
  import { newApiKeyIdentityVerifier } from "../domain/apikey/verifier.js";
68
78
  import type { IdentityVerifier } from "../extensions/identity.js";
69
79
  import { registerArtifactServices } from "../domain/artifact/controller.js";
@@ -117,6 +127,7 @@ import {
117
127
  } from "../observability/rpc-metrics.js";
118
128
  import { buildInterceptorChain } from "../pipeline/chain.js";
119
129
  import { createVerifierChainInterceptor } from "../pipeline/interceptors/auth.js";
130
+ import { operatorIdentitySnapshot } from "../pipeline/steps/defaults.js";
120
131
  import { createErrorBoundaryInterceptor } from "../pipeline/interceptors/error-boundary.js";
121
132
  import { createRequestMetricsInterceptor } from "../pipeline/interceptors/request-metrics.js";
122
133
  import { newPermissiveSingleTeamAuthorizer } from "../pipeline/steps/authorize.js";
@@ -221,6 +232,13 @@ export interface ComposedServer {
221
232
  * them or reorder them, and both are the drift DD-007 rules out.
222
233
  */
223
234
  identityVerifiers: ReadonlyArray<IdentityVerifier>;
235
+ /**
236
+ * The one routes function both routers are built from (exposed for
237
+ * tests, the `store` posture — the tier-truthfulness invariant
238
+ * enumerates the services a composition registers through it; the
239
+ * server registers no reflection service, deliberately).
240
+ */
241
+ routes: (router: ConnectRouter) => void;
224
242
  /** Completes wiring, flips SERVING, binds the port; returns the bound port. */
225
243
  start(): Promise<number>;
226
244
  /** NOT_SERVING first, stop background work, drain connections. */
@@ -294,6 +312,75 @@ export async function composeServer(
294
312
  });
295
313
  }
296
314
 
315
+ // The require-authentication posture has two sources OR'd here: the
316
+ // OSS OIDC issuer (O3 rulings Q1+Q2) and a composed unit's declaration
317
+ // (registry point, entry 20260904.02). Neither source = the OSS
318
+ // trusted-local posture, byte-identical wire behavior. Resolved in the
319
+ // storage stage because the posture decides a WRITE below (the operator
320
+ // account); the verifier chain it also governs is built at the end.
321
+ const authEnabled = config.oidcIssuer !== "";
322
+ const requireAuthentication =
323
+ authEnabled || extensions.requireAuthentication !== undefined;
324
+
325
+ // Stage: identity accounts (20260911.11) — the first domain whose
326
+ // persistence is a PORT rather than the generic Store. The composed
327
+ // driver (`drivers.identityAccountStore`, Q-IA-9) serves when a unit
328
+ // registers one; otherwise open source's adapter over `store` installs
329
+ // HERE (the default lives with the consumer, never in the registry).
330
+ // This ONE binding is what the create path, the provisioner, the
331
+ // operator ensure and both OSS verifiers read, so a composed driver is
332
+ // followed everywhere by construction. The domain's one create path is
333
+ // built once and shared by the create RPC, provisioning and the operator
334
+ // ensure below.
335
+ const identityAccounts =
336
+ extensions.drivers.identityAccountStore ??
337
+ newResourceIdentityAccountStore(store);
338
+ const identityAccountPath = {
339
+ accounts: identityAccounts,
340
+ logger,
341
+ authorizer,
342
+ authorizationLifecycle,
343
+ };
344
+ const createIdentityAccount = newCreateAccountPath(
345
+ identityAccountPath,
346
+ ApiResourceKind.identity_account,
347
+ );
348
+ // ONE issuer discovery for every lane that reads a well-known document
349
+ // (A8): provisioning's userinfo client here and the OIDC verifier's
350
+ // JWKS location below share it, so an issuer is discovered once per
351
+ // process and validated the same way wherever it is consumed. Built in
352
+ // this stage, not inside routes() (which runs twice) — the same reason
353
+ // the searchRegistry lives here.
354
+ const issuerDiscovery = newIssuerDiscovery();
355
+ // Provisioning reads the caller's profile from the userinfo endpoint of
356
+ // the issuer that vouched for the token, discovered per issuer and
357
+ // cached (A8) — so a composition whose own verifiers name their issuer
358
+ // on the identity needs no knob here.
359
+ const identityAccountProvisioner = newDirectAccountProvisioner({
360
+ accounts: identityAccounts,
361
+ createAccount: createIdentityAccount,
362
+ userInfo: newOidcUserInfoClient(issuerDiscovery),
363
+ });
364
+ // Under the trusted-local posture the server knows its one principal
365
+ // from the operator seam (the SAME seam the interceptor stamps callers
366
+ // from — never config directly, so the row and the stamp cannot
367
+ // disagree; T01_1_review.md A2, A5) and states the fact at boot:
368
+ // create-if-absent, so preferences set later survive every reboot. A
369
+ // composition with an authentication posture never creates one — its
370
+ // users provision themselves on first sign-in. A stage that writes a
371
+ // resource is new here; it earns its place because it is the truth of
372
+ // that posture, and a failure is a loud boot throw like the storage
373
+ // stage's own.
374
+ if (!requireAuthentication) {
375
+ const operator = await ensureOperatorAccount(
376
+ { accounts: identityAccounts, createAccount: createIdentityAccount },
377
+ operatorIdentitySnapshot(),
378
+ );
379
+ logger.info("trusted-local operator account ensured", {
380
+ accountId: operator.metadata?.id ?? "",
381
+ });
382
+ }
383
+
297
384
  // Stage: keys — the ratified fail-loud boot ASYMMETRY (D2 cross-domain
298
385
  // invariants; Go server.go:277-293):
299
386
  //
@@ -799,6 +886,17 @@ export async function composeServer(
799
886
  authorizationLifecycle,
800
887
  listReadScope: extensions.drivers.listReadScope,
801
888
  });
889
+ // IdentityAccount (20260911.11): served once by open source in every
890
+ // edition over the store PORT bound in the identity-accounts stage; a
891
+ // composition adds what differs per edition through the registry —
892
+ // the federation capability (the four federated RPC arms) and the
893
+ // provision slot's gate steps (the cloud's personal organization).
894
+ registerIdentityAccountServices(router, {
895
+ ...identityAccountPath,
896
+ provisioner: identityAccountProvisioner,
897
+ federation: extensions.drivers.identityFederation,
898
+ gateSteps: extensions.gateSteps,
899
+ });
802
900
  registerEnvironmentServices(router, {
803
901
  store,
804
902
  logger,
@@ -1092,14 +1190,8 @@ export async function composeServer(
1092
1190
  const inProcessWiring = createInProcessClients(routes, logger);
1093
1191
  inProcess = inProcessWiring.clients;
1094
1192
 
1095
- // The require-authentication posture has two sources OR'd here: the
1096
- // OSS OIDC issuer (O3 rulings Q1+Q2) and a composed unit's declaration
1097
- // (registry point, entry 20260904.02). Neither source = the OSS
1098
- // trusted-local posture, byte-identical wire behavior.
1099
- const authEnabled = config.oidcIssuer !== "";
1100
- const requireAuthentication =
1101
- authEnabled || extensions.requireAuthentication !== undefined;
1102
- // What the posture registers, in Java's ProviderManager order (opaque
1193
+ // What the posture (resolved in the storage stage) registers, in Java's
1194
+ // ProviderManager order (opaque
1103
1195
  // keys first — the cheapest claim — then the JWT lanes), ahead of the
1104
1196
  // extension entries ("OSS entries first", the registry contract):
1105
1197
  // - the API-key verifier rides the POSTURE, not the issuer
@@ -1108,19 +1200,31 @@ export async function composeServer(
1108
1200
  // authentication posture must honor the keys it issues. Gating it on
1109
1201
  // the issuer left a posture-declaring composition minting keys
1110
1202
  // nothing verified.
1111
- // - the OIDC verifier rides the ISSUER alone: it resolves callers to
1112
- // the token's `sub`, which is the wrong principal for a composition
1113
- // whose own lanes resolve users to their account ids — that is why a
1114
- // declared posture never borrows the issuer knob.
1203
+ // - the OIDC verifier rides the ISSUER alone: it is open source's
1204
+ // single-issuer lane, and a composition with verifiers of its own
1205
+ // names their issuers and audiences itself and declares the posture
1206
+ // instead — so the knob and the extension entries never both claim
1207
+ // one JWT.
1208
+ // - both OSS verifiers resolve their subject through the identity-
1209
+ // account domain (20260911.11 Q-IA-2, A6): a provisioned user is
1210
+ // stamped with the account id whichever credential they present,
1211
+ // an unprovisioned one is admitted idp-shaped. They read the SAME
1212
+ // port the domain writes through (`identityAccounts`, the
1213
+ // identity-accounts stage — a composed driver included), so a row
1214
+ // provisioning creates resolves on the very next request.
1115
1215
  // The runner's credential in either posture is an operator-minted API
1116
1216
  // token via STIGMER_TOKEN (O3 ruling Q3) — no runner-specific verifier.
1117
1217
  const identityVerifiers = [
1118
- ...(requireAuthentication ? [newApiKeyIdentityVerifier(store)] : []),
1218
+ ...(requireAuthentication
1219
+ ? [newApiKeyIdentityVerifier({ store, accounts: identityAccounts })]
1220
+ : []),
1119
1221
  ...(authEnabled
1120
1222
  ? [
1121
1223
  newOidcIdentityVerifier({
1122
1224
  issuer: config.oidcIssuer,
1123
1225
  audience: config.oidcAudience,
1226
+ accounts: identityAccounts,
1227
+ discovery: issuerDiscovery,
1124
1228
  }),
1125
1229
  ]
1126
1230
  : []),
@@ -1203,6 +1307,7 @@ export async function composeServer(
1203
1307
  workflowExecutionStreamBroker,
1204
1308
  inProcessTransport: inProcessWiring.transport,
1205
1309
  identityVerifiers,
1310
+ routes,
1206
1311
 
1207
1312
  async start(): Promise<number> {
1208
1313
  // Temporal boot is NON-fatal end to end (Go server.go): a failed
@@ -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
+ });