@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.
Files changed (73) hide show
  1. package/README.md +13 -14
  2. package/package.json +1 -1
  3. package/src/__tests__/admin-agent-grants.test.ts +1547 -0
  4. package/src/__tests__/{admin-channel-token.test.ts → admin-agent-token.test.ts} +32 -32
  5. package/src/__tests__/admin-connections-credentials.test.ts +8 -4
  6. package/src/__tests__/admin-connections.test.ts +211 -57
  7. package/src/__tests__/admin-csrf-belt.test.ts +7 -7
  8. package/src/__tests__/admin-lock.test.ts +600 -0
  9. package/src/__tests__/admin-module-token.test.ts +36 -8
  10. package/src/__tests__/admin-vaults.test.ts +8 -8
  11. package/src/__tests__/api-modules-ops.test.ts +17 -16
  12. package/src/__tests__/api-modules.test.ts +35 -36
  13. package/src/__tests__/api-ready.test.ts +2 -2
  14. package/src/__tests__/clients.test.ts +91 -0
  15. package/src/__tests__/grants-store.test.ts +219 -0
  16. package/src/__tests__/hub-server.test.ts +9 -5
  17. package/src/__tests__/migrate.test.ts +1 -1
  18. package/src/__tests__/module-manifest.test.ts +11 -11
  19. package/src/__tests__/oauth-client.test.ts +446 -0
  20. package/src/__tests__/oauth-flows-store.test.ts +141 -0
  21. package/src/__tests__/oauth-handlers.test.ts +124 -26
  22. package/src/__tests__/operator-token.test.ts +2 -2
  23. package/src/__tests__/scope-explanations.test.ts +3 -3
  24. package/src/__tests__/serve-boot.test.ts +14 -14
  25. package/src/__tests__/serve.test.ts +26 -0
  26. package/src/__tests__/service-spec-discovery.test.ts +26 -18
  27. package/src/__tests__/services-manifest.test.ts +60 -48
  28. package/src/__tests__/setup-gate.test.ts +52 -3
  29. package/src/__tests__/setup-wizard.test.ts +86 -280
  30. package/src/__tests__/setup.test.ts +1 -1
  31. package/src/__tests__/upgrade.test.ts +276 -0
  32. package/src/__tests__/vault-remove.test.ts +393 -0
  33. package/src/admin-agent-grants.ts +1365 -0
  34. package/src/admin-agent-token.ts +147 -0
  35. package/src/admin-connections.ts +67 -50
  36. package/src/admin-host-admin-token.ts +14 -1
  37. package/src/admin-lock.ts +281 -0
  38. package/src/admin-module-token.ts +15 -7
  39. package/src/admin-vault-admin-token.ts +8 -1
  40. package/src/admin-vaults.ts +12 -12
  41. package/src/api-admin-lock.ts +335 -0
  42. package/src/api-modules-ops.ts +3 -2
  43. package/src/api-modules.ts +9 -9
  44. package/src/cli.ts +13 -1
  45. package/src/clients.ts +88 -0
  46. package/src/commands/install.ts +7 -0
  47. package/src/commands/serve-boot.ts +5 -4
  48. package/src/commands/serve.ts +45 -19
  49. package/src/commands/setup.ts +4 -3
  50. package/src/commands/upgrade.ts +118 -2
  51. package/src/commands/vault-remove.ts +361 -0
  52. package/src/commands/wizard.ts +4 -4
  53. package/src/connections-store.ts +3 -3
  54. package/src/grants-store.ts +272 -0
  55. package/src/help.ts +4 -1
  56. package/src/hub-server.ts +209 -27
  57. package/src/hub-settings.ts +23 -8
  58. package/src/jwt-sign.ts +5 -1
  59. package/src/module-manifest.ts +2 -2
  60. package/src/oauth-client.ts +497 -0
  61. package/src/oauth-flows-store.ts +163 -0
  62. package/src/oauth-handlers.ts +40 -13
  63. package/src/operator-token.ts +1 -1
  64. package/src/origin-check.ts +7 -2
  65. package/src/resource-binding.ts +4 -4
  66. package/src/scope-explanations.ts +3 -3
  67. package/src/service-spec.ts +56 -43
  68. package/src/setup-wizard.ts +56 -240
  69. package/web/ui/dist/assets/index-B5AUE359.js +61 -0
  70. package/web/ui/dist/assets/{index-E_9wqjEm.css → index-DR6R8EFf.css} +1 -1
  71. package/web/ui/dist/index.html +2 -2
  72. package/src/admin-channel-token.ts +0 -135
  73. 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 channel (the module the whitelist used to hide) + the core set", () => {
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", "channel", "notes"]) {
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 — channel moved to KNOWN_MODULES in
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
- // channel rides in KNOWN_MODULES now but is still discoverable.
40
- expect(shorts).toContain("channel");
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
- // channel defaults experimental, but a manifest-declared core wins.
47
- expect(focusForShort("channel", "core")).toBe("core");
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("channel")).toBe("experimental");
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", "channel", "notes"]) {
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-channel`),
80
- // not the bare short (`channel`). The connection/channels wiring used to do
81
- // `services.find((s) => s.name === "channel")`, which never matched the on-disk
82
- // row → channelOrigin null → a spurious "channel module is not installed" when
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-channel", port: 1941 },
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, "channel");
94
- expect(found?.name).toBe("parachute-channel");
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 === "channel")).toBeUndefined();
100
+ expect(services.find((s) => s.name === "agent")).toBeUndefined();
101
101
  // The fix finds it.
102
- expect(findServiceByShort(services, "channel")).toBeDefined();
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
- // As of 2026-05-20, parachute-agent itself is retired (hub#334 added
881
- // `agent` to RETIRED_MODULES). The migration still runs — and operators
882
- // with claw rows on disk still see them rewritten to `agent` on the
883
- // first read but the retired-module GC then drops the rewritten row
884
- // on the next read. The migration is effectively a one-step retirement
885
- // path: claw → agent dropped. The tests below assert the
886
- // intermediate rewrite behavior; the retired-module suite asserts the
887
- // GC step.
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. Retired GC won't touch `claw`-
910
- // typed rows on the way in, only `agent`-typed rows so the
911
- // first read returns the migrated `agent` shape; the second read
912
- // will then GC it (covered below).
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("retired GC drops the migrated agent row on the next read (post-retirement)", () => {
926
- // Confirms the migration's role as an intermediate retirement step:
927
- // first read migrates claw agent and writes back; second read
928
- // applies the retired-module GC and drops the row entirely.
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).toHaveLength(0);
937
+ expect(second.services).toEqual([agent]);
936
938
  } finally {
937
939
  cleanup();
938
940
  }
939
941
  });
940
942
 
941
- test("an already-agent entry is dropped by retired GC (was: idempotent migration)", () => {
942
- // Pre-hub#334 this test verified the migration was idempotent an
943
- // already-agent row round-tripped unchanged. Post-retirement, the
944
- // GC takes over: an agent row is stale and gets removed on read.
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).toHaveLength(0);
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 1946
1232
- // (left over from parachute-agent's brief committed-core window
1233
- // 2026-05-052026-05-20) colliding with `parachute-app`'s new
1234
- // canonical slot at 1946. The legacy-short-name de-dupe doesn't help —
1235
- // `agent` isn't the short-name twin of `parachute-app`. The retired-
1236
- // module GC fires unconditionally on rows whose name appears in
1237
- // `RETIRED_MODULES`, regardless of port collision.
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 channelagent 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
- test("drops a row whose name is in RETIRED_MODULES (agent)", () => {
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: "agent",
1257
+ name: "app",
1248
1258
  port: 1946,
1249
- paths: ["/agent"],
1250
- health: "/agent/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 agent row sitting
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: "agent",
1287
+ name: "app",
1278
1288
  port: 9999,
1279
- paths: ["/agent"],
1280
- health: "/agent/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 — agent + parachute-app at same port resolves cleanly", () => {
1322
- // The motivating bug for hub#334. With dropRetiredModuleRows
1323
- // running before validateManifest, the stale agent row is GC'd
1324
- // and the duplicate-port gate doesn't trip downstream.
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: "agent",
1343
+ name: "app",
1333
1344
  port: 1946,
1334
- paths: ["/agent"],
1335
- health: "/agent/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 agent row is unconditional retire;
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: "agent",
1382
+ name: "app",
1371
1383
  port: 1946,
1372
- paths: ["/agent"],
1373
- health: "/agent/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
- * Gated routes (require an admin to be useful): `/login`, `/logout`,
8
- * `/admin/*` (except `/admin/setup`), `/api/*`.
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
- test("503s /login when no admin exists", async () => {
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 {