@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.
- package/dist/boot/compose.d.ts.map +1 -1
- package/dist/boot/compose.js +23 -2
- package/dist/boot/compose.js.map +1 -1
- package/dist/boot/config.d.ts +14 -0
- package/dist/boot/config.d.ts.map +1 -1
- package/dist/boot/config.js +1 -0
- package/dist/boot/config.js.map +1 -1
- package/dist/domain/agentchannel/controller.js +15 -26
- package/dist/domain/agentchannel/controller.js.map +1 -1
- package/dist/domain/agentexecution/controller.d.ts.map +1 -1
- package/dist/domain/agentexecution/controller.js +3 -2
- package/dist/domain/agentexecution/controller.js.map +1 -1
- package/dist/domain/agentexecution/steps.d.ts +20 -8
- package/dist/domain/agentexecution/steps.d.ts.map +1 -1
- package/dist/domain/agentexecution/steps.js +32 -12
- package/dist/domain/agentexecution/steps.js.map +1 -1
- package/dist/domain/agentinstance/steps.d.ts.map +1 -1
- package/dist/domain/agentinstance/steps.js +9 -22
- package/dist/domain/agentinstance/steps.js.map +1 -1
- package/dist/domain/agentshare/controller.js +15 -26
- package/dist/domain/agentshare/controller.js.map +1 -1
- package/dist/domain/environment/controller.js +8 -10
- package/dist/domain/environment/controller.js.map +1 -1
- package/dist/domain/memory/steps.d.ts.map +1 -1
- package/dist/domain/memory/steps.js +5 -10
- package/dist/domain/memory/steps.js.map +1 -1
- package/dist/domain/schedule/controller.js +15 -24
- package/dist/domain/schedule/controller.js.map +1 -1
- package/dist/domain/session/steps.d.ts.map +1 -1
- package/dist/domain/session/steps.js +8 -4
- package/dist/domain/session/steps.js.map +1 -1
- package/dist/domain/workflowexecution/controller.d.ts.map +1 -1
- package/dist/domain/workflowexecution/controller.js +12 -6
- package/dist/domain/workflowexecution/controller.js.map +1 -1
- package/dist/domain/workflowexecution/list-pending-approvals.d.ts.map +1 -1
- package/dist/domain/workflowexecution/list-pending-approvals.js +19 -8
- package/dist/domain/workflowexecution/list-pending-approvals.js.map +1 -1
- package/dist/domain/workflowinstance/controller.js +6 -13
- package/dist/domain/workflowinstance/controller.js.map +1 -1
- package/dist/extensions/list-read-scope.d.ts +31 -4
- package/dist/extensions/list-read-scope.d.ts.map +1 -1
- package/dist/extensions/list-read-scope.js +12 -10
- package/dist/extensions/list-read-scope.js.map +1 -1
- package/dist/transport/console/handler.d.ts +22 -5
- package/dist/transport/console/handler.d.ts.map +1 -1
- package/dist/transport/console/handler.js +53 -24
- package/dist/transport/console/handler.js.map +1 -1
- package/package.json +4 -4
- package/src/boot/__tests__/auth-enabled-composition.test.ts +49 -3
- package/src/boot/__tests__/config.test.ts +41 -3
- package/src/boot/compose.ts +30 -2
- package/src/boot/config.ts +15 -0
- package/src/domain/agentchannel/controller.ts +23 -31
- package/src/domain/agentexecution/controller.ts +11 -5
- package/src/domain/agentexecution/steps.ts +47 -22
- package/src/domain/agentinstance/steps.ts +17 -26
- package/src/domain/agentshare/controller.ts +23 -31
- package/src/domain/environment/controller.ts +12 -12
- package/src/domain/memory/steps.ts +6 -12
- package/src/domain/schedule/controller.ts +25 -33
- package/src/domain/session/steps.ts +24 -11
- package/src/domain/workflowexecution/controller.ts +32 -26
- package/src/domain/workflowexecution/list-pending-approvals.ts +37 -16
- package/src/domain/workflowinstance/controller.ts +11 -18
- package/src/extensions/__tests__/list-read-scope.test.ts +29 -7
- package/src/extensions/list-read-scope.ts +37 -12
- package/src/transport/console/__tests__/handler.test.ts +93 -23
- package/src/transport/console/__tests__/resolver.test.ts +6 -0
- 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
|
|
798
|
-
//
|
|
799
|
-
|
|
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
|
|
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
|
|
80
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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("
|
|
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:
|
|
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,
|
|
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,
|
|
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
|
|
138
|
-
* the nginx entrypoint script is not ported).
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
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
|
-
|
|
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);
|