@openparachute/hub 0.7.1 → 0.7.2-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -14
- package/package.json +1 -1
- package/src/__tests__/admin-agent-grants.test.ts +1547 -0
- package/src/__tests__/{admin-channel-token.test.ts → admin-agent-token.test.ts} +32 -32
- package/src/__tests__/admin-connections-credentials.test.ts +8 -4
- package/src/__tests__/admin-connections.test.ts +211 -57
- package/src/__tests__/admin-csrf-belt.test.ts +7 -7
- package/src/__tests__/admin-lock.test.ts +600 -0
- package/src/__tests__/admin-module-token.test.ts +36 -8
- package/src/__tests__/admin-vaults.test.ts +8 -8
- package/src/__tests__/api-modules-ops.test.ts +17 -16
- package/src/__tests__/api-modules.test.ts +35 -36
- package/src/__tests__/api-ready.test.ts +2 -2
- package/src/__tests__/clients.test.ts +91 -0
- package/src/__tests__/grants-store.test.ts +219 -0
- package/src/__tests__/hub-server.test.ts +9 -5
- package/src/__tests__/migrate.test.ts +1 -1
- package/src/__tests__/module-manifest.test.ts +11 -11
- package/src/__tests__/oauth-client.test.ts +446 -0
- package/src/__tests__/oauth-flows-store.test.ts +141 -0
- package/src/__tests__/oauth-handlers.test.ts +124 -26
- package/src/__tests__/operator-token.test.ts +2 -2
- package/src/__tests__/scope-explanations.test.ts +3 -3
- package/src/__tests__/serve-boot.test.ts +14 -14
- package/src/__tests__/serve.test.ts +26 -0
- package/src/__tests__/service-spec-discovery.test.ts +26 -18
- package/src/__tests__/services-manifest.test.ts +60 -48
- package/src/__tests__/setup-gate.test.ts +52 -3
- package/src/__tests__/setup-wizard.test.ts +86 -280
- package/src/__tests__/setup.test.ts +1 -1
- package/src/__tests__/upgrade.test.ts +276 -0
- package/src/__tests__/vault-remove.test.ts +393 -0
- package/src/admin-agent-grants.ts +1365 -0
- package/src/admin-agent-token.ts +147 -0
- package/src/admin-connections.ts +67 -50
- package/src/admin-host-admin-token.ts +14 -1
- package/src/admin-lock.ts +281 -0
- package/src/admin-module-token.ts +15 -7
- package/src/admin-vault-admin-token.ts +8 -1
- package/src/admin-vaults.ts +12 -12
- package/src/api-admin-lock.ts +335 -0
- package/src/api-modules-ops.ts +3 -2
- package/src/api-modules.ts +9 -9
- package/src/cli.ts +13 -1
- package/src/clients.ts +88 -0
- package/src/commands/install.ts +7 -0
- package/src/commands/serve-boot.ts +5 -4
- package/src/commands/serve.ts +45 -19
- package/src/commands/setup.ts +4 -3
- package/src/commands/upgrade.ts +118 -2
- package/src/commands/vault-remove.ts +361 -0
- package/src/commands/wizard.ts +4 -4
- package/src/connections-store.ts +3 -3
- package/src/grants-store.ts +272 -0
- package/src/help.ts +4 -1
- package/src/hub-server.ts +209 -27
- package/src/hub-settings.ts +23 -8
- package/src/jwt-sign.ts +5 -1
- package/src/module-manifest.ts +2 -2
- package/src/oauth-client.ts +497 -0
- package/src/oauth-flows-store.ts +163 -0
- package/src/oauth-handlers.ts +40 -13
- package/src/operator-token.ts +1 -1
- package/src/origin-check.ts +7 -2
- package/src/resource-binding.ts +4 -4
- package/src/scope-explanations.ts +3 -3
- package/src/service-spec.ts +56 -43
- package/src/setup-wizard.ts +56 -240
- package/web/ui/dist/assets/index-B5AUE359.js +61 -0
- package/web/ui/dist/assets/{index-E_9wqjEm.css → index-DR6R8EFf.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/src/admin-channel-token.ts +0 -135
- package/web/ui/dist/assets/index-Cxtod68O.js +0 -61
|
@@ -24,27 +24,27 @@ describe("discoverableShorts", () => {
|
|
|
24
24
|
expect(shorts.length).toBe(new Set(shorts).size);
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
-
test("includes
|
|
27
|
+
test("includes agent (the module the whitelist used to hide) + the core set", () => {
|
|
28
28
|
const shorts = discoverableShorts();
|
|
29
|
-
for (const s of ["vault", "scribe", "surface", "runner", "
|
|
29
|
+
for (const s of ["vault", "scribe", "surface", "runner", "agent", "notes"]) {
|
|
30
30
|
expect(shorts).toContain(s);
|
|
31
31
|
}
|
|
32
32
|
});
|
|
33
33
|
|
|
34
34
|
test("FIRST_PARTY_FALLBACKS shorts lead KNOWN_MODULES shorts (registry order)", () => {
|
|
35
35
|
const shorts = discoverableShorts();
|
|
36
|
-
// notes (the remaining FALLBACK —
|
|
36
|
+
// notes (the remaining FALLBACK — agent moved to KNOWN_MODULES in
|
|
37
37
|
// boundary D3) appears before vault (KNOWN_MODULES) in the union.
|
|
38
38
|
expect(shorts.indexOf("notes")).toBeLessThan(shorts.indexOf("vault"));
|
|
39
|
-
//
|
|
40
|
-
expect(shorts).toContain("
|
|
39
|
+
// agent rides in KNOWN_MODULES now but is still discoverable.
|
|
40
|
+
expect(shorts).toContain("agent");
|
|
41
41
|
});
|
|
42
42
|
});
|
|
43
43
|
|
|
44
44
|
describe("focusForShort", () => {
|
|
45
45
|
test("declared focus wins over the default map", () => {
|
|
46
|
-
//
|
|
47
|
-
expect(focusForShort("
|
|
46
|
+
// agent defaults experimental, but a manifest-declared core wins.
|
|
47
|
+
expect(focusForShort("agent", "core")).toBe("core");
|
|
48
48
|
// vault defaults core, but a declared experimental wins.
|
|
49
49
|
expect(focusForShort("vault", "experimental")).toBe("experimental");
|
|
50
50
|
});
|
|
@@ -53,7 +53,7 @@ describe("focusForShort", () => {
|
|
|
53
53
|
expect(focusForShort("vault")).toBe("core");
|
|
54
54
|
expect(focusForShort("scribe")).toBe("core");
|
|
55
55
|
expect(focusForShort("surface")).toBe("core");
|
|
56
|
-
expect(focusForShort("
|
|
56
|
+
expect(focusForShort("agent")).toBe("experimental");
|
|
57
57
|
expect(focusForShort("runner")).toBe("experimental");
|
|
58
58
|
expect(focusForShort("notes")).toBe("experimental");
|
|
59
59
|
});
|
|
@@ -65,7 +65,7 @@ describe("focusForShort", () => {
|
|
|
65
65
|
|
|
66
66
|
describe("isKnownModuleShort", () => {
|
|
67
67
|
test("true for every known module (the install/config gate)", () => {
|
|
68
|
-
for (const s of ["vault", "scribe", "surface", "runner", "
|
|
68
|
+
for (const s of ["vault", "scribe", "surface", "runner", "agent", "notes"]) {
|
|
69
69
|
expect(isKnownModuleShort(s)).toBe(true);
|
|
70
70
|
}
|
|
71
71
|
});
|
|
@@ -76,30 +76,38 @@ describe("isKnownModuleShort", () => {
|
|
|
76
76
|
});
|
|
77
77
|
});
|
|
78
78
|
|
|
79
|
-
// Regression: services.json rows carry the MANIFEST name (`parachute-
|
|
80
|
-
// not the bare short (`
|
|
81
|
-
// `services.find((s) => s.name === "
|
|
82
|
-
// row →
|
|
79
|
+
// Regression: services.json rows carry the MANIFEST name (`parachute-agent`),
|
|
80
|
+
// not the bare short (`agent`). The connection/channels wiring used to do
|
|
81
|
+
// `services.find((s) => s.name === "agent")`, which never matched the on-disk
|
|
82
|
+
// row → agentOrigin null → a spurious "agent module is not installed" when
|
|
83
83
|
// linking a vault-backed channel. findServiceByShort resolves through the
|
|
84
84
|
// short↔manifest map so the lookup hits the real row.
|
|
85
85
|
describe("findServiceByShort", () => {
|
|
86
86
|
const services = [
|
|
87
87
|
{ name: "parachute-vault-default", port: 1940 },
|
|
88
|
-
{ name: "parachute-
|
|
88
|
+
{ name: "parachute-agent", port: 1941 },
|
|
89
89
|
{ name: "parachute-scribe", port: 1943 },
|
|
90
90
|
];
|
|
91
91
|
|
|
92
92
|
test("matches a row by its manifest name via the short↔manifest map", () => {
|
|
93
|
-
const found = findServiceByShort(services, "
|
|
94
|
-
expect(found?.name).toBe("parachute-
|
|
93
|
+
const found = findServiceByShort(services, "agent");
|
|
94
|
+
expect(found?.name).toBe("parachute-agent");
|
|
95
95
|
expect(found?.port).toBe(1941);
|
|
96
96
|
});
|
|
97
97
|
|
|
98
98
|
test("the naive `name === short` comparison would have missed it (the bug)", () => {
|
|
99
99
|
// The exact pre-fix predicate: a bare short never matches a manifest-named row.
|
|
100
|
-
expect(services.find((s) => s.name === "
|
|
100
|
+
expect(services.find((s) => s.name === "agent")).toBeUndefined();
|
|
101
101
|
// The fix finds it.
|
|
102
|
-
expect(findServiceByShort(services, "
|
|
102
|
+
expect(findServiceByShort(services, "agent")).toBeDefined();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("resolves a legacy parachute-channel row to short `agent` (rename back-compat)", () => {
|
|
106
|
+
// Un-upgraded operators carry a `parachute-channel` row; the
|
|
107
|
+
// LEGACY_MANIFEST_ALIASES fallback keeps it routing to the agent module
|
|
108
|
+
// until the daemon re-registers under `parachute-agent`.
|
|
109
|
+
const legacy = [{ name: "parachute-channel", port: 1941 }];
|
|
110
|
+
expect(findServiceByShort(legacy, "agent")?.port).toBe(1941);
|
|
103
111
|
});
|
|
104
112
|
|
|
105
113
|
test("resolves scribe too, and returns undefined for an absent module", () => {
|
|
@@ -877,14 +877,14 @@ describe("claw → agent migration", () => {
|
|
|
877
877
|
// read-time rewrite that aliased legacy `name: "claw"` rows to
|
|
878
878
|
// `name: "agent"` so operators on the old shape kept routing.
|
|
879
879
|
//
|
|
880
|
-
//
|
|
881
|
-
// `agent` to RETIRED_MODULES)
|
|
882
|
-
//
|
|
883
|
-
//
|
|
884
|
-
//
|
|
885
|
-
//
|
|
886
|
-
//
|
|
887
|
-
//
|
|
880
|
+
// History: parachute-agent (the Claude-in-containers module) was retired
|
|
881
|
+
// 2026-05-20 (hub#334 added `agent` to RETIRED_MODULES), which briefly made
|
|
882
|
+
// this a one-step retirement path (claw → agent → GC'd). The 2026-06-17
|
|
883
|
+
// channel→agent rename RE-ASSIGNED `agent`/`parachute-agent` to the renamed
|
|
884
|
+
// channel module, so those names left RETIRED_MODULES — `agent` is a live
|
|
885
|
+
// module again. The claw → agent rewrite still runs; the migrated row now
|
|
886
|
+
// PERSISTS (it routes to the live agent module). The tests below assert the
|
|
887
|
+
// rewrite + the persistence.
|
|
888
888
|
const claw: ServiceEntry = {
|
|
889
889
|
name: "claw",
|
|
890
890
|
port: 1944,
|
|
@@ -906,10 +906,10 @@ describe("claw → agent migration", () => {
|
|
|
906
906
|
writeFileSync(path, `${JSON.stringify({ services: [claw] }, null, 2)}\n`);
|
|
907
907
|
const got = readManifest(path);
|
|
908
908
|
// Migration ran in this read (claw → agent on raw entries), then
|
|
909
|
-
// the row was rewritten to disk.
|
|
910
|
-
//
|
|
911
|
-
//
|
|
912
|
-
//
|
|
909
|
+
// the row was rewritten to disk. Post the 2026-06-17 channel→agent
|
|
910
|
+
// rename, `agent` is once again a LIVE module (the renamed channel
|
|
911
|
+
// module), so it is NO LONGER GC'd by RETIRED_MODULES — the migrated
|
|
912
|
+
// row persists and routes to the live agent module's mount.
|
|
913
913
|
expect(got.services).toEqual([agent]);
|
|
914
914
|
const reread = JSON.parse(readFileSync(path, "utf8")) as {
|
|
915
915
|
services: ServiceEntry[];
|
|
@@ -922,31 +922,34 @@ describe("claw → agent migration", () => {
|
|
|
922
922
|
}
|
|
923
923
|
});
|
|
924
924
|
|
|
925
|
-
test("
|
|
926
|
-
//
|
|
927
|
-
//
|
|
928
|
-
//
|
|
925
|
+
test("the migrated agent row PERSISTS on the next read (agent is live again post-rename)", () => {
|
|
926
|
+
// Pre-rename this was a one-step retirement path (claw → agent → GC'd).
|
|
927
|
+
// After the channel→agent rename (2026-06-17) `agent`/`parachute-agent`
|
|
928
|
+
// are re-assigned to the live module, so the row is NOT dropped — a
|
|
929
|
+
// stale paraclaw row now ends up pointing at the live agent module
|
|
930
|
+
// (harmless / arguably correct, since paraclaw was the original "agent").
|
|
929
931
|
const { path, cleanup } = makeTempPath();
|
|
930
932
|
try {
|
|
931
933
|
writeFileSync(path, `${JSON.stringify({ services: [claw] }, null, 2)}\n`);
|
|
932
934
|
const first = readManifest(path);
|
|
933
935
|
expect(first.services).toEqual([agent]);
|
|
934
936
|
const second = readManifest(path);
|
|
935
|
-
expect(second.services).
|
|
937
|
+
expect(second.services).toEqual([agent]);
|
|
936
938
|
} finally {
|
|
937
939
|
cleanup();
|
|
938
940
|
}
|
|
939
941
|
});
|
|
940
942
|
|
|
941
|
-
test("an already-agent entry
|
|
942
|
-
// Pre-hub#334 this
|
|
943
|
-
//
|
|
944
|
-
//
|
|
943
|
+
test("an already-agent entry round-trips unchanged (agent live again post-rename)", () => {
|
|
944
|
+
// Pre-hub#334 this verified the migration was idempotent; hub#334 made it
|
|
945
|
+
// GC the agent row (agent was retired). After the channel→agent rename
|
|
946
|
+
// (2026-06-17) agent is live again, so the row round-trips unchanged —
|
|
947
|
+
// back to the original idempotent behavior.
|
|
945
948
|
const { path, cleanup } = makeTempPath();
|
|
946
949
|
try {
|
|
947
950
|
writeFileSync(path, `${JSON.stringify({ services: [agent] }, null, 2)}\n`);
|
|
948
951
|
const got = readManifest(path);
|
|
949
|
-
expect(got.services).
|
|
952
|
+
expect(got.services).toEqual([agent]);
|
|
950
953
|
} finally {
|
|
951
954
|
cleanup();
|
|
952
955
|
}
|
|
@@ -1228,15 +1231,22 @@ describe("legacy short-name row de-dupe (parachute-app#13 / runner#4)", () => {
|
|
|
1228
1231
|
});
|
|
1229
1232
|
|
|
1230
1233
|
// Retired-module row cleanup (hub#334 — Aaron's actual reproducer on
|
|
1231
|
-
// 2026-05-22). His services.json carried a stale `agent` row at
|
|
1232
|
-
//
|
|
1233
|
-
// 2026-
|
|
1234
|
-
//
|
|
1235
|
-
//
|
|
1236
|
-
//
|
|
1237
|
-
//
|
|
1234
|
+
// 2026-05-22). His services.json originally carried a stale `agent` row at
|
|
1235
|
+
// 1946 colliding with `parachute-app`'s canonical slot. NOTE: post the
|
|
1236
|
+
// 2026-06-17 channel→agent rename, `agent` is a LIVE module again, so these
|
|
1237
|
+
// fixtures use the still-retired `app` (parachute-app) name to exercise the
|
|
1238
|
+
// same GC. The legacy-short-name de-dupe doesn't help — a retired short
|
|
1239
|
+
// isn't the short-name twin of the colliding row. The retired-module GC
|
|
1240
|
+
// fires unconditionally on rows whose name appears in `RETIRED_MODULES`,
|
|
1241
|
+
// regardless of port collision.
|
|
1238
1242
|
describe("retired-module row de-dupe (hub#334)", () => {
|
|
1239
|
-
|
|
1243
|
+
// NOTE: the original fixtures used `agent` — which was a RETIRED_MODULES
|
|
1244
|
+
// entry until the 2026-06-17 channel→agent rename re-assigned that name to
|
|
1245
|
+
// the live (renamed-from-channel) module. These tests now use `app`
|
|
1246
|
+
// (parachute-app, retired 2026-05-27 — still in RETIRED_MODULES) so they
|
|
1247
|
+
// keep exercising the retired-module GC mechanism on a name that is still
|
|
1248
|
+
// genuinely retired.
|
|
1249
|
+
test("drops a row whose name is in RETIRED_MODULES (app)", () => {
|
|
1240
1250
|
const { path, cleanup } = makeTempPath();
|
|
1241
1251
|
try {
|
|
1242
1252
|
writeFileSync(
|
|
@@ -1244,10 +1254,10 @@ describe("retired-module row de-dupe (hub#334)", () => {
|
|
|
1244
1254
|
JSON.stringify({
|
|
1245
1255
|
services: [
|
|
1246
1256
|
{
|
|
1247
|
-
name: "
|
|
1257
|
+
name: "app",
|
|
1248
1258
|
port: 1946,
|
|
1249
|
-
paths: ["/
|
|
1250
|
-
health: "/
|
|
1259
|
+
paths: ["/app"],
|
|
1260
|
+
health: "/app/health",
|
|
1251
1261
|
version: "0.1.4",
|
|
1252
1262
|
},
|
|
1253
1263
|
],
|
|
@@ -1265,7 +1275,7 @@ describe("retired-module row de-dupe (hub#334)", () => {
|
|
|
1265
1275
|
|
|
1266
1276
|
test("retirement is unconditional — no other rows required", () => {
|
|
1267
1277
|
// Verifies dropRetiredModuleRows doesn't depend on a collision
|
|
1268
|
-
// partner (unlike dropLegacyShortNameRows). An
|
|
1278
|
+
// partner (unlike dropLegacyShortNameRows). An app row sitting
|
|
1269
1279
|
// alone is still stale.
|
|
1270
1280
|
const { path, cleanup } = makeTempPath();
|
|
1271
1281
|
try {
|
|
@@ -1274,10 +1284,10 @@ describe("retired-module row de-dupe (hub#334)", () => {
|
|
|
1274
1284
|
JSON.stringify({
|
|
1275
1285
|
services: [
|
|
1276
1286
|
{
|
|
1277
|
-
name: "
|
|
1287
|
+
name: "app",
|
|
1278
1288
|
port: 9999,
|
|
1279
|
-
paths: ["/
|
|
1280
|
-
health: "/
|
|
1289
|
+
paths: ["/app"],
|
|
1290
|
+
health: "/app/health",
|
|
1281
1291
|
version: "0.1.4",
|
|
1282
1292
|
},
|
|
1283
1293
|
],
|
|
@@ -1318,10 +1328,11 @@ describe("retired-module row de-dupe (hub#334)", () => {
|
|
|
1318
1328
|
}
|
|
1319
1329
|
});
|
|
1320
1330
|
|
|
1321
|
-
test("Aaron's reproducer —
|
|
1322
|
-
// The motivating bug for hub#334
|
|
1323
|
-
//
|
|
1324
|
-
//
|
|
1331
|
+
test("Aaron's reproducer — retired row + parachute-surface at same port resolves cleanly", () => {
|
|
1332
|
+
// The motivating bug for hub#334 (originally an `agent` row; `agent` is
|
|
1333
|
+
// a live module again post-rename, so this uses the still-retired `app`).
|
|
1334
|
+
// With dropRetiredModuleRows running before validateManifest, the stale
|
|
1335
|
+
// retired row is GC'd and the duplicate-port gate doesn't trip downstream.
|
|
1325
1336
|
const { path, cleanup } = makeTempPath();
|
|
1326
1337
|
try {
|
|
1327
1338
|
writeFileSync(
|
|
@@ -1329,10 +1340,10 @@ describe("retired-module row de-dupe (hub#334)", () => {
|
|
|
1329
1340
|
JSON.stringify({
|
|
1330
1341
|
services: [
|
|
1331
1342
|
{
|
|
1332
|
-
name: "
|
|
1343
|
+
name: "app",
|
|
1333
1344
|
port: 1946,
|
|
1334
|
-
paths: ["/
|
|
1335
|
-
health: "/
|
|
1345
|
+
paths: ["/app"],
|
|
1346
|
+
health: "/app/health",
|
|
1336
1347
|
version: "0.1.4",
|
|
1337
1348
|
},
|
|
1338
1349
|
{
|
|
@@ -1357,7 +1368,8 @@ describe("retired-module row de-dupe (hub#334)", () => {
|
|
|
1357
1368
|
// Drop order matters: retired-module cleanup runs first, then
|
|
1358
1369
|
// legacy-short-name cleanup. This test ensures both passes
|
|
1359
1370
|
// compose correctly on a services.json that exercises both
|
|
1360
|
-
// shapes simultaneously. The
|
|
1371
|
+
// shapes simultaneously. The `app` row is unconditional retire
|
|
1372
|
+
// (the original `agent` fixture is a live module again post-rename);
|
|
1361
1373
|
// the parachute-runner + runner pair triggers legacy-short-name
|
|
1362
1374
|
// dedup at port 1945.
|
|
1363
1375
|
const { path, cleanup } = makeTempPath();
|
|
@@ -1367,10 +1379,10 @@ describe("retired-module row de-dupe (hub#334)", () => {
|
|
|
1367
1379
|
JSON.stringify({
|
|
1368
1380
|
services: [
|
|
1369
1381
|
{
|
|
1370
|
-
name: "
|
|
1382
|
+
name: "app",
|
|
1371
1383
|
port: 1946,
|
|
1372
|
-
paths: ["/
|
|
1373
|
-
health: "/
|
|
1384
|
+
paths: ["/app"],
|
|
1385
|
+
health: "/app/health",
|
|
1374
1386
|
version: "0.1.4",
|
|
1375
1387
|
},
|
|
1376
1388
|
{
|
|
@@ -4,8 +4,15 @@
|
|
|
4
4
|
* with `{error: "setup_required", setup_url: "/admin/setup"}` so
|
|
5
5
|
* callers can branch on the shape rather than scrape an HTML page.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* Exception (hub#644): the browser-facing `/login` GET is a server-
|
|
8
|
+
* rendered HTML surface (the sign-in form), so on a no-admin box it
|
|
9
|
+
* 302s to `/admin/setup` rather than emitting a raw JSON 503 the
|
|
10
|
+
* browser would render as text. A `/login` POST still 503s (no account
|
|
11
|
+
* to authenticate — the right shape for a stray non-browser caller).
|
|
12
|
+
*
|
|
13
|
+
* Gated routes (require an admin to be useful): `/login` (GET → 302
|
|
14
|
+
* wizard; POST → 503), `/logout`, `/admin/*` (except `/admin/setup`),
|
|
15
|
+
* `/api/*`.
|
|
9
16
|
*
|
|
10
17
|
* Routes that pass through (platform health, public discovery, OAuth
|
|
11
18
|
* third-party flows, content proxies, the setup page itself):
|
|
@@ -70,10 +77,33 @@ describe("setup gate (no admin yet)", () => {
|
|
|
70
77
|
}
|
|
71
78
|
});
|
|
72
79
|
|
|
73
|
-
|
|
80
|
+
// hub#644: `/login` GET is a browser-facing HTML surface (the sign-in
|
|
81
|
+
// form). On a no-admin box a JSON 503 would render as raw text in the
|
|
82
|
+
// visitor's tab — exactly what a visitor sees after clicking the "Sign in"
|
|
83
|
+
// banner on an open module surface. Funnel the GET to the wizard instead,
|
|
84
|
+
// mirroring the `/` + `/hub.html` redirect.
|
|
85
|
+
test("/login GET 302s to /admin/setup when no admin exists (hub#644)", async () => {
|
|
74
86
|
const db = openHubDb(hubDbPath(h.dir));
|
|
75
87
|
try {
|
|
76
88
|
const res = await hubFetch(h.dir, { getDb: () => db })(req("/login"));
|
|
89
|
+
expect(res.status).toBe(302);
|
|
90
|
+
expect(res.headers.get("location")).toBe("/admin/setup");
|
|
91
|
+
// Never a JSON body on the HTML sign-in surface.
|
|
92
|
+
expect(res.headers.get("content-type") ?? "").not.toContain("application/json");
|
|
93
|
+
} finally {
|
|
94
|
+
db.close();
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// A POST to `/login` pre-admin has no account to authenticate, so it falls
|
|
99
|
+
// through to the JSON-503 gate — the right shape for a stray non-browser
|
|
100
|
+
// caller. Only the browser GET is funneled to the wizard.
|
|
101
|
+
test("/login POST still 503s setup_required when no admin exists (hub#644)", async () => {
|
|
102
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
103
|
+
try {
|
|
104
|
+
const res = await hubFetch(h.dir, { getDb: () => db })(
|
|
105
|
+
req("/login", { method: "POST" }),
|
|
106
|
+
);
|
|
77
107
|
expect(res.status).toBe(503);
|
|
78
108
|
const body = (await res.json()) as Record<string, unknown>;
|
|
79
109
|
expect(body.error).toBe("setup_required");
|
|
@@ -199,6 +229,25 @@ describe("setup gate (admin exists)", () => {
|
|
|
199
229
|
}
|
|
200
230
|
});
|
|
201
231
|
|
|
232
|
+
// hub#644 guard: the no-admin `/login` funnel must NOT fire once an admin
|
|
233
|
+
// exists — the normal server-rendered sign-in form takes over so operators
|
|
234
|
+
// (and invited members) can actually sign in.
|
|
235
|
+
test("/login GET renders the sign-in form (not the setup funnel) once an admin exists (hub#644)", async () => {
|
|
236
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
237
|
+
try {
|
|
238
|
+
await createUser(db, "owner", "pw");
|
|
239
|
+
const res = await hubFetch(h.dir, { getDb: () => db })(req("/login"));
|
|
240
|
+
expect(res.status).toBe(200);
|
|
241
|
+
expect(res.headers.get("content-type")).toContain("text/html");
|
|
242
|
+
const html = await res.text();
|
|
243
|
+
// The actual sign-in form, posting back to /login.
|
|
244
|
+
expect(html).toContain('action="/login"');
|
|
245
|
+
expect(html).toContain('name="password"');
|
|
246
|
+
} finally {
|
|
247
|
+
db.close();
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
202
251
|
test("/admin/setup resumes at the vault step when admin exists but vault doesn't (hub#259)", async () => {
|
|
203
252
|
const db = openHubDb(hubDbPath(h.dir));
|
|
204
253
|
try {
|