@stigmer/server 3.14.1 → 3.15.1

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 (129) 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 +121 -14
  4. package/dist/boot/compose.js.map +1 -1
  5. package/dist/boot/config.d.ts +14 -0
  6. package/dist/boot/config.d.ts.map +1 -1
  7. package/dist/boot/config.js +1 -0
  8. package/dist/boot/config.js.map +1 -1
  9. package/dist/domain/apikey/verifier.d.ts +8 -1
  10. package/dist/domain/apikey/verifier.d.ts.map +1 -1
  11. package/dist/domain/apikey/verifier.js +14 -2
  12. package/dist/domain/apikey/verifier.js.map +1 -1
  13. package/dist/domain/identityaccount/constants.d.ts +66 -0
  14. package/dist/domain/identityaccount/constants.d.ts.map +1 -0
  15. package/dist/domain/identityaccount/constants.js +153 -0
  16. package/dist/domain/identityaccount/constants.js.map +1 -0
  17. package/dist/domain/identityaccount/controller.d.ts +34 -0
  18. package/dist/domain/identityaccount/controller.d.ts.map +1 -0
  19. package/dist/domain/identityaccount/controller.js +342 -0
  20. package/dist/domain/identityaccount/controller.js.map +1 -0
  21. package/dist/domain/identityaccount/operator.d.ts +14 -0
  22. package/dist/domain/identityaccount/operator.d.ts.map +1 -0
  23. package/dist/domain/identityaccount/operator.js +67 -0
  24. package/dist/domain/identityaccount/operator.js.map +1 -0
  25. package/dist/domain/identityaccount/provisioning.d.ts +52 -0
  26. package/dist/domain/identityaccount/provisioning.d.ts.map +1 -0
  27. package/dist/domain/identityaccount/provisioning.js +114 -0
  28. package/dist/domain/identityaccount/provisioning.js.map +1 -0
  29. package/dist/domain/identityaccount/resolve.d.ts +36 -0
  30. package/dist/domain/identityaccount/resolve.d.ts.map +1 -0
  31. package/dist/domain/identityaccount/resolve.js +12 -0
  32. package/dist/domain/identityaccount/resolve.js.map +1 -0
  33. package/dist/domain/identityaccount/resource-store.d.ts +4 -0
  34. package/dist/domain/identityaccount/resource-store.d.ts.map +1 -0
  35. package/dist/domain/identityaccount/resource-store.js +131 -0
  36. package/dist/domain/identityaccount/resource-store.js.map +1 -0
  37. package/dist/domain/identityaccount/steps.d.ts +79 -0
  38. package/dist/domain/identityaccount/steps.d.ts.map +1 -0
  39. package/dist/domain/identityaccount/steps.js +291 -0
  40. package/dist/domain/identityaccount/steps.js.map +1 -0
  41. package/dist/domain/identityaccount/store-contract.d.ts +22 -0
  42. package/dist/domain/identityaccount/store-contract.d.ts.map +1 -0
  43. package/dist/domain/identityaccount/store-contract.js +307 -0
  44. package/dist/domain/identityaccount/store-contract.js.map +1 -0
  45. package/dist/domain/identityaccount/store.d.ts +66 -0
  46. package/dist/domain/identityaccount/store.d.ts.map +1 -0
  47. package/dist/domain/identityaccount/store.js +13 -0
  48. package/dist/domain/identityaccount/store.js.map +1 -0
  49. package/dist/extensions/drivers.d.ts +26 -0
  50. package/dist/extensions/drivers.d.ts.map +1 -1
  51. package/dist/extensions/gate-slots.d.ts +16 -1
  52. package/dist/extensions/gate-slots.d.ts.map +1 -1
  53. package/dist/extensions/gate-slots.js +1 -0
  54. package/dist/extensions/gate-slots.js.map +1 -1
  55. package/dist/extensions/identity-federation.d.ts +36 -0
  56. package/dist/extensions/identity-federation.d.ts.map +1 -0
  57. package/dist/extensions/identity-federation.js +2 -0
  58. package/dist/extensions/identity-federation.js.map +1 -0
  59. package/dist/extensions/registry.d.ts +18 -1
  60. package/dist/extensions/registry.d.ts.map +1 -1
  61. package/dist/extensions/registry.js +20 -0
  62. package/dist/extensions/registry.js.map +1 -1
  63. package/dist/identity/oidc-discovery.d.ts +36 -0
  64. package/dist/identity/oidc-discovery.d.ts.map +1 -0
  65. package/dist/identity/oidc-discovery.js +60 -0
  66. package/dist/identity/oidc-discovery.js.map +1 -0
  67. package/dist/identity/oidc-userinfo.d.ts +30 -0
  68. package/dist/identity/oidc-userinfo.d.ts.map +1 -0
  69. package/dist/identity/oidc-userinfo.js +28 -0
  70. package/dist/identity/oidc-userinfo.js.map +1 -0
  71. package/dist/identity/oidc-verifier.d.ts +6 -0
  72. package/dist/identity/oidc-verifier.d.ts.map +1 -1
  73. package/dist/identity/oidc-verifier.js +35 -34
  74. package/dist/identity/oidc-verifier.js.map +1 -1
  75. package/dist/index.d.ts +7 -0
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +3 -0
  78. package/dist/index.js.map +1 -1
  79. package/dist/pipeline/interceptors/auth.d.ts +10 -0
  80. package/dist/pipeline/interceptors/auth.d.ts.map +1 -1
  81. package/dist/pipeline/interceptors/auth.js +9 -1
  82. package/dist/pipeline/interceptors/auth.js.map +1 -1
  83. package/dist/transport/console/handler.d.ts +22 -5
  84. package/dist/transport/console/handler.d.ts.map +1 -1
  85. package/dist/transport/console/handler.js +53 -24
  86. package/dist/transport/console/handler.js.map +1 -1
  87. package/package.json +4 -4
  88. package/src/boot/__tests__/auth-enabled-composition.test.ts +274 -4
  89. package/src/boot/__tests__/config.test.ts +41 -3
  90. package/src/boot/compose.ts +148 -15
  91. package/src/boot/config.ts +15 -0
  92. package/src/domain/apikey/__tests__/verifier.test.ts +146 -22
  93. package/src/domain/apikey/verifier.ts +24 -2
  94. package/src/domain/identityaccount/__tests__/constants.test.ts +155 -0
  95. package/src/domain/identityaccount/__tests__/identityaccount.test.ts +484 -0
  96. package/src/domain/identityaccount/__tests__/operator.test.ts +180 -0
  97. package/src/domain/identityaccount/__tests__/provisioning.test.ts +281 -0
  98. package/src/domain/identityaccount/__tests__/resolve.test.ts +114 -0
  99. package/src/domain/identityaccount/__tests__/resource-store.test.ts +216 -0
  100. package/src/domain/identityaccount/__tests__/support.ts +58 -0
  101. package/src/domain/identityaccount/constants.ts +178 -0
  102. package/src/domain/identityaccount/controller.ts +667 -0
  103. package/src/domain/identityaccount/operator.ts +94 -0
  104. package/src/domain/identityaccount/provisioning.ts +182 -0
  105. package/src/domain/identityaccount/resolve.ts +52 -0
  106. package/src/domain/identityaccount/resource-store.ts +157 -0
  107. package/src/domain/identityaccount/steps.ts +377 -0
  108. package/src/domain/identityaccount/store-contract.ts +471 -0
  109. package/src/domain/identityaccount/store.ts +70 -0
  110. package/src/extensions/__tests__/identity-account-composed.test.ts +803 -0
  111. package/src/extensions/__tests__/identity-account-points.test.ts +129 -0
  112. package/src/extensions/__tests__/registry.test.ts +4 -0
  113. package/src/extensions/__tests__/tier-truthfulness.test.ts +299 -0
  114. package/src/extensions/drivers.ts +26 -0
  115. package/src/extensions/gate-slots.ts +16 -0
  116. package/src/extensions/identity-federation.ts +58 -0
  117. package/src/extensions/registry.ts +44 -1
  118. package/src/identity/__tests__/oidc-discovery.test.ts +122 -0
  119. package/src/identity/__tests__/oidc-userinfo.test.ts +103 -0
  120. package/src/identity/__tests__/oidc-verifier.test.ts +173 -3
  121. package/src/identity/oidc-discovery.ts +84 -0
  122. package/src/identity/oidc-userinfo.ts +64 -0
  123. package/src/identity/oidc-verifier.ts +42 -45
  124. package/src/index.ts +19 -0
  125. package/src/pipeline/interceptors/auth.ts +13 -1
  126. package/src/pipeline/steps/__tests__/authorize-annotation-completeness.test.ts +184 -0
  127. package/src/transport/console/__tests__/handler.test.ts +93 -23
  128. package/src/transport/console/__tests__/resolver.test.ts +6 -0
  129. package/src/transport/console/handler.ts +84 -30
@@ -14,9 +14,24 @@
14
14
  * owning user (the runner's credential lane, ruling Q3 — the runner
15
15
  * presents exactly such a key via STIGMER_TOKEN);
16
16
  * 4. deleting the key revokes it on the very next request;
17
- * 5. garbage credentials keep the Q6 unclaimed-token rejection.
17
+ * 5. garbage credentials keep the Q6 unclaimed-token rejection;
18
+ * 6. (20260911.11) the identity-account arms: an unprovisioned subject is
19
+ * idp-shaped (whoAmI NOT_FOUND, writes stamped with the raw sub);
20
+ * provisionMyAccount creates the account under the derived id with
21
+ * the issuer's userinfo profile; from then on the verifier resolves
22
+ * the subject to the account id and API keys minted before OR after
23
+ * provisioning answer whoAmI with the owner's account; no operator
24
+ * account exists under the posture;
25
+ * 7. (20260913.02, sp.console-login) the console the server serves is
26
+ * told the posture through /config.json — the issuer and audience
27
+ * this composition runs under, with the console client id the
28
+ * operator did NOT register here left empty — and the composition
29
+ * root WARNs at wiring time that the served console cannot sign in
30
+ * (Q-CL-2), the STIGMER_OAUTH_REDIRECT_URI precedent. Proven through
31
+ * compose.ts with STIGMER_CONSOLE_DIR at a fixture export, not
32
+ * through the lane alone (handler.test.ts covers that).
18
33
  */
19
- import { mkdtempSync, rmSync } from "node:fs";
34
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
20
35
  import { tmpdir } from "node:os";
21
36
  import path from "node:path";
22
37
  import { createServer } from "node:http";
@@ -30,8 +45,12 @@ import { SignJWT, exportJWK, generateKeyPair } from "jose";
30
45
  import type { GenerateKeyPairResult } from "jose";
31
46
  import { afterAll, beforeAll, describe, expect, it } from "vitest";
32
47
 
48
+ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
33
49
  import { ApiKeyCommandController } from "@stigmer/protos/ai/stigmer/iam/apikey/v1/command_pb";
34
50
  import { ApiKeyQueryController } from "@stigmer/protos/ai/stigmer/iam/apikey/v1/query_pb";
51
+ import { IdentityAccountCommandController } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/command_pb";
52
+ import { IdentityAccountProvisioningMode } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/enum_pb";
53
+ import { IdentityAccountQueryController } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/query_pb";
35
54
  import { PlatformQueryController } from "@stigmer/protos/ai/stigmer/platform/v1/server_info_pb";
36
55
  import {
37
56
  Health,
@@ -42,6 +61,11 @@ import { loadConfig } from "../config.js";
42
61
  import { composeServer } from "../compose.js";
43
62
  import type { ComposedServer } from "../compose.js";
44
63
  import { createLogger } from "../logger.js";
64
+ import {
65
+ ACCOUNT_NOT_FOUND_FOR_CALLER_MESSAGE,
66
+ USERINFO_UNAVAILABLE_PREFIX,
67
+ accountIdFor,
68
+ } from "../../domain/identityaccount/constants.js";
45
69
  import { AUTHENTICATION_TOKEN_MISSING_MESSAGE } from "../../pipeline/interceptors/auth.js";
46
70
 
47
71
  const AUDIENCE = "https://api.stigmer.test/";
@@ -50,8 +74,18 @@ let dir: string;
50
74
  let issuerServer: Server;
51
75
  let issuer: string;
52
76
  let privateKey: GenerateKeyPairResult["privateKey"];
77
+ // A lever for the UNAVAILABLE arm: the issuer's /userinfo answers 503 for
78
+ // this subject while set. Undefined = every subject is served.
79
+ let userinfoRefusesSub: string | undefined;
53
80
  let server: ComposedServer;
54
81
  let port: number;
82
+ // A key minted while its owner was still idp-shaped: its creator stamp is
83
+ // the raw sub. The identity-account arms prove that stamp resolves to the
84
+ // owner's account once one exists (20260911.11 A1's derived id).
85
+ let keyMintedBeforeProvisioning: string;
86
+ // Every WARN the composition root logged while wiring — the console
87
+ // sign-in arm (7) reads it.
88
+ const warnings: string[] = [];
55
89
 
56
90
  function bearer(token: string): Interceptor {
57
91
  return (next) => (request) => {
@@ -88,8 +122,16 @@ beforeAll(async () => {
88
122
  };
89
123
  issuerServer = createServer((req, res) => {
90
124
  if (req.url === "/.well-known/openid-configuration") {
125
+ // The provisioning lane reads userinfo_endpoint from here (A8) —
126
+ // never a guessed `<issuer>/userinfo`.
91
127
  res.setHeader("content-type", "application/json");
92
- res.end(JSON.stringify({ issuer, jwks_uri: `${issuer}/jwks` }));
128
+ res.end(
129
+ JSON.stringify({
130
+ issuer,
131
+ jwks_uri: `${issuer}/jwks`,
132
+ userinfo_endpoint: `${issuer}/userinfo`,
133
+ }),
134
+ );
93
135
  return;
94
136
  }
95
137
  if (req.url === "/jwks") {
@@ -97,6 +139,43 @@ beforeAll(async () => {
97
139
  res.end(JSON.stringify({ keys: [jwk] }));
98
140
  return;
99
141
  }
142
+ if (req.url === "/userinfo") {
143
+ // The provisioning lane's profile source (20260911.11 A1/Q-IA-2):
144
+ // answers the bearer's own claims as an IdP would, given_name and
145
+ // family_name included — the signature was already proven upstream.
146
+ const header = req.headers.authorization ?? "";
147
+ const token = header.startsWith("Bearer ")
148
+ ? header.slice("Bearer ".length)
149
+ : "";
150
+ const payloadSegment = token.split(".")[1];
151
+ if (payloadSegment === undefined) {
152
+ res.statusCode = 401;
153
+ res.end();
154
+ return;
155
+ }
156
+ const claims = JSON.parse(
157
+ Buffer.from(payloadSegment, "base64url").toString("utf8"),
158
+ ) as { sub?: string; email?: string };
159
+ if (
160
+ userinfoRefusesSub !== undefined &&
161
+ claims.sub === userinfoRefusesSub
162
+ ) {
163
+ res.statusCode = 503;
164
+ res.end();
165
+ return;
166
+ }
167
+ res.setHeader("content-type", "application/json");
168
+ res.end(
169
+ JSON.stringify({
170
+ sub: claims.sub ?? "",
171
+ email: claims.email ?? "",
172
+ given_name: "Composed",
173
+ family_name: "User",
174
+ picture: `${issuer}/pictures/${encodeURIComponent(claims.sub ?? "")}`,
175
+ }),
176
+ );
177
+ return;
178
+ }
100
179
  res.statusCode = 404;
101
180
  res.end();
102
181
  });
@@ -109,6 +188,10 @@ beforeAll(async () => {
109
188
  issuer = `http://127.0.0.1:${address.port}`;
110
189
 
111
190
  dir = mkdtempSync(path.join(tmpdir(), "auth-enabled-test-"));
191
+ // The smallest export the console lane discovers: the app shell alone.
192
+ const consoleDir = path.join(dir, "console");
193
+ mkdirSync(consoleDir, { recursive: true });
194
+ writeFileSync(path.join(consoleDir, "index.html"), "<html>app shell</html>");
112
195
  server = await composeServer({
113
196
  config: loadConfig({
114
197
  STIGMER_MODEL_REGISTRY_REFRESH: "off",
@@ -118,8 +201,18 @@ beforeAll(async () => {
118
201
  ARTIFACT_LOCAL_BASE_PATH: path.join(dir, "artifacts"),
119
202
  STIGMER_OIDC_ISSUER: issuer,
120
203
  STIGMER_OIDC_AUDIENCE: AUDIENCE,
204
+ STIGMER_CONSOLE_DIR: consoleDir,
205
+ }),
206
+ logger: createLogger({
207
+ level: "warn",
208
+ pretty: false,
209
+ write: (line) => {
210
+ const entry = JSON.parse(line) as { level?: string; message?: string };
211
+ if (entry.level === "warn" && entry.message !== undefined) {
212
+ warnings.push(entry.message);
213
+ }
214
+ },
121
215
  }),
122
- logger: createLogger({ level: "error", pretty: false, write: () => {} }),
123
216
  portOverride: 0,
124
217
  host: "127.0.0.1",
125
218
  });
@@ -168,6 +261,27 @@ describe("the require-authentication posture on the wire", () => {
168
261
  });
169
262
  });
170
263
 
264
+ describe("the console the server serves is told the posture (20260913.02, sp.console-login)", () => {
265
+ it("/config.json carries the issuer and audience this composition verifies against, with the unregistered console client id empty", async () => {
266
+ const response = await fetch(`http://127.0.0.1:${port}/config.json`);
267
+ expect(response.status).toBe(200);
268
+ expect(await response.json()).toEqual({
269
+ apiUrl: "",
270
+ appUrl: "",
271
+ authMode: "oidc",
272
+ oidcIssuer: issuer,
273
+ oidcClientId: "",
274
+ oidcAudience: AUDIENCE,
275
+ });
276
+ });
277
+
278
+ it("the composition root WARNs at wiring time that the served console cannot sign in without STIGMER_OIDC_CONSOLE_CLIENT_ID (Q-CL-2)", () => {
279
+ expect(warnings).toContainEqual(
280
+ expect.stringContaining("STIGMER_OIDC_CONSOLE_CLIENT_ID"),
281
+ );
282
+ });
283
+ });
284
+
171
285
  describe("the full credential loop (OIDC login → API key → revocation)", () => {
172
286
  let apiKeyPlaintext: string;
173
287
  let apiKeyId: string;
@@ -221,6 +335,7 @@ describe("the full credential loop (OIDC login → API key → revocation)", ()
221
335
  expect(second.status?.audit?.specAudit?.createdBy?.id).toBe(
222
336
  "auth0|loop-user",
223
337
  );
338
+ keyMintedBeforeProvisioning = second.spec?.keyHash ?? "";
224
339
  });
225
340
 
226
341
  it("deleting the key revokes it on the very next request", async () => {
@@ -243,3 +358,158 @@ describe("the full credential loop (OIDC login → API key → revocation)", ()
243
358
  expect(ConnectError.from(error).rawMessage).toBe("invalid token");
244
359
  });
245
360
  });
361
+
362
+ describe("identity accounts under the OIDC posture (20260911.11; Q-IA-2, A1, A2)", () => {
363
+ const SUB = "auth0|loop-user";
364
+ const accountId = accountIdFor(SUB);
365
+
366
+ it("no operator account exists under an authentication posture — the boot-time ensure is trusted-local only", async () => {
367
+ const rows = await server.store.listResources(
368
+ ApiResourceKind.identity_account,
369
+ );
370
+ expect(rows).toHaveLength(0);
371
+ });
372
+
373
+ it("whoAmI for an unprovisioned subject is NOT_FOUND with the cloud's copy, and its writes stay stamped with the raw sub", async () => {
374
+ const token = await mintOidcToken(SUB, "loop@example.com");
375
+ const query = createClient(
376
+ IdentityAccountQueryController,
377
+ transportWith(token),
378
+ );
379
+ const error = await query.whoAmI({}).catch((e: unknown) => e);
380
+ expect(ConnectError.from(error).code).toBe(Code.NotFound);
381
+ expect(ConnectError.from(error).rawMessage).toBe(
382
+ ACCOUNT_NOT_FOUND_FOR_CALLER_MESSAGE,
383
+ );
384
+ });
385
+
386
+ it("provisionMyAccount creates the account under the derived id with the userinfo profile", async () => {
387
+ const token = await mintOidcToken(SUB, "loop@example.com");
388
+ const command = createClient(
389
+ IdentityAccountCommandController,
390
+ transportWith(token),
391
+ );
392
+ const account = await command.provisionMyAccount({});
393
+ expect(account.metadata?.id).toBe(accountId);
394
+ expect(account.metadata?.name).toBe("loop@example.com");
395
+ expect(account.spec).toMatchObject({
396
+ idpId: SUB,
397
+ email: "loop@example.com",
398
+ firstName: "Composed",
399
+ lastName: "User",
400
+ provisioningMode: IdentityAccountProvisioningMode.direct,
401
+ isMachineAccount: false,
402
+ });
403
+ // The account is created BY the subject it is for: the stamp is the
404
+ // sub the verifier admitted idp-shaped — no account existed yet.
405
+ expect(account.status?.audit?.specAudit?.createdBy?.id).toBe(SUB);
406
+ });
407
+
408
+ it("from the next request on, the verifier resolves the subject to the account id — whoAmI answers and writes are stamped with it", async () => {
409
+ const token = await mintOidcToken(SUB, "loop@example.com");
410
+ const query = createClient(
411
+ IdentityAccountQueryController,
412
+ transportWith(token),
413
+ );
414
+ const me = await query.whoAmI({});
415
+ expect(me.metadata?.id).toBe(accountId);
416
+
417
+ const keys = createClient(ApiKeyCommandController, transportWith(token));
418
+ const key = await keys.create({
419
+ apiVersion: "iam.stigmer.ai/v1",
420
+ kind: "ApiKey",
421
+ metadata: { name: "minted after provisioning", org: "local" },
422
+ spec: {},
423
+ });
424
+ expect(key.status?.audit?.specAudit?.createdBy?.id).toBe(accountId);
425
+ expect(key.status?.audit?.specAudit?.createdBy?.email).toBe(
426
+ "loop@example.com",
427
+ );
428
+
429
+ // Over that key, whoAmI is the owner's account — the key is the user.
430
+ const overKey = createClient(
431
+ IdentityAccountQueryController,
432
+ transportWith(key.spec?.keyHash ?? ""),
433
+ );
434
+ expect((await overKey.whoAmI({})).metadata?.id).toBe(accountId);
435
+ });
436
+
437
+ it("a key minted BEFORE provisioning still resolves to the owner's account — the raw-sub stamp derives to the same id", async () => {
438
+ const overLegacyKey = createClient(
439
+ IdentityAccountQueryController,
440
+ transportWith(keyMintedBeforeProvisioning),
441
+ );
442
+ expect((await overLegacyKey.whoAmI({})).metadata?.id).toBe(accountId);
443
+ });
444
+
445
+ it("provisionMyAccount is idempotent for a provisioned subject", async () => {
446
+ const token = await mintOidcToken(SUB, "loop@example.com");
447
+ const command = createClient(
448
+ IdentityAccountCommandController,
449
+ transportWith(token),
450
+ );
451
+ const before = (
452
+ await server.store.listResources(ApiResourceKind.identity_account)
453
+ ).length;
454
+ const again = await command.provisionMyAccount({});
455
+ expect(again.metadata?.id).toBe(accountId);
456
+ expect(
457
+ (await server.store.listResources(ApiResourceKind.identity_account))
458
+ .length,
459
+ ).toBe(before);
460
+ });
461
+
462
+ it("two concurrent first logins for one subject end in exactly one account", async () => {
463
+ const token = await mintOidcToken("auth0|racer", "racer@example.com");
464
+ const command = createClient(
465
+ IdentityAccountCommandController,
466
+ transportWith(token),
467
+ );
468
+ const before = (
469
+ await server.store.listResources(ApiResourceKind.identity_account)
470
+ ).length;
471
+ const [a, b] = await Promise.all([
472
+ command.provisionMyAccount({}),
473
+ command.provisionMyAccount({}),
474
+ ]);
475
+ expect(a.metadata?.id).toBe(accountIdFor("auth0|racer"));
476
+ expect(b.metadata?.id).toBe(a.metadata?.id);
477
+ expect(
478
+ (await server.store.listResources(ApiResourceKind.identity_account))
479
+ .length,
480
+ ).toBe(before + 1);
481
+ });
482
+
483
+ it("a userinfo failure is UNAVAILABLE with the cloud's copy, and no account is created", async () => {
484
+ // A real token (the verifier must admit it) whose subject the issuer's
485
+ // /userinfo refuses while the lever is set.
486
+ const token = await mintOidcToken(
487
+ "auth0|userinfo-down",
488
+ "down@example.com",
489
+ );
490
+ userinfoRefusesSub = "auth0|userinfo-down";
491
+ try {
492
+ const command = createClient(
493
+ IdentityAccountCommandController,
494
+ transportWith(token),
495
+ );
496
+ const error = await command
497
+ .provisionMyAccount({})
498
+ .catch((e: unknown) => e);
499
+ expect(ConnectError.from(error).code).toBe(Code.Unavailable);
500
+ expect(
501
+ ConnectError.from(error).rawMessage.startsWith(
502
+ USERINFO_UNAVAILABLE_PREFIX,
503
+ ),
504
+ ).toBe(true);
505
+ const query = createClient(
506
+ IdentityAccountQueryController,
507
+ transportWith(token),
508
+ );
509
+ const missing = await query.whoAmI({}).catch((e: unknown) => e);
510
+ expect(ConnectError.from(missing).code).toBe(Code.NotFound);
511
+ } finally {
512
+ userinfoRefusesSub = undefined;
513
+ }
514
+ });
515
+ });
@@ -59,15 +59,53 @@ describe("loadConfig", () => {
59
59
  });
60
60
 
61
61
  it("reads an explicit ARTIFACT_HTTP_HOST (the container override)", () => {
62
- expect(
63
- loadConfig({ ARTIFACT_HTTP_HOST: "0.0.0.0" }).artifactHttpHost,
64
- ).toBe("0.0.0.0");
62
+ expect(loadConfig({ ARTIFACT_HTTP_HOST: "0.0.0.0" }).artifactHttpHost).toBe(
63
+ "0.0.0.0",
64
+ );
65
65
  // Empty string is absent, per the Go getEnvString leniency.
66
66
  expect(loadConfig({ ARTIFACT_HTTP_HOST: "" }).artifactHttpHost).toBe(
67
67
  "127.0.0.1",
68
68
  );
69
69
  });
70
70
 
71
+ // 20260913.02 (sp.console-login, Q-CL-1): the console's public PKCE client
72
+ // id is lenient on purpose — a CLI-only self-host that set the issuer
73
+ // before this knob existed must keep booting on upgrade, and the served
74
+ // console reports the gap itself (Q-CL-2).
75
+ describe("STIGMER_OIDC_CONSOLE_CLIENT_ID (the console's sign-in client)", () => {
76
+ const OIDC = {
77
+ STIGMER_OIDC_ISSUER: "https://auth.example.com/realms/main",
78
+ STIGMER_OIDC_AUDIENCE: "https://stigmer.example.com/",
79
+ };
80
+
81
+ it("is empty when absent, and the issuer alone still boots", () => {
82
+ expect(loadConfig(OIDC).oidcConsoleClientId).toBe("");
83
+ });
84
+
85
+ it("reads the explicit value", () => {
86
+ expect(
87
+ loadConfig({
88
+ ...OIDC,
89
+ STIGMER_OIDC_CONSOLE_CLIENT_ID: "stigmer-console",
90
+ }).oidcConsoleClientId,
91
+ ).toBe("stigmer-console");
92
+ });
93
+
94
+ it("treats an empty string as absent (Go's getEnvString)", () => {
95
+ expect(
96
+ loadConfig({ ...OIDC, STIGMER_OIDC_CONSOLE_CLIENT_ID: "" })
97
+ .oidcConsoleClientId,
98
+ ).toBe("");
99
+ });
100
+
101
+ it("is carried even without an issuer — the posture, not this knob, decides whether it is used", () => {
102
+ expect(
103
+ loadConfig({ STIGMER_OIDC_CONSOLE_CLIENT_ID: "stigmer-console" })
104
+ .oidcConsoleClientId,
105
+ ).toBe("stigmer-console");
106
+ });
107
+ });
108
+
71
109
  it("disables the model-registry refresh only for the literal 'off'", () => {
72
110
  expect(
73
111
  loadConfig({ STIGMER_MODEL_REGISTRY_REFRESH: "off" })
@@ -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";
@@ -150,7 +161,10 @@ import { newWorkflowExecutionEngineStateProvider } from "../temporal/workflowexe
150
161
  import { newWorkflowExecutionWorkerFactory } from "../temporal/workflowexecution/worker.js";
151
162
  import { HealthState, registerHealthService } from "../transport/health.js";
152
163
  import { resolveConsoleAssets } from "../transport/console/assets.js";
153
- import { createConsoleLane } from "../transport/console/handler.js";
164
+ import {
165
+ createConsoleLane,
166
+ type ConsoleSignInPosture,
167
+ } from "../transport/console/handler.js";
154
168
  import { createRegistryLanes } from "../transport/registry/lanes.js";
155
169
  import { createUnifiedPortServer } from "../transport/server.js";
156
170
  import type { ServerConfig } from "./config.js";
@@ -221,6 +235,13 @@ export interface ComposedServer {
221
235
  * them or reorder them, and both are the drift DD-007 rules out.
222
236
  */
223
237
  identityVerifiers: ReadonlyArray<IdentityVerifier>;
238
+ /**
239
+ * The one routes function both routers are built from (exposed for
240
+ * tests, the `store` posture — the tier-truthfulness invariant
241
+ * enumerates the services a composition registers through it; the
242
+ * server registers no reflection service, deliberately).
243
+ */
244
+ routes: (router: ConnectRouter) => void;
224
245
  /** Completes wiring, flips SERVING, binds the port; returns the bound port. */
225
246
  start(): Promise<number>;
226
247
  /** NOT_SERVING first, stop background work, drain connections. */
@@ -294,6 +315,75 @@ export async function composeServer(
294
315
  });
295
316
  }
296
317
 
318
+ // The require-authentication posture has two sources OR'd here: the
319
+ // OSS OIDC issuer (O3 rulings Q1+Q2) and a composed unit's declaration
320
+ // (registry point, entry 20260904.02). Neither source = the OSS
321
+ // trusted-local posture, byte-identical wire behavior. Resolved in the
322
+ // storage stage because the posture decides a WRITE below (the operator
323
+ // account); the verifier chain it also governs is built at the end.
324
+ const authEnabled = config.oidcIssuer !== "";
325
+ const requireAuthentication =
326
+ authEnabled || extensions.requireAuthentication !== undefined;
327
+
328
+ // Stage: identity accounts (20260911.11) — the first domain whose
329
+ // persistence is a PORT rather than the generic Store. The composed
330
+ // driver (`drivers.identityAccountStore`, Q-IA-9) serves when a unit
331
+ // registers one; otherwise open source's adapter over `store` installs
332
+ // HERE (the default lives with the consumer, never in the registry).
333
+ // This ONE binding is what the create path, the provisioner, the
334
+ // operator ensure and both OSS verifiers read, so a composed driver is
335
+ // followed everywhere by construction. The domain's one create path is
336
+ // built once and shared by the create RPC, provisioning and the operator
337
+ // ensure below.
338
+ const identityAccounts =
339
+ extensions.drivers.identityAccountStore ??
340
+ newResourceIdentityAccountStore(store);
341
+ const identityAccountPath = {
342
+ accounts: identityAccounts,
343
+ logger,
344
+ authorizer,
345
+ authorizationLifecycle,
346
+ };
347
+ const createIdentityAccount = newCreateAccountPath(
348
+ identityAccountPath,
349
+ ApiResourceKind.identity_account,
350
+ );
351
+ // ONE issuer discovery for every lane that reads a well-known document
352
+ // (A8): provisioning's userinfo client here and the OIDC verifier's
353
+ // JWKS location below share it, so an issuer is discovered once per
354
+ // process and validated the same way wherever it is consumed. Built in
355
+ // this stage, not inside routes() (which runs twice) — the same reason
356
+ // the searchRegistry lives here.
357
+ const issuerDiscovery = newIssuerDiscovery();
358
+ // Provisioning reads the caller's profile from the userinfo endpoint of
359
+ // the issuer that vouched for the token, discovered per issuer and
360
+ // cached (A8) — so a composition whose own verifiers name their issuer
361
+ // on the identity needs no knob here.
362
+ const identityAccountProvisioner = newDirectAccountProvisioner({
363
+ accounts: identityAccounts,
364
+ createAccount: createIdentityAccount,
365
+ userInfo: newOidcUserInfoClient(issuerDiscovery),
366
+ });
367
+ // Under the trusted-local posture the server knows its one principal
368
+ // from the operator seam (the SAME seam the interceptor stamps callers
369
+ // from — never config directly, so the row and the stamp cannot
370
+ // disagree; T01_1_review.md A2, A5) and states the fact at boot:
371
+ // create-if-absent, so preferences set later survive every reboot. A
372
+ // composition with an authentication posture never creates one — its
373
+ // users provision themselves on first sign-in. A stage that writes a
374
+ // resource is new here; it earns its place because it is the truth of
375
+ // that posture, and a failure is a loud boot throw like the storage
376
+ // stage's own.
377
+ if (!requireAuthentication) {
378
+ const operator = await ensureOperatorAccount(
379
+ { accounts: identityAccounts, createAccount: createIdentityAccount },
380
+ operatorIdentitySnapshot(),
381
+ );
382
+ logger.info("trusted-local operator account ensured", {
383
+ accountId: operator.metadata?.id ?? "",
384
+ });
385
+ }
386
+
297
387
  // Stage: keys — the ratified fail-loud boot ASYMMETRY (D2 cross-domain
298
388
  // invariants; Go server.go:277-293):
299
389
  //
@@ -675,11 +765,36 @@ export async function composeServer(
675
765
  } else {
676
766
  logger.debug("no web console export bundled; console lane disabled");
677
767
  }
768
+ // The console is told how to sign in (20260913.02 sp.console-login): the
769
+ // OSS issuer arm of the posture, in the console's vocabulary. A composed
770
+ // unit's own `requireAuthentication` is NOT expressed here — a
771
+ // composition whose verifiers resolve its users serves its console its
772
+ // own way (the cloud's container does); the Enterprise console story is
773
+ // P4's. Issuer set but no console client registered is WARN-degrade, the
774
+ // STIGMER_OAUTH_REDIRECT_URI posture: the CLI and SDKs keep working with
775
+ // API keys, and the served console refuses with copy naming the knob.
776
+ const consoleSignIn: ConsoleSignInPosture = authEnabled
777
+ ? {
778
+ posture: "oidc",
779
+ issuer: config.oidcIssuer,
780
+ audience: config.oidcAudience,
781
+ consoleClientId: config.oidcConsoleClientId,
782
+ }
783
+ : { posture: "trusted-local" };
784
+ if (
785
+ consoleAssets !== undefined &&
786
+ consoleSignIn.posture === "oidc" &&
787
+ consoleSignIn.consoleClientId === ""
788
+ ) {
789
+ logger.warn(
790
+ "STIGMER_OIDC_ISSUER is set but STIGMER_OIDC_CONSOLE_CLIENT_ID is not — the web console this server serves cannot sign in; register a public PKCE client for the console at the issuer and set it",
791
+ );
792
+ }
678
793
  const consoleLane =
679
794
  consoleAssets !== undefined
680
795
  ? createConsoleLane({
681
796
  assets: consoleAssets,
682
- grpcPort: options.portOverride ?? config.grpcPort,
797
+ signIn: consoleSignIn,
683
798
  logger,
684
799
  })
685
800
  : undefined;
@@ -799,6 +914,17 @@ export async function composeServer(
799
914
  authorizationLifecycle,
800
915
  listReadScope: extensions.drivers.listReadScope,
801
916
  });
917
+ // IdentityAccount (20260911.11): served once by open source in every
918
+ // edition over the store PORT bound in the identity-accounts stage; a
919
+ // composition adds what differs per edition through the registry —
920
+ // the federation capability (the four federated RPC arms) and the
921
+ // provision slot's gate steps (the cloud's personal organization).
922
+ registerIdentityAccountServices(router, {
923
+ ...identityAccountPath,
924
+ provisioner: identityAccountProvisioner,
925
+ federation: extensions.drivers.identityFederation,
926
+ gateSteps: extensions.gateSteps,
927
+ });
802
928
  registerEnvironmentServices(router, {
803
929
  store,
804
930
  logger,
@@ -1092,14 +1218,8 @@ export async function composeServer(
1092
1218
  const inProcessWiring = createInProcessClients(routes, logger);
1093
1219
  inProcess = inProcessWiring.clients;
1094
1220
 
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
1221
+ // What the posture (resolved in the storage stage) registers, in Java's
1222
+ // ProviderManager order (opaque
1103
1223
  // keys first — the cheapest claim — then the JWT lanes), ahead of the
1104
1224
  // extension entries ("OSS entries first", the registry contract):
1105
1225
  // - the API-key verifier rides the POSTURE, not the issuer
@@ -1108,19 +1228,31 @@ export async function composeServer(
1108
1228
  // authentication posture must honor the keys it issues. Gating it on
1109
1229
  // the issuer left a posture-declaring composition minting keys
1110
1230
  // 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.
1231
+ // - the OIDC verifier rides the ISSUER alone: it is open source's
1232
+ // single-issuer lane, and a composition with verifiers of its own
1233
+ // names their issuers and audiences itself and declares the posture
1234
+ // instead — so the knob and the extension entries never both claim
1235
+ // one JWT.
1236
+ // - both OSS verifiers resolve their subject through the identity-
1237
+ // account domain (20260911.11 Q-IA-2, A6): a provisioned user is
1238
+ // stamped with the account id whichever credential they present,
1239
+ // an unprovisioned one is admitted idp-shaped. They read the SAME
1240
+ // port the domain writes through (`identityAccounts`, the
1241
+ // identity-accounts stage — a composed driver included), so a row
1242
+ // provisioning creates resolves on the very next request.
1115
1243
  // The runner's credential in either posture is an operator-minted API
1116
1244
  // token via STIGMER_TOKEN (O3 ruling Q3) — no runner-specific verifier.
1117
1245
  const identityVerifiers = [
1118
- ...(requireAuthentication ? [newApiKeyIdentityVerifier(store)] : []),
1246
+ ...(requireAuthentication
1247
+ ? [newApiKeyIdentityVerifier({ store, accounts: identityAccounts })]
1248
+ : []),
1119
1249
  ...(authEnabled
1120
1250
  ? [
1121
1251
  newOidcIdentityVerifier({
1122
1252
  issuer: config.oidcIssuer,
1123
1253
  audience: config.oidcAudience,
1254
+ accounts: identityAccounts,
1255
+ discovery: issuerDiscovery,
1124
1256
  }),
1125
1257
  ]
1126
1258
  : []),
@@ -1203,6 +1335,7 @@ export async function composeServer(
1203
1335
  workflowExecutionStreamBroker,
1204
1336
  inProcessTransport: inProcessWiring.transport,
1205
1337
  identityVerifiers,
1338
+ routes,
1206
1339
 
1207
1340
  async start(): Promise<number> {
1208
1341
  // Temporal boot is NON-fatal end to end (Go server.go): a failed