@openparachute/hub 0.5.13 → 0.5.14-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 (47) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/account-home-ui.test.ts +163 -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-ops.test.ts +97 -0
  8. package/src/__tests__/api-modules.test.ts +32 -32
  9. package/src/__tests__/api-users.test.ts +383 -11
  10. package/src/__tests__/chrome-strip.test.ts +15 -15
  11. package/src/__tests__/hub-db.test.ts +194 -29
  12. package/src/__tests__/hub-server.test.ts +23 -23
  13. package/src/__tests__/notes-redirect.test.ts +20 -20
  14. package/src/__tests__/oauth-handlers.test.ts +722 -28
  15. package/src/__tests__/serve.test.ts +9 -9
  16. package/src/__tests__/services-manifest.test.ts +40 -40
  17. package/src/__tests__/setup-wizard.test.ts +493 -25
  18. package/src/__tests__/setup.test.ts +1 -1
  19. package/src/__tests__/status.test.ts +39 -0
  20. package/src/__tests__/users.test.ts +396 -9
  21. package/src/__tests__/well-known.test.ts +9 -9
  22. package/src/account-home-ui.ts +434 -0
  23. package/src/admin-handlers.ts +49 -17
  24. package/src/admin-host-admin-token.ts +25 -0
  25. package/src/admin-vault-admin-token.ts +17 -0
  26. package/src/api-account.ts +72 -6
  27. package/src/api-modules-ops.ts +52 -16
  28. package/src/api-modules.ts +3 -3
  29. package/src/api-users.ts +468 -55
  30. package/src/bun-link.ts +55 -0
  31. package/src/chrome-strip.ts +6 -6
  32. package/src/commands/install.ts +8 -21
  33. package/src/commands/status.ts +10 -1
  34. package/src/help.ts +2 -2
  35. package/src/hub-db.ts +42 -0
  36. package/src/hub-server.ts +69 -10
  37. package/src/hub-settings.ts +2 -2
  38. package/src/hub.ts +6 -6
  39. package/src/notes-redirect.ts +5 -5
  40. package/src/oauth-handlers.ts +278 -173
  41. package/src/oauth-ui.ts +18 -2
  42. package/src/service-spec.ts +39 -18
  43. package/src/setup-wizard.ts +489 -42
  44. package/src/users.ts +307 -29
  45. package/web/ui/dist/assets/index-tRmPbbC7.js +61 -0
  46. package/web/ui/dist/index.html +1 -1
  47. package/web/ui/dist/assets/index-Dzrbe6EP.js +0 -61
@@ -48,6 +48,8 @@
48
48
  import type { Database } from "bun:sqlite";
49
49
  import { hash as argonHash } from "@node-rs/argon2";
50
50
  import { type ChangePasswordMode, renderChangePassword } from "./account-change-password-ui.ts";
51
+ import { renderAccountHome } from "./account-home-ui.ts";
52
+ import { POST_LOGIN_DEFAULT } from "./admin-handlers.ts";
51
53
  import { renderAdminError } from "./admin-login-ui.ts";
52
54
  import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
53
55
  import { changePasswordRateLimiter } from "./rate-limit.ts";
@@ -57,6 +59,7 @@ import {
57
59
  PASSWORD_MAX_LEN,
58
60
  UserNotFoundError,
59
61
  getUserById,
62
+ isFirstAdmin,
60
63
  validatePassword,
61
64
  verifyPassword,
62
65
  } from "./users.ts";
@@ -69,12 +72,10 @@ export interface ApiAccountDeps {
69
72
 
70
73
  /**
71
74
  * Where to land after a successful password change when no `next` param
72
- * is present. Matches `POST_LOGIN_DEFAULT` in `admin-handlers.ts` the
73
- * admin SPA's vault list. Kept as a local const (not imported) so this
74
- * file doesn't accidentally couple to admin-handlers' internals; if the
75
- * default ever diverges the two should reconcile via a shared config.
75
+ * is present. Re-exported from `admin-handlers.ts` so login + change-
76
+ * password share a single source of truth (reviewer fold on hub#425).
76
77
  */
77
- const POST_CHANGE_DEFAULT = "/admin/vaults";
78
+ const POST_CHANGE_DEFAULT = POST_LOGIN_DEFAULT;
78
79
 
79
80
  function safeNext(raw: string | null | undefined): string {
80
81
  if (!raw) return POST_CHANGE_DEFAULT;
@@ -234,7 +235,20 @@ export async function handleAccountChangePasswordPost(
234
235
  const currentPassword = String(form.get("current_password") ?? "");
235
236
  const newPassword = String(form.get("new_password") ?? "");
236
237
  const confirmPassword = String(form.get("new_password_confirm") ?? "");
237
- const next = safeNext(String(form.get("next") ?? ""));
238
+ // Friend-facing redirect: non-admin users who would otherwise land in the
239
+ // admin SPA (because POST_CHANGE_DEFAULT = /admin/vaults) get bounced to
240
+ // /account/ directly. Without this, the SPA loads, hits 403 on its host-
241
+ // admin-token mint, then redirects to /account/ via the SPA's auth.ts —
242
+ // a visible two-hop flash for the friend. The login-redirect path
243
+ // (admin-handlers.ts:118) does the same rewrite at sign-in time; this
244
+ // mirrors it for the change-password POST. (hub#425 reviewer fold —
245
+ // operator runbook accuracy: the doc said "lands at /account/", but
246
+ // without this fix the user briefly sees the admin shell.)
247
+ const rawNext = safeNext(String(form.get("next") ?? ""));
248
+ const next =
249
+ !isFirstAdmin(deps.db, user.id) && (rawNext === "/admin" || rawNext.startsWith("/admin/"))
250
+ ? "/account/"
251
+ : rawNext;
238
252
  const mode = modeFor(user.passwordChanged);
239
253
 
240
254
  // Rate-limit gate (hub#282). Fires *after* CSRF (so a junk cross-site
@@ -437,6 +451,58 @@ export async function handleAccountChangePasswordPost(
437
451
  });
438
452
  }
439
453
 
454
+ /**
455
+ * GET /account/ — friend-facing user home (multi-user Phase 1 follow-up).
456
+ *
457
+ * Companion surface to the first-admin gate on
458
+ * `/admin/host-admin-token`: friend users who can't reach the admin
459
+ * SPA need a coherent landing page that shows their assigned vault, a
460
+ * sign-out form, and a link to rotate their password. The admin lands
461
+ * here too (via the SPA's 403 redirect path) but mostly bounces back
462
+ * to `/admin/` immediately — for admins this is a "wait, what?" exit
463
+ * ramp.
464
+ *
465
+ * Auth: requires an active session. Session-less requests 302 to
466
+ * `/login?next=/account/` — same posture as `handleAccountChangePasswordGet`.
467
+ *
468
+ * `hubOrigin` is passed in by the route handler (resolved per-request
469
+ * via `resolveIssuer` in `hub-server.ts`). The page uses it to build
470
+ * the canonical Notes "Open" CTA URL and to show as inline code in
471
+ * the "use a custom client" disclosure.
472
+ */
473
+ export interface AccountHomeDeps extends ApiAccountDeps {
474
+ /** Canonical hub origin for this request (e.g. `https://my-hub.example`). */
475
+ hubOrigin: string;
476
+ }
477
+
478
+ export function handleAccountHomeGet(req: Request, deps: AccountHomeDeps): Response {
479
+ const session = findActiveSession(deps.db, req);
480
+ if (!session) {
481
+ return redirect(`/login?next=${encodeURIComponent("/account/")}`);
482
+ }
483
+ const user = getUserById(deps.db, session.userId);
484
+ if (!user) {
485
+ // Stale session pointing at a deleted user — hand back to /login;
486
+ // the orphaned session row will time out on its own.
487
+ return redirect("/login");
488
+ }
489
+ const adminFlag = isFirstAdmin(deps.db, user.id);
490
+ const csrf = ensureCsrfToken(req);
491
+ const extra: Record<string, string> = csrf.setCookie ? { "set-cookie": csrf.setCookie } : {};
492
+ return htmlResponse(
493
+ renderAccountHome({
494
+ username: user.username,
495
+ assignedVaults: user.assignedVaults,
496
+ passwordChanged: user.passwordChanged,
497
+ hubOrigin: deps.hubOrigin,
498
+ isFirstAdmin: adminFlag,
499
+ csrfToken: csrf.token,
500
+ }),
501
+ 200,
502
+ extra,
503
+ );
504
+ }
505
+
440
506
  /**
441
507
  * Flip `users.password_changed` from 0 to 1 for the given user.
442
508
  * Idempotent — running against an already-`true` row is a no-op.
@@ -36,6 +36,7 @@ import type { Database } from "bun:sqlite";
36
36
  import { randomUUID } from "node:crypto";
37
37
  import { dirname } from "node:path";
38
38
  import { CURATED_MODULES, type CuratedModuleShort } from "./api-modules.ts";
39
+ import { isLinked as defaultIsLinked } from "./bun-link.ts";
39
40
  import { PARACHUTE_INSTALL_CHANNEL_ENV } from "./commands/install.ts";
40
41
  import { getModuleInstallChannel } from "./hub-settings.ts";
41
42
  import { validateAccessToken } from "./jwt-sign.ts";
@@ -187,6 +188,21 @@ export interface ApiModulesOpsDeps {
187
188
  * null when not found.
188
189
  */
189
190
  findGlobalInstall?: (pkg: string) => string | null;
191
+ /**
192
+ * Override `isLinked` (test seam). Production probes bun's globals
193
+ * for a symlink-shaped entry under `<prefix>/node_modules/<pkg>` —
194
+ * true iff the package was installed via `bun link` from a local
195
+ * checkout. When true, `runInstall` skips `bun add -g <pkg>`
196
+ * entirely; the linked checkout already provides the binary on
197
+ * PATH and `bun add -g` would either be a wasted npm round-trip
198
+ * or fail outright on unrelated global-lockfile noise (smoke
199
+ * 2026-05-27 finding 1).
200
+ *
201
+ * Mirrors the CLI install path's `isLinked` short-circuit in
202
+ * `commands/install.ts`. Both paths use the same `src/bun-link.ts`
203
+ * helper so they can't drift again.
204
+ */
205
+ isLinked?: (pkg: string) => boolean;
190
206
  /**
191
207
  * Extra env vars merged onto the supervised child at spawn time (hub#267).
192
208
  *
@@ -543,23 +559,43 @@ export async function runInstall(
543
559
  // without a hub restart.
544
560
  const channel = resolveApiInstallChannel(channelOverride, deps);
545
561
  const spec_str = `${spec.package}@${channel}`;
546
- registry.update(opId, { status: "running" }, `running bun add -g ${spec_str}`);
547
- const code = await run(["bun", "add", "-g", spec_str]);
548
- if (code !== 0) {
549
- // Bun 1.2.x lockfile-recovery noise: probe the global prefix
550
- // before treating non-zero as fatal. Mirrors the same defense in
551
- // commands/install.ts.
552
- const findGlobalInstall = deps.findGlobalInstall;
553
- const probed = findGlobalInstall?.(spec.package) ?? null;
554
- if (!probed) {
555
- registry.update(
556
- opId,
557
- { status: "failed", error: `bun add -g exited ${code}` },
558
- `bun add -g ${spec_str} failed (exit ${code})`,
559
- );
560
- return;
562
+ // bun-link short-circuit (smoke 2026-05-27, finding 1): mirror the
563
+ // CLI install path's `isLinked` check. When the package is already
564
+ // linked globally via `bun link <abspath>` (the standard local-dev
565
+ // shape Aaron + every workspace contributor runs this way), the
566
+ // linked checkout already provides the binary on PATH. `bun add -g`
567
+ // is at best a wasted ~3s npm round-trip and at worst a hard failure
568
+ // on unrelated global-lockfile noise (one stale entry can crash the
569
+ // whole `bun add`, failing the wizard's vault step even though the
570
+ // linked vault is fine). The wizard's parallel install path diverged
571
+ // pre-this-fix; the shared `src/bun-link.ts` keeps both paths in
572
+ // lockstep going forward.
573
+ const isLinked = deps.isLinked ?? defaultIsLinked;
574
+ if (isLinked(spec.package)) {
575
+ registry.update(
576
+ opId,
577
+ { status: "running" },
578
+ `${spec.package} is already linked globally (bun link) — skipping bun add -g`,
579
+ );
580
+ } else {
581
+ registry.update(opId, { status: "running" }, `running bun add -g ${spec_str}`);
582
+ const code = await run(["bun", "add", "-g", spec_str]);
583
+ if (code !== 0) {
584
+ // Bun 1.2.x lockfile-recovery noise: probe the global prefix
585
+ // before treating non-zero as fatal. Mirrors the same defense in
586
+ // commands/install.ts.
587
+ const findGlobalInstall = deps.findGlobalInstall;
588
+ const probed = findGlobalInstall?.(spec.package) ?? null;
589
+ if (!probed) {
590
+ registry.update(
591
+ opId,
592
+ { status: "failed", error: `bun add -g exited ${code}` },
593
+ `bun add -g ${spec_str} failed (exit ${code})`,
594
+ );
595
+ return;
596
+ }
597
+ registry.update(opId, {}, `bun add reported exit ${code} but package landed at ${probed}`);
561
598
  }
562
- registry.update(opId, {}, `bun add reported exit ${code} but package landed at ${probed}`);
563
599
  }
564
600
 
565
601
  // Seed services.json if absent (the install flow does this for the
@@ -88,7 +88,7 @@ export const API_MODULES_REQUIRED_SCOPE = "parachute:host:auth";
88
88
  * the pre-app architecture; scribe + runner come last because they
89
89
  * depend on a working vault + app to be useful).
90
90
  */
91
- export const CURATED_MODULES = ["vault", "app", "notes", "scribe", "runner"] as const;
91
+ export const CURATED_MODULES = ["vault", "surface", "notes", "scribe", "runner"] as const;
92
92
  export type CuratedModuleShort = (typeof CURATED_MODULES)[number];
93
93
 
94
94
  export interface ApiModulesDeps {
@@ -385,8 +385,8 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
385
385
  // (e.g. `/vault/default` + `/admin/` → `/vault/default/admin/`).
386
386
  // - Single-instance modules (app, scribe, runner) declare a
387
387
  // full hub-origin path that ALREADY includes the mount
388
- // (e.g. `/app/admin/`, `/scribe/admin`); the mount must NOT
389
- // be prepended again or the result is `/app/app/admin/`
388
+ // (e.g. `/surface/admin/`, `/scribe/admin`); the mount must NOT
389
+ // be prepended again or the result is `/app/surface/admin/`
390
390
  // (the audit bug caught 2026-05-25 on the SPA's Services
391
391
  // dropdown).
392
392
  // Detect by checking if candidate is already mount-prefixed.