@openparachute/hub 0.7.6 → 0.7.7-rc.13
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 +8 -13
- package/src/__tests__/account-api.test.ts +798 -0
- package/src/__tests__/account-session.test.ts +252 -0
- package/src/__tests__/account-token.test.ts +316 -0
- package/src/__tests__/admin-connections-credentials.test.ts +41 -0
- package/src/__tests__/admin-handlers.test.ts +25 -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__/door-contract-parity.test.ts +46 -0
- 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 +359 -5
- package/src/__tests__/migrate.test.ts +3 -1
- package/src/__tests__/notes-serve.test.ts +216 -0
- 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 +37 -17
- package/src/__tests__/root-serve.test.ts +139 -0
- package/src/__tests__/scope-explanations.test.ts +22 -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 +372 -132
- package/src/__tests__/sessions.test.ts +75 -28
- package/src/__tests__/setup-wizard.test.ts +7 -10
- package/src/__tests__/setup.test.ts +13 -14
- package/src/__tests__/status.test.ts +0 -5
- package/src/__tests__/surface-notes-alias.test.ts +296 -0
- package/src/account-api.ts +677 -0
- package/src/account-session.ts +132 -0
- package/src/account-token.ts +200 -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-invites.ts +19 -0
- package/src/api-mint-token.ts +2 -1
- package/src/api-modules-ops.ts +2 -1
- package/src/api-modules.ts +10 -8
- 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 +97 -8
- package/src/commands/migrate.ts +5 -1
- package/src/commands/setup.ts +9 -6
- package/src/help.ts +6 -6
- package/src/hub-server.ts +318 -55
- package/src/hub-settings.ts +162 -1
- package/src/hub.ts +64 -31
- package/src/invites.ts +42 -0
- package/src/module-ops-client.ts +2 -1
- package/src/notes-serve.ts +73 -31
- 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 +35 -5
- package/src/service-spec.ts +129 -74
- package/src/services-manifest.ts +112 -52
- package/src/sessions.ts +66 -30
- 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
|
@@ -21,7 +21,7 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
21
21
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
22
22
|
import { tmpdir } from "node:os";
|
|
23
23
|
import { join } from "node:path";
|
|
24
|
-
|
|
24
|
+
|
|
25
25
|
import { handleHostAdminToken } from "../admin-host-admin-token.ts";
|
|
26
26
|
import {
|
|
27
27
|
DEFAULT_ADMIN_LOCK_IDLE_SECONDS,
|
|
@@ -239,10 +239,10 @@ describe("recordLoginUnlock", () => {
|
|
|
239
239
|
});
|
|
240
240
|
|
|
241
241
|
// ---------------------------------------------------------------------------
|
|
242
|
-
// The cascade: a locked session refuses
|
|
242
|
+
// The cascade: a locked session refuses every admin-token mint.
|
|
243
243
|
// ---------------------------------------------------------------------------
|
|
244
244
|
|
|
245
|
-
describe("lock cascade through the
|
|
245
|
+
describe("lock cascade through the admin-token mints", () => {
|
|
246
246
|
test("host-admin-token: 200 when no PIN, 423 when locked, 200 after unlock", async () => {
|
|
247
247
|
const { cookie, sessionId } = await withAdmin();
|
|
248
248
|
rotateSigningKey(harness.db);
|
|
@@ -262,17 +262,6 @@ describe("lock cascade through the four admin-token mints", () => {
|
|
|
262
262
|
expect((await handleHostAdminToken(mk(), { db: harness.db, issuer: ISSUER })).status).toBe(200);
|
|
263
263
|
});
|
|
264
264
|
|
|
265
|
-
test("agent-token cascades (423 when locked)", async () => {
|
|
266
|
-
const { cookie } = await withAdmin();
|
|
267
|
-
rotateSigningKey(harness.db);
|
|
268
|
-
await setPin(harness.db, "4827");
|
|
269
|
-
const res = await handleAgentToken(
|
|
270
|
-
new Request(`${ISSUER}/admin/agent-token`, { headers: { cookie } }),
|
|
271
|
-
{ db: harness.db, issuer: ISSUER },
|
|
272
|
-
);
|
|
273
|
-
expect(res.status).toBe(423);
|
|
274
|
-
});
|
|
275
|
-
|
|
276
265
|
test("vault-admin-token cascades (423 when locked)", async () => {
|
|
277
266
|
const { cookie } = await withAdmin();
|
|
278
267
|
rotateSigningKey(harness.db);
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* - 401 when the cookie names a deleted session.
|
|
8
8
|
* - 405 on POST.
|
|
9
9
|
* - 200 + JWT carrying `aud: "<short>"` and `<short>:admin` for known modules
|
|
10
|
-
* (scribe / surface
|
|
10
|
+
* (scribe / surface).
|
|
11
11
|
* - 400 for `vault` (per-instance — points at /admin/vault-admin-token/<name>).
|
|
12
12
|
* - 404 for an unknown short.
|
|
13
13
|
* - First-admin gate: 403 for a signed-in non-first-admin (friend).
|
|
@@ -114,7 +114,7 @@ describe("handleModuleToken", () => {
|
|
|
114
114
|
// `<short>:admin` with `aud: <short>`. (runner left this set with its
|
|
115
115
|
// 2026-07-01 registry removal — a LEGACY install still mints via the
|
|
116
116
|
// self-registration gate, pinned below.)
|
|
117
|
-
for (const short of ["scribe", "surface"
|
|
117
|
+
for (const short of ["scribe", "surface"]) {
|
|
118
118
|
test(`200 mints a JWT carrying aud:${short} + ${short}:admin`, async () => {
|
|
119
119
|
const { cookie, userId } = await withSession();
|
|
120
120
|
rotateSigningKey(harness.db);
|
|
@@ -321,7 +321,7 @@ describe("handleModuleToken", () => {
|
|
|
321
321
|
expect(body.error).toBe("use_vault_admin_token");
|
|
322
322
|
});
|
|
323
323
|
|
|
324
|
-
test("
|
|
324
|
+
test("retired Agent identifiers do not mint even when stale rows are supplied", async () => {
|
|
325
325
|
const { cookie } = await withSession();
|
|
326
326
|
rotateSigningKey(harness.db);
|
|
327
327
|
const installDir = writeManifestDir("agent");
|
|
@@ -335,40 +335,20 @@ describe("handleModuleToken", () => {
|
|
|
335
335
|
version: "0.1.0",
|
|
336
336
|
installDir,
|
|
337
337
|
},
|
|
338
|
-
];
|
|
339
|
-
const req = new Request(urlFor("agent"), { headers: { cookie } });
|
|
340
|
-
const res = await handleModuleToken(req, "agent", depsWith(services));
|
|
341
|
-
expect(res.status).toBe(200);
|
|
342
|
-
const body = (await res.json()) as { scopes: string[] };
|
|
343
|
-
expect(body.scopes).toEqual(["agent:admin"]);
|
|
344
|
-
} finally {
|
|
345
|
-
rmSync(installDir, { recursive: true, force: true });
|
|
346
|
-
}
|
|
347
|
-
});
|
|
348
|
-
|
|
349
|
-
test("a legacy parachute-channel row still resolves to short `agent` (rename back-compat)", async () => {
|
|
350
|
-
// Un-upgraded operators carry a `parachute-channel` services.json row;
|
|
351
|
-
// LEGACY_MANIFEST_ALIASES maps it to short `agent` so the agent config UI
|
|
352
|
-
// can still mint its admin Bearer until the daemon re-registers.
|
|
353
|
-
const { cookie } = await withSession();
|
|
354
|
-
rotateSigningKey(harness.db);
|
|
355
|
-
const installDir = writeManifestDir("agent");
|
|
356
|
-
try {
|
|
357
|
-
const services: ServiceEntry[] = [
|
|
358
338
|
{
|
|
359
339
|
name: "parachute-channel",
|
|
360
|
-
port:
|
|
361
|
-
paths: ["/
|
|
340
|
+
port: 1942,
|
|
341
|
+
paths: ["/channel"],
|
|
362
342
|
health: "/health",
|
|
363
343
|
version: "0.1.0",
|
|
364
344
|
installDir,
|
|
365
345
|
},
|
|
366
346
|
];
|
|
367
|
-
const
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
347
|
+
for (const short of ["agent", "parachute-agent", "parachute-channel"]) {
|
|
348
|
+
const req = new Request(urlFor(short), { headers: { cookie } });
|
|
349
|
+
const res = await handleModuleToken(req, short, depsWith(services));
|
|
350
|
+
expect(res.status).toBe(404);
|
|
351
|
+
}
|
|
372
352
|
} finally {
|
|
373
353
|
rmSync(installDir, { recursive: true, force: true });
|
|
374
354
|
}
|
|
@@ -151,6 +151,27 @@ describe("routeAdminSurfaces — register + list", () => {
|
|
|
151
151
|
}
|
|
152
152
|
});
|
|
153
153
|
|
|
154
|
+
// H1.1 — Bearer scheme is case-insensitive per RFC 7235 (V1.4/C1.3 parity).
|
|
155
|
+
test("lowercase bearer scheme authenticates identically to canonical Bearer", async () => {
|
|
156
|
+
const h = await makeHarness();
|
|
157
|
+
try {
|
|
158
|
+
const token = await mint(h, ["parachute:host:admin"]);
|
|
159
|
+
const res = await routeAdminSurfaces(
|
|
160
|
+
req(
|
|
161
|
+
"POST",
|
|
162
|
+
{ authorization: `bearer ${token}`, "content-type": "application/json" },
|
|
163
|
+
{ name: "brain", mount: "/surface/brain", mode: "prod" },
|
|
164
|
+
),
|
|
165
|
+
deps(h),
|
|
166
|
+
);
|
|
167
|
+
expect(res?.status).toBe(200);
|
|
168
|
+
const body = (await res?.json()) as { ok: boolean };
|
|
169
|
+
expect(body.ok).toBe(true);
|
|
170
|
+
} finally {
|
|
171
|
+
h.cleanup();
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
154
175
|
test("POST with a missing name → 400", async () => {
|
|
155
176
|
const h = await makeHarness();
|
|
156
177
|
try {
|
|
@@ -510,6 +510,17 @@ describe("GET /api/hub/upgrade/status", () => {
|
|
|
510
510
|
const res = await handleHubUpgradeStatus(getStatusReq({}), deps);
|
|
511
511
|
expect(res.status).toBe(401);
|
|
512
512
|
});
|
|
513
|
+
|
|
514
|
+
// H1.1 — Bearer scheme is case-insensitive per RFC 7235 (V1.4/C1.3 parity).
|
|
515
|
+
test("lowercase bearer scheme authenticates identically to canonical Bearer (404, not 401)", async () => {
|
|
516
|
+
const bearer = await mintBearer(harness, ["parachute:host:admin"]);
|
|
517
|
+
const { deps } = baseDeps(harness);
|
|
518
|
+
const res = await handleHubUpgradeStatus(
|
|
519
|
+
getStatusReq({ authorization: `bearer ${bearer}` }),
|
|
520
|
+
deps,
|
|
521
|
+
);
|
|
522
|
+
expect(res.status).toBe(404);
|
|
523
|
+
});
|
|
513
524
|
});
|
|
514
525
|
|
|
515
526
|
describe("detectHubUpgradeMode — §5.3 heuristic", () => {
|
|
@@ -172,6 +172,31 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
|
|
|
172
172
|
}
|
|
173
173
|
});
|
|
174
174
|
|
|
175
|
+
// H1.1 — Bearer scheme is case-insensitive per RFC 7235 (V1.4/C1.3 parity).
|
|
176
|
+
test("lowercase bearer scheme authenticates identically to canonical Bearer", async () => {
|
|
177
|
+
const h = makeHarness();
|
|
178
|
+
try {
|
|
179
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
180
|
+
try {
|
|
181
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
182
|
+
const resp = await handleApiMintToken(
|
|
183
|
+
jsonRequest(
|
|
184
|
+
{ scope: "scribe:transcribe", expires_in: 3600 },
|
|
185
|
+
{ authorization: `bearer ${op.token}` },
|
|
186
|
+
),
|
|
187
|
+
{ db, issuer: ISSUER },
|
|
188
|
+
);
|
|
189
|
+
expect(resp.status).toBe(200);
|
|
190
|
+
const body = (await resp.json()) as { scope: string };
|
|
191
|
+
expect(body.scope).toBe("scribe:transcribe");
|
|
192
|
+
} finally {
|
|
193
|
+
db.close();
|
|
194
|
+
}
|
|
195
|
+
} finally {
|
|
196
|
+
h.cleanup();
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
175
200
|
// hub#516 parity — the live "mint refused" after `parachute hub set-origin`.
|
|
176
201
|
// An operator/agent credential minted under a PRIOR origin (still a member of
|
|
177
202
|
// the hub's bound-origin set) must keep minting after the canonical issuer
|
|
@@ -208,21 +208,12 @@ describe("parseModulesPath", () => {
|
|
|
208
208
|
});
|
|
209
209
|
});
|
|
210
210
|
|
|
211
|
-
test("accepts
|
|
212
|
-
// Post-2026-06-09 (modular-UI architecture, P2) the install-path gate is
|
|
213
|
-
// `isKnownModuleShort` (KNOWN_MODULES ∪ FIRST_PARTY_FALLBACKS), NOT the old
|
|
214
|
-
// CURATED_MODULES whitelist. agent (renamed from channel 2026-06-17) is in
|
|
215
|
-
// KNOWN_MODULES, so its install path now resolves — fixing the
|
|
216
|
-
// running-but-uninstallable bug.
|
|
217
|
-
expect(parseModulesPath("/api/modules/agent/install")).toEqual({
|
|
218
|
-
short: "agent",
|
|
219
|
-
rest: "install",
|
|
220
|
-
});
|
|
221
|
-
// Other known modules (surface) resolve too.
|
|
211
|
+
test("accepts supported module shorts and rejects retired Agent", () => {
|
|
222
212
|
expect(parseModulesPath("/api/modules/surface/install")).toEqual({
|
|
223
213
|
short: "surface",
|
|
224
214
|
rest: "install",
|
|
225
215
|
});
|
|
216
|
+
expect(parseModulesPath("/api/modules/agent/install")).toBeUndefined();
|
|
226
217
|
});
|
|
227
218
|
|
|
228
219
|
test("rejects unknown shorts (the hub itself + genuinely third-party rows)", () => {
|
|
@@ -309,6 +300,29 @@ describe("POST /api/modules/:short/install", () => {
|
|
|
309
300
|
expect(body.error_description).toContain("parachute:host:admin");
|
|
310
301
|
});
|
|
311
302
|
|
|
303
|
+
// H1.1 — Bearer scheme is case-insensitive per RFC 7235 (V1.4/C1.3 parity).
|
|
304
|
+
test("lowercase bearer scheme authenticates identically to canonical Bearer", async () => {
|
|
305
|
+
const { supervisor } = makeIdleSupervisor();
|
|
306
|
+
const { run } = alwaysOkRun();
|
|
307
|
+
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
308
|
+
const res = await handleInstall(
|
|
309
|
+
postReq("/api/modules/vault/install", { authorization: `bearer ${bearer}` }),
|
|
310
|
+
"vault",
|
|
311
|
+
{
|
|
312
|
+
db: h.db,
|
|
313
|
+
issuer: ISSUER,
|
|
314
|
+
manifestPath: h.manifestPath,
|
|
315
|
+
configDir: h.dir,
|
|
316
|
+
supervisor,
|
|
317
|
+
run,
|
|
318
|
+
isLinked: TEST_DEFAULT_NOT_LINKED,
|
|
319
|
+
},
|
|
320
|
+
);
|
|
321
|
+
expect(res.status).toBe(202);
|
|
322
|
+
const body = (await res.json()) as { operation_id: string };
|
|
323
|
+
expect(body.operation_id).toBeDefined();
|
|
324
|
+
});
|
|
325
|
+
|
|
312
326
|
test("202 + operation_id, runs bun add + seeds services.json + spawns", async () => {
|
|
313
327
|
const { supervisor, spawns } = makeIdleSupervisor();
|
|
314
328
|
const { run, calls } = alwaysOkRun();
|
|
@@ -1019,16 +1033,12 @@ describe("POST /api/modules/:short/start", () => {
|
|
|
1019
1033
|
expect(calls).toEqual([]);
|
|
1020
1034
|
});
|
|
1021
1035
|
|
|
1022
|
-
test("
|
|
1023
|
-
// The live signature: the agent (then channel) row carried 19415 instead of
|
|
1024
|
-
// canonical 1941. The API start path (admin SPA / `parachute start agent`)
|
|
1025
|
-
// must apply the SAME reconcile the boot path does — otherwise an
|
|
1026
|
-
// operator-triggered start re-strands the module on the dead port.
|
|
1036
|
+
test("start reconciles a supported module's drifted port back to canonical", async () => {
|
|
1027
1037
|
writeManifest(h.manifestPath, [
|
|
1028
1038
|
{
|
|
1029
|
-
name: "parachute-
|
|
1039
|
+
name: "parachute-scribe",
|
|
1030
1040
|
port: 19415,
|
|
1031
|
-
paths: ["/
|
|
1041
|
+
paths: ["/scribe"],
|
|
1032
1042
|
health: "/health",
|
|
1033
1043
|
version: "0.0.0-linked",
|
|
1034
1044
|
},
|
|
@@ -1038,8 +1048,8 @@ describe("POST /api/modules/:short/start", () => {
|
|
|
1038
1048
|
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
1039
1049
|
|
|
1040
1050
|
const res = await handleStart(
|
|
1041
|
-
postReq("/api/modules/
|
|
1042
|
-
"
|
|
1051
|
+
postReq("/api/modules/scribe/start", { authorization: `Bearer ${bearer}` }),
|
|
1052
|
+
"scribe",
|
|
1043
1053
|
{
|
|
1044
1054
|
db: h.db,
|
|
1045
1055
|
issuer: ISSUER,
|
|
@@ -1051,20 +1061,15 @@ describe("POST /api/modules/:short/start", () => {
|
|
|
1051
1061
|
);
|
|
1052
1062
|
|
|
1053
1063
|
expect(res.status).toBe(200);
|
|
1054
|
-
// The supervisor child gets PORT=1941 (canonical), not the drifted 19415 —
|
|
1055
|
-
// so it binds + the readiness probe checks the right port.
|
|
1056
1064
|
expect(spawns.length).toBe(1);
|
|
1057
|
-
expect(spawns[0]?.short).toBe("
|
|
1058
|
-
expect(spawns[0]?.env?.PORT).toBe("
|
|
1059
|
-
// services.json row is rewritten to 1941 → the reverse-proxy (which reads
|
|
1060
|
-
// services.json) routes /agent/* to the live port.
|
|
1065
|
+
expect(spawns[0]?.short).toBe("scribe");
|
|
1066
|
+
expect(spawns[0]?.env?.PORT).toBe("1943");
|
|
1061
1067
|
const onDisk = JSON.parse(readFileSync(h.manifestPath, "utf8")) as {
|
|
1062
1068
|
services: { name: string; port: number }[];
|
|
1063
1069
|
};
|
|
1064
|
-
expect(onDisk.services.find((s) => s.name === "parachute-
|
|
1065
|
-
// The reconcile event logged on the API path too (deps.log wired — #41 review).
|
|
1070
|
+
expect(onDisk.services.find((s) => s.name === "parachute-scribe")?.port).toBe(1943);
|
|
1066
1071
|
expect(
|
|
1067
|
-
logs.some((l) => l.includes("reconciled") && l.includes("19415") && l.includes("
|
|
1072
|
+
logs.some((l) => l.includes("reconciled") && l.includes("19415") && l.includes("1943")),
|
|
1068
1073
|
).toBe(true);
|
|
1069
1074
|
});
|
|
1070
1075
|
});
|
|
@@ -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}` }), {
|