@openparachute/hub 0.5.2 → 0.5.9-rc.6

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 (76) 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 +159 -320
  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 +123 -0
  12. package/src/__tests__/expose-cloudflare.test.ts +101 -0
  13. package/src/__tests__/expose.test.ts +199 -340
  14. package/src/__tests__/hub-server.test.ts +986 -66
  15. package/src/__tests__/hub.test.ts +108 -55
  16. package/src/__tests__/install-source.test.ts +249 -0
  17. package/src/__tests__/install.test.ts +50 -31
  18. package/src/__tests__/jwt-sign.test.ts +205 -0
  19. package/src/__tests__/lifecycle.test.ts +97 -2
  20. package/src/__tests__/module-manifest.test.ts +48 -0
  21. package/src/__tests__/notes-serve.test.ts +154 -2
  22. package/src/__tests__/oauth-handlers.test.ts +1000 -3
  23. package/src/__tests__/operator-token.test.ts +379 -3
  24. package/src/__tests__/origin-check.test.ts +220 -0
  25. package/src/__tests__/port-assign.test.ts +41 -52
  26. package/src/__tests__/rate-limit.test.ts +190 -0
  27. package/src/__tests__/services-manifest.test.ts +341 -0
  28. package/src/__tests__/setup.test.ts +12 -9
  29. package/src/__tests__/status.test.ts +372 -0
  30. package/src/__tests__/well-known.test.ts +69 -0
  31. package/src/admin-clients.ts +139 -0
  32. package/src/admin-handlers.ts +63 -260
  33. package/src/admin-host-admin-token.ts +25 -10
  34. package/src/admin-login-ui.ts +256 -0
  35. package/src/admin-vault-admin-token.ts +1 -1
  36. package/src/api-me.ts +124 -0
  37. package/src/api-mint-token.ts +239 -0
  38. package/src/api-revocation-list.ts +59 -0
  39. package/src/api-revoke-token.ts +153 -0
  40. package/src/api-tokens.ts +224 -0
  41. package/src/commands/auth.ts +408 -51
  42. package/src/commands/expose-2fa-warning.ts +82 -0
  43. package/src/commands/expose-cloudflare.ts +27 -0
  44. package/src/commands/expose-public-auto.ts +3 -7
  45. package/src/commands/expose.ts +88 -173
  46. package/src/commands/install.ts +11 -13
  47. package/src/commands/lifecycle.ts +53 -4
  48. package/src/commands/status.ts +99 -8
  49. package/src/csrf.ts +6 -3
  50. package/src/help.ts +13 -7
  51. package/src/hub-db.ts +63 -0
  52. package/src/hub-server.ts +572 -106
  53. package/src/hub.ts +272 -149
  54. package/src/install-source.ts +291 -0
  55. package/src/jwt-sign.ts +265 -5
  56. package/src/module-manifest.ts +48 -10
  57. package/src/notes-serve.ts +70 -9
  58. package/src/oauth-handlers.ts +395 -29
  59. package/src/oauth-ui.ts +188 -0
  60. package/src/operator-token.ts +272 -18
  61. package/src/origin-check.ts +127 -0
  62. package/src/port-assign.ts +28 -35
  63. package/src/rate-limit.ts +166 -0
  64. package/src/scope-explanations.ts +33 -2
  65. package/src/service-spec.ts +58 -13
  66. package/src/services-manifest.ts +62 -3
  67. package/src/sessions.ts +19 -0
  68. package/src/well-known.ts +54 -1
  69. package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
  70. package/web/ui/dist/assets/index-D54otIhv.css +1 -0
  71. package/web/ui/dist/index.html +2 -2
  72. package/src/__tests__/admin-config.test.ts +0 -281
  73. package/src/admin-config-ui.ts +0 -534
  74. package/src/admin-config.ts +0 -226
  75. package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
  76. package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
@@ -1,66 +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
- import type { ServicesManifest } from "../services-manifest.ts";
13
+ import { __resetForTests as resetRateLimit } from "../rate-limit.ts";
17
14
  import { SESSION_TTL_MS, buildSessionCookie, createSession, findSession } from "../sessions.ts";
18
15
  import { createUser } from "../users.ts";
19
16
 
20
17
  const TEST_CSRF = "csrf-handlers-test-token";
21
18
  const CSRF_COOKIE = `${CSRF_COOKIE_NAME}=${TEST_CSRF}`;
22
19
 
23
- const VAULT_SCHEMA: ConfigSchema = {
24
- type: "object",
25
- required: ["transcribe_provider"],
26
- properties: {
27
- transcribe_provider: {
28
- type: "string",
29
- description: "Speech-to-text backend.",
30
- enum: ["openai", "deepgram", "groq"],
31
- default: "openai",
32
- },
33
- max_tags_per_note: { type: "integer", default: 10 },
34
- public: { type: "boolean", default: false },
35
- },
36
- };
37
-
38
- const VAULT_MANIFEST: ModuleManifest = {
39
- name: "vault",
40
- manifestName: "parachute-vault",
41
- displayName: "Vault",
42
- kind: "api",
43
- port: 1940,
44
- paths: ["/vault"],
45
- health: "/health",
46
- configSchema: VAULT_SCHEMA,
47
- };
48
-
49
- function vaultServices(): ServicesManifest {
50
- return {
51
- services: [
52
- {
53
- name: "vault",
54
- port: 1940,
55
- paths: ["/vault"],
56
- health: "/health",
57
- version: "0.0.0",
58
- installDir: "/fake/vault",
59
- },
60
- ],
61
- };
62
- }
63
-
64
20
  interface Harness {
65
21
  db: Database;
66
22
  configDir: string;
@@ -98,14 +54,13 @@ function formBody(values: Record<string, string>): {
98
54
  };
99
55
  }
100
56
 
101
- function fakeReadManifest(installDir: string): Promise<ModuleManifest | null> {
102
- if (installDir === "/fake/vault") return Promise.resolve(VAULT_MANIFEST);
103
- return Promise.resolve(null);
104
- }
105
-
106
57
  let harness: Harness;
107
58
  beforeEach(() => {
108
59
  harness = makeHarness();
60
+ // Per-test rate-limit state — login tests share the UNKNOWN_IP sentinel
61
+ // bucket since they don't set CF-Connecting-IP, so without a reset the
62
+ // 6th test in this file would 429 spuriously.
63
+ resetRateLimit();
109
64
  });
110
65
  afterEach(() => {
111
66
  harness.cleanup();
@@ -120,20 +75,29 @@ describe("handleAdminLoginGet", () => {
120
75
  });
121
76
 
122
77
  test("echoes the next= query param into the form", async () => {
123
- 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");
124
79
  const res = handleAdminLoginGet(harness.db, req);
125
80
  const html = await res.text();
126
- expect(html).toContain('value="/admin/config"');
81
+ expect(html).toContain('value="/admin/permissions"');
127
82
  });
128
83
 
129
- test("rewrites unsafe next= to /admin/config", async () => {
84
+ test("rewrites unsafe next= to /admin/vaults", async () => {
130
85
  const req = new Request("http://hub.test/admin/login?next=https%3A%2F%2Fevil.example%2Fpwn");
131
86
  const res = handleAdminLoginGet(harness.db, req);
132
87
  const html = await res.text();
133
- expect(html).toContain('value="/admin/config"');
88
+ expect(html).toContain('value="/admin/vaults"');
134
89
  expect(html).not.toContain("evil.example");
135
90
  });
136
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
+
137
101
  test("hidden __csrf input value matches the freshly-minted cookie value (#113)", async () => {
138
102
  const req = new Request("http://hub.test/admin/login");
139
103
  const res = handleAdminLoginGet(harness.db, req);
@@ -156,7 +120,7 @@ describe("handleAdminLoginPost", () => {
156
120
  [CSRF_FIELD_NAME]: "wrong",
157
121
  username: "admin",
158
122
  password: "pw",
159
- next: "/admin/config",
123
+ next: "/admin/vaults",
160
124
  });
161
125
  const req = new Request("http://hub.test/admin/login", {
162
126
  method: "POST",
@@ -174,7 +138,7 @@ describe("handleAdminLoginPost", () => {
174
138
  [CSRF_FIELD_NAME]: TEST_CSRF,
175
139
  username: "admin",
176
140
  password: "wrong",
177
- next: "/admin/config",
141
+ next: "/admin/vaults",
178
142
  });
179
143
  const req = new Request("http://hub.test/admin/login", {
180
144
  method: "POST",
@@ -192,7 +156,7 @@ describe("handleAdminLoginPost", () => {
192
156
  [CSRF_FIELD_NAME]: TEST_CSRF,
193
157
  username: "admin",
194
158
  password: "pw",
195
- next: "/admin/config",
159
+ next: "/admin/permissions",
196
160
  });
197
161
  const req = new Request("http://hub.test/admin/login", {
198
162
  method: "POST",
@@ -201,7 +165,7 @@ describe("handleAdminLoginPost", () => {
201
165
  });
202
166
  const res = await handleAdminLoginPost(harness.db, req);
203
167
  expect(res.status).toBe(302);
204
- expect(res.headers.get("location")).toBe("/admin/config");
168
+ expect(res.headers.get("location")).toBe("/admin/permissions");
205
169
  expect(res.headers.get("set-cookie") ?? "").toContain("parachute_hub_session=");
206
170
  });
207
171
 
@@ -220,7 +184,137 @@ describe("handleAdminLoginPost", () => {
220
184
  });
221
185
  const res = await handleAdminLoginPost(harness.db, req);
222
186
  expect(res.status).toBe(302);
223
- 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");
231
+ });
232
+
233
+ // hub#185 — per-IP rate-limit (5 attempts / 15 min) on POST /admin/login.
234
+ test("6 rapid POSTs from same IP get 200/401×4/429 and the 429 carries Retry-After", async () => {
235
+ await createUser(harness.db, "admin", "correct-pw");
236
+ const buildReq = (password: string) => {
237
+ const { body, headers } = formBody({
238
+ [CSRF_FIELD_NAME]: TEST_CSRF,
239
+ username: "admin",
240
+ password,
241
+ next: "/admin/vaults",
242
+ });
243
+ return new Request("http://hub.test/admin/login", {
244
+ method: "POST",
245
+ headers: { ...headers, cookie: CSRF_COOKIE, "cf-connecting-ip": "203.0.113.42" },
246
+ body,
247
+ });
248
+ };
249
+ // First attempt: correct password → 302. Counts as attempt #1.
250
+ const first = await handleAdminLoginPost(harness.db, buildReq("correct-pw"));
251
+ expect(first.status).toBe(302);
252
+ // Attempts 2–5: wrong password → 401 each.
253
+ for (let i = 2; i <= 5; i++) {
254
+ const r = await handleAdminLoginPost(harness.db, buildReq("wrong"));
255
+ expect(r.status).toBe(401);
256
+ }
257
+ // Attempt 6: rate-limit fires before credential check → 429 + Retry-After.
258
+ const denied = await handleAdminLoginPost(harness.db, buildReq("wrong"));
259
+ expect(denied.status).toBe(429);
260
+ const retryAfter = denied.headers.get("retry-after");
261
+ expect(retryAfter).not.toBeNull();
262
+ const seconds = Number(retryAfter);
263
+ expect(seconds).toBeGreaterThan(0);
264
+ // Window is 15 min = 900s, so retry-after sits in (0, 900].
265
+ expect(seconds).toBeLessThanOrEqual(900);
266
+ });
267
+
268
+ test("rate-limit is per-IP: a different IP can still log in after another's bucket fills", async () => {
269
+ await createUser(harness.db, "admin", "pw");
270
+ const buildReq = (ip: string, password: string) => {
271
+ const { body, headers } = formBody({
272
+ [CSRF_FIELD_NAME]: TEST_CSRF,
273
+ username: "admin",
274
+ password,
275
+ next: "/admin/vaults",
276
+ });
277
+ return new Request("http://hub.test/admin/login", {
278
+ method: "POST",
279
+ headers: { ...headers, cookie: CSRF_COOKIE, "cf-connecting-ip": ip },
280
+ body,
281
+ });
282
+ };
283
+ // Exhaust ip-a's bucket with 5 wrong-password attempts, then confirm 429.
284
+ for (let i = 0; i < 5; i++) {
285
+ await handleAdminLoginPost(harness.db, buildReq("203.0.113.7", "wrong"));
286
+ }
287
+ const aDenied = await handleAdminLoginPost(harness.db, buildReq("203.0.113.7", "wrong"));
288
+ expect(aDenied.status).toBe(429);
289
+ // Different IP: fresh bucket, correct credentials → 302.
290
+ const bOk = await handleAdminLoginPost(harness.db, buildReq("198.51.100.99", "pw"));
291
+ expect(bOk.status).toBe(302);
292
+ });
293
+
294
+ test("rate-limit fires before credential check (denied request never touches DB)", async () => {
295
+ // No user exists in the harness DB. First 5 attempts should be 401
296
+ // ("Invalid credentials" — no such user). 6th should be 429 with the
297
+ // rate-limit body, NOT a credential-failure body.
298
+ const buildReq = () => {
299
+ const { body, headers } = formBody({
300
+ [CSRF_FIELD_NAME]: TEST_CSRF,
301
+ username: "ghost",
302
+ password: "x",
303
+ next: "/admin/vaults",
304
+ });
305
+ return new Request("http://hub.test/admin/login", {
306
+ method: "POST",
307
+ headers: { ...headers, cookie: CSRF_COOKIE, "cf-connecting-ip": "203.0.113.99" },
308
+ body,
309
+ });
310
+ };
311
+ for (let i = 0; i < 5; i++) {
312
+ const r = await handleAdminLoginPost(harness.db, buildReq());
313
+ expect(r.status).toBe(401);
314
+ }
315
+ const denied = await handleAdminLoginPost(harness.db, buildReq());
316
+ expect(denied.status).toBe(429);
317
+ expect(await denied.text()).toContain("Too many login attempts");
224
318
  });
225
319
  });
226
320
 
@@ -238,7 +332,7 @@ describe("handleAdminLogoutPost (#113)", () => {
238
332
  expect(res.headers.get("set-cookie")).toBeNull();
239
333
  });
240
334
 
241
- 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 () => {
242
336
  const user = await createUser(harness.db, "admin", "pw");
243
337
  const session = createSession(harness.db, { userId: user.id });
244
338
  const cookie = `${CSRF_COOKIE}; ${buildSessionCookie(
@@ -246,14 +340,14 @@ describe("handleAdminLogoutPost (#113)", () => {
246
340
  Math.floor(SESSION_TTL_MS / 1000),
247
341
  )}`;
248
342
  const { body, headers } = formBody({ [CSRF_FIELD_NAME]: TEST_CSRF });
249
- const req = new Request("http://hub.test/admin/logout", {
343
+ const req = new Request("http://hub.test/logout", {
250
344
  method: "POST",
251
345
  headers: { ...headers, cookie },
252
346
  body,
253
347
  });
254
348
  const res = await handleAdminLogoutPost(harness.db, req);
255
349
  expect(res.status).toBe(302);
256
- expect(res.headers.get("location")).toBe("/admin/login");
350
+ expect(res.headers.get("location")).toBe("/login");
257
351
  const setCookie = res.headers.get("set-cookie") ?? "";
258
352
  expect(setCookie).toContain("parachute_hub_session=;");
259
353
  expect(setCookie).toContain("Max-Age=0");
@@ -262,269 +356,14 @@ describe("handleAdminLogoutPost (#113)", () => {
262
356
 
263
357
  test("idempotent — clears cookie even with no active session", async () => {
264
358
  const { body, headers } = formBody({ [CSRF_FIELD_NAME]: TEST_CSRF });
265
- const req = new Request("http://hub.test/admin/logout", {
359
+ const req = new Request("http://hub.test/logout", {
266
360
  method: "POST",
267
361
  headers: { ...headers, cookie: CSRF_COOKIE },
268
362
  body,
269
363
  });
270
364
  const res = await handleAdminLogoutPost(harness.db, req);
271
365
  expect(res.status).toBe(302);
272
- expect(res.headers.get("location")).toBe("/admin/login");
366
+ expect(res.headers.get("location")).toBe("/login");
273
367
  expect(res.headers.get("set-cookie") ?? "").toContain("parachute_hub_session=;");
274
368
  });
275
369
  });
276
-
277
- describe("handleAdminConfigGet", () => {
278
- test("redirects unauthenticated requests to /admin/login", async () => {
279
- const req = new Request("http://hub.test/admin/config");
280
- const res = await handleAdminConfigGet(harness.db, req);
281
- expect(res.status).toBe(302);
282
- expect(res.headers.get("location")).toBe("/admin/login?next=%2Fadmin%2Fconfig");
283
- });
284
-
285
- test("renders the empty-state when no module declares a configSchema", async () => {
286
- const cookie = await cookieForUser(harness.db, "admin", "pw");
287
- const req = new Request("http://hub.test/admin/config", {
288
- headers: { cookie },
289
- });
290
- const res = await handleAdminConfigGet(harness.db, req, {
291
- loadServicesManifest: () => ({ services: [] }),
292
- configDir: harness.configDir,
293
- readManifest: fakeReadManifest,
294
- });
295
- expect(res.status).toBe(200);
296
- const html = await res.text();
297
- expect(html).toContain("No installed module declares");
298
- });
299
-
300
- test("renders one section per configurable module", async () => {
301
- const cookie = await cookieForUser(harness.db, "admin", "pw");
302
- const req = new Request("http://hub.test/admin/config", {
303
- headers: { cookie },
304
- });
305
- const res = await handleAdminConfigGet(harness.db, req, {
306
- loadServicesManifest: vaultServices,
307
- configDir: harness.configDir,
308
- readManifest: fakeReadManifest,
309
- });
310
- expect(res.status).toBe(200);
311
- const html = await res.text();
312
- expect(html).toContain('id="module-vault"');
313
- expect(html).toContain("transcribe_provider");
314
- expect(html).toContain('action="/admin/config/vault"');
315
- });
316
-
317
- test("surfaces flash success message after a saved redirect", async () => {
318
- const cookie = await cookieForUser(harness.db, "admin", "pw");
319
- const req = new Request("http://hub.test/admin/config?_status=saved&_module=vault", {
320
- headers: { cookie },
321
- });
322
- const res = await handleAdminConfigGet(harness.db, req, {
323
- loadServicesManifest: vaultServices,
324
- configDir: harness.configDir,
325
- readManifest: fakeReadManifest,
326
- });
327
- const html = await res.text();
328
- expect(html).toContain("Saved and restarted Vault");
329
- });
330
- });
331
-
332
- describe("handleAdminConfigPost", () => {
333
- function postBody(values: Record<string, string>) {
334
- return formBody({ [CSRF_FIELD_NAME]: TEST_CSRF, ...values });
335
- }
336
-
337
- test("redirects unauthenticated requests to /admin/login", async () => {
338
- const { body, headers } = postBody({ transcribe_provider: "openai" });
339
- const req = new Request("http://hub.test/admin/config/vault", {
340
- method: "POST",
341
- headers: { ...headers, cookie: CSRF_COOKIE },
342
- body,
343
- });
344
- const res = await handleAdminConfigPost(harness.db, req, "vault", {
345
- loadServicesManifest: vaultServices,
346
- configDir: harness.configDir,
347
- readManifest: fakeReadManifest,
348
- });
349
- expect(res.status).toBe(302);
350
- expect(res.headers.get("location")).toContain("/admin/login");
351
- });
352
-
353
- test("returns 400 when the CSRF token is wrong", async () => {
354
- const cookie = await cookieForUser(harness.db, "admin", "pw");
355
- const { body, headers } = formBody({
356
- [CSRF_FIELD_NAME]: "wrong",
357
- transcribe_provider: "openai",
358
- });
359
- const req = new Request("http://hub.test/admin/config/vault", {
360
- method: "POST",
361
- headers: { ...headers, cookie },
362
- body,
363
- });
364
- const res = await handleAdminConfigPost(harness.db, req, "vault", {
365
- loadServicesManifest: vaultServices,
366
- configDir: harness.configDir,
367
- readManifest: fakeReadManifest,
368
- });
369
- expect(res.status).toBe(400);
370
- });
371
-
372
- test("returns 404 for unknown module names", async () => {
373
- const cookie = await cookieForUser(harness.db, "admin", "pw");
374
- const { body, headers } = postBody({});
375
- const req = new Request("http://hub.test/admin/config/nope", {
376
- method: "POST",
377
- headers: { ...headers, cookie },
378
- body,
379
- });
380
- const res = await handleAdminConfigPost(harness.db, req, "nope", {
381
- loadServicesManifest: vaultServices,
382
- configDir: harness.configDir,
383
- readManifest: fakeReadManifest,
384
- });
385
- expect(res.status).toBe(404);
386
- });
387
-
388
- test("re-renders with field errors (422) when validation fails", async () => {
389
- const cookie = await cookieForUser(harness.db, "admin", "pw");
390
- const restarts: string[] = [];
391
- const { body, headers } = postBody({
392
- transcribe_provider: "whisper", // not in enum
393
- max_tags_per_note: "10",
394
- });
395
- const req = new Request("http://hub.test/admin/config/vault", {
396
- method: "POST",
397
- headers: { ...headers, cookie },
398
- body,
399
- });
400
- const res = await handleAdminConfigPost(harness.db, req, "vault", {
401
- loadServicesManifest: vaultServices,
402
- configDir: harness.configDir,
403
- readManifest: fakeReadManifest,
404
- restartService: async (name) => {
405
- restarts.push(name);
406
- return 0;
407
- },
408
- });
409
- expect(res.status).toBe(422);
410
- const html = await res.text();
411
- expect(html).toContain("must be one of");
412
- expect(restarts).toEqual([]); // restart never called
413
- // The on-disk config must not have been written.
414
- expect(existsSync(join(harness.configDir, "vault", "config.json"))).toBe(false);
415
- });
416
-
417
- test("writes config + triggers restart + redirects with saved flash", async () => {
418
- const cookie = await cookieForUser(harness.db, "admin", "pw");
419
- const restarts: string[] = [];
420
- const { body, headers } = postBody({
421
- transcribe_provider: "deepgram",
422
- max_tags_per_note: "25",
423
- // checkbox absent → public stays false
424
- });
425
- const req = new Request("http://hub.test/admin/config/vault", {
426
- method: "POST",
427
- headers: { ...headers, cookie },
428
- body,
429
- });
430
- const res = await handleAdminConfigPost(harness.db, req, "vault", {
431
- loadServicesManifest: vaultServices,
432
- configDir: harness.configDir,
433
- readManifest: fakeReadManifest,
434
- restartService: async (name) => {
435
- restarts.push(name);
436
- return 0;
437
- },
438
- });
439
- expect(res.status).toBe(302);
440
- const location = res.headers.get("location") ?? "";
441
- expect(location).toContain("/admin/config?");
442
- expect(location).toContain("_status=saved");
443
- expect(location).toContain("_module=vault");
444
- expect(location).toContain("#module-vault");
445
- expect(restarts).toEqual(["vault"]);
446
- const written = JSON.parse(
447
- readFileSync(join(harness.configDir, "vault", "config.json"), "utf8"),
448
- );
449
- expect(written).toEqual({
450
- transcribe_provider: "deepgram",
451
- max_tags_per_note: 25,
452
- public: false,
453
- });
454
- });
455
-
456
- test("flashes saved-restart-failed when the restart returns non-zero", async () => {
457
- const cookie = await cookieForUser(harness.db, "admin", "pw");
458
- const { body, headers } = postBody({
459
- transcribe_provider: "openai",
460
- max_tags_per_note: "5",
461
- });
462
- const req = new Request("http://hub.test/admin/config/vault", {
463
- method: "POST",
464
- headers: { ...headers, cookie },
465
- body,
466
- });
467
- const res = await handleAdminConfigPost(harness.db, req, "vault", {
468
- loadServicesManifest: vaultServices,
469
- configDir: harness.configDir,
470
- readManifest: fakeReadManifest,
471
- restartService: async () => 1,
472
- });
473
- expect(res.status).toBe(302);
474
- const location = res.headers.get("location") ?? "";
475
- expect(location).toContain("_status=saved-restart-failed");
476
- // Config was still written before the restart attempt.
477
- expect(existsSync(join(harness.configDir, "vault", "config.json"))).toBe(true);
478
- });
479
-
480
- test("flashes saved-restart-failed with err detail when restart throws", async () => {
481
- const cookie = await cookieForUser(harness.db, "admin", "pw");
482
- const { body, headers } = postBody({
483
- transcribe_provider: "openai",
484
- max_tags_per_note: "5",
485
- });
486
- const req = new Request("http://hub.test/admin/config/vault", {
487
- method: "POST",
488
- headers: { ...headers, cookie },
489
- body,
490
- });
491
- const res = await handleAdminConfigPost(harness.db, req, "vault", {
492
- loadServicesManifest: vaultServices,
493
- configDir: harness.configDir,
494
- readManifest: fakeReadManifest,
495
- restartService: async () => {
496
- throw new Error("launchctl unavailable");
497
- },
498
- });
499
- expect(res.status).toBe(302);
500
- const location = res.headers.get("location") ?? "";
501
- expect(location).toContain("_status=saved-restart-failed");
502
- const errParam = new URL(location, "http://hub.test").searchParams.get("_err") ?? "";
503
- expect(errParam).toContain("launchctl unavailable");
504
- });
505
-
506
- test("checkbox-on translates to `public: true` in the written config", async () => {
507
- const cookie = await cookieForUser(harness.db, "admin", "pw");
508
- const { body, headers } = postBody({
509
- transcribe_provider: "openai",
510
- max_tags_per_note: "5",
511
- public: "true",
512
- });
513
- const req = new Request("http://hub.test/admin/config/vault", {
514
- method: "POST",
515
- headers: { ...headers, cookie },
516
- body,
517
- });
518
- const res = await handleAdminConfigPost(harness.db, req, "vault", {
519
- loadServicesManifest: vaultServices,
520
- configDir: harness.configDir,
521
- readManifest: fakeReadManifest,
522
- restartService: async () => 0,
523
- });
524
- expect(res.status).toBe(302);
525
- const written = JSON.parse(
526
- readFileSync(join(harness.configDir, "vault", "config.json"), "utf8"),
527
- );
528
- expect(written.public).toBe(true);
529
- });
530
- });
@@ -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
  });