@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.
- package/package.json +2 -2
- package/src/__tests__/account-home-ui.test.ts +163 -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-ops.test.ts +97 -0
- package/src/__tests__/api-modules.test.ts +32 -32
- package/src/__tests__/api-users.test.ts +383 -11
- package/src/__tests__/chrome-strip.test.ts +15 -15
- package/src/__tests__/hub-db.test.ts +194 -29
- package/src/__tests__/hub-server.test.ts +23 -23
- package/src/__tests__/notes-redirect.test.ts +20 -20
- package/src/__tests__/oauth-handlers.test.ts +722 -28
- package/src/__tests__/serve.test.ts +9 -9
- package/src/__tests__/services-manifest.test.ts +40 -40
- package/src/__tests__/setup-wizard.test.ts +493 -25
- package/src/__tests__/setup.test.ts +1 -1
- package/src/__tests__/status.test.ts +39 -0
- package/src/__tests__/users.test.ts +396 -9
- package/src/__tests__/well-known.test.ts +9 -9
- package/src/account-home-ui.ts +434 -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-ops.ts +52 -16
- package/src/api-modules.ts +3 -3
- package/src/api-users.ts +468 -55
- package/src/bun-link.ts +55 -0
- package/src/chrome-strip.ts +6 -6
- package/src/commands/install.ts +8 -21
- package/src/commands/status.ts +10 -1
- package/src/help.ts +2 -2
- package/src/hub-db.ts +42 -0
- package/src/hub-server.ts +69 -10
- package/src/hub-settings.ts +2 -2
- package/src/hub.ts +6 -6
- package/src/notes-redirect.ts +5 -5
- package/src/oauth-handlers.ts +278 -173
- package/src/oauth-ui.ts +18 -2
- package/src/service-spec.ts +39 -18
- package/src/setup-wizard.ts +489 -42
- package/src/users.ts +307 -29
- package/web/ui/dist/assets/index-tRmPbbC7.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
|
+
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.
|
package/src/api-modules-ops.ts
CHANGED
|
@@ -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
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
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.
|