@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
@@ -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
+ assignedVault: user.assignedVault,
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.
@@ -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.
package/src/api-users.ts CHANGED
@@ -9,18 +9,23 @@
9
9
  *
10
10
  * Surfaces:
11
11
  *
12
- * GET /api/users list users (host:admin)
13
- * POST /api/users create user (host:admin)
14
- * DELETE /api/users/:id hard-delete user (host:admin)
15
- * GET /api/users/vaults vault-name list for the assigned-vault
16
- * dropdown (host:admin)
12
+ * GET /api/users list users (host:admin)
13
+ * POST /api/users create user (host:admin)
14
+ * DELETE /api/users/:id hard-delete user (host:admin)
15
+ * POST /api/users/:id/reset-password admin password reset (host:admin)
16
+ * GET /api/users/vaults vault-name list for the
17
+ * assigned-vault dropdown
18
+ * (host:admin)
17
19
  *
18
20
  * Wire shape is snake_case (matches `/api/grants`, `/api/auth/tokens`).
19
21
  * Responses never include `password_hash` — hashes never leave the DB.
20
22
  *
21
- * Phase 1 deliberately ships only list / create / delete. Editing a user
22
- * (reassign vault, reset password) is Phase 2 work Phase 1's admin
23
- * recovery shape is "delete + re-create" per the design doc's §6.
23
+ * Phase 1 shipped list / create / delete. Phase 2 PR 1 adds admin
24
+ * password reset (this file's `handleResetUserPassword`)the highest-
25
+ * pain operator UX gap from Phase 1 was "friend forgot their password
26
+ * → operator has to delete+recreate," which is destructive-feeling
27
+ * even though vaults are independent of accounts. Reassign-vault and
28
+ * other edits land in later Phase 2 PRs.
24
29
  *
25
30
  * Auth: every endpoint requires a bearer token carrying the
26
31
  * `parachute:host:admin` scope. Same gate as `/api/grants`, `/vaults`,
@@ -45,9 +50,12 @@ import {
45
50
  UsernameTakenError,
46
51
  createUser,
47
52
  deleteUser,
53
+ getFirstAdminId,
48
54
  getUserById,
49
55
  getUserByUsernameCI,
56
+ isFirstAdmin,
50
57
  listUsers,
58
+ resetUserPassword,
51
59
  validatePassword,
52
60
  validateUsername,
53
61
  } from "./users.ts";
@@ -324,10 +332,13 @@ export async function handleDeleteUser(
324
332
  // by a state conflict — RFC 7231 §6.5.3 fits cleaner than §6.5.8.
325
333
  // Either is defensible; the wire `error` string is the part the SPA
326
334
  // matches on for the "first admin can't be deleted" surface).
327
- const firstAdminRow = deps.db
328
- .query<{ id: string }, []>("SELECT id FROM users ORDER BY created_at ASC LIMIT 1")
329
- .get();
330
- if (firstAdminRow && firstAdminRow.id === userId) {
335
+ //
336
+ // The `getFirstAdminId` helper (users.ts) is the single source of
337
+ // truth for "who is the admin" — same SELECT also gates the SPA
338
+ // bearer-mint endpoint (admin-host-admin-token.ts) and drives the
339
+ // non-admin login-redirect default.
340
+ const firstAdminId = getFirstAdminId(deps.db);
341
+ if (firstAdminId && firstAdminId === userId) {
331
342
  return jsonError(
332
343
  403,
333
344
  "first_admin_undeletable",
@@ -381,6 +392,156 @@ export async function handleListVaults(req: Request, deps: ApiUsersDeps): Promis
381
392
  });
382
393
  }
383
394
 
395
+ // ---------------------------------------------------------------------------
396
+ // POST /api/users/:id/reset-password — admin-initiated password reset
397
+ // ---------------------------------------------------------------------------
398
+
399
+ interface ResetPasswordBody {
400
+ new_password: string;
401
+ }
402
+
403
+ async function parseResetPasswordBody(
404
+ req: Request,
405
+ ): Promise<{ ok: true; body: ResetPasswordBody } | ParseErr> {
406
+ const ctype = req.headers.get("content-type") ?? "";
407
+ if (!ctype.toLowerCase().includes("application/json")) {
408
+ return {
409
+ ok: false,
410
+ status: 400,
411
+ error: "invalid_request",
412
+ description: "Content-Type must be application/json",
413
+ };
414
+ }
415
+ let raw: unknown;
416
+ try {
417
+ raw = await req.json();
418
+ } catch (err) {
419
+ const msg = err instanceof Error ? err.message : String(err);
420
+ return {
421
+ ok: false,
422
+ status: 400,
423
+ error: "invalid_request",
424
+ description: `invalid JSON body: ${msg}`,
425
+ };
426
+ }
427
+ if (!raw || typeof raw !== "object") {
428
+ return {
429
+ ok: false,
430
+ status: 400,
431
+ error: "invalid_request",
432
+ description: "request body must be a JSON object",
433
+ };
434
+ }
435
+ const obj = raw as Record<string, unknown>;
436
+ const newPassword = obj.new_password;
437
+ if (typeof newPassword !== "string" || newPassword.length === 0) {
438
+ return {
439
+ ok: false,
440
+ status: 400,
441
+ error: "invalid_request",
442
+ description: '"new_password" must be a non-empty string',
443
+ };
444
+ }
445
+ // Same CPU-DoS cap as `parseCreateBody` — bound the payload BEFORE
446
+ // argon2id touches it. 413 (request entity too large) is the canonical
447
+ // RFC 7231 status for "body fits, but a specific field exceeds policy."
448
+ if (newPassword.length > PASSWORD_MAX_LEN) {
449
+ return {
450
+ ok: false,
451
+ status: 413,
452
+ error: "password_too_long",
453
+ description: `password length must be ≤ ${PASSWORD_MAX_LEN} characters`,
454
+ };
455
+ }
456
+ return { ok: true, body: { new_password: newPassword } };
457
+ }
458
+
459
+ /**
460
+ * POST /api/users/:id/reset-password — admin sets a new temp password
461
+ * for a non-admin user. The user is force-redirected through
462
+ * `/account/change-password` on next sign-in (same rail as admin-created
463
+ * users), so the admin's chosen value is genuinely a "temporary one-
464
+ * time handoff" string rather than a long-lived password.
465
+ *
466
+ * Order of checks (mirrors `handleDeleteUser` for the first-admin gate
467
+ * and `handleCreateUser` for the parse / validate pipeline):
468
+ *
469
+ * 1. Method gate (405 on non-POST).
470
+ * 2. Bearer carries `parachute:host:admin` (401 / 403 via `requireScope`).
471
+ * 3. Parse + cap body (400 on shape, 413 on > PASSWORD_MAX_LEN).
472
+ * 4. Target user exists (404 `not_found`).
473
+ * 5. Target is NOT the first admin (403 `cannot_reset_first_admin`).
474
+ * Admin self-service uses `/account/change-password`; admin-reset
475
+ * is for friends only. Mirrors the first-admin-undeletable rail.
476
+ * 6. `validatePassword(new_password)` (400 `invalid_password`).
477
+ * 7. `resetUserPassword` — rotates hash, flips `password_changed=0`,
478
+ * revokes the user's still-active tokens, all in one tx.
479
+ *
480
+ * Response on success: `200 { ok: true, user: <wire shape> }`. We
481
+ * deliberately don't echo the password — the admin already typed it
482
+ * and will hand it to the friend out-of-band (Signal, in-person —
483
+ * same as the create-user default-password flow).
484
+ */
485
+ export async function handleResetUserPassword(
486
+ req: Request,
487
+ userId: string,
488
+ deps: ApiUsersDeps,
489
+ ): Promise<Response> {
490
+ if (req.method !== "POST") {
491
+ return jsonError(405, "method_not_allowed", "use POST");
492
+ }
493
+ try {
494
+ await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
495
+ } catch (err) {
496
+ return adminAuthErrorResponse(err as AdminAuthError);
497
+ }
498
+ const parsed = await parseResetPasswordBody(req);
499
+ if (!parsed.ok) {
500
+ return jsonError(parsed.status, parsed.error, parsed.description);
501
+ }
502
+ const target = getUserById(deps.db, userId);
503
+ if (!target) {
504
+ return jsonError(404, "not_found", `no user with id "${userId}"`);
505
+ }
506
+ // First-admin protection. The earliest-created row is the wizard or
507
+ // env-seeded admin by construction (Phase 1 has no role model). Reset
508
+ // by admin would be a self-action — the admin should use the normal
509
+ // `/account/change-password` rotate flow instead, which requires
510
+ // knowing the current password (genuine credential rotation, not a
511
+ // recovery reset). Pairs with the first-admin-undeletable rail above.
512
+ if (isFirstAdmin(deps.db, userId)) {
513
+ return jsonError(
514
+ 403,
515
+ "cannot_reset_first_admin",
516
+ "the first admin must use /account/change-password directly — admin password reset is for friend accounts",
517
+ );
518
+ }
519
+ const validity = validatePassword(parsed.body.new_password);
520
+ if (!validity.valid) {
521
+ return jsonError(
522
+ 400,
523
+ "invalid_password",
524
+ "password must be at least 12 characters (passphrase-friendly; no complexity rules)",
525
+ );
526
+ }
527
+ // `resetUserPassword` is idempotent on a missing row — returns false
528
+ // when the target vanished between `getUserById` and this call. Same
529
+ // race-tolerant 404 as `handleDeleteUser` for that path.
530
+ const ok = await resetUserPassword(deps.db, userId, parsed.body.new_password);
531
+ if (!ok) {
532
+ return jsonError(404, "not_found", `no user with id "${userId}"`);
533
+ }
534
+ console.log(`password reset by admin: id=${userId} username=${target.username}`);
535
+ // Re-read so the response carries the updated `password_changed=false`
536
+ // + bumped `updated_at`. Cheap (single SELECT). Saves the SPA a refetch
537
+ // to see the row's "pending first login" badge come back.
538
+ const fresh = getUserById(deps.db, userId);
539
+ return new Response(JSON.stringify({ ok: true, user: fresh ? toWire(fresh) : null }), {
540
+ status: 200,
541
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
542
+ });
543
+ }
544
+
384
545
  function describeUsernameReason(reason: "format" | "length" | "reserved"): string {
385
546
  switch (reason) {
386
547
  case "length":
@@ -13,7 +13,7 @@
13
13
  * (above that threshold the response is almost certainly not an HTML shell
14
14
  * anyway — SPA index.html files are < 16 KB in this ecosystem).
15
15
  *
16
- * Opt-out: hub-side path-prefix deny list. The Notes PWA at `/app/notes/*`
16
+ * Opt-out: hub-side path-prefix deny list. The Notes PWA at `/surface/notes/*`
17
17
  * is the canonical opt-out — it owns its own chrome (see design-system §7
18
18
  * "Where NOT to inject" + AUDIT §4: "Notes is the proof this can work: own
19
19
  * application, looks distinctively Notes, reads as Parachute because the
@@ -22,7 +22,7 @@
22
22
  * Why path-based and not module-declared:
23
23
  * - Notes is a `uis[]` sub-unit of parachute-app, not its own module —
24
24
  * adding `chrome: "off"` to parachute-app's module.json would suppress
25
- * chrome on `/app/admin/*` too (wrong: that surface SHOULD get chrome).
25
+ * chrome on `/surface/admin/*` too (wrong: that surface SHOULD get chrome).
26
26
  * - The per-uis well-known fan-out (workstream C/4) is in flight but the
27
27
  * hub side doesn't yet thread per-uis metadata into proxy dispatch.
28
28
  * - HTML meta-tag peeking adds parsing overhead on every response.
@@ -46,10 +46,10 @@ import { CSRF_FIELD_NAME, ensureCsrfToken } from "./csrf.ts";
46
46
  * prefix" or "pathname startsWith prefix" — the same shape as
47
47
  * `findServiceUpstream`'s mount comparison.
48
48
  *
49
- * `/app/notes/` covers the Notes PWA bundled by parachute-app. Notes is a
49
+ * `/surface/notes/` covers the Notes PWA bundled by parachute-app. Notes is a
50
50
  * destination, not chrome; it owns its own header (see design-system.md §7).
51
51
  */
52
- export const CHROME_OPT_OUT_PREFIXES: readonly string[] = ["/app/notes/"];
52
+ export const CHROME_OPT_OUT_PREFIXES: readonly string[] = ["/surface/notes/"];
53
53
 
54
54
  /**
55
55
  * Buffer size cap. Responses larger than this are passed through unchanged.
@@ -208,8 +208,8 @@ function renderSignedOutCluster(nextPath: string): string {
208
208
  * when any opt-out prefix matches (`pathname === prefix` or
209
209
  * `pathname startsWith prefix`).
210
210
  *
211
- * Match shape mirrors `findServiceUpstream` so an opt-out for `"/app/notes/"`
212
- * suppresses chrome for `/app/notes`, `/app/notes/`, and every sub-path.
211
+ * Match shape mirrors `findServiceUpstream` so an opt-out for `"/surface/notes/"`
212
+ * suppresses chrome for `/surface/notes`, `/surface/notes/`, and every sub-path.
213
213
  */
214
214
  export function shouldInjectChrome(
215
215
  pathname: string,
@@ -56,9 +56,18 @@ export async function probe(
56
56
  try {
57
57
  const res = await fetchImpl(url, { signal: controller.signal });
58
58
  const latencyMs = Math.round(performance.now() - start);
59
+ // A 401 is the service replying "I'm up but this endpoint requires auth"
60
+ // — that's strictly healthy from a liveness perspective. Vault's
61
+ // canonical health path `/vault/<name>/health` is auth-gated; without
62
+ // this carve-out, `parachute status` shows vault as "failing" on every
63
+ // fresh install (first impression UX disaster despite vault being fine).
64
+ // 5xx → unhealthy; 200-class → healthy; 401 → healthy + auth-gated.
65
+ // Other 4xx (404 / 400 / etc.) still count as unhealthy — those mean
66
+ // the configured health path doesn't exist or is shaped wrong.
67
+ const healthy = res.ok || res.status === 401;
59
68
  return {
60
69
  entry,
61
- healthy: res.ok,
70
+ healthy,
62
71
  statusCode: res.status,
63
72
  latencyMs,
64
73
  };
package/src/help.ts CHANGED
@@ -82,7 +82,7 @@ Environment:
82
82
 
83
83
  Examples:
84
84
  parachute install vault # installs, runs init, starts vault
85
- parachute install app # installs app (auto-bootstraps Notes)
85
+ parachute install surface # installs surface (auto-bootstraps Notes)
86
86
  parachute install notes # back-compat: legacy notes-daemon (Phase 2 deprecating)
87
87
  parachute install scribe # installs, prompts for provider, starts scribe
88
88
  parachute install scribe --scribe-provider groq --scribe-key gsk_…
@@ -188,7 +188,7 @@ Example:
188
188
  parachute-vault 1940 0.2.4 active 12345 2h 13m 2ms bun-linked → parachute-vault @ 8aa167b
189
189
  → http://127.0.0.1:1940/vault/default/mcp
190
190
  parachute-app 1946 0.2.0 active 12346 2h 12m 3ms npm (0.2.0-rc.4)
191
- → http://127.0.0.1:1946/app/notes
191
+ → http://127.0.0.1:1946/surface/notes
192
192
  `;
193
193
  }
194
194
 
package/src/hub-server.ts CHANGED
@@ -23,7 +23,7 @@
23
23
  * /admin/login, /admin/logout → 301 → /login, /logout
24
24
  *
25
25
  * # Notes-as-app migration Phase 2 (parachute-app design doc §16).
26
- * /notes, /notes/, /notes/* → 301 → /app/notes[/...]
26
+ * /notes, /notes/, /notes/* → 301 → /surface/notes[/...]
27
27
  * (opt-out via
28
28
  * hub_settings.notes_redirect_disabled)
29
29
  *
@@ -65,6 +65,7 @@
65
65
  * /api/users (GET + POST) → list / create user (host:admin)
66
66
  * /api/users/vaults (GET) → vault-name list for assigned-vault picker (host:admin)
67
67
  * /api/users/<id> (DELETE) → hard-delete user + revoke tokens (host:admin)
68
+ * /api/users/<id>/reset-password (POST) → admin-initiated password reset (host:admin)
68
69
  * /login (GET + POST) → operator password login
69
70
  * /logout (POST) → end admin session
70
71
  * /account/change-password (GET + POST) → user self-service change-password
@@ -117,7 +118,11 @@ import {
117
118
  import { handleHostAdminToken } from "./admin-host-admin-token.ts";
118
119
  import { handleVaultAdminToken } from "./admin-vault-admin-token.ts";
119
120
  import { handleCreateVault } from "./admin-vaults.ts";
120
- import { handleAccountChangePasswordGet, handleAccountChangePasswordPost } from "./api-account.ts";
121
+ import {
122
+ handleAccountChangePasswordGet,
123
+ handleAccountChangePasswordPost,
124
+ handleAccountHomeGet,
125
+ } from "./api-account.ts";
121
126
  import { handleApiHub } from "./api-hub.ts";
122
127
  import { handleApiMe } from "./api-me.ts";
123
128
  import { handleApiMintToken } from "./api-mint-token.ts";
@@ -141,6 +146,7 @@ import {
141
146
  handleDeleteUser,
142
147
  handleListUsers,
143
148
  handleListVaults,
149
+ handleResetUserPassword,
144
150
  } from "./api-users.ts";
145
151
  import { buildChromeForRequest, injectChromeIntoResponse } from "./chrome-strip.ts";
146
152
  import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "./config.ts";
@@ -266,10 +272,7 @@ export function parseArgs(argv: string[], env: NodeJS.ProcessEnv = process.env):
266
272
  if (hostname === undefined) hostname = env.PARACHUTE_BIND_HOST || "127.0.0.1";
267
273
  if (wellKnownDir === undefined) wellKnownDir = WELL_KNOWN_DIR;
268
274
  if (issuer === undefined) {
269
- const fromEnv =
270
- env.PARACHUTE_HUB_ORIGIN ??
271
- env.RENDER_EXTERNAL_URL ??
272
- flyDefaultOrigin(env);
275
+ const fromEnv = env.PARACHUTE_HUB_ORIGIN ?? env.RENDER_EXTERNAL_URL ?? flyDefaultOrigin(env);
273
276
  if (fromEnv) issuer = fromEnv.replace(/\/+$/, "") || undefined;
274
277
  }
275
278
  return { port, hostname, wellKnownDir, dbPath: dbPath ?? hubDbPath(), issuer };
@@ -1118,8 +1121,7 @@ export function hubFetch(
1118
1121
  // browser POSTs and must be trusted even when the operator's
1119
1122
  // configured issuer points elsewhere. See origin-check.ts
1120
1123
  // jsdoc for the failure case this closes.
1121
- platformOrigin:
1122
- process.env.RENDER_EXTERNAL_URL ?? flyDefaultOrigin(process.env),
1124
+ platformOrigin: process.env.RENDER_EXTERNAL_URL ?? flyDefaultOrigin(process.env),
1123
1125
  }),
1124
1126
  };
1125
1127
  };
@@ -1199,7 +1201,7 @@ export function hubFetch(
1199
1201
  }
1200
1202
 
1201
1203
  // Notes-as-app migration Phase 2 (parachute-app design doc §16).
1202
- // `/notes/*` 301-redirects to `/app/notes/*` so legacy bookmarks land on
1204
+ // `/notes/*` 301-redirects to `/surface/notes/*` so legacy bookmarks land on
1203
1205
  // the apps-hosted Notes. Default-on; operators on notes-as-module-only
1204
1206
  // installs can opt out via `hub_settings.notes_redirect_disabled = true`
1205
1207
  // (see hub-settings.ts). The opt-out exists so a legacy operator
@@ -1908,6 +1910,26 @@ export function hubFetch(
1908
1910
  manifestPath,
1909
1911
  });
1910
1912
  }
1913
+ // Phase 2 PR 1 — `/api/users/:id/reset-password` (admin-initiated
1914
+ // password reset for non-admin users). Routed BEFORE the per-id DELETE
1915
+ // catch-all so the trailing `/reset-password` segment isn't mistaken
1916
+ // for part of a user id. Same `host:admin` Bearer gate as the other
1917
+ // /api/users surfaces.
1918
+ {
1919
+ const resetMatch = pathname.match(/^\/api\/users\/([^/]+)\/reset-password$/);
1920
+ if (resetMatch) {
1921
+ if (!getDb) return dbNotConfigured();
1922
+ const id = decodeURIComponent(resetMatch[1] ?? "");
1923
+ if (!id) {
1924
+ return new Response("not found", { status: 404 });
1925
+ }
1926
+ return handleResetUserPassword(req, id, {
1927
+ db: getDb(),
1928
+ issuer: oauthDeps(req).issuer,
1929
+ manifestPath,
1930
+ });
1931
+ }
1932
+ }
1911
1933
  if (pathname.startsWith("/api/users/")) {
1912
1934
  if (!getDb) return dbNotConfigured();
1913
1935
  const id = decodeURIComponent(pathname.slice("/api/users/".length));
@@ -1961,6 +1983,24 @@ export function hubFetch(
1961
1983
  return new Response("method not allowed", { status: 405 });
1962
1984
  }
1963
1985
 
1986
+ // /account/ — friend-facing user home (multi-user Phase 1 follow-up).
1987
+ // Companion to the first-admin gate on `/admin/host-admin-token`: a
1988
+ // signed-in non-admin (friend) lands here instead of bouncing against
1989
+ // a 403 wall on the admin SPA. Admin users also land here when they
1990
+ // hit `/account/` directly, with a "you're the administrator → /admin/"
1991
+ // exit ramp. Bare `/account` 301-redirects to `/account/` so links
1992
+ // without the trailing slash work.
1993
+ if (pathname === "/account" || pathname === "/account/") {
1994
+ if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
1995
+ if (pathname === "/account") {
1996
+ return new Response(null, { status: 301, headers: { location: "/account/" } });
1997
+ }
1998
+ if (!getDb) return dbNotConfigured();
1999
+ const db = getDb();
2000
+ const hubOrigin = resolveIssuer(req, db, configuredIssuer);
2001
+ return handleAccountHomeGet(req, { db, hubOrigin });
2002
+ }
2003
+
1964
2004
  // Legacy `/admin/config` (server-rendered module-config portal, #46)
1965
2005
  // retired post-SPA-rework. 301 → the SPA home so any bookmark or stale
1966
2006
  // post-login redirect lands somewhere useful. The route stays here in
@@ -2032,7 +2072,7 @@ export function hubFetch(
2032
2072
  * Inject the persistent chrome strip (workstream G) into a proxied response.
2033
2073
  *
2034
2074
  * Skips the rewrite when the response is non-200, non-HTML, on an opt-out
2035
- * path (e.g. `/app/notes/*`), or larger than `MAX_INJECT_SIZE_BYTES`.
2075
+ * path (e.g. `/surface/notes/*`), or larger than `MAX_INJECT_SIZE_BYTES`.
2036
2076
  * `injectChromeIntoResponse` is the no-side-effects implementation; this
2037
2077
  * wrapper threads in the session-aware chrome HTML and a `set-cookie`
2038
2078
  * append when a fresh CSRF cookie was minted.
@@ -71,7 +71,7 @@ export type HubSettingKey =
71
71
  | "hub_origin"
72
72
  // Notes-as-app migration Phase 2 (parachute-app design doc §16).
73
73
  // When unset (default) or "false", hub serves a 301 redirect from
74
- // `/notes/*` → `/app/notes/*` so existing bookmarks transparently
74
+ // `/notes/*` → `/surface/notes/*` so existing bookmarks transparently
75
75
  // follow the operator to the apps-hosted Notes. When "true", the
76
76
  // redirect is skipped and `/notes/*` falls through to the existing
77
77
  // services.json-driven proxy — the escape hatch for operators
@@ -372,7 +372,7 @@ export function setHubOrigin(db: Database, value: string | null): void {
372
372
  // --- domain helpers: notes-as-app redirect (parachute-app §16 Phase 2) ----
373
373
 
374
374
  /**
375
- * Read whether the `/notes/*` → `/app/notes/*` redirect is disabled. Default
375
+ * Read whether the `/notes/*` → `/surface/notes/*` redirect is disabled. Default
376
376
  * is `false` (redirect on) — Phase 2 migrates operators to apps-hosted
377
377
  * Notes, so the bookmark-friendly path is the default-on behavior. Only an
378
378
  * operator running notes-as-a-module without parachute-app installed should
package/src/hub.ts CHANGED
@@ -471,9 +471,9 @@ const HTML_TEMPLATE = `<!doctype html>
471
471
  * Render the "Get started" section (hub#342) above the Services grid.
472
472
  *
473
473
  * One hardcoded target, conditional on its prerequisite being installed:
474
- * - "Open Notes" → /app/notes/ (requires parachute-app installed;
475
- * App auto-bootstraps Notes-as-UI per parachute-app §17, so the
476
- * mere presence of App means /app/notes/ is live)
474
+ * - "Open Notes" → /surface/notes/ (requires parachute-surface installed;
475
+ * Surface auto-bootstraps Notes-as-UI per parachute-surface §17, so the
476
+ * mere presence of Surface means /surface/notes/ is live)
477
477
  *
478
478
  * The earlier "Browse Vault" tile retired in workstream C (2026-05-25)
479
479
  * once vault declared uiUrl in its module.json (per patterns#96). With
@@ -500,12 +500,12 @@ const HTML_TEMPLATE = `<!doctype html>
500
500
  function renderGetStarted(services) {
501
501
  if (!getStartedGrid || !getStartedSection) return;
502
502
  const tiles = [];
503
- const hasApp = services.some((s) => s && s.name === 'parachute-app');
504
- if (hasApp) {
503
+ const hasSurface = services.some((s) => s && s.name === 'parachute-surface');
504
+ if (hasSurface) {
505
505
  tiles.push({
506
506
  title: 'Open Notes',
507
507
  desc: 'Browse + capture in the Notes app — reads from your vault.',
508
- href: '/app/notes/',
508
+ href: '/surface/notes/',
509
509
  });
510
510
  }
511
511
  if (tiles.length === 0) {
@@ -2,9 +2,9 @@
2
2
  * Notes-as-app migration Phase 2 (parachute-app design doc §16).
3
3
  *
4
4
  * When parachute-app ships and Notes installs as `parachute-app add
5
- * @openparachute/notes-ui --name notes --path /app/notes`, operators with
5
+ * @openparachute/notes-ui --name notes --path /surface/notes`, operators with
6
6
  * existing `/notes/*` bookmarks need a transparent bridge. The hub serves a
7
- * 301 redirect from `/notes/*` → `/app/notes/*` so:
7
+ * 301 redirect from `/notes/*` → `/surface/notes/*` so:
8
8
  *
9
9
  * - cached operator URLs (notes PWA install banners, browser history,
10
10
  * in-vault links) keep working
@@ -44,14 +44,14 @@ export function isLegacyNotesPath(pathname: string): boolean {
44
44
  * string. The query is preserved verbatim; the fragment isn't visible
45
45
  * server-side (clients reassemble it after following the redirect).
46
46
  *
47
- * The transform is purely path-rewrite — `/notes` → `/app/notes`, `/notes/`
48
- * → `/app/notes/`, `/notes/foo/bar` → `/app/notes/foo/bar`.
47
+ * The transform is purely path-rewrite — `/notes` → `/surface/notes`, `/notes/`
48
+ * → `/surface/notes/`, `/notes/foo/bar` → `/surface/notes/foo/bar`.
49
49
  */
50
50
  export function buildNotesRedirectTarget(pathname: string, search: string): string {
51
51
  // Slice off the leading "/notes" — what remains is either "" (bare /notes),
52
52
  // "/" (trailing slash), or "/<rest>" (sub-path).
53
53
  const tail = pathname.slice("/notes".length);
54
- return `/app/notes${tail}${search}`;
54
+ return `/surface/notes${tail}${search}`;
55
55
  }
56
56
 
57
57
  /**