@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
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.5.13",
4
- "description": "parachute the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
3
+ "version": "0.5.14-rc.2",
4
+ "description": "parachute \u2014 the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
7
7
  "access": "public"
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Renderer tests for the friend-facing `/account/` home (multi-user
3
+ * Phase 1 follow-up). The page is a pure function over its opts — these
4
+ * tests pin the load-bearing shape:
5
+ *
6
+ * - Assigned-vault branch: Notes CTA href encodes the hub+vault URL,
7
+ * vault name shows in the body, hub origin appears as inline code
8
+ * in the custom-client disclosure.
9
+ * - Admin (no assigned vault) branch: link to /admin/ visible.
10
+ * - Defensive third branch (non-admin + no vault): "ask the operator"
11
+ * copy renders.
12
+ * - Common scaffolding: username in welcome, sign-out POST form,
13
+ * change-password link.
14
+ */
15
+ import { describe, expect, test } from "bun:test";
16
+ import { renderAccountHome } from "../account-home-ui.ts";
17
+
18
+ const HUB_ORIGIN = "https://hub.example";
19
+ const CSRF = "test-csrf-token";
20
+
21
+ describe("renderAccountHome", () => {
22
+ test("assigned-vault branch — Notes CTA carries the encoded hub+vault URL", () => {
23
+ const html = renderAccountHome({
24
+ username: "alice",
25
+ assignedVaults: ["alice"],
26
+ passwordChanged: true,
27
+ hubOrigin: HUB_ORIGIN,
28
+ isFirstAdmin: false,
29
+ csrfToken: CSRF,
30
+ });
31
+ // Welcome header includes the username.
32
+ expect(html).toContain("Welcome, alice");
33
+ // Vault name renders as inline content in the vault card.
34
+ expect(html).toContain("<strong>alice</strong>");
35
+ // Notes "Open" CTA — same shape as setup-wizard's renderStartUsingTile.
36
+ // The href encodes `${hubOrigin}/vault/<name>` via encodeURIComponent.
37
+ const encodedVaultUrl = encodeURIComponent(`${HUB_ORIGIN}/vault/alice`);
38
+ expect(html).toContain(`https://notes.parachute.computer/add?url=${encodedVaultUrl}`);
39
+ expect(html).toContain('target="_blank"');
40
+ expect(html).toContain('rel="noopener"');
41
+ // Hub origin renders as inline <code> in the custom-client disclosure.
42
+ expect(html).toContain(`<code>${HUB_ORIGIN}</code>`);
43
+ expect(html).toContain("Use a custom client");
44
+ });
45
+
46
+ test("assigned-vault branch — trailing slash on hubOrigin is normalized", () => {
47
+ // The handler resolves origin per-request via `resolveIssuer`; some
48
+ // operators set a hub_origin setting with a trailing slash. The
49
+ // renderer must produce a clean `/vault/<name>` join either way.
50
+ const html = renderAccountHome({
51
+ username: "alice",
52
+ assignedVaults: ["alice"],
53
+ passwordChanged: true,
54
+ hubOrigin: `${HUB_ORIGIN}/`,
55
+ isFirstAdmin: false,
56
+ csrfToken: CSRF,
57
+ });
58
+ const cleanEncoded = encodeURIComponent(`${HUB_ORIGIN}/vault/alice`);
59
+ expect(html).toContain(`https://notes.parachute.computer/add?url=${cleanEncoded}`);
60
+ // The inline-code display also drops the trailing slash.
61
+ expect(html).toContain(`<code>${HUB_ORIGIN}</code>`);
62
+ expect(html).not.toContain(`<code>${HUB_ORIGIN}/</code>`);
63
+ });
64
+
65
+ test("admin branch — null assignedVault + isFirstAdmin renders an /admin/ link", () => {
66
+ const html = renderAccountHome({
67
+ username: "admin",
68
+ assignedVaults: [],
69
+ passwordChanged: true,
70
+ hubOrigin: HUB_ORIGIN,
71
+ isFirstAdmin: true,
72
+ csrfToken: CSRF,
73
+ });
74
+ expect(html).toContain("Welcome, admin");
75
+ expect(html).toContain("hub administrator");
76
+ expect(html).toContain('href="/admin/"');
77
+ // Should NOT carry the Notes CTA (no vault).
78
+ expect(html).not.toContain("notes.parachute.computer/add");
79
+ });
80
+
81
+ test("defensive branch — non-admin with null assignedVault renders an 'ask operator' message", () => {
82
+ // Shouldn't normally occur in Phase 1 (PR 2's /api/users always
83
+ // assigns a vault on create), but the renderer carries a clear
84
+ // explanation rather than a blank card if a row gets into that
85
+ // state via hand-edit or migration race.
86
+ const html = renderAccountHome({
87
+ username: "ghost",
88
+ assignedVaults: [],
89
+ passwordChanged: true,
90
+ hubOrigin: HUB_ORIGIN,
91
+ isFirstAdmin: false,
92
+ csrfToken: CSRF,
93
+ });
94
+ expect(html).toContain("Welcome, ghost");
95
+ expect(html).toContain("Ask the hub operator");
96
+ // No /admin/ link in this branch — they have no admin role.
97
+ expect(html).not.toContain('href="/admin/"');
98
+ // No Notes CTA.
99
+ expect(html).not.toContain("notes.parachute.computer/add");
100
+ });
101
+
102
+ test("account card — change-password link and sign-out form are present", () => {
103
+ const html = renderAccountHome({
104
+ username: "alice",
105
+ assignedVaults: ["alice"],
106
+ passwordChanged: true,
107
+ hubOrigin: HUB_ORIGIN,
108
+ isFirstAdmin: false,
109
+ csrfToken: CSRF,
110
+ });
111
+ // Change-password link points at the existing /account/change-password
112
+ // route (server-rendered HTML, separate handler).
113
+ expect(html).toContain('href="/account/change-password"');
114
+ // Sign-out form POSTs to /logout (existing handler), CSRF token
115
+ // round-trips via the renderCsrfHiddenInput helper.
116
+ expect(html).toContain('action="/logout"');
117
+ expect(html).toContain('method="POST"');
118
+ expect(html).toContain(CSRF);
119
+ // Username renders inside the account card too.
120
+ expect(html).toContain("<code>alice</code>");
121
+ });
122
+
123
+ test("multi-vault branch — renders one tile per assigned vault (Phase 2 PR 2)", () => {
124
+ const html = renderAccountHome({
125
+ username: "alice",
126
+ assignedVaults: ["personal", "family"],
127
+ passwordChanged: true,
128
+ hubOrigin: HUB_ORIGIN,
129
+ isFirstAdmin: false,
130
+ csrfToken: CSRF,
131
+ });
132
+ // Plural heading.
133
+ expect(html).toContain("Your vaults");
134
+ // Each vault name appears.
135
+ expect(html).toContain("<strong>personal</strong>");
136
+ expect(html).toContain("<strong>family</strong>");
137
+ // One CTA per vault with the right encoded URL.
138
+ const personalEncoded = encodeURIComponent(`${HUB_ORIGIN}/vault/personal`);
139
+ const familyEncoded = encodeURIComponent(`${HUB_ORIGIN}/vault/family`);
140
+ expect(html).toContain(`https://notes.parachute.computer/add?url=${personalEncoded}`);
141
+ expect(html).toContain(`https://notes.parachute.computer/add?url=${familyEncoded}`);
142
+ // Hub origin block appears once at the section level, not per tile.
143
+ expect(html.split(`<code>${HUB_ORIGIN}</code>`).length - 1).toBe(1);
144
+ });
145
+
146
+ test("escapes hostile content in username and vault name", () => {
147
+ // Defense-in-depth: usernames pass validateUsername (lowercase alnum
148
+ // + `_-`), so HTML metacharacters won't normally make it through. But
149
+ // the renderer is a pure function over arbitrary string input and the
150
+ // escape is load-bearing if the validator ever loosens.
151
+ const html = renderAccountHome({
152
+ username: "<script>",
153
+ assignedVaults: ["<vault>"],
154
+ passwordChanged: true,
155
+ hubOrigin: HUB_ORIGIN,
156
+ isFirstAdmin: false,
157
+ csrfToken: CSRF,
158
+ });
159
+ expect(html).not.toContain("<script>");
160
+ expect(html).toContain("&lt;script&gt;");
161
+ expect(html).toContain("&lt;vault&gt;");
162
+ });
163
+ });
@@ -426,6 +426,80 @@ describe("loginRedirectTarget — force-change-password (multi-user PR 3)", () =
426
426
  expect(res.headers.get("location")).toBe("/admin/tokens");
427
427
  });
428
428
 
429
+ test("non-admin (friend) targeting /admin/* gets rewritten to /account/", async () => {
430
+ // Multi-user follow-up: a signed-in friend account hitting an /admin/*
431
+ // URL via post-login `next=` would otherwise land on the admin SPA,
432
+ // which 403s on `/admin/host-admin-token` (first-admin gate) and
433
+ // bounces them back via the SPA-side 403 handler. Rewriting at the
434
+ // login boundary skips the bouncing-around UX. Friend has
435
+ // passwordChanged=true so the force-change path doesn't pre-empt.
436
+ await createUser(harness.db, "admin", "admin-pw", { passwordChanged: true });
437
+ await createUser(harness.db, "alice", "alice-pw", {
438
+ allowMulti: true,
439
+ passwordChanged: true,
440
+ });
441
+ const { body, headers } = formBody({
442
+ [CSRF_FIELD_NAME]: TEST_CSRF,
443
+ username: "alice",
444
+ password: "alice-pw",
445
+ next: "/admin/users",
446
+ });
447
+ const req = new Request("http://hub.test/login", {
448
+ method: "POST",
449
+ headers: { ...headers, cookie: CSRF_COOKIE },
450
+ body,
451
+ });
452
+ const res = await handleAdminLoginPost(harness.db, req);
453
+ expect(res.status).toBe(302);
454
+ expect(res.headers.get("location")).toBe("/account/");
455
+ });
456
+
457
+ test("admin targeting /admin/* still lands at the original next= (rewrite doesn't fire for admin)", async () => {
458
+ // Belt-and-suspenders for the friend rewrite above: the same next=
459
+ // for the admin user must NOT redirect to /account/.
460
+ await createUser(harness.db, "admin", "admin-pw", { passwordChanged: true });
461
+ const { body, headers } = formBody({
462
+ [CSRF_FIELD_NAME]: TEST_CSRF,
463
+ username: "admin",
464
+ password: "admin-pw",
465
+ next: "/admin/users",
466
+ });
467
+ const req = new Request("http://hub.test/login", {
468
+ method: "POST",
469
+ headers: { ...headers, cookie: CSRF_COOKIE },
470
+ body,
471
+ });
472
+ const res = await handleAdminLoginPost(harness.db, req);
473
+ expect(res.status).toBe(302);
474
+ expect(res.headers.get("location")).toBe("/admin/users");
475
+ });
476
+
477
+ test("non-admin (friend) keeps non-/admin next= intact (e.g. /oauth/authorize, /vault/...)", async () => {
478
+ // Legitimate user destinations — OAuth consent + per-vault routes —
479
+ // must pass through unchanged. Friends sign in through /login as part
480
+ // of the OAuth user-consent flow; rewriting their next= to /account/
481
+ // would break that.
482
+ await createUser(harness.db, "admin", "admin-pw", { passwordChanged: true });
483
+ await createUser(harness.db, "alice", "alice-pw", {
484
+ allowMulti: true,
485
+ passwordChanged: true,
486
+ });
487
+ const { body, headers } = formBody({
488
+ [CSRF_FIELD_NAME]: TEST_CSRF,
489
+ username: "alice",
490
+ password: "alice-pw",
491
+ next: "/oauth/authorize?client_id=abc",
492
+ });
493
+ const req = new Request("http://hub.test/login", {
494
+ method: "POST",
495
+ headers: { ...headers, cookie: CSRF_COOKIE },
496
+ body,
497
+ });
498
+ const res = await handleAdminLoginPost(harness.db, req);
499
+ expect(res.status).toBe(302);
500
+ expect(res.headers.get("location")).toBe("/oauth/authorize?client_id=abc");
501
+ });
502
+
429
503
  test("password_changed=false defense-in-depth: unsafe next= is sanitized before encoding", async () => {
430
504
  // safeNext rewrites unsafe next values to /admin/vaults BEFORE the
431
505
  // redirect-target helper runs. The change-password URL should never
@@ -56,6 +56,31 @@ async function withSession(): Promise<{ cookie: string; userId: string }> {
56
56
  return { cookie, userId: user.id };
57
57
  }
58
58
 
59
+ /**
60
+ * Seed an admin (first-created user) + a second non-admin "friend"
61
+ * account, return cookies + ids for both. Used by the first-admin-gate
62
+ * tests.
63
+ */
64
+ async function withAdminAndFriend(): Promise<{
65
+ adminCookie: string;
66
+ adminId: string;
67
+ friendCookie: string;
68
+ friendId: string;
69
+ }> {
70
+ const admin = await createUser(harness.db, "admin", "admin-passphrase");
71
+ const friend = await createUser(harness.db, "alice", "alice-passphrase", {
72
+ allowMulti: true,
73
+ });
74
+ const adminSession = createSession(harness.db, { userId: admin.id });
75
+ const friendSession = createSession(harness.db, { userId: friend.id });
76
+ return {
77
+ adminCookie: buildSessionCookie(adminSession.id, Math.floor(SESSION_TTL_MS / 1000)),
78
+ adminId: admin.id,
79
+ friendCookie: buildSessionCookie(friendSession.id, Math.floor(SESSION_TTL_MS / 1000)),
80
+ friendId: friend.id,
81
+ };
82
+ }
83
+
59
84
  describe("handleHostAdminToken", () => {
60
85
  test("401 when no session cookie is present", async () => {
61
86
  const req = new Request(`${ISSUER}/admin/host-admin-token`);
@@ -124,6 +149,43 @@ describe("handleHostAdminToken", () => {
124
149
  expect(scopes).toContain("parachute:host:auth");
125
150
  });
126
151
 
152
+ test("403 not_admin when a signed-in non-first-admin (friend) hits the endpoint", async () => {
153
+ // Privesc closure: without the first-admin gate, any signed-in
154
+ // friend account could mint a JWT carrying parachute:host:admin +
155
+ // parachute:host:auth — the SPA bearer that gates vault provisioning,
156
+ // grants, and the token registry. The friend's session is valid;
157
+ // the endpoint must refuse because the session.userId doesn't match
158
+ // the first-admin row.
159
+ const { friendCookie } = await withAdminAndFriend();
160
+ rotateSigningKey(harness.db);
161
+ const req = new Request(`${ISSUER}/admin/host-admin-token`, {
162
+ headers: { cookie: friendCookie },
163
+ });
164
+ const res = await handleHostAdminToken(req, { db: harness.db, issuer: ISSUER });
165
+ expect(res.status).toBe(403);
166
+ const body = (await res.json()) as { error: string; error_description: string };
167
+ expect(body.error).toBe("not_admin");
168
+ // The wire description steers the SPA-side handler toward /account/.
169
+ expect(body.error_description).toContain("/account/");
170
+ });
171
+
172
+ test("first-admin path still succeeds when a friend exists alongside", async () => {
173
+ // Belt-and-suspenders for the gate above: adding a second user must
174
+ // not break the admin's own happy path. Same DB, same query — the
175
+ // admin's session.userId matches the earliest-created row, so the
176
+ // gate passes.
177
+ const { adminCookie, adminId } = await withAdminAndFriend();
178
+ rotateSigningKey(harness.db);
179
+ const req = new Request(`${ISSUER}/admin/host-admin-token`, {
180
+ headers: { cookie: adminCookie },
181
+ });
182
+ const res = await handleHostAdminToken(req, { db: harness.db, issuer: ISSUER });
183
+ expect(res.status).toBe(200);
184
+ const body = (await res.json()) as { token: string };
185
+ const validated = await validateAccessToken(harness.db, body.token, ISSUER);
186
+ expect(validated.payload.sub).toBe(adminId);
187
+ });
188
+
127
189
  // Regression for the end-to-end bug that motivated adding `:host:auth`
128
190
  // here: the SPA's session-bearer was rejected by `/api/auth/tokens` (and
129
191
  // its peers) because it carried `:host:admin` only. This test mints
@@ -152,6 +152,50 @@ describe("handleVaultAdminToken", () => {
152
152
  expect(scopeClaim.split(/\s+/)).toContain("vault:work:admin");
153
153
  });
154
154
 
155
+ test("403 not_admin when the session belongs to a non-first-admin user", async () => {
156
+ // Multi-user Phase 1 privesc gate (mirrors host-admin-token). The first-
157
+ // created user is the hub admin; subsequent users are friends pinned to
158
+ // a single vault and must NOT be able to mint vault:<name>:admin via
159
+ // this endpoint. The session check alone would let them — that's the
160
+ // whole reason this gate exists.
161
+ await createUser(harness.db, "operator", "hunter2-admin");
162
+ const friend = await createUser(harness.db, "friend", "hunter2-friend", {
163
+ assignedVaults: ["work"],
164
+ allowMulti: true,
165
+ });
166
+ const session = createSession(harness.db, { userId: friend.id });
167
+ const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
168
+ const req = new Request(`${ISSUER}/admin/vault-admin-token/work`, { headers: { cookie } });
169
+ const res = await handleVaultAdminToken(req, "work", {
170
+ db: harness.db,
171
+ issuer: ISSUER,
172
+ knownVaultNames: known("work", "default"),
173
+ });
174
+ expect(res.status).toBe(403);
175
+ const body = (await res.json()) as { error: string; error_description: string };
176
+ expect(body.error).toBe("not_admin");
177
+ expect(body.error_description).toContain("/account/");
178
+ });
179
+
180
+ test("first admin still mints when other users exist", async () => {
181
+ // Regression: the first-admin gate must not regress the operator's
182
+ // happy path when there are friends in the DB.
183
+ const admin = await createUser(harness.db, "operator", "hunter2-admin");
184
+ await createUser(harness.db, "friend", "hunter2-friend", {
185
+ assignedVaults: ["work"],
186
+ allowMulti: true,
187
+ });
188
+ const session = createSession(harness.db, { userId: admin.id });
189
+ const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
190
+ const req = new Request(`${ISSUER}/admin/vault-admin-token/work`, { headers: { cookie } });
191
+ const res = await handleVaultAdminToken(req, "work", {
192
+ db: harness.db,
193
+ issuer: ISSUER,
194
+ knownVaultNames: known("work"),
195
+ });
196
+ expect(res.status).toBe(200);
197
+ });
198
+
155
199
  test("audience is per-vault — different vaults get different aud claims", async () => {
156
200
  // Regression for PR #173 follow-up: a single shared audience constant
157
201
  // meant a token minted for vault `boulder` carried `aud: "hub"` and
@@ -25,6 +25,7 @@ import { join } from "node:path";
25
25
  import {
26
26
  handleAccountChangePasswordGet,
27
27
  handleAccountChangePasswordPost,
28
+ handleAccountHomeGet,
28
29
  markPasswordChanged,
29
30
  } from "../api-account.ts";
30
31
  import { CSRF_COOKIE_NAME, CSRF_FIELD_NAME } from "../csrf.ts";
@@ -256,7 +257,110 @@ describe("POST /account/change-password", () => {
256
257
  }
257
258
  });
258
259
 
259
- test("missing next defaults to /admin/vaults", async () => {
260
+ test("non-admin user with no next defaults to /account/ (no admin-shell flash)", async () => {
261
+ // Without this rewrite, a friend's change-password POST would 302 to
262
+ // /admin/vaults, the SPA would load, the 403 from
263
+ // /admin/host-admin-token would bounce them to /account/ — a visible
264
+ // two-hop flash. Mirror the login-redirect rewrite in admin-handlers.ts.
265
+ await createUser(harness.db, "operator", "operator-strong-passphrase");
266
+ const { cookie } = await sessionCookieFor(harness.db, "friend", "old-default-pw", {
267
+ passwordChanged: false,
268
+ allowMulti: true,
269
+ });
270
+ const { body, headers } = formBody({
271
+ [CSRF_FIELD_NAME]: TEST_CSRF,
272
+ current_password: "old-default-pw",
273
+ new_password: "user-chosen-strong-passphrase",
274
+ new_password_confirm: "user-chosen-strong-passphrase",
275
+ });
276
+ const req = new Request("http://hub.test/account/change-password", {
277
+ method: "POST",
278
+ headers: { ...headers, cookie },
279
+ body,
280
+ });
281
+ const res = await handleAccountChangePasswordPost(req, { db: harness.db });
282
+ expect(res.status).toBe(302);
283
+ expect(res.headers.get("location")).toBe("/account/");
284
+ });
285
+
286
+ test("non-admin user with next=/admin/users gets rewritten to /account/", async () => {
287
+ await createUser(harness.db, "operator", "operator-strong-passphrase");
288
+ const { cookie } = await sessionCookieFor(harness.db, "friend", "old-default-pw", {
289
+ passwordChanged: false,
290
+ allowMulti: true,
291
+ });
292
+ const { body, headers } = formBody({
293
+ [CSRF_FIELD_NAME]: TEST_CSRF,
294
+ current_password: "old-default-pw",
295
+ new_password: "user-chosen-strong-passphrase",
296
+ new_password_confirm: "user-chosen-strong-passphrase",
297
+ next: "/admin/users",
298
+ });
299
+ const req = new Request("http://hub.test/account/change-password", {
300
+ method: "POST",
301
+ headers: { ...headers, cookie },
302
+ body,
303
+ });
304
+ const res = await handleAccountChangePasswordPost(req, { db: harness.db });
305
+ expect(res.status).toBe(302);
306
+ expect(res.headers.get("location")).toBe("/account/");
307
+ });
308
+
309
+ test("non-admin user with non-admin next= passes through unchanged", async () => {
310
+ // Friends with a legitimate non-admin destination (e.g. /oauth/authorize
311
+ // mid-flow) should land where they intended — the rewrite only catches
312
+ // /admin/* targets.
313
+ await createUser(harness.db, "operator", "operator-strong-passphrase");
314
+ const { cookie } = await sessionCookieFor(harness.db, "friend", "old-default-pw", {
315
+ passwordChanged: false,
316
+ allowMulti: true,
317
+ });
318
+ const { body, headers } = formBody({
319
+ [CSRF_FIELD_NAME]: TEST_CSRF,
320
+ current_password: "old-default-pw",
321
+ new_password: "user-chosen-strong-passphrase",
322
+ new_password_confirm: "user-chosen-strong-passphrase",
323
+ next: "/oauth/authorize?client_id=abc",
324
+ });
325
+ const req = new Request("http://hub.test/account/change-password", {
326
+ method: "POST",
327
+ headers: { ...headers, cookie },
328
+ body,
329
+ });
330
+ const res = await handleAccountChangePasswordPost(req, { db: harness.db });
331
+ expect(res.status).toBe(302);
332
+ expect(res.headers.get("location")).toBe("/oauth/authorize?client_id=abc");
333
+ });
334
+
335
+ test("non-admin with exact next=/admin (no trailing slash) rewrites to /account/", async () => {
336
+ // Pins the exact-match arm of the prefix gate. Tests in #426 cover
337
+ // /admin/users (prefix match) and the no-next case (POST_CHANGE_DEFAULT
338
+ // → rewrite). This is the third arm.
339
+ await createUser(harness.db, "operator", "operator-strong-passphrase");
340
+ const { cookie } = await sessionCookieFor(harness.db, "friend", "old-default-pw", {
341
+ passwordChanged: false,
342
+ allowMulti: true,
343
+ });
344
+ const { body, headers } = formBody({
345
+ [CSRF_FIELD_NAME]: TEST_CSRF,
346
+ current_password: "old-default-pw",
347
+ new_password: "user-chosen-strong-passphrase",
348
+ new_password_confirm: "user-chosen-strong-passphrase",
349
+ next: "/admin",
350
+ });
351
+ const req = new Request("http://hub.test/account/change-password", {
352
+ method: "POST",
353
+ headers: { ...headers, cookie },
354
+ body,
355
+ });
356
+ const res = await handleAccountChangePasswordPost(req, { db: harness.db });
357
+ expect(res.status).toBe(302);
358
+ expect(res.headers.get("location")).toBe("/account/");
359
+ });
360
+
361
+ test("first admin with no next still defaults to /admin/vaults", async () => {
362
+ // Existing behavior — preserved by the non-admin gate. The first user
363
+ // is the admin under Phase 1; admin SPA is the intended landing.
260
364
  const { cookie } = await sessionCookieFor(harness.db, "newbie", "old-default-pw", {
261
365
  passwordChanged: false,
262
366
  });
@@ -628,3 +732,89 @@ describe("markPasswordChanged", () => {
628
732
  expect(after?.passwordChanged).toBe(true);
629
733
  });
630
734
  });
735
+
736
+ describe("handleAccountHomeGet", () => {
737
+ // Integration smoke for `GET /account/` — verifies session gating
738
+ // (302 → /login when missing) and the happy path (200 + rendered HTML
739
+ // with the user's vault). The pure-renderer assertions live in
740
+ // `account-home-ui.test.ts`; this suite pins handler-level wiring.
741
+
742
+ const HUB_ORIGIN = "https://hub.test";
743
+
744
+ test("302 → /login when no session cookie is present", async () => {
745
+ const req = new Request(`${HUB_ORIGIN}/account/`);
746
+ const res = handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
747
+ expect(res.status).toBe(302);
748
+ const location = res.headers.get("location") ?? "";
749
+ // Round-trip /account/ as the `next` param so post-login lands back.
750
+ expect(location).toBe(`/login?next=${encodeURIComponent("/account/")}`);
751
+ });
752
+
753
+ test("200 + HTML for a signed-in friend with an assigned vault", async () => {
754
+ // Create the admin first (so the friend is NOT the first admin),
755
+ // then a friend with an assigned vault.
756
+ await createUser(harness.db, "admin", "admin-passphrase", { passwordChanged: true });
757
+ const friend = await createUser(harness.db, "alice", "alice-passphrase", {
758
+ allowMulti: true,
759
+ passwordChanged: true,
760
+ assignedVaults: ["alice"],
761
+ });
762
+ const session = createSession(harness.db, { userId: friend.id });
763
+ const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
764
+ const req = new Request(`${HUB_ORIGIN}/account/`, { headers: { cookie } });
765
+ const res = handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
766
+ expect(res.status).toBe(200);
767
+ expect(res.headers.get("content-type")).toContain("text/html");
768
+ const html = await res.text();
769
+ expect(html).toContain("Welcome, alice");
770
+ // Vault name visible.
771
+ expect(html).toContain("<strong>alice</strong>");
772
+ // Notes CTA carries the hub-origin-encoded vault URL.
773
+ const encoded = encodeURIComponent(`${HUB_ORIGIN}/vault/alice`);
774
+ expect(html).toContain(`https://notes.parachute.computer/add?url=${encoded}`);
775
+ });
776
+
777
+ test("200 + admin branch when the first-admin signs in (no vault assignments)", async () => {
778
+ // The first-created user with no vault pin is the admin posture.
779
+ const admin = await createUser(harness.db, "admin", "admin-passphrase", {
780
+ passwordChanged: true,
781
+ });
782
+ const session = createSession(harness.db, { userId: admin.id });
783
+ const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
784
+ const req = new Request(`${HUB_ORIGIN}/account/`, { headers: { cookie } });
785
+ const res = handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
786
+ expect(res.status).toBe(200);
787
+ const html = await res.text();
788
+ expect(html).toContain("Welcome, admin");
789
+ expect(html).toContain("hub administrator");
790
+ expect(html).toContain('href="/admin/"');
791
+ });
792
+
793
+ test("302 → /login when the session points at a deleted user", async () => {
794
+ // Stale-session shape: a session row outlives its user. The handler
795
+ // hands back to /login rather than rendering against null.
796
+ //
797
+ // Construction note: `deleteUser` drops the session as part of its
798
+ // cleanup transaction, and the sessions.user_id FK is RESTRICT, so
799
+ // we briefly drop FK enforcement to fabricate the orphan-session
800
+ // shape. The handler's job is robustness against an externally-
801
+ // induced orphan (e.g. a race between session-read and user-delete
802
+ // on a different connection); the test exercises that defensive
803
+ // branch directly.
804
+ const user = await createUser(harness.db, "ghost", "ghost-passphrase", {
805
+ passwordChanged: true,
806
+ });
807
+ const session = createSession(harness.db, { userId: user.id });
808
+ const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
809
+ harness.db.exec("PRAGMA foreign_keys = OFF");
810
+ try {
811
+ harness.db.prepare("DELETE FROM users WHERE id = ?").run(user.id);
812
+ } finally {
813
+ harness.db.exec("PRAGMA foreign_keys = ON");
814
+ }
815
+ const req = new Request(`${HUB_ORIGIN}/account/`, { headers: { cookie } });
816
+ const res = handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
817
+ expect(res.status).toBe(302);
818
+ expect(res.headers.get("location")).toBe("/login");
819
+ });
820
+ });