@openparachute/hub 0.5.10-rc.6 → 0.5.10

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 (51) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-handlers.test.ts +141 -6
  3. package/src/__tests__/api-account.test.ts +463 -0
  4. package/src/__tests__/api-modules-ops.test.ts +139 -0
  5. package/src/__tests__/api-modules.test.ts +134 -0
  6. package/src/__tests__/api-users.test.ts +522 -0
  7. package/src/__tests__/cors.test.ts +587 -0
  8. package/src/__tests__/hub-db.test.ts +126 -1
  9. package/src/__tests__/hub-server.test.ts +29 -4
  10. package/src/__tests__/hub-settings.test.ts +377 -0
  11. package/src/__tests__/hub.test.ts +17 -0
  12. package/src/__tests__/jwt-sign.test.ts +59 -0
  13. package/src/__tests__/oauth-handlers.test.ts +1059 -10
  14. package/src/__tests__/oauth-ui.test.ts +210 -0
  15. package/src/__tests__/scope-explanations.test.ts +23 -0
  16. package/src/__tests__/serve.test.ts +8 -1
  17. package/src/__tests__/setup-wizard.test.ts +1500 -13
  18. package/src/__tests__/supervisor.test.ts +76 -2
  19. package/src/__tests__/users.test.ts +196 -0
  20. package/src/__tests__/vault-name.test.ts +79 -0
  21. package/src/__tests__/vault-names.test.ts +172 -0
  22. package/src/account-change-password-ui.ts +379 -0
  23. package/src/admin-handlers.ts +68 -2
  24. package/src/admin-host-admin-token.ts +5 -0
  25. package/src/admin-vault-admin-token.ts +7 -0
  26. package/src/api-account.ts +443 -0
  27. package/src/api-mint-token.ts +6 -0
  28. package/src/api-modules-ops.ts +30 -6
  29. package/src/api-modules.ts +101 -0
  30. package/src/api-users.ts +393 -0
  31. package/src/commands/auth.ts +10 -1
  32. package/src/commands/serve.ts +5 -1
  33. package/src/cors.ts +263 -0
  34. package/src/hub-db.ts +54 -0
  35. package/src/hub-server.ts +162 -18
  36. package/src/hub-settings.ts +259 -0
  37. package/src/hub.ts +34 -9
  38. package/src/jwt-sign.ts +17 -1
  39. package/src/oauth-handlers.ts +256 -29
  40. package/src/oauth-ui.ts +451 -38
  41. package/src/operator-token.ts +4 -0
  42. package/src/scope-explanations.ts +26 -1
  43. package/src/setup-wizard.ts +1100 -56
  44. package/src/supervisor.ts +66 -14
  45. package/src/users.ts +210 -3
  46. package/src/vault-name.ts +71 -0
  47. package/src/vault-names.ts +57 -0
  48. package/web/ui/dist/assets/index-XhxYXDT5.js +61 -0
  49. package/web/ui/dist/assets/{index-D54otIhv.css → index-p6DkOcsk.css} +1 -1
  50. package/web/ui/dist/index.html +2 -2
  51. package/web/ui/dist/assets/index-AX_UHJ5e.js +0 -61
@@ -13,6 +13,7 @@ import {
13
13
  parseModulesPath,
14
14
  } from "../api-modules-ops.ts";
15
15
  import { hubDbPath, openHubDb } from "../hub-db.ts";
16
+ import { setModuleInstallChannel } from "../hub-settings.ts";
16
17
  import { recordTokenMint, signAccessToken } from "../jwt-sign.ts";
17
18
  import { rotateSigningKey } from "../signing-keys.ts";
18
19
  import { type SpawnRequest, type SupervisedProc, Supervisor } from "../supervisor.ts";
@@ -298,6 +299,54 @@ describe("POST /api/modules/:short/install", () => {
298
299
  expect(op.status).toBe("succeeded");
299
300
  });
300
301
 
302
+ test("uses the rc channel when hub_settings.module_install_channel = rc (hub#275)", async () => {
303
+ // Operator's set the channel via the SPA toggle / env var bootstrap;
304
+ // the next install must construct `<pkg>@rc` rather than `<pkg>@latest`.
305
+ setModuleInstallChannel(h.db, "rc");
306
+ const { supervisor } = makeIdleSupervisor();
307
+ const { run, calls } = alwaysOkRun();
308
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
309
+ const res = await handleInstall(
310
+ postReq("/api/modules/vault/install", { authorization: `Bearer ${bearer}` }),
311
+ "vault",
312
+ {
313
+ db: h.db,
314
+ issuer: ISSUER,
315
+ manifestPath: h.manifestPath,
316
+ configDir: h.dir,
317
+ supervisor,
318
+ run,
319
+ },
320
+ );
321
+ expect(res.status).toBe(202);
322
+ await new Promise((r) => setTimeout(r, 10));
323
+ expect(calls).toContainEqual(["bun", "add", "-g", "@openparachute/vault@rc"]);
324
+ expect(calls).not.toContainEqual(["bun", "add", "-g", "@openparachute/vault@latest"]);
325
+ });
326
+
327
+ test("toggling channel back to latest takes effect on next install (no restart)", async () => {
328
+ setModuleInstallChannel(h.db, "rc");
329
+ setModuleInstallChannel(h.db, "latest");
330
+ const { supervisor } = makeIdleSupervisor();
331
+ const { run, calls } = alwaysOkRun();
332
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
333
+ await handleInstall(
334
+ postReq("/api/modules/vault/install", { authorization: `Bearer ${bearer}` }),
335
+ "vault",
336
+ {
337
+ db: h.db,
338
+ issuer: ISSUER,
339
+ manifestPath: h.manifestPath,
340
+ configDir: h.dir,
341
+ supervisor,
342
+ run,
343
+ },
344
+ );
345
+ await new Promise((r) => setTimeout(r, 10));
346
+ expect(calls).toContainEqual(["bun", "add", "-g", "@openparachute/vault@latest"]);
347
+ expect(calls).not.toContainEqual(["bun", "add", "-g", "@openparachute/vault@rc"]);
348
+ });
349
+
301
350
  test("failed bun-add surfaces failed status on the operation", async () => {
302
351
  const { supervisor } = makeIdleSupervisor();
303
352
  // Run returns 1 + findGlobalInstall returns null = real failure.
@@ -415,6 +464,96 @@ describe("POST /api/modules/:short/upgrade", () => {
415
464
  await new Promise((r) => setTimeout(r, 10));
416
465
  expect(calls).toContainEqual(["bun", "add", "-g", "@openparachute/vault@latest"]);
417
466
  });
467
+
468
+ test("uses the rc channel when hub_settings.module_install_channel = rc (hub#275)", async () => {
469
+ setModuleInstallChannel(h.db, "rc");
470
+ const { supervisor } = makeIdleSupervisor();
471
+ await supervisor.start({ short: "vault", cmd: ["parachute-vault", "serve"] });
472
+
473
+ const { run, calls } = alwaysOkRun();
474
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
475
+ const res = await handleUpgrade(
476
+ postReq("/api/modules/vault/upgrade", { authorization: `Bearer ${bearer}` }),
477
+ "vault",
478
+ {
479
+ db: h.db,
480
+ issuer: ISSUER,
481
+ manifestPath: h.manifestPath,
482
+ configDir: h.dir,
483
+ supervisor,
484
+ run,
485
+ },
486
+ );
487
+ expect(res.status).toBe(202);
488
+ await new Promise((r) => setTimeout(r, 10));
489
+ expect(calls).toContainEqual(["bun", "add", "-g", "@openparachute/vault@rc"]);
490
+ expect(calls).not.toContainEqual(["bun", "add", "-g", "@openparachute/vault@latest"]);
491
+ });
492
+
493
+ test("fails with 'try install first' when module is installed but never supervised", async () => {
494
+ // Module has a services.json row (e.g. seeded by `parachute install`
495
+ // pre-supervisor era) but the supervisor never spawned it.
496
+ // `bun add -g` succeeds, then `supervisor.restart()` returns
497
+ // undefined because there's no entry in the Map. The operation
498
+ // should land in `failed` with the canonical "try install first"
499
+ // message rather than silently succeed (hub#265).
500
+ writeManifest(h.manifestPath, [
501
+ {
502
+ name: "parachute-vault",
503
+ port: 1940,
504
+ paths: ["/vault/default"],
505
+ health: "/vault/default/health",
506
+ version: "0.4.5",
507
+ },
508
+ ]);
509
+ const { supervisor, spawns } = makeIdleSupervisor();
510
+ // Intentionally do NOT call supervisor.start(...) — that's the
511
+ // path under test.
512
+
513
+ const { run, calls } = alwaysOkRun();
514
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
515
+ const deps = {
516
+ db: h.db,
517
+ issuer: ISSUER,
518
+ manifestPath: h.manifestPath,
519
+ configDir: h.dir,
520
+ supervisor,
521
+ run,
522
+ };
523
+ const res = await handleUpgrade(
524
+ postReq("/api/modules/vault/upgrade", { authorization: `Bearer ${bearer}` }),
525
+ "vault",
526
+ deps,
527
+ );
528
+ expect(res.status).toBe(202);
529
+ const body = (await res.json()) as { operation_id: string };
530
+ // Give the async runUpgrade chain a tick to settle.
531
+ await new Promise((r) => setTimeout(r, 10));
532
+
533
+ // bun add was still attempted (it's the first step).
534
+ expect(calls).toContainEqual(["bun", "add", "-g", "@openparachute/vault@latest"]);
535
+ // No supervisor spawn ever happened — confirms the missing
536
+ // supervisor entry is what we exercised, not some other branch.
537
+ expect(spawns).toEqual([]);
538
+
539
+ // Poll the operation: status `failed`, message points the
540
+ // operator at the install path.
541
+ const opRes = await handleOperationGet(
542
+ getReq(`/api/modules/operations/${body.operation_id}`, {
543
+ authorization: `Bearer ${bearer}`,
544
+ }),
545
+ body.operation_id,
546
+ deps,
547
+ );
548
+ const op = (await opRes.json()) as {
549
+ status: string;
550
+ error?: string;
551
+ log: string[];
552
+ };
553
+ expect(op.status).toBe("failed");
554
+ expect(op.error).toMatch(/supervisor restart found no module/);
555
+ expect(op.log.join(" ")).toMatch(/try install first/);
556
+ });
418
557
  });
419
558
 
420
559
  describe("POST /api/modules/:short/uninstall", () => {
@@ -3,11 +3,14 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import {
6
+ API_MODULES_CHANNEL_REQUIRED_SCOPE,
6
7
  API_MODULES_REQUIRED_SCOPE,
7
8
  _clearLatestVersionCacheForTests,
8
9
  handleApiModules,
10
+ handleApiModulesChannel,
9
11
  } from "../api-modules.ts";
10
12
  import { hubDbPath, openHubDb } from "../hub-db.ts";
13
+ import { getSetting, setModuleInstallChannel } from "../hub-settings.ts";
11
14
  import { recordTokenMint, signAccessToken } from "../jwt-sign.ts";
12
15
  import { rotateSigningKey } from "../signing-keys.ts";
13
16
  import { type SpawnRequest, type SupervisedProc, Supervisor } from "../supervisor.ts";
@@ -289,4 +292,135 @@ describe("GET /api/modules", () => {
289
292
  expect(callsAfterFirst).toBeGreaterThan(0);
290
293
  expect(calls).toBe(callsAfterFirst);
291
294
  });
295
+
296
+ test("surfaces module_install_channel in the response (hub#275)", async () => {
297
+ // Default — first read seeds with `latest`.
298
+ const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
299
+ const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
300
+ db: h.db,
301
+ issuer: ISSUER,
302
+ manifestPath: h.manifestPath,
303
+ fetchLatestVersion: async () => null,
304
+ });
305
+ expect(res.status).toBe(200);
306
+ const body = (await res.json()) as { module_install_channel: string };
307
+ expect(body.module_install_channel).toBe("latest");
308
+ });
309
+
310
+ test("module_install_channel reflects toggled value on next GET", async () => {
311
+ setModuleInstallChannel(h.db, "rc");
312
+ const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
313
+ const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
314
+ db: h.db,
315
+ issuer: ISSUER,
316
+ manifestPath: h.manifestPath,
317
+ fetchLatestVersion: async () => null,
318
+ });
319
+ const body = (await res.json()) as { module_install_channel: string };
320
+ expect(body.module_install_channel).toBe("rc");
321
+ });
322
+ });
323
+
324
+ describe("PUT /api/modules/channel — hub#275 channel toggle", () => {
325
+ let h: Harness;
326
+
327
+ beforeEach(async () => {
328
+ h = await makeHarness();
329
+ });
330
+ afterEach(() => h.cleanup());
331
+
332
+ function putReq(body: unknown, headers: Record<string, string> = {}): Request {
333
+ return new Request("http://localhost/api/modules/channel", {
334
+ method: "PUT",
335
+ headers: { "content-type": "application/json", ...headers },
336
+ body: typeof body === "string" ? body : JSON.stringify(body),
337
+ });
338
+ }
339
+
340
+ test("405 on non-PUT", async () => {
341
+ const bearer = await mintBearer(h, [API_MODULES_CHANNEL_REQUIRED_SCOPE]);
342
+ const res = await handleApiModulesChannel(
343
+ new Request("http://localhost/api/modules/channel", {
344
+ method: "POST",
345
+ headers: { authorization: `Bearer ${bearer}` },
346
+ }),
347
+ { db: h.db, issuer: ISSUER },
348
+ );
349
+ expect(res.status).toBe(405);
350
+ });
351
+
352
+ test("401 on missing bearer", async () => {
353
+ const res = await handleApiModulesChannel(putReq({ channel: "rc" }), {
354
+ db: h.db,
355
+ issuer: ISSUER,
356
+ });
357
+ expect(res.status).toBe(401);
358
+ });
359
+
360
+ test("403 on bearer without parachute:host:admin", async () => {
361
+ // `:host:auth` reads the GET catalog — it must NOT be allowed to
362
+ // flip the install channel. Boundary matches install/upgrade/uninstall.
363
+ const bearer = await mintBearer(h, ["parachute:host:auth"]);
364
+ const res = await handleApiModulesChannel(
365
+ putReq({ channel: "rc" }, { authorization: `Bearer ${bearer}` }),
366
+ { db: h.db, issuer: ISSUER },
367
+ );
368
+ expect(res.status).toBe(403);
369
+ const body = (await res.json()) as { error: string; error_description: string };
370
+ expect(body.error).toBe("insufficient_scope");
371
+ expect(body.error_description).toContain("parachute:host:admin");
372
+ });
373
+
374
+ test("400 on malformed body (not JSON)", async () => {
375
+ const bearer = await mintBearer(h, [API_MODULES_CHANNEL_REQUIRED_SCOPE]);
376
+ const res = await handleApiModulesChannel(
377
+ putReq("not-json", { authorization: `Bearer ${bearer}` }),
378
+ { db: h.db, issuer: ISSUER },
379
+ );
380
+ expect(res.status).toBe(400);
381
+ });
382
+
383
+ test("400 on invalid channel value", async () => {
384
+ const bearer = await mintBearer(h, [API_MODULES_CHANNEL_REQUIRED_SCOPE]);
385
+ const res = await handleApiModulesChannel(
386
+ putReq({ channel: "stable" }, { authorization: `Bearer ${bearer}` }),
387
+ { db: h.db, issuer: ISSUER },
388
+ );
389
+ expect(res.status).toBe(400);
390
+ const body = (await res.json()) as { error: string; error_description: string };
391
+ expect(body.error).toBe("invalid_channel");
392
+ expect(body.error_description).toMatch(/latest, rc/);
393
+ });
394
+
395
+ test("400 on missing channel field", async () => {
396
+ const bearer = await mintBearer(h, [API_MODULES_CHANNEL_REQUIRED_SCOPE]);
397
+ const res = await handleApiModulesChannel(
398
+ putReq({ foo: "bar" }, { authorization: `Bearer ${bearer}` }),
399
+ { db: h.db, issuer: ISSUER },
400
+ );
401
+ expect(res.status).toBe(400);
402
+ });
403
+
404
+ test("200 + writes the new channel to hub_settings", async () => {
405
+ const bearer = await mintBearer(h, [API_MODULES_CHANNEL_REQUIRED_SCOPE]);
406
+ const res = await handleApiModulesChannel(
407
+ putReq({ channel: "rc" }, { authorization: `Bearer ${bearer}` }),
408
+ { db: h.db, issuer: ISSUER },
409
+ );
410
+ expect(res.status).toBe(200);
411
+ const body = (await res.json()) as { channel: string };
412
+ expect(body.channel).toBe("rc");
413
+ expect(getSetting(h.db, "module_install_channel")).toBe("rc");
414
+ });
415
+
416
+ test("200 + can toggle back to latest", async () => {
417
+ setModuleInstallChannel(h.db, "rc");
418
+ const bearer = await mintBearer(h, [API_MODULES_CHANNEL_REQUIRED_SCOPE]);
419
+ const res = await handleApiModulesChannel(
420
+ putReq({ channel: "latest" }, { authorization: `Bearer ${bearer}` }),
421
+ { db: h.db, issuer: ISSUER },
422
+ );
423
+ expect(res.status).toBe(200);
424
+ expect(getSetting(h.db, "module_install_channel")).toBe("latest");
425
+ });
292
426
  });