@openparachute/hub 0.5.7 → 0.5.10-rc.2

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 (69) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-clients.test.ts +275 -0
  3. package/src/__tests__/admin-handlers.test.ts +70 -323
  4. package/src/__tests__/admin-host-admin-token.test.ts +52 -4
  5. package/src/__tests__/api-me.test.ts +149 -0
  6. package/src/__tests__/api-mint-token.test.ts +381 -0
  7. package/src/__tests__/api-revocation-list.test.ts +198 -0
  8. package/src/__tests__/api-revoke-token.test.ts +320 -0
  9. package/src/__tests__/api-tokens.test.ts +629 -0
  10. package/src/__tests__/auth.test.ts +680 -16
  11. package/src/__tests__/expose-2fa-warning.test.ts +3 -5
  12. package/src/__tests__/expose-cloudflare.test.ts +1 -1
  13. package/src/__tests__/expose.test.ts +2 -2
  14. package/src/__tests__/hub-server.test.ts +526 -67
  15. package/src/__tests__/hub.test.ts +108 -55
  16. package/src/__tests__/install-source.test.ts +249 -0
  17. package/src/__tests__/jwt-sign.test.ts +205 -0
  18. package/src/__tests__/module-manifest.test.ts +48 -0
  19. package/src/__tests__/oauth-handlers.test.ts +375 -5
  20. package/src/__tests__/operator-token.test.ts +427 -3
  21. package/src/__tests__/origin-check.test.ts +220 -0
  22. package/src/__tests__/serve.test.ts +100 -0
  23. package/src/__tests__/setup-gate.test.ts +196 -0
  24. package/src/__tests__/status.test.ts +199 -0
  25. package/src/__tests__/supervisor.test.ts +408 -0
  26. package/src/__tests__/upgrade.test.ts +247 -4
  27. package/src/__tests__/well-known.test.ts +69 -0
  28. package/src/admin-clients.ts +139 -0
  29. package/src/admin-handlers.ts +32 -254
  30. package/src/admin-host-admin-token.ts +25 -10
  31. package/src/admin-login-ui.ts +256 -0
  32. package/src/admin-vault-admin-token.ts +1 -1
  33. package/src/api-me.ts +124 -0
  34. package/src/api-mint-token.ts +239 -0
  35. package/src/api-revocation-list.ts +59 -0
  36. package/src/api-revoke-token.ts +153 -0
  37. package/src/api-tokens.ts +224 -0
  38. package/src/cli.ts +28 -0
  39. package/src/commands/auth.ts +408 -51
  40. package/src/commands/expose-2fa-warning.ts +6 -6
  41. package/src/commands/serve.ts +157 -0
  42. package/src/commands/status.ts +74 -10
  43. package/src/commands/upgrade.ts +33 -6
  44. package/src/csrf.ts +6 -3
  45. package/src/help.ts +54 -5
  46. package/src/hub-control.ts +1 -0
  47. package/src/hub-db.ts +63 -0
  48. package/src/hub-server.ts +630 -135
  49. package/src/hub.ts +272 -149
  50. package/src/install-source.ts +291 -0
  51. package/src/jwt-sign.ts +265 -5
  52. package/src/module-manifest.ts +48 -10
  53. package/src/oauth-handlers.ts +238 -54
  54. package/src/oauth-ui.ts +23 -2
  55. package/src/operator-token.ts +349 -18
  56. package/src/origin-check.ts +127 -0
  57. package/src/rate-limit.ts +5 -2
  58. package/src/scope-explanations.ts +33 -2
  59. package/src/sessions.ts +1 -1
  60. package/src/supervisor.ts +359 -0
  61. package/src/well-known.ts +54 -1
  62. package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
  63. package/web/ui/dist/assets/index-D54otIhv.css +1 -0
  64. package/web/ui/dist/index.html +2 -2
  65. package/src/__tests__/admin-config.test.ts +0 -281
  66. package/src/admin-config-ui.ts +0 -534
  67. package/src/admin-config.ts +0 -226
  68. package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
  69. package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
@@ -1,67 +1,22 @@
1
1
  import type { Database } from "bun:sqlite";
2
2
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
3
- import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import {
7
- handleAdminConfigGet,
8
- handleAdminConfigPost,
9
7
  handleAdminLoginGet,
10
8
  handleAdminLoginPost,
11
9
  handleAdminLogoutPost,
12
10
  } from "../admin-handlers.ts";
13
11
  import { CSRF_COOKIE_NAME, CSRF_FIELD_NAME } from "../csrf.ts";
14
12
  import { hubDbPath, openHubDb } from "../hub-db.ts";
15
- import type { ConfigSchema, ModuleManifest } from "../module-manifest.ts";
16
13
  import { __resetForTests as resetRateLimit } from "../rate-limit.ts";
17
- import type { ServicesManifest } from "../services-manifest.ts";
18
14
  import { SESSION_TTL_MS, buildSessionCookie, createSession, findSession } from "../sessions.ts";
19
15
  import { createUser } from "../users.ts";
20
16
 
21
17
  const TEST_CSRF = "csrf-handlers-test-token";
22
18
  const CSRF_COOKIE = `${CSRF_COOKIE_NAME}=${TEST_CSRF}`;
23
19
 
24
- const VAULT_SCHEMA: ConfigSchema = {
25
- type: "object",
26
- required: ["transcribe_provider"],
27
- properties: {
28
- transcribe_provider: {
29
- type: "string",
30
- description: "Speech-to-text backend.",
31
- enum: ["openai", "deepgram", "groq"],
32
- default: "openai",
33
- },
34
- max_tags_per_note: { type: "integer", default: 10 },
35
- public: { type: "boolean", default: false },
36
- },
37
- };
38
-
39
- const VAULT_MANIFEST: ModuleManifest = {
40
- name: "vault",
41
- manifestName: "parachute-vault",
42
- displayName: "Vault",
43
- kind: "api",
44
- port: 1940,
45
- paths: ["/vault"],
46
- health: "/health",
47
- configSchema: VAULT_SCHEMA,
48
- };
49
-
50
- function vaultServices(): ServicesManifest {
51
- return {
52
- services: [
53
- {
54
- name: "vault",
55
- port: 1940,
56
- paths: ["/vault"],
57
- health: "/health",
58
- version: "0.0.0",
59
- installDir: "/fake/vault",
60
- },
61
- ],
62
- };
63
- }
64
-
65
20
  interface Harness {
66
21
  db: Database;
67
22
  configDir: string;
@@ -99,11 +54,6 @@ function formBody(values: Record<string, string>): {
99
54
  };
100
55
  }
101
56
 
102
- function fakeReadManifest(installDir: string): Promise<ModuleManifest | null> {
103
- if (installDir === "/fake/vault") return Promise.resolve(VAULT_MANIFEST);
104
- return Promise.resolve(null);
105
- }
106
-
107
57
  let harness: Harness;
108
58
  beforeEach(() => {
109
59
  harness = makeHarness();
@@ -125,20 +75,29 @@ describe("handleAdminLoginGet", () => {
125
75
  });
126
76
 
127
77
  test("echoes the next= query param into the form", async () => {
128
- const req = new Request("http://hub.test/admin/login?next=/admin/config");
78
+ const req = new Request("http://hub.test/admin/login?next=/admin/permissions");
129
79
  const res = handleAdminLoginGet(harness.db, req);
130
80
  const html = await res.text();
131
- expect(html).toContain('value="/admin/config"');
81
+ expect(html).toContain('value="/admin/permissions"');
132
82
  });
133
83
 
134
- test("rewrites unsafe next= to /admin/config", async () => {
84
+ test("rewrites unsafe next= to /admin/vaults", async () => {
135
85
  const req = new Request("http://hub.test/admin/login?next=https%3A%2F%2Fevil.example%2Fpwn");
136
86
  const res = handleAdminLoginGet(harness.db, req);
137
87
  const html = await res.text();
138
- expect(html).toContain('value="/admin/config"');
88
+ expect(html).toContain('value="/admin/vaults"');
139
89
  expect(html).not.toContain("evil.example");
140
90
  });
141
91
 
92
+ test("missing next= falls back to /admin/vaults (SPA home)", async () => {
93
+ // Post-SPA-rework default: the legacy `/admin/config` portal was retired,
94
+ // so the login form's hidden `next` defaults to the SPA's vault list.
95
+ const req = new Request("http://hub.test/admin/login");
96
+ const res = handleAdminLoginGet(harness.db, req);
97
+ const html = await res.text();
98
+ expect(html).toContain('value="/admin/vaults"');
99
+ });
100
+
142
101
  test("hidden __csrf input value matches the freshly-minted cookie value (#113)", async () => {
143
102
  const req = new Request("http://hub.test/admin/login");
144
103
  const res = handleAdminLoginGet(harness.db, req);
@@ -161,7 +120,7 @@ describe("handleAdminLoginPost", () => {
161
120
  [CSRF_FIELD_NAME]: "wrong",
162
121
  username: "admin",
163
122
  password: "pw",
164
- next: "/admin/config",
123
+ next: "/admin/vaults",
165
124
  });
166
125
  const req = new Request("http://hub.test/admin/login", {
167
126
  method: "POST",
@@ -179,7 +138,7 @@ describe("handleAdminLoginPost", () => {
179
138
  [CSRF_FIELD_NAME]: TEST_CSRF,
180
139
  username: "admin",
181
140
  password: "wrong",
182
- next: "/admin/config",
141
+ next: "/admin/vaults",
183
142
  });
184
143
  const req = new Request("http://hub.test/admin/login", {
185
144
  method: "POST",
@@ -197,7 +156,7 @@ describe("handleAdminLoginPost", () => {
197
156
  [CSRF_FIELD_NAME]: TEST_CSRF,
198
157
  username: "admin",
199
158
  password: "pw",
200
- next: "/admin/config",
159
+ next: "/admin/permissions",
201
160
  });
202
161
  const req = new Request("http://hub.test/admin/login", {
203
162
  method: "POST",
@@ -206,7 +165,7 @@ describe("handleAdminLoginPost", () => {
206
165
  });
207
166
  const res = await handleAdminLoginPost(harness.db, req);
208
167
  expect(res.status).toBe(302);
209
- expect(res.headers.get("location")).toBe("/admin/config");
168
+ expect(res.headers.get("location")).toBe("/admin/permissions");
210
169
  expect(res.headers.get("set-cookie") ?? "").toContain("parachute_hub_session=");
211
170
  });
212
171
 
@@ -225,7 +184,50 @@ describe("handleAdminLoginPost", () => {
225
184
  });
226
185
  const res = await handleAdminLoginPost(harness.db, req);
227
186
  expect(res.status).toBe(302);
228
- expect(res.headers.get("location")).toBe("/admin/config");
187
+ expect(res.headers.get("location")).toBe("/admin/vaults");
188
+ });
189
+
190
+ test("ignores a protocol-relative next= from the form (open-redirect defense)", async () => {
191
+ // Scheme-relative `//host/path` URLs would otherwise resolve to a
192
+ // different origin when followed by the browser. `safeNext` rejects them
193
+ // alongside scheme-absolute URLs; this test pins the POST path
194
+ // explicitly so future refactors of the redirect builder don't quietly
195
+ // re-open the open-redirect.
196
+ await createUser(harness.db, "admin", "pw");
197
+ const { body, headers } = formBody({
198
+ [CSRF_FIELD_NAME]: TEST_CSRF,
199
+ username: "admin",
200
+ password: "pw",
201
+ next: "//evil.example/pwn",
202
+ });
203
+ const req = new Request("http://hub.test/admin/login", {
204
+ method: "POST",
205
+ headers: { ...headers, cookie: CSRF_COOKIE },
206
+ body,
207
+ });
208
+ const res = await handleAdminLoginPost(harness.db, req);
209
+ expect(res.status).toBe(302);
210
+ expect(res.headers.get("location")).toBe("/admin/vaults");
211
+ });
212
+
213
+ test("missing next= lands the operator on /admin/vaults (SPA home)", async () => {
214
+ // Post-SPA-rework default: when the form omits `next`, login lands on
215
+ // the SPA's vault list. Previously the legacy `/admin/config` portal
216
+ // was the default; that page is retired and 301s to /admin/vaults.
217
+ await createUser(harness.db, "admin", "pw");
218
+ const { body, headers } = formBody({
219
+ [CSRF_FIELD_NAME]: TEST_CSRF,
220
+ username: "admin",
221
+ password: "pw",
222
+ });
223
+ const req = new Request("http://hub.test/admin/login", {
224
+ method: "POST",
225
+ headers: { ...headers, cookie: CSRF_COOKIE },
226
+ body,
227
+ });
228
+ const res = await handleAdminLoginPost(harness.db, req);
229
+ expect(res.status).toBe(302);
230
+ expect(res.headers.get("location")).toBe("/admin/vaults");
229
231
  });
230
232
 
231
233
  // hub#185 — per-IP rate-limit (5 attempts / 15 min) on POST /admin/login.
@@ -236,7 +238,7 @@ describe("handleAdminLoginPost", () => {
236
238
  [CSRF_FIELD_NAME]: TEST_CSRF,
237
239
  username: "admin",
238
240
  password,
239
- next: "/admin/config",
241
+ next: "/admin/vaults",
240
242
  });
241
243
  return new Request("http://hub.test/admin/login", {
242
244
  method: "POST",
@@ -270,7 +272,7 @@ describe("handleAdminLoginPost", () => {
270
272
  [CSRF_FIELD_NAME]: TEST_CSRF,
271
273
  username: "admin",
272
274
  password,
273
- next: "/admin/config",
275
+ next: "/admin/vaults",
274
276
  });
275
277
  return new Request("http://hub.test/admin/login", {
276
278
  method: "POST",
@@ -298,7 +300,7 @@ describe("handleAdminLoginPost", () => {
298
300
  [CSRF_FIELD_NAME]: TEST_CSRF,
299
301
  username: "ghost",
300
302
  password: "x",
301
- next: "/admin/config",
303
+ next: "/admin/vaults",
302
304
  });
303
305
  return new Request("http://hub.test/admin/login", {
304
306
  method: "POST",
@@ -330,7 +332,7 @@ describe("handleAdminLogoutPost (#113)", () => {
330
332
  expect(res.headers.get("set-cookie")).toBeNull();
331
333
  });
332
334
 
333
- test("clears session cookie, deletes session row, and redirects to /admin/login", async () => {
335
+ test("clears session cookie, deletes session row, and redirects to /login", async () => {
334
336
  const user = await createUser(harness.db, "admin", "pw");
335
337
  const session = createSession(harness.db, { userId: user.id });
336
338
  const cookie = `${CSRF_COOKIE}; ${buildSessionCookie(
@@ -338,14 +340,14 @@ describe("handleAdminLogoutPost (#113)", () => {
338
340
  Math.floor(SESSION_TTL_MS / 1000),
339
341
  )}`;
340
342
  const { body, headers } = formBody({ [CSRF_FIELD_NAME]: TEST_CSRF });
341
- const req = new Request("http://hub.test/admin/logout", {
343
+ const req = new Request("http://hub.test/logout", {
342
344
  method: "POST",
343
345
  headers: { ...headers, cookie },
344
346
  body,
345
347
  });
346
348
  const res = await handleAdminLogoutPost(harness.db, req);
347
349
  expect(res.status).toBe(302);
348
- expect(res.headers.get("location")).toBe("/admin/login");
350
+ expect(res.headers.get("location")).toBe("/login");
349
351
  const setCookie = res.headers.get("set-cookie") ?? "";
350
352
  expect(setCookie).toContain("parachute_hub_session=;");
351
353
  expect(setCookie).toContain("Max-Age=0");
@@ -354,269 +356,14 @@ describe("handleAdminLogoutPost (#113)", () => {
354
356
 
355
357
  test("idempotent — clears cookie even with no active session", async () => {
356
358
  const { body, headers } = formBody({ [CSRF_FIELD_NAME]: TEST_CSRF });
357
- const req = new Request("http://hub.test/admin/logout", {
359
+ const req = new Request("http://hub.test/logout", {
358
360
  method: "POST",
359
361
  headers: { ...headers, cookie: CSRF_COOKIE },
360
362
  body,
361
363
  });
362
364
  const res = await handleAdminLogoutPost(harness.db, req);
363
365
  expect(res.status).toBe(302);
364
- expect(res.headers.get("location")).toBe("/admin/login");
366
+ expect(res.headers.get("location")).toBe("/login");
365
367
  expect(res.headers.get("set-cookie") ?? "").toContain("parachute_hub_session=;");
366
368
  });
367
369
  });
368
-
369
- describe("handleAdminConfigGet", () => {
370
- test("redirects unauthenticated requests to /admin/login", async () => {
371
- const req = new Request("http://hub.test/admin/config");
372
- const res = await handleAdminConfigGet(harness.db, req);
373
- expect(res.status).toBe(302);
374
- expect(res.headers.get("location")).toBe("/admin/login?next=%2Fadmin%2Fconfig");
375
- });
376
-
377
- test("renders the empty-state when no module declares a configSchema", async () => {
378
- const cookie = await cookieForUser(harness.db, "admin", "pw");
379
- const req = new Request("http://hub.test/admin/config", {
380
- headers: { cookie },
381
- });
382
- const res = await handleAdminConfigGet(harness.db, req, {
383
- loadServicesManifest: () => ({ services: [] }),
384
- configDir: harness.configDir,
385
- readManifest: fakeReadManifest,
386
- });
387
- expect(res.status).toBe(200);
388
- const html = await res.text();
389
- expect(html).toContain("No installed module declares");
390
- });
391
-
392
- test("renders one section per configurable module", async () => {
393
- const cookie = await cookieForUser(harness.db, "admin", "pw");
394
- const req = new Request("http://hub.test/admin/config", {
395
- headers: { cookie },
396
- });
397
- const res = await handleAdminConfigGet(harness.db, req, {
398
- loadServicesManifest: vaultServices,
399
- configDir: harness.configDir,
400
- readManifest: fakeReadManifest,
401
- });
402
- expect(res.status).toBe(200);
403
- const html = await res.text();
404
- expect(html).toContain('id="module-vault"');
405
- expect(html).toContain("transcribe_provider");
406
- expect(html).toContain('action="/admin/config/vault"');
407
- });
408
-
409
- test("surfaces flash success message after a saved redirect", async () => {
410
- const cookie = await cookieForUser(harness.db, "admin", "pw");
411
- const req = new Request("http://hub.test/admin/config?_status=saved&_module=vault", {
412
- headers: { cookie },
413
- });
414
- const res = await handleAdminConfigGet(harness.db, req, {
415
- loadServicesManifest: vaultServices,
416
- configDir: harness.configDir,
417
- readManifest: fakeReadManifest,
418
- });
419
- const html = await res.text();
420
- expect(html).toContain("Saved and restarted Vault");
421
- });
422
- });
423
-
424
- describe("handleAdminConfigPost", () => {
425
- function postBody(values: Record<string, string>) {
426
- return formBody({ [CSRF_FIELD_NAME]: TEST_CSRF, ...values });
427
- }
428
-
429
- test("redirects unauthenticated requests to /admin/login", async () => {
430
- const { body, headers } = postBody({ transcribe_provider: "openai" });
431
- const req = new Request("http://hub.test/admin/config/vault", {
432
- method: "POST",
433
- headers: { ...headers, cookie: CSRF_COOKIE },
434
- body,
435
- });
436
- const res = await handleAdminConfigPost(harness.db, req, "vault", {
437
- loadServicesManifest: vaultServices,
438
- configDir: harness.configDir,
439
- readManifest: fakeReadManifest,
440
- });
441
- expect(res.status).toBe(302);
442
- expect(res.headers.get("location")).toContain("/admin/login");
443
- });
444
-
445
- test("returns 400 when the CSRF token is wrong", async () => {
446
- const cookie = await cookieForUser(harness.db, "admin", "pw");
447
- const { body, headers } = formBody({
448
- [CSRF_FIELD_NAME]: "wrong",
449
- transcribe_provider: "openai",
450
- });
451
- const req = new Request("http://hub.test/admin/config/vault", {
452
- method: "POST",
453
- headers: { ...headers, cookie },
454
- body,
455
- });
456
- const res = await handleAdminConfigPost(harness.db, req, "vault", {
457
- loadServicesManifest: vaultServices,
458
- configDir: harness.configDir,
459
- readManifest: fakeReadManifest,
460
- });
461
- expect(res.status).toBe(400);
462
- });
463
-
464
- test("returns 404 for unknown module names", async () => {
465
- const cookie = await cookieForUser(harness.db, "admin", "pw");
466
- const { body, headers } = postBody({});
467
- const req = new Request("http://hub.test/admin/config/nope", {
468
- method: "POST",
469
- headers: { ...headers, cookie },
470
- body,
471
- });
472
- const res = await handleAdminConfigPost(harness.db, req, "nope", {
473
- loadServicesManifest: vaultServices,
474
- configDir: harness.configDir,
475
- readManifest: fakeReadManifest,
476
- });
477
- expect(res.status).toBe(404);
478
- });
479
-
480
- test("re-renders with field errors (422) when validation fails", async () => {
481
- const cookie = await cookieForUser(harness.db, "admin", "pw");
482
- const restarts: string[] = [];
483
- const { body, headers } = postBody({
484
- transcribe_provider: "whisper", // not in enum
485
- max_tags_per_note: "10",
486
- });
487
- const req = new Request("http://hub.test/admin/config/vault", {
488
- method: "POST",
489
- headers: { ...headers, cookie },
490
- body,
491
- });
492
- const res = await handleAdminConfigPost(harness.db, req, "vault", {
493
- loadServicesManifest: vaultServices,
494
- configDir: harness.configDir,
495
- readManifest: fakeReadManifest,
496
- restartService: async (name) => {
497
- restarts.push(name);
498
- return 0;
499
- },
500
- });
501
- expect(res.status).toBe(422);
502
- const html = await res.text();
503
- expect(html).toContain("must be one of");
504
- expect(restarts).toEqual([]); // restart never called
505
- // The on-disk config must not have been written.
506
- expect(existsSync(join(harness.configDir, "vault", "config.json"))).toBe(false);
507
- });
508
-
509
- test("writes config + triggers restart + redirects with saved flash", async () => {
510
- const cookie = await cookieForUser(harness.db, "admin", "pw");
511
- const restarts: string[] = [];
512
- const { body, headers } = postBody({
513
- transcribe_provider: "deepgram",
514
- max_tags_per_note: "25",
515
- // checkbox absent → public stays false
516
- });
517
- const req = new Request("http://hub.test/admin/config/vault", {
518
- method: "POST",
519
- headers: { ...headers, cookie },
520
- body,
521
- });
522
- const res = await handleAdminConfigPost(harness.db, req, "vault", {
523
- loadServicesManifest: vaultServices,
524
- configDir: harness.configDir,
525
- readManifest: fakeReadManifest,
526
- restartService: async (name) => {
527
- restarts.push(name);
528
- return 0;
529
- },
530
- });
531
- expect(res.status).toBe(302);
532
- const location = res.headers.get("location") ?? "";
533
- expect(location).toContain("/admin/config?");
534
- expect(location).toContain("_status=saved");
535
- expect(location).toContain("_module=vault");
536
- expect(location).toContain("#module-vault");
537
- expect(restarts).toEqual(["vault"]);
538
- const written = JSON.parse(
539
- readFileSync(join(harness.configDir, "vault", "config.json"), "utf8"),
540
- );
541
- expect(written).toEqual({
542
- transcribe_provider: "deepgram",
543
- max_tags_per_note: 25,
544
- public: false,
545
- });
546
- });
547
-
548
- test("flashes saved-restart-failed when the restart returns non-zero", async () => {
549
- const cookie = await cookieForUser(harness.db, "admin", "pw");
550
- const { body, headers } = postBody({
551
- transcribe_provider: "openai",
552
- max_tags_per_note: "5",
553
- });
554
- const req = new Request("http://hub.test/admin/config/vault", {
555
- method: "POST",
556
- headers: { ...headers, cookie },
557
- body,
558
- });
559
- const res = await handleAdminConfigPost(harness.db, req, "vault", {
560
- loadServicesManifest: vaultServices,
561
- configDir: harness.configDir,
562
- readManifest: fakeReadManifest,
563
- restartService: async () => 1,
564
- });
565
- expect(res.status).toBe(302);
566
- const location = res.headers.get("location") ?? "";
567
- expect(location).toContain("_status=saved-restart-failed");
568
- // Config was still written before the restart attempt.
569
- expect(existsSync(join(harness.configDir, "vault", "config.json"))).toBe(true);
570
- });
571
-
572
- test("flashes saved-restart-failed with err detail when restart throws", async () => {
573
- const cookie = await cookieForUser(harness.db, "admin", "pw");
574
- const { body, headers } = postBody({
575
- transcribe_provider: "openai",
576
- max_tags_per_note: "5",
577
- });
578
- const req = new Request("http://hub.test/admin/config/vault", {
579
- method: "POST",
580
- headers: { ...headers, cookie },
581
- body,
582
- });
583
- const res = await handleAdminConfigPost(harness.db, req, "vault", {
584
- loadServicesManifest: vaultServices,
585
- configDir: harness.configDir,
586
- readManifest: fakeReadManifest,
587
- restartService: async () => {
588
- throw new Error("launchctl unavailable");
589
- },
590
- });
591
- expect(res.status).toBe(302);
592
- const location = res.headers.get("location") ?? "";
593
- expect(location).toContain("_status=saved-restart-failed");
594
- const errParam = new URL(location, "http://hub.test").searchParams.get("_err") ?? "";
595
- expect(errParam).toContain("launchctl unavailable");
596
- });
597
-
598
- test("checkbox-on translates to `public: true` in the written config", async () => {
599
- const cookie = await cookieForUser(harness.db, "admin", "pw");
600
- const { body, headers } = postBody({
601
- transcribe_provider: "openai",
602
- max_tags_per_note: "5",
603
- public: "true",
604
- });
605
- const req = new Request("http://hub.test/admin/config/vault", {
606
- method: "POST",
607
- headers: { ...headers, cookie },
608
- body,
609
- });
610
- const res = await handleAdminConfigPost(harness.db, req, "vault", {
611
- loadServicesManifest: vaultServices,
612
- configDir: harness.configDir,
613
- readManifest: fakeReadManifest,
614
- restartService: async () => 0,
615
- });
616
- expect(res.status).toBe(302);
617
- const written = JSON.parse(
618
- readFileSync(join(harness.configDir, "vault", "config.json"), "utf8"),
619
- );
620
- expect(written.public).toBe(true);
621
- });
622
- });
@@ -2,9 +2,12 @@
2
2
  * Tests for the SPA's session→bearer mint endpoint. Covers:
3
3
  * - 401 when no admin session cookie is present.
4
4
  * - 401 when the cookie names a deleted/expired session.
5
- * - 200 + JWT carrying parachute:host:admin when the session is valid.
5
+ * - 200 + JWT carrying parachute:host:admin AND parachute:host:auth.
6
6
  * - Token validates against the hub's own keys and the configured issuer.
7
7
  * - Method-not-allowed on POST.
8
+ * - End-to-end regression: the minted JWT actually unlocks the new
9
+ * `/api/auth/tokens` endpoint (hub#212 Phase 2 backend) — the bug from
10
+ * end-to-end testing that motivated adding `parachute:host:auth` here.
8
11
  */
9
12
  import type { Database } from "bun:sqlite";
10
13
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
@@ -12,9 +15,11 @@ import { mkdtempSync, rmSync } from "node:fs";
12
15
  import { tmpdir } from "node:os";
13
16
  import { join } from "node:path";
14
17
  import { HOST_ADMIN_TOKEN_TTL_SECONDS, handleHostAdminToken } from "../admin-host-admin-token.ts";
18
+ import { handleApiTokens } from "../api-tokens.ts";
15
19
  import { hubDbPath, openHubDb } from "../hub-db.ts";
16
20
  import { validateAccessToken } from "../jwt-sign.ts";
17
21
  import { SESSION_TTL_MS, buildSessionCookie, createSession, deleteSession } from "../sessions.ts";
22
+ import { rotateSigningKey } from "../signing-keys.ts";
18
23
  import { createUser } from "../users.ts";
19
24
 
20
25
  const ISSUER = "https://hub.test";
@@ -82,8 +87,9 @@ describe("handleHostAdminToken", () => {
82
87
  expect(res.status).toBe(405);
83
88
  });
84
89
 
85
- test("200 mints a JWT carrying parachute:host:admin and the configured issuer", async () => {
90
+ test("200 mints a JWT carrying parachute:host:admin + parachute:host:auth and the configured issuer", async () => {
86
91
  const { cookie, userId } = await withSession();
92
+ rotateSigningKey(harness.db);
87
93
  const req = new Request(`${ISSUER}/admin/host-admin-token`, {
88
94
  headers: { cookie },
89
95
  });
@@ -96,7 +102,10 @@ describe("handleHostAdminToken", () => {
96
102
  expires_at: string;
97
103
  scopes: string[];
98
104
  };
99
- expect(body.scopes).toEqual(["parachute:host:admin"]);
105
+ // Both scopes — the SPA now needs `:host:auth` for the hub#212 Phase 2
106
+ // token-registry endpoints alongside the existing `:host:admin` for
107
+ // vault provisioning + grant management.
108
+ expect(body.scopes).toEqual(["parachute:host:admin", "parachute:host:auth"]);
100
109
  expect(body.token.length).toBeGreaterThan(20);
101
110
 
102
111
  // expires_at is roughly TTL_SECONDS in the future.
@@ -110,6 +119,45 @@ describe("handleHostAdminToken", () => {
110
119
  expect(validated.payload.sub).toBe(userId);
111
120
  expect(validated.payload.iss).toBe(ISSUER);
112
121
  const scopeClaim = (validated.payload as { scope?: string }).scope ?? "";
113
- expect(scopeClaim.split(/\s+/)).toContain("parachute:host:admin");
122
+ const scopes = scopeClaim.split(/\s+/);
123
+ expect(scopes).toContain("parachute:host:admin");
124
+ expect(scopes).toContain("parachute:host:auth");
125
+ });
126
+
127
+ // Regression for the end-to-end bug that motivated adding `:host:auth`
128
+ // here: the SPA's session-bearer was rejected by `/api/auth/tokens` (and
129
+ // its peers) because it carried `:host:admin` only. This test mints
130
+ // through the SPA path and exercises one of the new endpoints
131
+ // end-to-end — the Phase 2 backend tests only minted operator-style
132
+ // tokens with `:host:auth` directly, leaving the SPA-flow gap uncaught.
133
+ test("regression: the minted SPA bearer is accepted by /api/auth/tokens", async () => {
134
+ const { cookie } = await withSession();
135
+ rotateSigningKey(harness.db);
136
+
137
+ // Step 1: SPA grabs its bearer via the cookie path.
138
+ const mintRes = await handleHostAdminToken(
139
+ new Request(`${ISSUER}/admin/host-admin-token`, { headers: { cookie } }),
140
+ { db: harness.db, issuer: ISSUER },
141
+ );
142
+ expect(mintRes.status).toBe(200);
143
+ const { token } = (await mintRes.json()) as { token: string };
144
+
145
+ // Step 2: SPA hits /api/auth/tokens with that bearer. Pre-fix this
146
+ // returned 403 `bearer token lacks parachute:host:auth`; post-fix it
147
+ // returns 200 with the (empty-by-default) tokens list.
148
+ const tokensRes = await handleApiTokens(
149
+ new Request(`${ISSUER}/api/auth/tokens`, {
150
+ method: "GET",
151
+ headers: { authorization: `Bearer ${token}` },
152
+ }),
153
+ { db: harness.db, issuer: ISSUER },
154
+ );
155
+ expect(tokensRes.status).toBe(200);
156
+ const tokensBody = (await tokensRes.json()) as {
157
+ tokens: unknown[];
158
+ next_cursor: string | null;
159
+ };
160
+ expect(Array.isArray(tokensBody.tokens)).toBe(true);
161
+ expect(tokensBody.next_cursor).toBeNull();
114
162
  });
115
163
  });