@openparachute/hub 0.5.13 → 0.5.14-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 (37) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/account-home-ui.test.ts +140 -0
  3. package/src/__tests__/admin-handlers.test.ts +74 -0
  4. package/src/__tests__/admin-host-admin-token.test.ts +62 -0
  5. package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
  6. package/src/__tests__/api-account.test.ts +191 -1
  7. package/src/__tests__/api-modules.test.ts +32 -32
  8. package/src/__tests__/api-users.test.ts +192 -2
  9. package/src/__tests__/chrome-strip.test.ts +15 -15
  10. package/src/__tests__/hub-server.test.ts +23 -23
  11. package/src/__tests__/notes-redirect.test.ts +20 -20
  12. package/src/__tests__/services-manifest.test.ts +40 -40
  13. package/src/__tests__/setup-wizard.test.ts +157 -19
  14. package/src/__tests__/setup.test.ts +1 -1
  15. package/src/__tests__/status.test.ts +39 -0
  16. package/src/__tests__/users.test.ts +261 -0
  17. package/src/__tests__/well-known.test.ts +9 -9
  18. package/src/account-home-ui.ts +404 -0
  19. package/src/admin-handlers.ts +49 -17
  20. package/src/admin-host-admin-token.ts +25 -0
  21. package/src/admin-vault-admin-token.ts +17 -0
  22. package/src/api-account.ts +72 -6
  23. package/src/api-modules.ts +3 -3
  24. package/src/api-users.ts +173 -12
  25. package/src/chrome-strip.ts +6 -6
  26. package/src/commands/status.ts +10 -1
  27. package/src/help.ts +2 -2
  28. package/src/hub-server.ts +50 -10
  29. package/src/hub-settings.ts +2 -2
  30. package/src/hub.ts +6 -6
  31. package/src/notes-redirect.ts +5 -5
  32. package/src/service-spec.ts +39 -18
  33. package/src/setup-wizard.ts +335 -28
  34. package/src/users.ts +112 -0
  35. package/web/ui/dist/assets/index-Qf56GsGm.js +61 -0
  36. package/web/ui/dist/index.html +1 -1
  37. package/web/ui/dist/assets/index-Dzrbe6EP.js +0 -61
@@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { hubDbPath, openHubDb } from "../hub-db.ts";
6
+ import { recordTokenMint, signAccessToken } from "../jwt-sign.ts";
6
7
  import {
7
8
  PASSWORD_MIN_LEN,
8
9
  SingleUserModeError,
@@ -11,10 +12,13 @@ import {
11
12
  UsernameTakenError,
12
13
  createUser,
13
14
  deleteUser,
15
+ getFirstAdminId,
14
16
  getUserById,
15
17
  getUserByUsername,
16
18
  getUserByUsernameCI,
19
+ isFirstAdmin,
17
20
  listUsers,
21
+ resetUserPassword,
18
22
  setPassword,
19
23
  userCount,
20
24
  validatePassword,
@@ -348,3 +352,260 @@ describe("validatePassword", () => {
348
352
  expect(validatePassword("aaaaaaaaaaaa").valid).toBe(true);
349
353
  });
350
354
  });
355
+
356
+ describe("resetUserPassword", () => {
357
+ test("returns false when user does not exist", async () => {
358
+ const { db, cleanup } = makeDb();
359
+ try {
360
+ expect(await resetUserPassword(db, "no-such-id", "twelvechars1")).toBe(false);
361
+ } finally {
362
+ cleanup();
363
+ }
364
+ });
365
+
366
+ test("returns false when user vanishes between pre-check and tx body", async () => {
367
+ // Reviewer-flagged race path (hub#427). The argon2 hash is computed
368
+ // outside the transaction (async), giving a window where a concurrent
369
+ // delete can land between the existence pre-check and the UPDATE tx.
370
+ // The helper must return false in that case so the caller can 404
371
+ // instead of cosmetically claiming success.
372
+ const { db, cleanup } = makeDb();
373
+ try {
374
+ const user = await createUser(db, "alice", "alice-strong-passphrase", {
375
+ passwordChanged: true,
376
+ });
377
+ // Simulate the race: delete the row, then invoke the reset. The
378
+ // pre-check runs in `resetUserPassword` against the now-empty table.
379
+ // (We can't intercept between pre-check and tx without forking the
380
+ // helper; deleting before the call is the equivalent post-condition
381
+ // — if the row is gone the tx body will UPDATE 0 rows.)
382
+ db.prepare("DELETE FROM users WHERE id = ?").run(user.id);
383
+ expect(await resetUserPassword(db, user.id, "new-temp-passphrase")).toBe(false);
384
+ } finally {
385
+ cleanup();
386
+ }
387
+ });
388
+
389
+ test("rotates hash, flips password_changed back to 0, bumps updated_at", async () => {
390
+ const { db, cleanup } = makeDb();
391
+ try {
392
+ // Seed user as "already changed their password" (true) to prove the
393
+ // reset flips it back to false for the force-redirect rail.
394
+ const initial = await createUser(db, "alice", "alice-strong-passphrase", {
395
+ passwordChanged: true,
396
+ now: () => new Date(1000),
397
+ });
398
+ const oldHash = initial.passwordHash;
399
+ const oldUpdated = initial.updatedAt;
400
+ const later = new Date(2000);
401
+ expect(await resetUserPassword(db, initial.id, "new-temp-passphrase", () => later)).toBe(
402
+ true,
403
+ );
404
+ const fresh = getUserById(db, initial.id);
405
+ expect(fresh).not.toBeNull();
406
+ expect(fresh?.passwordHash).not.toBe(oldHash);
407
+ expect(fresh?.passwordChanged).toBe(false);
408
+ expect(fresh?.updatedAt).not.toBe(oldUpdated);
409
+ // Round-trip verify: old password no longer works, new one does.
410
+ expect(await verifyPassword(fresh!, "alice-strong-passphrase")).toBe(false);
411
+ expect(await verifyPassword(fresh!, "new-temp-passphrase")).toBe(true);
412
+ } finally {
413
+ cleanup();
414
+ }
415
+ });
416
+
417
+ test("revokes still-active tokens belonging to the user", async () => {
418
+ const { db, cleanup } = makeDb();
419
+ try {
420
+ const user = await createUser(db, "alice", "alice-strong-passphrase", {
421
+ passwordChanged: true,
422
+ });
423
+ const minted = await signAccessToken(db, {
424
+ sub: user.id,
425
+ scopes: ["vault:home:read"],
426
+ audience: "vault",
427
+ clientId: "notes-client",
428
+ issuer: "https://hub.test",
429
+ ttlSeconds: 600,
430
+ });
431
+ recordTokenMint(db, {
432
+ jti: minted.jti,
433
+ createdVia: "operator_mint",
434
+ subject: user.username,
435
+ userId: user.id,
436
+ clientId: "notes-client",
437
+ scopes: ["vault:home:read"],
438
+ expiresAt: minted.expiresAt,
439
+ });
440
+ // Pre-state: token row not yet revoked.
441
+ const before = db
442
+ .query<{ revoked_at: string | null }, [string]>(
443
+ "SELECT revoked_at FROM tokens WHERE jti = ?",
444
+ )
445
+ .get(minted.jti);
446
+ expect(before?.revoked_at).toBeNull();
447
+
448
+ expect(await resetUserPassword(db, user.id, "new-temp-passphrase")).toBe(true);
449
+
450
+ // Post-state: token row has revoked_at set, user_id retained (the
451
+ // user row sticks around, audit trail re-anchors naturally).
452
+ const after = db
453
+ .query<{ revoked_at: string | null; user_id: string | null }, [string]>(
454
+ "SELECT revoked_at, user_id FROM tokens WHERE jti = ?",
455
+ )
456
+ .get(minted.jti);
457
+ expect(after?.revoked_at).not.toBeNull();
458
+ expect(after?.user_id).toBe(user.id);
459
+ } finally {
460
+ cleanup();
461
+ }
462
+ });
463
+
464
+ test("does not re-revoke an already-revoked token", async () => {
465
+ // Defense-in-depth: a previously-revoked token shouldn't have its
466
+ // revoked_at timestamp overwritten by a fresh reset. The UPDATE's
467
+ // WHERE clause filters on `revoked_at IS NULL` so this is naturally
468
+ // enforced; pinning it here so a future refactor that drops the
469
+ // filter trips the test.
470
+ const { db, cleanup } = makeDb();
471
+ try {
472
+ const user = await createUser(db, "alice", "alice-strong-passphrase", {
473
+ passwordChanged: true,
474
+ });
475
+ const minted = await signAccessToken(db, {
476
+ sub: user.id,
477
+ scopes: ["vault:home:read"],
478
+ audience: "vault",
479
+ clientId: "notes-client",
480
+ issuer: "https://hub.test",
481
+ ttlSeconds: 600,
482
+ });
483
+ const earlierStamp = "2026-01-01T00:00:00.000Z";
484
+ recordTokenMint(db, {
485
+ jti: minted.jti,
486
+ createdVia: "operator_mint",
487
+ subject: user.username,
488
+ userId: user.id,
489
+ clientId: "notes-client",
490
+ scopes: ["vault:home:read"],
491
+ expiresAt: minted.expiresAt,
492
+ });
493
+ db.prepare("UPDATE tokens SET revoked_at = ? WHERE jti = ?").run(earlierStamp, minted.jti);
494
+
495
+ expect(await resetUserPassword(db, user.id, "new-temp-passphrase")).toBe(true);
496
+
497
+ const row = db
498
+ .query<{ revoked_at: string | null }, [string]>(
499
+ "SELECT revoked_at FROM tokens WHERE jti = ?",
500
+ )
501
+ .get(minted.jti);
502
+ expect(row?.revoked_at).toBe(earlierStamp);
503
+ } finally {
504
+ cleanup();
505
+ }
506
+ });
507
+
508
+ test("leaves tokens for other users untouched", async () => {
509
+ const { db, cleanup } = makeDb();
510
+ try {
511
+ const alice = await createUser(db, "alice", "alice-strong-passphrase", {
512
+ passwordChanged: true,
513
+ });
514
+ const bob = await createUser(db, "bob", "bob-strong-passphrase", {
515
+ allowMulti: true,
516
+ passwordChanged: true,
517
+ });
518
+ const bobToken = await signAccessToken(db, {
519
+ sub: bob.id,
520
+ scopes: ["vault:home:read"],
521
+ audience: "vault",
522
+ clientId: "notes-client",
523
+ issuer: "https://hub.test",
524
+ ttlSeconds: 600,
525
+ });
526
+ recordTokenMint(db, {
527
+ jti: bobToken.jti,
528
+ createdVia: "operator_mint",
529
+ subject: bob.username,
530
+ userId: bob.id,
531
+ clientId: "notes-client",
532
+ scopes: ["vault:home:read"],
533
+ expiresAt: bobToken.expiresAt,
534
+ });
535
+
536
+ await resetUserPassword(db, alice.id, "new-temp-passphrase");
537
+
538
+ const bobRow = db
539
+ .query<{ revoked_at: string | null }, [string]>(
540
+ "SELECT revoked_at FROM tokens WHERE jti = ?",
541
+ )
542
+ .get(bobToken.jti);
543
+ expect(bobRow?.revoked_at).toBeNull();
544
+ } finally {
545
+ cleanup();
546
+ }
547
+ });
548
+ });
549
+
550
+ describe("getFirstAdminId / isFirstAdmin", () => {
551
+ test("getFirstAdminId returns null on an empty users table", () => {
552
+ const { db, cleanup } = makeDb();
553
+ try {
554
+ expect(getFirstAdminId(db)).toBeNull();
555
+ } finally {
556
+ cleanup();
557
+ }
558
+ });
559
+
560
+ test("getFirstAdminId returns the earliest-created user id", async () => {
561
+ const { db, cleanup } = makeDb();
562
+ try {
563
+ const admin = await createUser(db, "admin", "pw1", { now: () => new Date(1000) });
564
+ await createUser(db, "alice", "pw2", {
565
+ allowMulti: true,
566
+ now: () => new Date(2000),
567
+ });
568
+ await createUser(db, "bob", "pw3", {
569
+ allowMulti: true,
570
+ now: () => new Date(3000),
571
+ });
572
+ expect(getFirstAdminId(db)).toBe(admin.id);
573
+ } finally {
574
+ cleanup();
575
+ }
576
+ });
577
+
578
+ test("isFirstAdmin matches earliest user, false for everyone else", async () => {
579
+ const { db, cleanup } = makeDb();
580
+ try {
581
+ const admin = await createUser(db, "admin", "pw1", { now: () => new Date(1000) });
582
+ const friend = await createUser(db, "alice", "pw2", {
583
+ allowMulti: true,
584
+ now: () => new Date(2000),
585
+ });
586
+ expect(isFirstAdmin(db, admin.id)).toBe(true);
587
+ expect(isFirstAdmin(db, friend.id)).toBe(false);
588
+ expect(isFirstAdmin(db, "no-such-id")).toBe(false);
589
+ } finally {
590
+ cleanup();
591
+ }
592
+ });
593
+
594
+ test("isFirstAdmin tracks the admin even after a later user is deleted", async () => {
595
+ // Deleting a non-first user doesn't promote anyone — the original
596
+ // admin still holds the "first" slot.
597
+ const { db, cleanup } = makeDb();
598
+ try {
599
+ const admin = await createUser(db, "admin", "pw1", { now: () => new Date(1000) });
600
+ const friend = await createUser(db, "alice", "pw2", {
601
+ allowMulti: true,
602
+ now: () => new Date(2000),
603
+ });
604
+ deleteUser(db, friend.id);
605
+ expect(getFirstAdminId(db)).toBe(admin.id);
606
+ expect(isFirstAdmin(db, admin.id)).toBe(true);
607
+ } finally {
608
+ cleanup();
609
+ }
610
+ });
611
+ });
@@ -426,10 +426,10 @@ describe("buildWellKnown", () => {
426
426
  // joined onto the canonical origin into a deep-linkable `url`.
427
427
  describe("uis hierarchical sub-units (hub#313)", () => {
428
428
  const app: ServiceEntry = {
429
- name: "parachute-app",
429
+ name: "parachute-surface",
430
430
  port: 1946,
431
- paths: ["/app"],
432
- health: "/app/healthz",
431
+ paths: ["/surface"],
432
+ health: "/surface/healthz",
433
433
  version: "0.1.0",
434
434
  };
435
435
 
@@ -455,7 +455,7 @@ describe("buildWellKnown", () => {
455
455
  services: [withUis],
456
456
  canonicalOrigin: "https://x.example",
457
457
  });
458
- const appSvc = doc.services.find((s) => s.name === "parachute-app");
458
+ const appSvc = doc.services.find((s) => s.name === "parachute-surface");
459
459
  expect(appSvc?.uis).toEqual([
460
460
  {
461
461
  name: "gitcoin-brain",
@@ -500,7 +500,7 @@ describe("buildWellKnown", () => {
500
500
  services: [empty],
501
501
  canonicalOrigin: "https://x.example",
502
502
  });
503
- const svc = doc.services.find((s) => s.name === "parachute-app");
503
+ const svc = doc.services.find((s) => s.name === "parachute-surface");
504
504
  expect(svc).not.toHaveProperty("uis");
505
505
  });
506
506
 
@@ -519,7 +519,7 @@ describe("buildWellKnown", () => {
519
519
  services: [withIcon],
520
520
  canonicalOrigin: "https://x.example",
521
521
  });
522
- const svc = doc.services.find((s) => s.name === "parachute-app");
522
+ const svc = doc.services.find((s) => s.name === "parachute-surface");
523
523
  expect(svc?.uis?.[0]?.iconUrl).toBe("https://x.example/app/slug/icon.svg");
524
524
  });
525
525
 
@@ -538,7 +538,7 @@ describe("buildWellKnown", () => {
538
538
  services: [withIcon],
539
539
  canonicalOrigin: "https://x.example",
540
540
  });
541
- const svc = doc.services.find((s) => s.name === "parachute-app");
541
+ const svc = doc.services.find((s) => s.name === "parachute-surface");
542
542
  expect(svc?.uis?.[0]?.iconUrl).toBe("https://cdn.example.com/icon.svg");
543
543
  });
544
544
 
@@ -562,7 +562,7 @@ describe("buildWellKnown", () => {
562
562
  services: [mixed],
563
563
  canonicalOrigin: "https://x.example",
564
564
  });
565
- const svc = doc.services.find((s) => s.name === "parachute-app");
565
+ const svc = doc.services.find((s) => s.name === "parachute-surface");
566
566
  const full = svc?.uis?.find((u) => u.name === "full");
567
567
  const minimal = svc?.uis?.find((u) => u.name === "minimal");
568
568
  expect(full?.tagline).toBe("Has it all");
@@ -593,7 +593,7 @@ describe("buildWellKnown", () => {
593
593
  services: [app1, app2],
594
594
  canonicalOrigin: "https://x.example",
595
595
  });
596
- const svc1 = doc.services.find((s) => s.name === "parachute-app");
596
+ const svc1 = doc.services.find((s) => s.name === "parachute-surface");
597
597
  const svc2 = doc.services.find((s) => s.name === "parachute-app-2");
598
598
  expect(svc1?.uis?.map((u) => u.name)).toEqual(["a"]);
599
599
  expect(svc2?.uis?.map((u) => u.name)).toEqual(["b"]);