@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.
- package/package.json +2 -2
- package/src/__tests__/account-home-ui.test.ts +140 -0
- package/src/__tests__/admin-handlers.test.ts +74 -0
- package/src/__tests__/admin-host-admin-token.test.ts +62 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
- package/src/__tests__/api-account.test.ts +191 -1
- package/src/__tests__/api-modules.test.ts +32 -32
- package/src/__tests__/api-users.test.ts +192 -2
- package/src/__tests__/chrome-strip.test.ts +15 -15
- package/src/__tests__/hub-server.test.ts +23 -23
- package/src/__tests__/notes-redirect.test.ts +20 -20
- package/src/__tests__/services-manifest.test.ts +40 -40
- package/src/__tests__/setup-wizard.test.ts +157 -19
- package/src/__tests__/setup.test.ts +1 -1
- package/src/__tests__/status.test.ts +39 -0
- package/src/__tests__/users.test.ts +261 -0
- package/src/__tests__/well-known.test.ts +9 -9
- package/src/account-home-ui.ts +404 -0
- package/src/admin-handlers.ts +49 -17
- package/src/admin-host-admin-token.ts +25 -0
- package/src/admin-vault-admin-token.ts +17 -0
- package/src/api-account.ts +72 -6
- package/src/api-modules.ts +3 -3
- package/src/api-users.ts +173 -12
- package/src/chrome-strip.ts +6 -6
- package/src/commands/status.ts +10 -1
- package/src/help.ts +2 -2
- package/src/hub-server.ts +50 -10
- package/src/hub-settings.ts +2 -2
- package/src/hub.ts +6 -6
- package/src/notes-redirect.ts +5 -5
- package/src/service-spec.ts +39 -18
- package/src/setup-wizard.ts +335 -28
- package/src/users.ts +112 -0
- package/web/ui/dist/assets/index-Qf56GsGm.js +61 -0
- package/web/ui/dist/index.html +1 -1
- package/web/ui/dist/assets/index-Dzrbe6EP.js +0 -61
package/src/api-account.ts
CHANGED
|
@@ -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.
|
|
73
|
-
*
|
|
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 =
|
|
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
|
-
|
|
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.
|
package/src/api-modules.ts
CHANGED
|
@@ -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", "
|
|
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. `/
|
|
389
|
-
// be prepended again or the result is `/app/
|
|
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
|
|
13
|
-
* POST /api/users
|
|
14
|
-
* DELETE /api/users/:id
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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":
|
package/src/chrome-strip.ts
CHANGED
|
@@ -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 `/
|
|
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 `/
|
|
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
|
-
* `/
|
|
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[] = ["/
|
|
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 `"/
|
|
212
|
-
* suppresses chrome for `/
|
|
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,
|
package/src/commands/status.ts
CHANGED
|
@@ -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
|
|
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
|
|
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/
|
|
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 → /
|
|
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 {
|
|
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 `/
|
|
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. `/
|
|
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.
|
package/src/hub-settings.ts
CHANGED
|
@@ -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/*` → `/
|
|
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/*` → `/
|
|
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" → /
|
|
475
|
-
*
|
|
476
|
-
* mere presence of
|
|
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
|
|
504
|
-
if (
|
|
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: '/
|
|
508
|
+
href: '/surface/notes/',
|
|
509
509
|
});
|
|
510
510
|
}
|
|
511
511
|
if (tiles.length === 0) {
|
package/src/notes-redirect.ts
CHANGED
|
@@ -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 /
|
|
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/*` → `/
|
|
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` → `/
|
|
48
|
-
* → `/
|
|
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 `/
|
|
54
|
+
return `/surface/notes${tail}${search}`;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
/**
|