@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
@@ -18,27 +18,43 @@
18
18
  * - everything else → "invalid token"
19
19
  *
20
20
  * Discovery and key handling: the issuer's /.well-known/openid-configuration
21
- * is fetched once (memoized on success, retried on the next request after
22
- * a failure — a flaky IdP at boot must not permanently brick the lane),
23
- * its `issuer` field must match the configured issuer exactly (RFC 8414
24
- * §3.3), and the JWKS rides jose's createRemoteJWKSet (cached, rotation-
25
- * aware). Discovery/JWKS OUTAGES are infrastructure faults — thrown as
21
+ * is read through the shared oidc-discovery module the composition root
22
+ * builds once for every lane (20260911.11 A8 — the userinfo client reads
23
+ * the same document, so one issuer is discovered once and validated the
24
+ * same way wherever it is consumed: `issuer` must match exactly, RFC 8414
25
+ * §3.3; `jwks_uri` required). The module memoizes on success only, so a
26
+ * flaky IdP at boot never permanently bricks the lane. The JWKS client
27
+ * (jose's createRemoteJWKSet — cached, rotation-aware) is built ONCE per
28
+ * verifier from the discovered location and kept: discovery hands back a
29
+ * URI, and a client rebuilt per request would refetch the keys per
30
+ * request. Discovery/JWKS OUTAGES are infrastructure faults — thrown as
26
31
  * plain errors so the chassis maps them to INTERNAL, never a credential
27
32
  * rejection (the DD-007 unavailable doctrine).
28
33
  *
29
- * Identity: identityId = `sub` (rejected as invalid when absent), issuer =
30
- * the configured issuer, email/displayName from the standard `email`/`name`
31
- * claims when present (the DD-007 Q5 addendum added the fields for exactly
32
- * these claims).
34
+ * Identity (20260911.11 Q-IA-2, A1 — the cloud's direct-login posture):
35
+ * after the token verifies, `sub` (rejected as invalid when absent) is
36
+ * resolved through the identity-account domain (identityIdForSubject):
37
+ * a subject with a direct account stamps the ACCOUNT id; a subject
38
+ * without one is admitted idp-shaped (identityId = sub) so whoAmI can
39
+ * answer NOT_FOUND and provisionMyAccount can run. One primary-key read
40
+ * per request, no cache; a store fault is an infrastructure fault. The
41
+ * issuer is the configured issuer; email/displayName come from the
42
+ * standard `email`/`name` claims when present (the DD-007 Q5 addendum
43
+ * added the fields for exactly these claims), whichever way the subject
44
+ * resolved. Claim-or-pass runs before any of this: a non-JWT token
45
+ * passes with no network.
33
46
  */
34
47
  import { createRemoteJWKSet, jwtVerify, errors as joseErrors } from "jose";
35
48
  import type { JWTPayload } from "jose";
36
49
  import { Code, ConnectError } from "@connectrpc/connect";
37
50
 
51
+ import { identityIdForSubject } from "../domain/identityaccount/resolve.js";
52
+ import type { AccountsBySubject } from "../domain/identityaccount/resolve.js";
38
53
  import type {
39
54
  CallerIdentity,
40
55
  IdentityVerifier,
41
56
  } from "../extensions/identity.js";
57
+ import type { IssuerDiscovery } from "./oidc-discovery.js";
42
58
 
43
59
  /** Java classifyAuthError arms — byte-pinned cross-edition copy. */
44
60
  export const TOKEN_EXPIRED_MESSAGE = "token has expired";
@@ -52,42 +68,28 @@ export interface OidcVerifierConfig {
52
68
  readonly issuer: string;
53
69
  /** The audience access tokens must carry. */
54
70
  readonly audience: string;
71
+ /** The identity-account domain's subject lookup — REQUIRED: what identityId means depends on it. */
72
+ readonly accounts: AccountsBySubject;
73
+ /** The composition root's one discovery for every lane (A8). */
74
+ readonly discovery: IssuerDiscovery;
55
75
  }
56
76
 
57
- interface DiscoveredIssuer {
58
- readonly jwks: ReturnType<typeof createRemoteJWKSet>;
59
- }
77
+ type RemoteJwks = ReturnType<typeof createRemoteJWKSet>;
60
78
 
61
79
  export function newOidcIdentityVerifier(
62
80
  config: OidcVerifierConfig,
63
81
  ): IdentityVerifier {
64
- let discovered: DiscoveredIssuer | undefined;
82
+ // Set on the first successful discovery only: a failed discovery leaves
83
+ // it unset so the next request retries (the module caches no failure).
84
+ let jwks: RemoteJwks | undefined;
65
85
 
66
- async function discover(): Promise<DiscoveredIssuer> {
67
- if (discovered !== undefined) {
68
- return discovered;
69
- }
70
- const url = discoveryUrl(config.issuer);
71
- const response = await fetch(url);
72
- if (!response.ok) {
73
- throw new Error(
74
- `OIDC discovery failed: ${url} answered HTTP ${response.status}`,
75
- );
76
- }
77
- const document = (await response.json()) as {
78
- issuer?: string;
79
- jwks_uri?: string;
80
- };
81
- if (document.issuer !== config.issuer) {
82
- throw new Error(
83
- `OIDC discovery failed: document issuer '${document.issuer ?? ""}' does not match configured issuer '${config.issuer}'`,
84
- );
86
+ async function jwksFor(): Promise<RemoteJwks> {
87
+ if (jwks !== undefined) {
88
+ return jwks;
85
89
  }
86
- if (typeof document.jwks_uri !== "string" || document.jwks_uri === "") {
87
- throw new Error(`OIDC discovery failed: ${url} carries no jwks_uri`);
88
- }
89
- discovered = { jwks: createRemoteJWKSet(new URL(document.jwks_uri)) };
90
- return discovered;
90
+ const { jwksUri } = await config.discovery.discover(config.issuer);
91
+ jwks = createRemoteJWKSet(new URL(jwksUri));
92
+ return jwks;
91
93
  }
92
94
 
93
95
  return {
@@ -96,10 +98,10 @@ export function newOidcIdentityVerifier(
96
98
  if (!isJwtShaped(token)) {
97
99
  return null;
98
100
  }
99
- const issuer = await discover();
101
+ const keys = await jwksFor();
100
102
  let payload: JWTPayload;
101
103
  try {
102
- ({ payload } = await jwtVerify(token, issuer.jwks, {
104
+ ({ payload } = await jwtVerify(token, keys, {
103
105
  issuer: config.issuer,
104
106
  audience: config.audience,
105
107
  }));
@@ -110,7 +112,7 @@ export function newOidcIdentityVerifier(
110
112
  throw new ConnectError(INVALID_TOKEN_MESSAGE, Code.Unauthenticated);
111
113
  }
112
114
  return {
113
- identityId: payload.sub,
115
+ identityId: await identityIdForSubject(config.accounts, payload.sub),
114
116
  callerClass: "user",
115
117
  issuer: config.issuer,
116
118
  rawToken: token,
@@ -125,11 +127,6 @@ export function newOidcIdentityVerifier(
125
127
  };
126
128
  }
127
129
 
128
- /** issuer + /.well-known/openid-configuration (trailing-slash tolerant). */
129
- function discoveryUrl(issuer: string): string {
130
- return `${issuer.replace(/\/$/, "")}/.well-known/openid-configuration`;
131
- }
132
-
133
130
  /** Three non-empty dot-separated segments — the JWS compact shape. */
134
131
  function isJwtShaped(token: string): boolean {
135
132
  const segments = token.split(".");
package/src/index.ts CHANGED
@@ -109,6 +109,25 @@ export type {
109
109
  } from "./extensions/resource-authorization.js";
110
110
  export type { OrganizationDirectory } from "./extensions/organization-directory.js";
111
111
  export { ALL_ORGANIZATIONS } from "./extensions/organization-directory.js";
112
+ // The 20260911.11 identity-account seams (Q-IA-9): the store PORT a
113
+ // composition drives the domain through (drivers.identityAccountStore;
114
+ // a driver throws DuplicateAccountError for a held id), the federation
115
+ // capability (drivers.identityFederation), and the ONE subject →
116
+ // identityId rule both OSS verifiers already apply, exported so a
117
+ // composition's own verifier converges on it instead of restating it.
118
+ export type { IdentityAccountStore } from "./domain/identityaccount/store.js";
119
+ export { DuplicateAccountError } from "./domain/identityaccount/store.js";
120
+ // The port's contract as a vitest-free kit: a driver's own test iterates
121
+ // the same cases the OSS adapter passes, so the port is proven per
122
+ // driver, never restated per repository.
123
+ export type {
124
+ IdentityAccountStoreContractCase,
125
+ IdentityAccountStoreContractFixture,
126
+ } from "./domain/identityaccount/store-contract.js";
127
+ export { identityAccountStoreContract } from "./domain/identityaccount/store-contract.js";
128
+ export type { IdentityFederation } from "./extensions/identity-federation.js";
129
+ export type { AccountsBySubject } from "./domain/identityaccount/resolve.js";
130
+ export { identityIdForSubject } from "./domain/identityaccount/resolve.js";
112
131
  export type {
113
132
  ListReadScope,
114
133
  ListEntryMeta,
@@ -172,7 +172,19 @@ export function callerIdentityOf(ctx: HandlerContext): CallerIdentity {
172
172
  * operator seam remains process-global.
173
173
  */
174
174
  export function trustedLocalIdentity(): CallerIdentity {
175
- const operator = operatorIdentitySnapshot();
175
+ return trustedLocalIdentityFor(operatorIdentitySnapshot());
176
+ }
177
+
178
+ /**
179
+ * The trusted-local identity for a given operator — the one construction
180
+ * of that principal, shared by the per-request stamp above and by the
181
+ * boot-time operator account (domain/identityaccount/operator.ts), so the
182
+ * row the server ensures and the caller it stamps can never disagree.
183
+ */
184
+ export function trustedLocalIdentityFor(operator: {
185
+ readonly email: string;
186
+ readonly displayName: string;
187
+ }): CallerIdentity {
176
188
  if (operator.email === "") {
177
189
  return {
178
190
  identityId: "system",
@@ -0,0 +1,184 @@
1
+ /**
2
+ * The annotation-completeness invariant for the Authorize step (stigmer#1073;
3
+ * found on the 20260911.11 cloud readout, 2026-09-13): an `rpc.config`
4
+ * annotation that names a resource KIND must also name where the resource
5
+ * ID comes from (`field_path` or `resource_id`). A kind without an id is a
6
+ * check against `<kind>:` with an empty id — the step resolves it to "" by
7
+ * doctrine (resolution never throws, see authorize.ts) and an enforcing
8
+ * authorizer refuses it for EVERY caller, while the open-source permissive
9
+ * default allows it, so no `local*` conformance target can see the gap.
10
+ * `getActorInfo` shipped that way: green on every OSS gate, denied on the
11
+ * cloud composition the moment the annotation-driven step replaced the
12
+ * cloud's hand-written handler (which had passed `id.value` by hand).
13
+ *
14
+ * Scope is every method the open-source server SERVES — the empty
15
+ * composition's `routes` replayed into a recording router, the
16
+ * tier-truthfulness precedent — because that is exactly the set the OSS
17
+ * Authorize step governs; a composition's own services are its own
18
+ * `direct-handler-authorization` conformance suite's business. Two arms are
19
+ * exempt by construction: `is_public` and `is_skip_authorization` methods
20
+ * never reach the annotation's resolution (the skip lanes resolve their
21
+ * target in the handler through `authorizeDirect`'s override). A config
22
+ * that names NO kind (static or by path) is outside this invariant: the
23
+ * check is then about the kind, not the id, and today no OSS-served method
24
+ * is shaped that way (the IamPolicy RPCs that are belong to
25
+ * `sp.oss-organization-roles`).
26
+ *
27
+ * The mutation arms prove the check bites over the same function.
28
+ */
29
+ import { mkdtempSync, rmSync } from "node:fs";
30
+ import { tmpdir } from "node:os";
31
+ import path from "node:path";
32
+
33
+ import { getOption, hasOption } from "@bufbuild/protobuf";
34
+ import type { DescService } from "@bufbuild/protobuf";
35
+ import type { ConnectRouter } from "@connectrpc/connect";
36
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
37
+
38
+ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
39
+ import {
40
+ config as rpcAuthorizationConfig,
41
+ is_public,
42
+ is_skip_authorization,
43
+ } from "@stigmer/protos/ai/stigmer/commons/rpc/method_options_pb";
44
+
45
+ import { loadConfig } from "../../../boot/config.js";
46
+ import { composeServer } from "../../../boot/compose.js";
47
+ import type { ComposedServer } from "../../../boot/compose.js";
48
+ import { createLogger } from "../../../boot/logger.js";
49
+
50
+ /** One annotated method as the invariant reads it. */
51
+ interface AnnotationTarget {
52
+ readonly method: string;
53
+ readonly namesKind: boolean;
54
+ readonly namesId: boolean;
55
+ readonly skipsResolution: boolean;
56
+ }
57
+
58
+ /** Replays the composed routes into a recorder and returns the served services. */
59
+ function servedServices(
60
+ routes: (router: ConnectRouter) => void,
61
+ ): DescService[] {
62
+ const served: DescService[] = [];
63
+ const recorder = {
64
+ handlers: [],
65
+ service(desc: DescService) {
66
+ served.push(desc);
67
+ return recorder;
68
+ },
69
+ rpc() {
70
+ return recorder;
71
+ },
72
+ };
73
+ routes(recorder as unknown as ConnectRouter);
74
+ return served;
75
+ }
76
+
77
+ /** Every `rpc.config`-annotated method of the given services, read once. */
78
+ function annotationTargets(services: DescService[]): AnnotationTarget[] {
79
+ const targets: AnnotationTarget[] = [];
80
+ for (const service of services) {
81
+ for (const method of service.methods) {
82
+ if (!hasOption(method, rpcAuthorizationConfig)) continue;
83
+ const config = getOption(method, rpcAuthorizationConfig);
84
+ targets.push({
85
+ method: `${service.typeName}/${method.name}`,
86
+ namesKind:
87
+ config.resourceKind !== ApiResourceKind.api_resource_kind_unknown ||
88
+ config.resourceKindPath !== "",
89
+ namesId: config.fieldPath !== "" || config.resourceId !== "",
90
+ skipsResolution:
91
+ getOption(method, is_public) ||
92
+ getOption(method, is_skip_authorization),
93
+ });
94
+ }
95
+ }
96
+ return targets;
97
+ }
98
+
99
+ /** The methods whose annotation names a kind but no id, one sentence each. */
100
+ function unresolvableTargets(targets: AnnotationTarget[]): string[] {
101
+ return targets
102
+ .filter((t) => !t.skipsResolution && t.namesKind && !t.namesId)
103
+ .map(
104
+ (t) =>
105
+ `${t.method} names a resource kind but neither field_path nor resource_id — the Authorize step would check an empty id`,
106
+ );
107
+ }
108
+
109
+ describe("authorization annotations name where the resource id comes from", () => {
110
+ let dir: string;
111
+ let server: ComposedServer;
112
+
113
+ beforeAll(async () => {
114
+ dir = mkdtempSync(path.join(tmpdir(), "authorize-annotations-test-"));
115
+ server = await composeServer({
116
+ config: loadConfig({
117
+ STIGMER_MODEL_REGISTRY_REFRESH: "off",
118
+ TEMPORAL_HOST_PORT: "127.0.0.1:1",
119
+ DB_PATH: path.join(dir, "stigmer.db"),
120
+ STORAGE_PATH: path.join(dir, "storage"),
121
+ ARTIFACT_LOCAL_BASE_PATH: path.join(dir, "artifacts"),
122
+ }),
123
+ logger: createLogger({ level: "error", pretty: false, write: () => {} }),
124
+ portOverride: 0,
125
+ host: "127.0.0.1",
126
+ });
127
+ });
128
+
129
+ afterAll(async () => {
130
+ await server.shutdown();
131
+ rmSync(dir, { recursive: true, force: true });
132
+ });
133
+
134
+ it("every served method that names a kind also names its id (field_path or resource_id)", () => {
135
+ const targets = annotationTargets(servedServices(server.routes));
136
+ expect(targets.length).toBeGreaterThan(100);
137
+ expect(unresolvableTargets(targets)).toEqual([]);
138
+ });
139
+
140
+ it("getActorInfo is annotated exactly like get — the arm stigmer#1073 was missing", () => {
141
+ const targets = annotationTargets(servedServices(server.routes));
142
+ const byName = new Map(targets.map((t) => [t.method, t]));
143
+ const get = byName.get(
144
+ "ai.stigmer.iam.identityaccount.v1.IdentityAccountQueryController/get",
145
+ );
146
+ const actor = byName.get(
147
+ "ai.stigmer.iam.identityaccount.v1.IdentityAccountQueryController/getActorInfo",
148
+ );
149
+ expect(get?.namesId, "get names its id").toBe(true);
150
+ expect(actor?.namesId, "getActorInfo names its id").toBe(true);
151
+ });
152
+ });
153
+
154
+ describe("the check bites (mutation proofs over the same function)", () => {
155
+ const shaped = (overrides: Partial<AnnotationTarget>): AnnotationTarget => ({
156
+ method: "x.v1.Query/probe",
157
+ namesKind: true,
158
+ namesId: true,
159
+ skipsResolution: false,
160
+ ...overrides,
161
+ });
162
+
163
+ it("a kind with no id is reported, by method name", () => {
164
+ expect(unresolvableTargets([shaped({ namesId: false })])).toEqual([
165
+ "x.v1.Query/probe names a resource kind but neither field_path nor resource_id — the Authorize step would check an empty id",
166
+ ]);
167
+ });
168
+
169
+ it("a kind with an id is not", () => {
170
+ expect(unresolvableTargets([shaped({})])).toEqual([]);
171
+ });
172
+
173
+ it("a skip or public method is exempt — its target is the handler's, not the annotation's", () => {
174
+ expect(
175
+ unresolvableTargets([shaped({ namesId: false, skipsResolution: true })]),
176
+ ).toEqual([]);
177
+ });
178
+
179
+ it("a config that names no kind is outside this invariant", () => {
180
+ expect(
181
+ unresolvableTargets([shaped({ namesKind: false, namesId: false })]),
182
+ ).toEqual([]);
183
+ });
184
+ });
@@ -4,7 +4,14 @@
4
4
  *
5
5
  * - documents, flight payloads, redirects, and the 404 posture arrive
6
6
  * with the right status, Content-Type, and Cache-Control;
7
- * - /config.json is synthesized per request with a Host-derived apiUrl;
7
+ * - /config.json is synthesized per request: apiUrl is the empty string
8
+ * the console reads as "my own origin" (20260913.02 Q-CL-3), and the
9
+ * sign-in block tells the truth about the server's posture — trusted
10
+ * local says `disabled`; the OIDC posture says `oidc` with the issuer,
11
+ * the audience and the console's client id, the client id empty when
12
+ * the operator has not registered one (Q-CL-1, Q-CL-2);
13
+ * - the OIDC return leg: /auth/callback with the provider's query
14
+ * resolves to the callback document;
8
15
  * - the guard: RPC (POST and service-shaped GET), /v1/* paths, and
9
16
  * OPTIONS preflights flow exactly as they do WITHOUT the lane — the
10
17
  * wire-invisibility half of the P3 acceptance;
@@ -30,8 +37,8 @@ import {
30
37
  createUnifiedPortServer,
31
38
  type UnifiedPortServer,
32
39
  } from "../../server.js";
33
- import { resolveConsoleAssets } from "../assets.js";
34
- import { createConsoleLane } from "../handler.js";
40
+ import { resolveConsoleAssets, type ConsoleAssets } from "../assets.js";
41
+ import { createConsoleLane, type ConsoleSignInPosture } from "../handler.js";
35
42
 
36
43
  const silentLogger = createLogger({
37
44
  level: "error",
@@ -46,6 +53,7 @@ const FIXTURE_FILES: Record<string, string> = {
46
53
  "embed.js": "// embed loader",
47
54
  "conversations.html": "<html>conversations</html>",
48
55
  "conversations.txt": "flight:conversations",
56
+ "auth/callback.html": "<html>completing sign-in</html>",
49
57
  "sessions/__placeholder__.html": "<html>session detail</html>",
50
58
  "sessions/__placeholder__.txt": "flight:session",
51
59
  "sessions/__placeholder__/__next.sessions.txt": "flight:nested-session",
@@ -53,25 +61,18 @@ const FIXTURE_FILES: Record<string, string> = {
53
61
  };
54
62
 
55
63
  let fixtureRoot: string;
64
+ let assets: ConsoleAssets;
56
65
  let server: UnifiedPortServer;
57
66
  let port: number;
58
67
  let baseUrl: string;
59
68
 
60
- beforeAll(async () => {
61
- fixtureRoot = mkdtempSync(path.join(tmpdir(), "console-lane-fixture-"));
62
- for (const [file, content] of Object.entries(FIXTURE_FILES)) {
63
- const target = path.join(fixtureRoot, ...file.split("/"));
64
- mkdirSync(path.dirname(target), { recursive: true });
65
- writeFileSync(target, content);
66
- }
67
-
68
- const assets = resolveConsoleAssets(fixtureRoot, silentLogger);
69
- if (assets === undefined) {
70
- throw new Error("fixture export was not discovered");
71
- }
69
+ /** A unified-port server whose console lane serves the fixture under `signIn`. */
70
+ async function startServer(
71
+ signIn: ConsoleSignInPosture,
72
+ ): Promise<UnifiedPortServer> {
72
73
  const healthState = new HealthState();
73
74
  healthState.setOverall(ServingStatus.SERVING);
74
- server = createUnifiedPortServer({
75
+ return createUnifiedPortServer({
75
76
  logger: silentLogger,
76
77
  routes: (router) => registerHealthService(router, healthState),
77
78
  interceptors: [],
@@ -83,12 +84,24 @@ beforeAll(async () => {
83
84
  response.setHeader("x-lane", "model");
84
85
  response.end("{}");
85
86
  },
86
- consoleLane: createConsoleLane({
87
- assets,
88
- grpcPort: 7234,
89
- logger: silentLogger,
90
- }),
87
+ consoleLane: createConsoleLane({ assets, signIn, logger: silentLogger }),
91
88
  });
89
+ }
90
+
91
+ beforeAll(async () => {
92
+ fixtureRoot = mkdtempSync(path.join(tmpdir(), "console-lane-fixture-"));
93
+ for (const [file, content] of Object.entries(FIXTURE_FILES)) {
94
+ const target = path.join(fixtureRoot, ...file.split("/"));
95
+ mkdirSync(path.dirname(target), { recursive: true });
96
+ writeFileSync(target, content);
97
+ }
98
+
99
+ const discovered = resolveConsoleAssets(fixtureRoot, silentLogger);
100
+ if (discovered === undefined) {
101
+ throw new Error("fixture export was not discovered");
102
+ }
103
+ assets = discovered;
104
+ server = await startServer({ posture: "trusted-local" });
92
105
  port = await server.listen(0, "127.0.0.1");
93
106
  baseUrl = `http://127.0.0.1:${port}`;
94
107
  });
@@ -175,8 +188,19 @@ describe("document serving", () => {
175
188
  });
176
189
  });
177
190
 
191
+ describe("the OIDC return leg", () => {
192
+ it("serves /auth/callback with the provider's query the callback document", async () => {
193
+ const response = await fetch(`${baseUrl}/auth/callback?code=abc&state=xyz`);
194
+ expect(response.status).toBe(200);
195
+ expect(response.headers.get("content-type")).toBe(
196
+ "text/html; charset=utf-8",
197
+ );
198
+ expect(await response.text()).toBe(FIXTURE_FILES["auth/callback.html"]);
199
+ });
200
+ });
201
+
178
202
  describe("/config.json synthesis", () => {
179
- it("derives apiUrl from the request Host and forbids caching", async () => {
203
+ it("under trusted local: same-origin apiUrl, sign-in disabled, never cached", async () => {
180
204
  const response = await fetch(`${baseUrl}/config.json`);
181
205
  expect(response.status).toBe(200);
182
206
  expect(response.headers.get("content-type")).toBe("application/json");
@@ -184,7 +208,7 @@ describe("/config.json synthesis", () => {
184
208
  "no-cache, no-store, must-revalidate",
185
209
  );
186
210
  expect(await response.json()).toEqual({
187
- apiUrl: `http://127.0.0.1:${port}`,
211
+ apiUrl: "",
188
212
  appUrl: "",
189
213
  authMode: "disabled",
190
214
  oidcIssuer: "",
@@ -192,6 +216,52 @@ describe("/config.json synthesis", () => {
192
216
  oidcAudience: "",
193
217
  });
194
218
  });
219
+
220
+ describe("under the OIDC posture", () => {
221
+ const ISSUER = "https://auth.example.com/realms/main";
222
+ const AUDIENCE = "https://stigmer.example.com/";
223
+
224
+ async function configUnder(consoleClientId: string): Promise<unknown> {
225
+ const oidcServer = await startServer({
226
+ posture: "oidc",
227
+ issuer: ISSUER,
228
+ audience: AUDIENCE,
229
+ consoleClientId,
230
+ });
231
+ const oidcPort = await oidcServer.listen(0, "127.0.0.1");
232
+ try {
233
+ const response = await fetch(
234
+ `http://127.0.0.1:${oidcPort}/config.json`,
235
+ );
236
+ expect(response.status).toBe(200);
237
+ return await response.json();
238
+ } finally {
239
+ await oidcServer.shutdown();
240
+ }
241
+ }
242
+
243
+ it("tells the console the issuer, the audience and its client id", async () => {
244
+ expect(await configUnder("stigmer-console")).toEqual({
245
+ apiUrl: "",
246
+ appUrl: "",
247
+ authMode: "oidc",
248
+ oidcIssuer: ISSUER,
249
+ oidcClientId: "stigmer-console",
250
+ oidcAudience: AUDIENCE,
251
+ });
252
+ });
253
+
254
+ it("keeps authMode honest when no console client is registered — the client id is empty, never a disabled lie", async () => {
255
+ expect(await configUnder("")).toEqual({
256
+ apiUrl: "",
257
+ appUrl: "",
258
+ authMode: "oidc",
259
+ oidcIssuer: ISSUER,
260
+ oidcClientId: "",
261
+ oidcAudience: AUDIENCE,
262
+ });
263
+ });
264
+ });
195
265
  });
196
266
 
197
267
  describe("the guard: RPC and /v1/* flow exactly as without the lane", () => {
@@ -32,6 +32,7 @@ const index = buildConsoleFileIndex([
32
32
  "/__next._tree.txt",
33
33
  "/conversations.html",
34
34
  "/conversations.txt",
35
+ "/auth/callback.html",
35
36
  "/auth/github/callback.html",
36
37
  "/sessions/__placeholder__.html",
37
38
  "/sessions/__placeholder__.txt",
@@ -69,6 +70,11 @@ describe("page contract (the nginx chain)", () => {
69
70
  expect(servedFile("/auth/github/callback")).toBe(
70
71
  "/auth/github/callback.html",
71
72
  );
73
+ // The OIDC return leg (20260913.02): the provider redirects here with
74
+ // `?code&state`; the handler strips the query, so the resolver sees
75
+ // exactly this path and must answer the callback document, never
76
+ // the 404 page — a 404 here is a login that can never complete.
77
+ expect(servedFile("/auth/callback")).toBe("/auth/callback.html");
72
78
  });
73
79
 
74
80
  it("resolves one trailing dynamic segment to its placeholder document", () => {