@stigmer/server 3.15.0 → 3.15.2-dev.20260913223433

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 (69) hide show
  1. package/dist/boot/compose.d.ts.map +1 -1
  2. package/dist/boot/compose.js +23 -2
  3. package/dist/boot/compose.js.map +1 -1
  4. package/dist/boot/config.d.ts +14 -0
  5. package/dist/boot/config.d.ts.map +1 -1
  6. package/dist/boot/config.js +1 -0
  7. package/dist/boot/config.js.map +1 -1
  8. package/dist/domain/agentchannel/controller.js +15 -26
  9. package/dist/domain/agentchannel/controller.js.map +1 -1
  10. package/dist/domain/agentexecution/controller.d.ts.map +1 -1
  11. package/dist/domain/agentexecution/controller.js +3 -2
  12. package/dist/domain/agentexecution/controller.js.map +1 -1
  13. package/dist/domain/agentexecution/steps.d.ts +20 -8
  14. package/dist/domain/agentexecution/steps.d.ts.map +1 -1
  15. package/dist/domain/agentexecution/steps.js +32 -12
  16. package/dist/domain/agentexecution/steps.js.map +1 -1
  17. package/dist/domain/agentinstance/steps.d.ts.map +1 -1
  18. package/dist/domain/agentinstance/steps.js +9 -22
  19. package/dist/domain/agentinstance/steps.js.map +1 -1
  20. package/dist/domain/agentshare/controller.js +15 -26
  21. package/dist/domain/agentshare/controller.js.map +1 -1
  22. package/dist/domain/environment/controller.js +8 -10
  23. package/dist/domain/environment/controller.js.map +1 -1
  24. package/dist/domain/memory/steps.d.ts.map +1 -1
  25. package/dist/domain/memory/steps.js +5 -10
  26. package/dist/domain/memory/steps.js.map +1 -1
  27. package/dist/domain/schedule/controller.js +15 -24
  28. package/dist/domain/schedule/controller.js.map +1 -1
  29. package/dist/domain/session/steps.d.ts.map +1 -1
  30. package/dist/domain/session/steps.js +8 -4
  31. package/dist/domain/session/steps.js.map +1 -1
  32. package/dist/domain/workflowexecution/controller.d.ts.map +1 -1
  33. package/dist/domain/workflowexecution/controller.js +12 -6
  34. package/dist/domain/workflowexecution/controller.js.map +1 -1
  35. package/dist/domain/workflowexecution/list-pending-approvals.d.ts.map +1 -1
  36. package/dist/domain/workflowexecution/list-pending-approvals.js +19 -8
  37. package/dist/domain/workflowexecution/list-pending-approvals.js.map +1 -1
  38. package/dist/domain/workflowinstance/controller.js +6 -13
  39. package/dist/domain/workflowinstance/controller.js.map +1 -1
  40. package/dist/extensions/list-read-scope.d.ts +31 -4
  41. package/dist/extensions/list-read-scope.d.ts.map +1 -1
  42. package/dist/extensions/list-read-scope.js +12 -10
  43. package/dist/extensions/list-read-scope.js.map +1 -1
  44. package/dist/transport/console/handler.d.ts +22 -5
  45. package/dist/transport/console/handler.d.ts.map +1 -1
  46. package/dist/transport/console/handler.js +53 -24
  47. package/dist/transport/console/handler.js.map +1 -1
  48. package/package.json +4 -4
  49. package/src/boot/__tests__/auth-enabled-composition.test.ts +49 -3
  50. package/src/boot/__tests__/config.test.ts +41 -3
  51. package/src/boot/compose.ts +30 -2
  52. package/src/boot/config.ts +15 -0
  53. package/src/domain/agentchannel/controller.ts +23 -31
  54. package/src/domain/agentexecution/controller.ts +11 -5
  55. package/src/domain/agentexecution/steps.ts +47 -22
  56. package/src/domain/agentinstance/steps.ts +17 -26
  57. package/src/domain/agentshare/controller.ts +23 -31
  58. package/src/domain/environment/controller.ts +12 -12
  59. package/src/domain/memory/steps.ts +6 -12
  60. package/src/domain/schedule/controller.ts +25 -33
  61. package/src/domain/session/steps.ts +24 -11
  62. package/src/domain/workflowexecution/controller.ts +32 -26
  63. package/src/domain/workflowexecution/list-pending-approvals.ts +37 -16
  64. package/src/domain/workflowinstance/controller.ts +11 -18
  65. package/src/extensions/__tests__/list-read-scope.test.ts +29 -7
  66. package/src/extensions/list-read-scope.ts +37 -12
  67. package/src/transport/console/__tests__/handler.test.ts +93 -23
  68. package/src/transport/console/__tests__/resolver.test.ts +6 -0
  69. package/src/transport/console/handler.ts +84 -30
@@ -748,9 +748,7 @@ async function getByWorkflow(
748
748
  ),
749
749
  )
750
750
  .addStep(newValidateProtoStep())
751
- .addStep(
752
- newLoadByWorkflowStep(deps.store, deps.logger, deps.listReadScope),
753
- )
751
+ .addStep(newLoadByWorkflowStep(deps.store, deps.logger, deps.listReadScope))
754
752
  .build()
755
753
  .execute(reqCtx);
756
754
 
@@ -794,27 +792,22 @@ function newLoadByWorkflowStep(
794
792
  continue;
795
793
  }
796
794
  }
797
- // 20260830.01 census lane 20: the scope narrows the decoded scan;
798
- // the org/workflow filters below are contract parity in both editions.
799
- const visible = await restrictListByReadScope(
795
+ // 20260830.01 census lane 20: the org/workflow filters are contract
796
+ // parity in both editions and run FIRST; the scope narrows the
797
+ // workflow's instances last (the scope is the last per-row
798
+ // predicate, stigmer-cloud 20260913.04 T02).
799
+ const filtered = await restrictListByReadScope(
800
800
  listReadScope,
801
801
  ctx.callerIdentity,
802
802
  ApiResourceKind.workflow_instance,
803
- decoded,
803
+ decoded.filter(
804
+ (instance) =>
805
+ instance.spec?.workflowId === workflowId &&
806
+ (request.org === "" || instance.metadata?.org === request.org),
807
+ ),
804
808
  "",
805
809
  );
806
810
 
807
- const filtered: WorkflowInstance[] = [];
808
- for (const instance of visible) {
809
- if (instance.spec?.workflowId !== workflowId) {
810
- continue;
811
- }
812
- if (request.org !== "" && instance.metadata?.org !== request.org) {
813
- continue;
814
- }
815
- filtered.push(instance);
816
- }
817
-
818
811
  logger.info("found workflow instances for workflow", {
819
812
  workflowId,
820
813
  count: filtered.length,
@@ -7,8 +7,11 @@
7
7
  * byte-identity arm the four local conformance rosters also pin);
8
8
  * - a composed scope narrows to the kept ids — and can only narrow:
9
9
  * ids the scope answers that were never offered add nothing;
10
- * - the org argument narrows AFTER the scope, only when non-blank
11
- * (the Java repos' uniform blank-org posture);
10
+ * - the org argument narrows BEFORE the scope, only when non-blank
11
+ * (the Java repos' uniform blank-org posture; since stigmer-cloud
12
+ * 20260913.04 T02 the scope is the last per-row predicate, so it is
13
+ * offered the org's rows and never the kind's — the same result set,
14
+ * a fraction of the candidates);
12
15
  * - the candidates carry {id, org, labels} — the driver's guest
13
16
  * cookie rule keys on labels;
14
17
  * - a scope failure PROPAGATES — never an empty result (the outage
@@ -76,24 +79,39 @@ describe("restrictListByReadScope", () => {
76
79
  expect(kept.map((r) => r.metadata.id)).toEqual(["ses_b"]);
77
80
  });
78
81
 
79
- it("a non-blank org narrows AFTER the scope; blank org does not", async () => {
80
- const scoped = scopeKeeping(["ses_a", "ses_c"]);
82
+ it("a non-blank org narrows BEFORE the scope: the scope is offered the org's rows only; blank org offers them all", async () => {
83
+ const offered: ReadonlyArray<ListEntryMeta>[] = [];
84
+ const recording: ListReadScope = {
85
+ authorizedResourceIds: () => Promise.resolve(new Set<string>()),
86
+ restrictListEntries: (_caller, _kind, entries) => {
87
+ offered.push(entries);
88
+ return Promise.resolve(new Set(["ses_a", "ses_c"]));
89
+ },
90
+ };
81
91
  const withOrg = await restrictListByReadScope(
82
- scoped,
92
+ recording,
83
93
  caller,
84
94
  ApiResourceKind.session,
85
95
  rows,
86
96
  "acme",
87
97
  );
88
98
  expect(withOrg.map((r) => r.metadata.id)).toEqual(["ses_a"]);
99
+ // The rival's row was never a candidate: the engine is not asked
100
+ // about rows the request's own predicate excludes.
101
+ expect(offered[0]!.map((entry) => entry.id)).toEqual(["ses_a", "ses_b"]);
89
102
  const blankOrg = await restrictListByReadScope(
90
- scoped,
103
+ recording,
91
104
  caller,
92
105
  ApiResourceKind.session,
93
106
  rows,
94
107
  "",
95
108
  );
96
109
  expect(blankOrg.map((r) => r.metadata.id)).toEqual(["ses_a", "ses_c"]);
110
+ expect(offered[1]!.map((entry) => entry.id)).toEqual([
111
+ "ses_a",
112
+ "ses_b",
113
+ "ses_c",
114
+ ]);
97
115
  });
98
116
 
99
117
  it("an empty kept set is a real answer — the empty list", async () => {
@@ -127,7 +145,11 @@ describe("restrictListByReadScope", () => {
127
145
  );
128
146
  expect(seenKind).toBe(ApiResourceKind.session);
129
147
  expect(seen).toEqual([
130
- { id: "ses_a", org: "acme", labels: { "stigmer.ai/guest-cookie-id": "ck_1" } },
148
+ {
149
+ id: "ses_a",
150
+ org: "acme",
151
+ labels: { "stigmer.ai/guest-cookie-id": "ck_1" },
152
+ },
131
153
  { id: "ses_b", org: "acme", labels: {} },
132
154
  { id: "ses_c", org: "rival", labels: {} },
133
155
  ]);
@@ -47,6 +47,30 @@
47
47
  * semantics (the Java baseline propagates the original caller
48
48
  * through in-process calls, so a composed driver normally never
49
49
  * sees `internal` on these lanes).
50
+ * - THE SCOPE IS THE LAST PER-ROW PREDICATE IN A LANE (stigmer-cloud
51
+ * 20260913.04 T02, Q-LB-11). Every request predicate a lane applies
52
+ * — the request's org, a phase, labels, a parent id, filter criteria
53
+ * — is a pure function of one row and the request; the scope is a
54
+ * pure function of one row and the caller; an intersection of
55
+ * per-row predicates commutes, so the order is free and the cheap
56
+ * ones go first: a lane offers the scope ONLY the rows its own
57
+ * predicates keep. Sort, page and aggregate are not per-row and stay
58
+ * after the scope, where they were. Why it matters: a composed
59
+ * driver's cost is proportional to the candidates offered (the cloud
60
+ * checks each one against its authorization engine), and the whole
61
+ * kind across every tenant is what rolled back on 2026-09-14; the
62
+ * request's org is tens of rows. Two idioms carry the rule and both
63
+ * keep their reason: a lane whose Go/Java contract org-narrows in
64
+ * BOTH editions (environment, memory, schedule, agent-instance,
65
+ * agent-share and agent-channel lists; the getBy* parent filters)
66
+ * applies its own filter ABOVE the helper call and passes `""`; a lane
67
+ * whose contract is cross-org on OSS (`agentExecution.list`,
68
+ * `workflowExecution.list`, `listPendingApprovals`) hands the request
69
+ * org to the helper, which applies it before the scope ONLY when a
70
+ * scope is composed — a scope-less server still returns the input
71
+ * untouched with the org unconsulted (the first line above). Two lanes
72
+ * have no org on the wire (`session.list`, `apiKey.findAll`) and offer
73
+ * the kind until P1 entry 12 pages them.
50
74
  */
51
75
  import type { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
52
76
 
@@ -112,10 +136,13 @@ export interface ScopedListResource {
112
136
  * `requestOrg` deliberately not consulted — the OSS single-tenant
113
137
  * posture treats the org field as a no-op, and new filtering on a
114
138
  * scope-less server would be a silent wire change);
115
- * - scope composed → entries narrowed to the kept ids, then to
116
- * `requestOrg` when non-blank (the Java repos' uniform posture:
117
- * blank org = permission-bounded across orgs, verified per lane in
118
- * the entry's census — lanes Java does not org-narrow pass "").
139
+ * - scope composed → entries narrowed to `requestOrg` when non-blank
140
+ * FIRST (the Java repos' uniform posture: blank org =
141
+ * permission-bounded across orgs, verified per lane in the entry's
142
+ * census — lanes Java does not org-narrow pass ""), then to the kept
143
+ * ids. The same set either way (both are per-row predicates); the
144
+ * order is the header's last contract line: the scope sees the org's
145
+ * rows, not the kind's.
119
146
  */
120
147
  export async function restrictListByReadScope<T extends ScopedListResource>(
121
148
  scope: ListReadScope | undefined,
@@ -127,17 +154,15 @@ export async function restrictListByReadScope<T extends ScopedListResource>(
127
154
  if (scope === undefined) {
128
155
  return [...resources];
129
156
  }
130
- const entries: ListEntryMeta[] = resources.map((resource) => ({
157
+ const offered =
158
+ requestOrg === ""
159
+ ? resources
160
+ : resources.filter((resource) => resource.metadata?.org === requestOrg);
161
+ const entries: ListEntryMeta[] = offered.map((resource) => ({
131
162
  id: resource.metadata?.id ?? "",
132
163
  org: resource.metadata?.org ?? "",
133
164
  labels: resource.metadata?.labels ?? {},
134
165
  }));
135
166
  const keptIds = await scope.restrictListEntries(caller, kind, entries);
136
- let kept = resources.filter((resource) =>
137
- keptIds.has(resource.metadata?.id ?? ""),
138
- );
139
- if (requestOrg !== "") {
140
- kept = kept.filter((resource) => resource.metadata?.org === requestOrg);
141
- }
142
- return kept;
167
+ return offered.filter((resource) => keptIds.has(resource.metadata?.id ?? ""));
143
168
  }
@@ -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", () => {
@@ -6,6 +6,13 @@
6
6
  * nginx-equivalence-gated); this module owns the HTTP half: the lane
7
7
  * guard, header policy, config synthesis, and file streaming.
8
8
  *
9
+ * /config.json is also how the served console learns to sign in
10
+ * (20260913.02 sp.console-login; stigmer#924): the composition root hands
11
+ * the lane the server's authentication posture (ConsoleSignInPosture) and
12
+ * the lane publishes it in the console's own vocabulary — the OIDC
13
+ * issuer, audience and the console's PKCE client id under the posture,
14
+ * `disabled` under trusted local.
15
+ *
9
16
  * Lane guard (consoleLaneEligible): the lane claims GET/HEAD only — RPC
10
17
  * traffic is POST on service-qualified paths, and OPTIONS preflights keep
11
18
  * flowing to the RPC lane's CORS handling — and it never claims:
@@ -74,18 +81,39 @@ function isServiceShapedPath(pathname: string): boolean {
74
81
  return segments.length === 2 && (segments[0] ?? "").includes(".");
75
82
  }
76
83
 
84
+ /**
85
+ * How the console this lane serves signs in — the server's authentication
86
+ * posture as the console needs to hear it (20260913.02 sp.console-login).
87
+ * A modeled state, never a nullable: "no issuer configured" and "forgot to
88
+ * wire the posture" must stay distinguishable at the call site.
89
+ *
90
+ * Under `oidc`, `consoleClientId` is the PUBLIC client the operator
91
+ * registered for the browser's PKCE flow, or "" when they have not
92
+ * (Q-CL-1): the lane publishes that truth as-is and the console refuses
93
+ * with copy that names the knob (Q-CL-2), while the composition root has
94
+ * already WARNed at wiring time. Emitting `disabled` instead would send
95
+ * the console into every RPC tokenless, to fail with a worse message.
96
+ */
97
+ export type ConsoleSignInPosture =
98
+ | { readonly posture: "trusted-local" }
99
+ | {
100
+ readonly posture: "oidc";
101
+ readonly issuer: string;
102
+ readonly audience: string;
103
+ readonly consoleClientId: string;
104
+ };
105
+
77
106
  export interface ConsoleLaneOptions {
78
107
  readonly assets: ConsoleAssets;
79
- /**
80
- * Fallback port for the synthesized apiUrl when a request carries no
81
- * Host/:authority (technically possible on HTTP/1.0-style clients).
82
- */
83
- readonly grpcPort: number;
108
+ readonly signIn: ConsoleSignInPosture;
84
109
  readonly logger: Logger;
85
110
  }
86
111
 
87
112
  export function createConsoleLane(options: ConsoleLaneOptions): LaneHandler {
88
- const { assets, grpcPort, logger } = options;
113
+ const { assets, signIn, logger } = options;
114
+ // The console's runtime config is a pure function of the posture: one
115
+ // body, built once, served on every request.
116
+ const configJsonBody = JSON.stringify(consoleRuntimeConfig(signIn));
89
117
 
90
118
  return (request: LaneRequest, response: LaneResponse): void => {
91
119
  const url = request.url ?? "";
@@ -94,7 +122,7 @@ export function createConsoleLane(options: ConsoleLaneOptions): LaneHandler {
94
122
  const query = queryStart === -1 ? "" : url.slice(queryStart);
95
123
 
96
124
  if (rawPath === CONFIG_JSON_PATH) {
97
- serveConfigJson(request, response, grpcPort);
125
+ serveConfigJson(request, response, configJsonBody);
98
126
  return;
99
127
  }
100
128
 
@@ -134,34 +162,60 @@ export function createConsoleLane(options: ConsoleLaneOptions): LaneHandler {
134
162
 
135
163
  /**
136
164
  * The runtime config the cloud container's entrypoint.sh generates from
137
- * env — synthesized here from the server's own knowledge instead (DD-012:
138
- * the nginx entrypoint script is not ported). apiUrl derives from the
139
- * request's own Host so the answer is correct wherever the browser
140
- * reached us from (localhost, a LAN address, a self-host name) — the
141
- * console cannot express "same origin" itself (its config loader maps
142
- * empty fields to a localhost default). TLS-terminating proxies
143
- * (x-forwarded-proto) are out of scope until a tier serves TLS.
165
+ * env, synthesized here from the server's own knowledge instead (DD-012:
166
+ * the nginx entrypoint script is not ported). The field names are the
167
+ * console's loader's (client-apps/web/src/config/runtime-config.ts).
168
+ *
169
+ * `apiUrl` is the empty string, which the console reads as "my own
170
+ * origin" — the rule its `appUrl` already follows (20260913.02 Q-CL-3).
171
+ * This lane and the RPC lane share one port, so the console can never
172
+ * need a different origin, and it makes the answer right behind a
173
+ * TLS-terminating proxy without trusting x-forwarded-proto: the earlier
174
+ * Host-derived `http://<host>` sent an https-loaded console to an http
175
+ * API and died on mixed content.
176
+ *
177
+ * Every value here is public metadata: an OIDC issuer URL, an audience
178
+ * string and a PKCE client id are what a browser is handed by design.
144
179
  */
180
+ function consoleRuntimeConfig(signIn: ConsoleSignInPosture): {
181
+ readonly apiUrl: "";
182
+ readonly appUrl: "";
183
+ readonly authMode: "disabled" | "oidc";
184
+ readonly oidcIssuer: string;
185
+ readonly oidcClientId: string;
186
+ readonly oidcAudience: string;
187
+ } {
188
+ switch (signIn.posture) {
189
+ case "trusted-local":
190
+ return {
191
+ apiUrl: "",
192
+ appUrl: "",
193
+ authMode: "disabled",
194
+ oidcIssuer: "",
195
+ oidcClientId: "",
196
+ oidcAudience: "",
197
+ };
198
+ case "oidc":
199
+ return {
200
+ apiUrl: "",
201
+ appUrl: "",
202
+ authMode: "oidc",
203
+ oidcIssuer: signIn.issuer,
204
+ oidcClientId: signIn.consoleClientId,
205
+ oidcAudience: signIn.audience,
206
+ };
207
+ default: {
208
+ const exhaustive: never = signIn;
209
+ throw new Error(`unhandled sign-in posture ${String(exhaustive)}`);
210
+ }
211
+ }
212
+ }
213
+
145
214
  function serveConfigJson(
146
215
  request: LaneRequest,
147
216
  response: LaneResponse,
148
- grpcPort: number,
217
+ body: string,
149
218
  ): void {
150
- // HTTP/1.1 carries Host; Node maps HTTP/2's :authority into the same
151
- // headers view (and Http2ServerRequest.authority falls back to it).
152
- const host = request.headers.host ?? request.headers[":authority"];
153
- const apiUrl =
154
- typeof host === "string" && host !== ""
155
- ? `http://${host}`
156
- : `http://localhost:${grpcPort}`;
157
- const body = JSON.stringify({
158
- apiUrl,
159
- appUrl: "",
160
- authMode: "disabled",
161
- oidcIssuer: "",
162
- oidcClientId: "",
163
- oidcAudience: "",
164
- });
165
219
  response.statusCode = 200;
166
220
  response.setHeader("Content-Type", "application/json");
167
221
  response.setHeader("Cache-Control", CONFIG_JSON_CACHE_CONTROL);