@openparachute/hub 0.7.7-rc.9 → 0.7.7
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/README.md +7 -7
- package/package.json +4 -12
- package/src/__tests__/account-api.test.ts +29 -2
- package/src/__tests__/account-session.test.ts +9 -1
- package/src/__tests__/account-token.test.ts +26 -2
- package/src/__tests__/admin-connections-credentials.test.ts +41 -0
- package/src/__tests__/admin-lock.test.ts +3 -14
- package/src/__tests__/admin-module-token.test.ts +10 -30
- package/src/__tests__/admin-surfaces.test.ts +21 -0
- package/src/__tests__/api-hub-upgrade.test.ts +11 -0
- package/src/__tests__/api-mint-token.test.ts +25 -0
- package/src/__tests__/api-modules-ops.test.ts +34 -29
- package/src/__tests__/api-modules.test.ts +50 -58
- package/src/__tests__/api-revoke-token.test.ts +23 -0
- package/src/__tests__/api-settings-hub-origin.test.ts +12 -0
- package/src/__tests__/api-settings-root-redirect.test.ts +159 -4
- package/src/__tests__/api-tokens.test.ts +44 -0
- package/src/__tests__/audience-gate.test.ts +24 -0
- package/src/__tests__/bearer-scheme-casing.test.ts +110 -0
- package/src/__tests__/chrome-strip.test.ts +18 -1
- package/src/__tests__/doctor.test.ts +10 -17
- package/src/__tests__/hub-command.test.ts +70 -2
- package/src/__tests__/hub-server.test.ts +322 -1
- package/src/__tests__/hub-settings.test.ts +110 -6
- package/src/__tests__/hub.test.ts +29 -0
- package/src/__tests__/install.test.ts +210 -38
- package/src/__tests__/migrate.test.ts +3 -1
- package/src/__tests__/oauth-handlers.test.ts +19 -8
- package/src/__tests__/operator-token.test.ts +1 -2
- package/src/__tests__/port-assign.test.ts +17 -26
- package/src/__tests__/root-serve.test.ts +139 -0
- package/src/__tests__/scope-explanations.test.ts +0 -2
- package/src/__tests__/serve-boot.test.ts +25 -36
- package/src/__tests__/service-spec-discovery.test.ts +30 -35
- package/src/__tests__/services-manifest.test.ts +22 -92
- package/src/__tests__/setup-wizard.test.ts +301 -22
- package/src/__tests__/setup.test.ts +8 -13
- package/src/__tests__/status.test.ts +0 -5
- package/src/__tests__/surface-notes-alias.test.ts +296 -0
- package/src/__tests__/wizard-transcription.test.ts +35 -0
- package/src/__tests__/wizard.test.ts +79 -0
- package/src/account-api.ts +6 -0
- package/src/admin-connections.ts +4 -2
- package/src/admin-lock.ts +1 -2
- package/src/admin-module-token.ts +13 -8
- package/src/admin-surfaces.ts +2 -1
- package/src/api-hub-upgrade.ts +2 -1
- package/src/api-mint-token.ts +2 -1
- package/src/api-modules-ops.ts +2 -1
- package/src/api-modules.ts +9 -7
- package/src/api-revoke-token.ts +2 -1
- package/src/api-settings-hub-origin.ts +2 -1
- package/src/api-settings-root-redirect.ts +97 -20
- package/src/api-tokens.ts +2 -1
- package/src/audience-gate.ts +2 -1
- package/src/chrome-strip.ts +16 -4
- package/src/commands/hub.ts +104 -0
- package/src/commands/install.ts +72 -27
- package/src/commands/migrate.ts +5 -1
- package/src/commands/setup.ts +0 -2
- package/src/commands/wizard-transcription.ts +24 -0
- package/src/commands/wizard.ts +34 -1
- package/src/help.ts +0 -1
- package/src/hub-server.ts +111 -54
- package/src/hub-settings.ts +147 -0
- package/src/hub.ts +64 -31
- package/src/module-ops-client.ts +2 -1
- package/src/oauth-handlers.ts +1 -11
- package/src/operator-token.ts +0 -1
- package/src/origin-check.ts +2 -2
- package/src/root-serve.ts +156 -0
- package/src/scope-explanations.ts +2 -5
- package/src/service-spec.ts +18 -47
- package/src/services-manifest.ts +9 -42
- package/src/setup-wizard.ts +144 -31
- package/src/surface-notes-alias.ts +126 -0
- package/src/__tests__/admin-agent-token.test.ts +0 -173
- package/src/admin-agent-token.ts +0 -147
|
@@ -175,6 +175,18 @@ describe("GET /api/modules", () => {
|
|
|
175
175
|
expect(body.error).toBe("insufficient_scope");
|
|
176
176
|
});
|
|
177
177
|
|
|
178
|
+
// H1.1 — Bearer scheme is case-insensitive per RFC 7235 (V1.4/C1.3 parity).
|
|
179
|
+
test("lowercase bearer scheme authenticates identically to canonical Bearer", async () => {
|
|
180
|
+
const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
|
|
181
|
+
const res = await handleApiModules(getReq({ authorization: `bearer ${bearer}` }), {
|
|
182
|
+
db: h.db,
|
|
183
|
+
issuer: ISSUER,
|
|
184
|
+
manifestPath: h.manifestPath,
|
|
185
|
+
fetchLatestVersion: async () => null,
|
|
186
|
+
});
|
|
187
|
+
expect(res.status).toBe(200);
|
|
188
|
+
});
|
|
189
|
+
|
|
178
190
|
// hub#516: `parachute status` reads /api/modules on loopback presenting the
|
|
179
191
|
// operator token, whose `iss` is the PUBLIC origin after `expose`. The
|
|
180
192
|
// host-admin bearer's iss is validated against `knownIssuers` (loopback ∪
|
|
@@ -250,15 +262,11 @@ describe("GET /api/modules", () => {
|
|
|
250
262
|
// The core tier leads, in the recommended install order (vault → scribe),
|
|
251
263
|
// ahead of every experimental module, which lead the deprecated ones.
|
|
252
264
|
expect(shorts.indexOf("vault")).toBeLessThan(shorts.indexOf("scribe"));
|
|
253
|
-
expect(shorts.indexOf("scribe")).toBeLessThan(shorts.indexOf("
|
|
254
|
-
|
|
255
|
-
expect(shorts.indexOf("agent")).toBeLessThan(shorts.indexOf("notes"));
|
|
256
|
-
// Every known module is discoverable — vault/scribe/surface (core),
|
|
257
|
-
// agent (experimental), notes (deprecated). runner is gone (2026-07-01
|
|
258
|
-
// registry removal) — a fresh box never sees it.
|
|
259
|
-
for (const s of ["vault", "scribe", "surface", "agent", "notes"]) {
|
|
265
|
+
expect(shorts.indexOf("scribe")).toBeLessThan(shorts.indexOf("notes"));
|
|
266
|
+
for (const s of ["vault", "scribe", "surface", "app", "notes"]) {
|
|
260
267
|
expect(shorts).toContain(s);
|
|
261
268
|
}
|
|
269
|
+
expect(shorts).not.toContain("agent");
|
|
262
270
|
expect(shorts).not.toContain("runner");
|
|
263
271
|
expect(shorts).not.toContain("parachute-runner");
|
|
264
272
|
// Focus tier resolves from the default map.
|
|
@@ -266,7 +274,8 @@ describe("GET /api/modules", () => {
|
|
|
266
274
|
expect(byShort.get("vault")?.focus).toBe("core");
|
|
267
275
|
expect(byShort.get("scribe")?.focus).toBe("core");
|
|
268
276
|
expect(byShort.get("surface")?.focus).toBe("core");
|
|
269
|
-
expect(byShort.get("
|
|
277
|
+
expect(byShort.get("app")?.focus).toBe("core");
|
|
278
|
+
expect(byShort.get("agent")).toBeUndefined();
|
|
270
279
|
expect(byShort.get("notes")?.focus).toBe("deprecated");
|
|
271
280
|
// `available` stays true for every known module (re-installable), but the
|
|
272
281
|
// fresh-install OFFER (`available_to_install`) drops the deprecated tier —
|
|
@@ -275,7 +284,7 @@ describe("GET /api/modules", () => {
|
|
|
275
284
|
expect(byShort.get("vault")?.available_to_install).toBe(true);
|
|
276
285
|
expect(byShort.get("scribe")?.available_to_install).toBe(true);
|
|
277
286
|
expect(byShort.get("surface")?.available_to_install).toBe(true);
|
|
278
|
-
expect(byShort.get("
|
|
287
|
+
expect(byShort.get("app")?.available_to_install).toBe(true);
|
|
279
288
|
expect(byShort.get("notes")?.available_to_install).toBe(false);
|
|
280
289
|
expect(body.modules.every((m) => !m.installed)).toBe(true);
|
|
281
290
|
expect(body.modules.every((m) => m.latest_version === "0.9.9")).toBe(true);
|
|
@@ -369,13 +378,7 @@ describe("GET /api/modules", () => {
|
|
|
369
378
|
expect(scribe?.available).toBe(true);
|
|
370
379
|
});
|
|
371
380
|
|
|
372
|
-
test("
|
|
373
|
-
// THE bug this PR fixes (2026-06-09 modular-UI architecture, P2): agent (renamed from channel 2026-06-17)
|
|
374
|
-
// was running, proxied, supervised, and self-registered in services.json
|
|
375
|
-
// yet invisible on the Modules screen — because the old CURATED_MODULES =
|
|
376
|
-
// ["vault","scribe"] whitelist gated discovery. Now discovery is driven by
|
|
377
|
-
// self-registration ∪ the known registries, so a self-registered agent
|
|
378
|
-
// row surfaces as installed, in the experimental tier, with its run-state.
|
|
381
|
+
test("retired Agent rows do not surface in the module catalog", async () => {
|
|
379
382
|
writeManifest(h.manifestPath, [
|
|
380
383
|
{
|
|
381
384
|
name: "parachute-agent",
|
|
@@ -385,32 +388,16 @@ describe("GET /api/modules", () => {
|
|
|
385
388
|
version: "0.3.1",
|
|
386
389
|
},
|
|
387
390
|
]);
|
|
388
|
-
const { supervisor } = makeIdleSupervisor();
|
|
389
|
-
await supervisor.start({ short: "agent", cmd: ["parachute-agent"] });
|
|
390
|
-
|
|
391
391
|
const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
|
|
392
392
|
const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
|
|
393
393
|
db: h.db,
|
|
394
394
|
issuer: ISSUER,
|
|
395
395
|
manifestPath: h.manifestPath,
|
|
396
|
-
supervisor,
|
|
397
396
|
fetchLatestVersion: async () => null,
|
|
398
397
|
});
|
|
399
|
-
const body = (await res.json()) as {
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
focus: "core" | "experimental";
|
|
403
|
-
installed: boolean;
|
|
404
|
-
installed_version: string | null;
|
|
405
|
-
supervisor_status: string | null;
|
|
406
|
-
}>;
|
|
407
|
-
};
|
|
408
|
-
const agent = body.modules.find((m) => m.short === "agent");
|
|
409
|
-
expect(agent).toBeDefined();
|
|
410
|
-
expect(agent?.installed).toBe(true);
|
|
411
|
-
expect(agent?.installed_version).toBe("0.3.1");
|
|
412
|
-
expect(agent?.focus).toBe("experimental");
|
|
413
|
-
expect(agent?.supervisor_status).toBe("running");
|
|
398
|
+
const body = (await res.json()) as { modules: Array<{ short: string }> };
|
|
399
|
+
expect(body.modules.find((m) => m.short === "agent")).toBeUndefined();
|
|
400
|
+
expect(body.modules.find((m) => m.short === "parachute-agent")).toBeUndefined();
|
|
414
401
|
});
|
|
415
402
|
|
|
416
403
|
test("every self-registered + known module appears in `modules` — no running-but-invisible class", async () => {
|
|
@@ -834,20 +821,15 @@ describe("GET /api/modules", () => {
|
|
|
834
821
|
expect(vault?.management_url).toBe("/vault/default/admin");
|
|
835
822
|
});
|
|
836
823
|
|
|
837
|
-
test("populates config_ui_url from a
|
|
838
|
-
// Agent declares `configUiUrl: "/agent/admin"` (a single-instance,
|
|
839
|
-
// origin-absolute path) + `uiUrl: "/agent/ui"`. The hub surfaces
|
|
840
|
-
// both: `config_ui_url` drives the Modules page Configure action,
|
|
841
|
-
// `management_url` drives Open. configUiUrl resolves identically to
|
|
842
|
-
// managementUrl (same B4 unified semantics).
|
|
824
|
+
test("populates config_ui_url from a third-party module manifest", async () => {
|
|
843
825
|
writeManifest(h.manifestPath, [
|
|
844
826
|
{
|
|
845
|
-
name: "
|
|
846
|
-
port:
|
|
847
|
-
paths: ["/
|
|
827
|
+
name: "widgets",
|
|
828
|
+
port: 1947,
|
|
829
|
+
paths: ["/widgets"],
|
|
848
830
|
health: "/health",
|
|
849
831
|
version: "0.1.0",
|
|
850
|
-
installDir: "/install/dir/
|
|
832
|
+
installDir: "/install/dir/widgets",
|
|
851
833
|
},
|
|
852
834
|
]);
|
|
853
835
|
const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
|
|
@@ -857,17 +839,17 @@ describe("GET /api/modules", () => {
|
|
|
857
839
|
manifestPath: h.manifestPath,
|
|
858
840
|
fetchLatestVersion: async () => null,
|
|
859
841
|
readModuleManifest: async (installDir) => {
|
|
860
|
-
if (installDir === "/install/dir/
|
|
842
|
+
if (installDir === "/install/dir/widgets") {
|
|
861
843
|
return {
|
|
862
|
-
name: "
|
|
863
|
-
manifestName: "
|
|
864
|
-
displayName: "
|
|
844
|
+
name: "widgets",
|
|
845
|
+
manifestName: "widgets",
|
|
846
|
+
displayName: "Widgets",
|
|
865
847
|
tagline: "",
|
|
866
|
-
port:
|
|
867
|
-
paths: ["/
|
|
848
|
+
port: 1947,
|
|
849
|
+
paths: ["/widgets"],
|
|
868
850
|
health: "/health",
|
|
869
|
-
uiUrl: "/
|
|
870
|
-
configUiUrl: "/
|
|
851
|
+
uiUrl: "/widgets/ui",
|
|
852
|
+
configUiUrl: "/widgets/admin",
|
|
871
853
|
} as unknown as Awaited<
|
|
872
854
|
ReturnType<typeof import("../module-manifest.ts").readModuleManifest>
|
|
873
855
|
>;
|
|
@@ -883,11 +865,9 @@ describe("GET /api/modules", () => {
|
|
|
883
865
|
management_url: string | null;
|
|
884
866
|
}>;
|
|
885
867
|
};
|
|
886
|
-
const
|
|
887
|
-
|
|
888
|
-
expect(
|
|
889
|
-
// uiUrl (no managementUrl) drives the Open action's management_url.
|
|
890
|
-
expect(agent?.management_url).toBe("/agent/ui");
|
|
868
|
+
const widgets = body.modules.find((m) => m.short === "widgets");
|
|
869
|
+
expect(widgets?.config_ui_url).toBe("/widgets/admin");
|
|
870
|
+
expect(widgets?.management_url).toBe("/widgets/ui");
|
|
891
871
|
});
|
|
892
872
|
|
|
893
873
|
test("config_ui_url is null when the module declares no configUiUrl", async () => {
|
|
@@ -1498,6 +1478,18 @@ describe("PUT /api/modules/channel — hub#275 channel toggle", () => {
|
|
|
1498
1478
|
expect(res.status).toBe(401);
|
|
1499
1479
|
});
|
|
1500
1480
|
|
|
1481
|
+
// H1.1 — Bearer scheme is case-insensitive per RFC 7235 (V1.4/C1.3 parity).
|
|
1482
|
+
test("lowercase bearer scheme authenticates identically to canonical Bearer", async () => {
|
|
1483
|
+
const bearer = await mintBearer(h, [API_MODULES_CHANNEL_REQUIRED_SCOPE]);
|
|
1484
|
+
const res = await handleApiModulesChannel(
|
|
1485
|
+
putReq({ channel: "rc" }, { authorization: `bearer ${bearer}` }),
|
|
1486
|
+
{ db: h.db, issuer: ISSUER },
|
|
1487
|
+
);
|
|
1488
|
+
expect(res.status).toBe(200);
|
|
1489
|
+
const body = (await res.json()) as { channel: string };
|
|
1490
|
+
expect(body.channel).toBe("rc");
|
|
1491
|
+
});
|
|
1492
|
+
|
|
1501
1493
|
test("403 on bearer without parachute:host:admin", async () => {
|
|
1502
1494
|
// `:host:auth` reads the GET catalog — it must NOT be allowed to
|
|
1503
1495
|
// flip the install channel. Boundary matches install/upgrade/uninstall.
|
|
@@ -263,6 +263,29 @@ describe("POST /api/auth/revoke-token (closes hub#220)", () => {
|
|
|
263
263
|
}
|
|
264
264
|
});
|
|
265
265
|
|
|
266
|
+
// H1.1 — Bearer scheme is case-insensitive per RFC 7235 (V1.4/C1.3 parity).
|
|
267
|
+
test("lowercase bearer scheme authenticates identically to canonical Bearer", async () => {
|
|
268
|
+
const h = makeHarness();
|
|
269
|
+
try {
|
|
270
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
271
|
+
try {
|
|
272
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
273
|
+
const jti = await seedToken(db, userId);
|
|
274
|
+
const resp = await handleApiRevokeToken(
|
|
275
|
+
jsonRequest({ jti }, { authorization: `bearer ${op.token}` }),
|
|
276
|
+
{ db, issuer: ISSUER },
|
|
277
|
+
);
|
|
278
|
+
expect(resp.status).toBe(200);
|
|
279
|
+
const body = (await resp.json()) as { jti: string };
|
|
280
|
+
expect(body.jti).toBe(jti);
|
|
281
|
+
} finally {
|
|
282
|
+
db.close();
|
|
283
|
+
}
|
|
284
|
+
} finally {
|
|
285
|
+
h.cleanup();
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
266
289
|
test("idempotent: re-revoking returns 200 with the original revoked_at", async () => {
|
|
267
290
|
const h = makeHarness();
|
|
268
291
|
try {
|
|
@@ -258,6 +258,18 @@ describe("GET /api/settings/hub-origin", () => {
|
|
|
258
258
|
expect(body.resolved_issuer).toBe("http://localhost"); // request origin from getReq()
|
|
259
259
|
});
|
|
260
260
|
|
|
261
|
+
// H1.1 — Bearer scheme is case-insensitive per RFC 7235 (V1.4/C1.3 parity).
|
|
262
|
+
test("lowercase bearer scheme authenticates identically to canonical Bearer", async () => {
|
|
263
|
+
const bearer = await mintBearer(h, [API_SETTINGS_HUB_ORIGIN_REQUIRED_SCOPE]);
|
|
264
|
+
const res = await handleApiSettingsHubOrigin(
|
|
265
|
+
getReq({ authorization: `bearer ${bearer}` }),
|
|
266
|
+
deps(h),
|
|
267
|
+
);
|
|
268
|
+
expect(res.status).toBe(200);
|
|
269
|
+
const body = (await res.json()) as { hub_origin: string | null };
|
|
270
|
+
expect(body.hub_origin).toBeNull();
|
|
271
|
+
});
|
|
272
|
+
|
|
261
273
|
test("returns env source + env-resolved issuer when configuredIssuer is set", async () => {
|
|
262
274
|
const bearer = await mintBearer(h, [API_SETTINGS_HUB_ORIGIN_REQUIRED_SCOPE]);
|
|
263
275
|
const res = await handleApiSettingsHubOrigin(getReq({ authorization: `Bearer ${bearer}` }), {
|
|
@@ -18,10 +18,11 @@ import { join } from "node:path";
|
|
|
18
18
|
import {
|
|
19
19
|
API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE,
|
|
20
20
|
handleApiSettingsRootRedirect,
|
|
21
|
+
validateRootMode,
|
|
21
22
|
validateRootRedirect,
|
|
22
23
|
} from "../api-settings-root-redirect.ts";
|
|
23
24
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
24
|
-
import { getRootRedirect, setRootRedirect } from "../hub-settings.ts";
|
|
25
|
+
import { getRootMode, getRootRedirect, setRootMode, setRootRedirect } from "../hub-settings.ts";
|
|
25
26
|
import { recordTokenMint, signAccessToken } from "../jwt-sign.ts";
|
|
26
27
|
import { rotateSigningKey } from "../signing-keys.ts";
|
|
27
28
|
import { createUser } from "../users.ts";
|
|
@@ -192,7 +193,33 @@ describe("GET /api/settings/root-redirect", () => {
|
|
|
192
193
|
);
|
|
193
194
|
expect(res.status).toBe(200);
|
|
194
195
|
const body = (await res.json()) as Record<string, unknown>;
|
|
195
|
-
expect(body).toEqual({
|
|
196
|
+
expect(body).toEqual({
|
|
197
|
+
root_redirect: null,
|
|
198
|
+
resolved: "/admin",
|
|
199
|
+
source: "default",
|
|
200
|
+
root_mode: null,
|
|
201
|
+
resolved_mode: "redirect",
|
|
202
|
+
mode_source: "default",
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// H1.1 — Bearer scheme is case-insensitive per RFC 7235 (V1.4/C1.3 parity).
|
|
207
|
+
test("lowercase bearer scheme authenticates identically to canonical Bearer", async () => {
|
|
208
|
+
const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
|
|
209
|
+
const res = await handleApiSettingsRootRedirect(
|
|
210
|
+
getReq({ authorization: `bearer ${bearer}` }),
|
|
211
|
+
deps(h),
|
|
212
|
+
);
|
|
213
|
+
expect(res.status).toBe(200);
|
|
214
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
215
|
+
expect(body).toEqual({
|
|
216
|
+
root_redirect: null,
|
|
217
|
+
resolved: "/admin",
|
|
218
|
+
source: "default",
|
|
219
|
+
root_mode: null,
|
|
220
|
+
resolved_mode: "redirect",
|
|
221
|
+
mode_source: "default",
|
|
222
|
+
});
|
|
196
223
|
});
|
|
197
224
|
|
|
198
225
|
test("reflects a stored value with source=db", async () => {
|
|
@@ -207,6 +234,9 @@ describe("GET /api/settings/root-redirect", () => {
|
|
|
207
234
|
root_redirect: "/surface/reading-room",
|
|
208
235
|
resolved: "/surface/reading-room",
|
|
209
236
|
source: "db",
|
|
237
|
+
root_mode: null,
|
|
238
|
+
resolved_mode: "redirect",
|
|
239
|
+
mode_source: "default",
|
|
210
240
|
});
|
|
211
241
|
});
|
|
212
242
|
|
|
@@ -221,6 +251,9 @@ describe("GET /api/settings/root-redirect", () => {
|
|
|
221
251
|
root_redirect: null,
|
|
222
252
|
resolved: "/surface/from-env",
|
|
223
253
|
source: "env",
|
|
254
|
+
root_mode: null,
|
|
255
|
+
resolved_mode: "redirect",
|
|
256
|
+
mode_source: "default",
|
|
224
257
|
});
|
|
225
258
|
});
|
|
226
259
|
});
|
|
@@ -239,7 +272,10 @@ describe("PUT /api/settings/root-redirect", () => {
|
|
|
239
272
|
deps(h),
|
|
240
273
|
);
|
|
241
274
|
expect(put.status).toBe(200);
|
|
242
|
-
expect((await put.json()) as unknown).toEqual({
|
|
275
|
+
expect((await put.json()) as unknown).toEqual({
|
|
276
|
+
root_redirect: "/surface/reading-room",
|
|
277
|
+
root_mode: null,
|
|
278
|
+
});
|
|
243
279
|
expect(getRootRedirect(h.db)).toBe("/surface/reading-room");
|
|
244
280
|
|
|
245
281
|
const get = await handleApiSettingsRootRedirect(
|
|
@@ -282,7 +318,7 @@ describe("PUT /api/settings/root-redirect", () => {
|
|
|
282
318
|
}
|
|
283
319
|
});
|
|
284
320
|
|
|
285
|
-
test("400 on a body
|
|
321
|
+
test("400 on a body with neither root_redirect nor root_mode", async () => {
|
|
286
322
|
const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
|
|
287
323
|
const res = await handleApiSettingsRootRedirect(
|
|
288
324
|
putReq({ wrong: "x" }, { authorization: `Bearer ${bearer}` }),
|
|
@@ -300,3 +336,122 @@ describe("PUT /api/settings/root-redirect", () => {
|
|
|
300
336
|
expect(res.status).toBe(400);
|
|
301
337
|
});
|
|
302
338
|
});
|
|
339
|
+
|
|
340
|
+
describe("validateRootMode — pure validator", () => {
|
|
341
|
+
test("null + empty string → normalized null (clear)", () => {
|
|
342
|
+
expect(validateRootMode(null)).toEqual({ ok: true, normalized: null });
|
|
343
|
+
expect(validateRootMode("")).toEqual({ ok: true, normalized: null });
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("valid modes normalize verbatim", () => {
|
|
347
|
+
expect(validateRootMode("redirect")).toEqual({ ok: true, normalized: "redirect" });
|
|
348
|
+
expect(validateRootMode("serve-app")).toEqual({ ok: true, normalized: "serve-app" });
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("rejects unknown modes + non-strings", () => {
|
|
352
|
+
for (const bad of ["serveapp", "SERVE-APP", "app", 42, {}]) {
|
|
353
|
+
expect(validateRootMode(bad).ok).toBe(false);
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
describe("root_mode over the endpoint", () => {
|
|
359
|
+
let h: Harness;
|
|
360
|
+
beforeEach(async () => {
|
|
361
|
+
h = await makeHarness();
|
|
362
|
+
});
|
|
363
|
+
afterEach(() => h.cleanup());
|
|
364
|
+
|
|
365
|
+
test("GET reflects a stored serve-app mode with mode_source=db", async () => {
|
|
366
|
+
setRootMode(h.db, "serve-app");
|
|
367
|
+
const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
|
|
368
|
+
const res = await handleApiSettingsRootRedirect(
|
|
369
|
+
getReq({ authorization: `Bearer ${bearer}` }),
|
|
370
|
+
deps(h),
|
|
371
|
+
);
|
|
372
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
373
|
+
expect(body.root_mode).toBe("serve-app");
|
|
374
|
+
expect(body.resolved_mode).toBe("serve-app");
|
|
375
|
+
expect(body.mode_source).toBe("db");
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("GET surfaces an env-sourced mode while the stored row is null", async () => {
|
|
379
|
+
const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
|
|
380
|
+
const res = await handleApiSettingsRootRedirect(
|
|
381
|
+
getReq({ authorization: `Bearer ${bearer}` }),
|
|
382
|
+
deps(h, { env: { PARACHUTE_HUB_ROOT_MODE: "serve-app" } }),
|
|
383
|
+
);
|
|
384
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
385
|
+
expect(body.root_mode).toBeNull();
|
|
386
|
+
expect(body.resolved_mode).toBe("serve-app");
|
|
387
|
+
expect(body.mode_source).toBe("env");
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("PUT stores serve-app + GET reflects it on the next request (no restart)", async () => {
|
|
391
|
+
const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
|
|
392
|
+
const put = await handleApiSettingsRootRedirect(
|
|
393
|
+
putReq({ root_mode: "serve-app" }, { authorization: `Bearer ${bearer}` }),
|
|
394
|
+
deps(h),
|
|
395
|
+
);
|
|
396
|
+
expect(put.status).toBe(200);
|
|
397
|
+
expect((await put.json()) as unknown).toEqual({ root_redirect: null, root_mode: "serve-app" });
|
|
398
|
+
expect(getRootMode(h.db)).toBe("serve-app");
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("PUT redirect / null clears the row back to the default", async () => {
|
|
402
|
+
setRootMode(h.db, "serve-app");
|
|
403
|
+
const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
|
|
404
|
+
// Writing the default "redirect" deletes the row (footgun-guard parity).
|
|
405
|
+
const res = await handleApiSettingsRootRedirect(
|
|
406
|
+
putReq({ root_mode: "redirect" }, { authorization: `Bearer ${bearer}` }),
|
|
407
|
+
deps(h),
|
|
408
|
+
);
|
|
409
|
+
expect(res.status).toBe(200);
|
|
410
|
+
expect(getRootMode(h.db)).toBeNull();
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test("PUT rejects an invalid mode with 400 + writes nothing", async () => {
|
|
414
|
+
const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
|
|
415
|
+
const res = await handleApiSettingsRootRedirect(
|
|
416
|
+
putReq({ root_mode: "serveapp" }, { authorization: `Bearer ${bearer}` }),
|
|
417
|
+
deps(h),
|
|
418
|
+
);
|
|
419
|
+
expect(res.status).toBe(400);
|
|
420
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
421
|
+
expect(body.error).toBe("invalid_root_mode");
|
|
422
|
+
expect(getRootMode(h.db)).toBeNull();
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test("PUT applies both root_mode + root_redirect in one call", async () => {
|
|
426
|
+
const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
|
|
427
|
+
const res = await handleApiSettingsRootRedirect(
|
|
428
|
+
putReq(
|
|
429
|
+
{ root_mode: "serve-app", root_redirect: "/surface/fallback" },
|
|
430
|
+
{ authorization: `Bearer ${bearer}` },
|
|
431
|
+
),
|
|
432
|
+
deps(h),
|
|
433
|
+
);
|
|
434
|
+
expect(res.status).toBe(200);
|
|
435
|
+
expect((await res.json()) as unknown).toEqual({
|
|
436
|
+
root_redirect: "/surface/fallback",
|
|
437
|
+
root_mode: "serve-app",
|
|
438
|
+
});
|
|
439
|
+
expect(getRootMode(h.db)).toBe("serve-app");
|
|
440
|
+
expect(getRootRedirect(h.db)).toBe("/surface/fallback");
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test("a rejected mode does NOT half-apply a valid redirect in the same call", async () => {
|
|
444
|
+
const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
|
|
445
|
+
const res = await handleApiSettingsRootRedirect(
|
|
446
|
+
putReq(
|
|
447
|
+
{ root_mode: "bogus", root_redirect: "/surface/should-not-land" },
|
|
448
|
+
{ authorization: `Bearer ${bearer}` },
|
|
449
|
+
),
|
|
450
|
+
deps(h),
|
|
451
|
+
);
|
|
452
|
+
expect(res.status).toBe(400);
|
|
453
|
+
// Both-validated-before-either-applied: the redirect was NOT written.
|
|
454
|
+
expect(getRootRedirect(h.db)).toBeNull();
|
|
455
|
+
expect(getRootMode(h.db)).toBeNull();
|
|
456
|
+
});
|
|
457
|
+
});
|
|
@@ -139,6 +139,50 @@ describe("GET /api/auth/tokens (admin token list — Phase 2 backend)", () => {
|
|
|
139
139
|
}
|
|
140
140
|
});
|
|
141
141
|
|
|
142
|
+
// H1.1 — Bearer scheme is case-insensitive per RFC 7235 (V1.4/C1.3 parity).
|
|
143
|
+
test("lowercase bearer scheme authenticates identically to canonical Bearer", async () => {
|
|
144
|
+
const h = makeHarness();
|
|
145
|
+
try {
|
|
146
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
147
|
+
try {
|
|
148
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
149
|
+
const resp = await handleApiTokens(
|
|
150
|
+
getRequest("", { authorization: `bearer ${op.token}` }),
|
|
151
|
+
{
|
|
152
|
+
db,
|
|
153
|
+
issuer: ISSUER,
|
|
154
|
+
},
|
|
155
|
+
);
|
|
156
|
+
expect(resp.status).toBe(200);
|
|
157
|
+
const body = (await resp.json()) as { tokens: unknown[] };
|
|
158
|
+
expect(body.tokens).toHaveLength(1);
|
|
159
|
+
} finally {
|
|
160
|
+
db.close();
|
|
161
|
+
}
|
|
162
|
+
} finally {
|
|
163
|
+
h.cleanup();
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("mixed-case bearer scheme (BeArEr) authenticates identically to canonical Bearer", async () => {
|
|
168
|
+
const h = makeHarness();
|
|
169
|
+
try {
|
|
170
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
171
|
+
try {
|
|
172
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
173
|
+
const resp = await handleApiTokens(
|
|
174
|
+
getRequest("", { authorization: `BeArEr ${op.token}` }),
|
|
175
|
+
{ db, issuer: ISSUER },
|
|
176
|
+
);
|
|
177
|
+
expect(resp.status).toBe(200);
|
|
178
|
+
} finally {
|
|
179
|
+
db.close();
|
|
180
|
+
}
|
|
181
|
+
} finally {
|
|
182
|
+
h.cleanup();
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
142
186
|
test("happy path: empty registry returns empty array", async () => {
|
|
143
187
|
const h = makeHarness();
|
|
144
188
|
try {
|
|
@@ -217,6 +217,30 @@ describe("audience gate matrix (H3)", () => {
|
|
|
217
217
|
}
|
|
218
218
|
});
|
|
219
219
|
|
|
220
|
+
// H1.1 — Bearer scheme is case-insensitive per RFC 7235 (V1.4/C1.3 parity).
|
|
221
|
+
test("audience: hub-users — lowercase/mixed-case bearer scheme authenticates identically to canonical Bearer", async () => {
|
|
222
|
+
const upstream = startEchoUpstream();
|
|
223
|
+
try {
|
|
224
|
+
writeServices(surfaceEntry(upstream.port, { audience: "hub-users" }));
|
|
225
|
+
const f = fetcher();
|
|
226
|
+
const bearer = await mintBearer(["vault:default:read", "vault:default:write"]);
|
|
227
|
+
|
|
228
|
+
const lower = await f(
|
|
229
|
+
req("/surface/notes/x", { headers: { authorization: `bearer ${bearer}` } }),
|
|
230
|
+
fakeServer("127.0.0.1"),
|
|
231
|
+
);
|
|
232
|
+
expect(lower?.status).toBe(200);
|
|
233
|
+
|
|
234
|
+
const mixed = await f(
|
|
235
|
+
req("/surface/notes/x", { headers: { authorization: `BeArEr ${bearer}` } }),
|
|
236
|
+
fakeServer("127.0.0.1"),
|
|
237
|
+
);
|
|
238
|
+
expect(mixed?.status).toBe(200);
|
|
239
|
+
} finally {
|
|
240
|
+
upstream.stop();
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
220
244
|
test("audience: hub-users — Bearer with NON-satisfying scopes → 403; garbage Bearer → 401", async () => {
|
|
221
245
|
const upstream = startEchoUpstream();
|
|
222
246
|
try {
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* H1.1 (contracts-brief H1) — Bearer scheme-casing unification.
|
|
3
|
+
*
|
|
4
|
+
* RFC 7235 §2.1: the auth-scheme token ("Bearer") is case-insensitive; the
|
|
5
|
+
* credential (the token itself) is opaque and must be passed VERBATIM — never
|
|
6
|
+
* case-folded, never trimmed beyond surrounding whitespace. This mirrors
|
|
7
|
+
* parachute-vault's V1.4 (`BEARER_PREFIX`, case-insensitive) and
|
|
8
|
+
* parachute-cloud's C1.3 (`workers/vault/src/auth.ts:130-143`) — the other two
|
|
9
|
+
* door surfaces already converged on this shape; this file (plus the per-site
|
|
10
|
+
* tests below) closes the hub's half.
|
|
11
|
+
*
|
|
12
|
+
* Every converted call site (`src/api-hub-upgrade.ts`, `src/api-tokens.ts`,
|
|
13
|
+
* `src/api-revoke-token.ts`, `src/api-settings-root-redirect.ts`,
|
|
14
|
+
* `src/api-modules.ts` ×2, `src/api-modules-ops.ts`, `src/api-mint-token.ts`,
|
|
15
|
+
* `src/api-settings-hub-origin.ts`, `src/admin-surfaces.ts`,
|
|
16
|
+
* `src/admin-connections.ts` ×2, `src/audience-gate.ts`) uses the SAME
|
|
17
|
+
* two-step shape: a case-insensitive `/^Bearer\s+/i` scheme test, followed by
|
|
18
|
+
* the PRE-EXISTING `header.slice("Bearer ".length).trim()` extraction
|
|
19
|
+
* (unchanged — "Bearer ".length === "bearer ".length === 7, so the fixed-
|
|
20
|
+
* offset slice is casing-agnostic and the token substring is untouched).
|
|
21
|
+
* `src/admin-auth.ts`'s `extractBearerToken` (the documented shared helper,
|
|
22
|
+
* used by `requireScope` + several `/admin/*` and `/api/*` routes already)
|
|
23
|
+
* uses an equivalent `/^Bearer\s+(.+)$/i` regex. This file pins that shared
|
|
24
|
+
* pattern directly, independent of any one handler's DB/deps scaffolding.
|
|
25
|
+
*
|
|
26
|
+
* Live end-to-end coverage (a lowercase/mixed-case Authorization header
|
|
27
|
+
* reaching a real handler and authenticating) lives alongside each site's
|
|
28
|
+
* existing tests, NOT here:
|
|
29
|
+
* - src/__tests__/api-hub-upgrade.test.ts
|
|
30
|
+
* - src/__tests__/api-tokens.test.ts
|
|
31
|
+
* - src/__tests__/api-revoke-token.test.ts
|
|
32
|
+
* - src/__tests__/api-settings-root-redirect.test.ts
|
|
33
|
+
* - src/__tests__/api-modules.test.ts (both handleApiModules + handleApiModulesChannel)
|
|
34
|
+
* - src/__tests__/api-modules-ops.test.ts
|
|
35
|
+
* - src/__tests__/api-mint-token.test.ts
|
|
36
|
+
* - src/__tests__/api-settings-hub-origin.test.ts
|
|
37
|
+
* - src/__tests__/admin-surfaces.test.ts
|
|
38
|
+
* - src/__tests__/admin-connections-credentials.test.ts (renew + claim)
|
|
39
|
+
* - src/__tests__/audience-gate.test.ts
|
|
40
|
+
*/
|
|
41
|
+
import { describe, expect, test } from "bun:test";
|
|
42
|
+
import { extractBearerToken } from "../admin-auth.ts";
|
|
43
|
+
|
|
44
|
+
/** The exact regex + slice shape used at all 12 converted call sites. */
|
|
45
|
+
function siteShapeExtract(header: string | null): string | null {
|
|
46
|
+
if (!header || !/^Bearer\s+/i.test(header)) return null;
|
|
47
|
+
return header.slice("Bearer ".length).trim();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// A JWT-shaped token: base64url alphabet includes uppercase letters, so any
|
|
51
|
+
// real access/operator token already exercises "verbatim, case preserved."
|
|
52
|
+
// Use one explicitly so the pin doesn't depend on a real signer.
|
|
53
|
+
const MIXED_CASE_TOKEN = "eyJhbGciOiJFZERTQSJ9.eyJzdWIiOiJBQkNkZWYxMjMifQ.SIGNATURE-Xyz";
|
|
54
|
+
|
|
55
|
+
describe("H1.1 — site-shape extraction (regex test + fixed-offset slice)", () => {
|
|
56
|
+
test.each([
|
|
57
|
+
["Bearer", `Bearer ${MIXED_CASE_TOKEN}`],
|
|
58
|
+
["bearer", `bearer ${MIXED_CASE_TOKEN}`],
|
|
59
|
+
["BEARER", `BEARER ${MIXED_CASE_TOKEN}`],
|
|
60
|
+
["BeArEr", `BeArEr ${MIXED_CASE_TOKEN}`],
|
|
61
|
+
["bEARER", `bEARER ${MIXED_CASE_TOKEN}`],
|
|
62
|
+
])("scheme %s → token extracted verbatim, case preserved", (_label, header) => {
|
|
63
|
+
expect(siteShapeExtract(header)).toBe(MIXED_CASE_TOKEN);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("wrong scheme keyword (not Bearer) → rejected regardless of case", () => {
|
|
67
|
+
expect(siteShapeExtract(`Basic ${MIXED_CASE_TOKEN}`)).toBeNull();
|
|
68
|
+
expect(siteShapeExtract(`Digest ${MIXED_CASE_TOKEN}`)).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("missing header → rejected", () => {
|
|
72
|
+
expect(siteShapeExtract(null)).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("scheme with no token → empty string (site's own 'empty bearer token' check catches this)", () => {
|
|
76
|
+
expect(siteShapeExtract("Bearer ")).toBe("");
|
|
77
|
+
expect(siteShapeExtract("bearer ")).toBe("");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("extra internal whitespace after the scheme is tolerated (trim absorbs it), casing still ignored", () => {
|
|
81
|
+
expect(siteShapeExtract(`bearer ${MIXED_CASE_TOKEN}`)).toBe(MIXED_CASE_TOKEN);
|
|
82
|
+
expect(siteShapeExtract(`Bearer\t${MIXED_CASE_TOKEN}`)).toBe(MIXED_CASE_TOKEN);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("H1.1 — admin-auth.ts extractBearerToken (the documented shared helper)", () => {
|
|
87
|
+
function reqWith(header: string | undefined): Request {
|
|
88
|
+
return new Request("http://127.0.0.1/admin/probe", {
|
|
89
|
+
headers: header !== undefined ? { authorization: header } : {},
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
test.each([
|
|
94
|
+
["Bearer", `Bearer ${MIXED_CASE_TOKEN}`],
|
|
95
|
+
["bearer", `bearer ${MIXED_CASE_TOKEN}`],
|
|
96
|
+
["BeArEr", `BeArEr ${MIXED_CASE_TOKEN}`],
|
|
97
|
+
])("scheme %s → extractBearerToken returns the token verbatim", (_label, header) => {
|
|
98
|
+
expect(extractBearerToken(reqWith(header))).toBe(MIXED_CASE_TOKEN);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("missing Authorization header → AdminAuthError(401)", () => {
|
|
102
|
+
expect(() => extractBearerToken(reqWith(undefined))).toThrow(/missing Authorization header/);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("non-Bearer scheme → AdminAuthError(401)", () => {
|
|
106
|
+
expect(() => extractBearerToken(reqWith(`Basic ${MIXED_CASE_TOKEN}`))).toThrow(
|
|
107
|
+
/Authorization header must be 'Bearer <token>'/,
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
});
|