@openparachute/hub 0.5.13-rc.4 → 0.5.13-rc.41

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 (81) hide show
  1. package/README.md +28 -4
  2. package/package.json +1 -1
  3. package/src/__tests__/admin-clients.test.ts +187 -0
  4. package/src/__tests__/api-account.test.ts +167 -0
  5. package/src/__tests__/api-hub.test.ts +251 -0
  6. package/src/__tests__/api-modules-config.test.ts +139 -4
  7. package/src/__tests__/api-modules-ops.test.ts +339 -4
  8. package/src/__tests__/api-modules.test.ts +403 -5
  9. package/src/__tests__/cli.test.ts +13 -0
  10. package/src/__tests__/hub-origin-resolution.test.ts +50 -0
  11. package/src/__tests__/hub-server.test.ts +506 -65
  12. package/src/__tests__/hub-settings.test.ts +65 -0
  13. package/src/__tests__/hub.test.ts +24 -4
  14. package/src/__tests__/install.test.ts +259 -24
  15. package/src/__tests__/lifecycle.test.ts +102 -14
  16. package/src/__tests__/module-manifest.test.ts +19 -3
  17. package/src/__tests__/notes-redirect.test.ts +195 -0
  18. package/src/__tests__/oauth-handlers.test.ts +1101 -13
  19. package/src/__tests__/oauth-ui.test.ts +31 -1
  20. package/src/__tests__/origin-check.test.ts +36 -0
  21. package/src/__tests__/port-assign.test.ts +6 -1
  22. package/src/__tests__/post-install.test.ts +0 -2
  23. package/src/__tests__/rate-limit.test.ts +114 -0
  24. package/src/__tests__/scope-explanations.test.ts +4 -0
  25. package/src/__tests__/scope-registry.test.ts +9 -9
  26. package/src/__tests__/serve-boot.test.ts +21 -0
  27. package/src/__tests__/serve.test.ts +73 -0
  28. package/src/__tests__/services-manifest.test.ts +760 -43
  29. package/src/__tests__/setup-wizard.test.ts +267 -15
  30. package/src/__tests__/setup.test.ts +1 -0
  31. package/src/__tests__/spawn-env-propagation.test.ts +78 -0
  32. package/src/__tests__/status.test.ts +30 -14
  33. package/src/__tests__/upgrade.test.ts +362 -3
  34. package/src/__tests__/well-known.test.ts +223 -3
  35. package/src/admin-clients.ts +97 -13
  36. package/src/admin-vaults.ts +6 -1
  37. package/src/api-account.ts +29 -0
  38. package/src/api-hub.ts +201 -0
  39. package/src/api-modules-config.ts +41 -9
  40. package/src/api-modules-ops.ts +188 -24
  41. package/src/api-modules.ts +260 -36
  42. package/src/cli.ts +50 -4
  43. package/src/clients.ts +25 -2
  44. package/src/commands/auth.ts +7 -1
  45. package/src/commands/expose-auth-preflight.ts +6 -1
  46. package/src/commands/expose-cloudflare.ts +6 -1
  47. package/src/commands/expose-interactive.ts +7 -1
  48. package/src/commands/install.ts +181 -12
  49. package/src/commands/lifecycle.ts +84 -5
  50. package/src/commands/serve-boot.ts +8 -4
  51. package/src/commands/serve.ts +54 -9
  52. package/src/commands/setup.ts +49 -17
  53. package/src/commands/status.ts +81 -31
  54. package/src/commands/upgrade.ts +218 -27
  55. package/src/commands/vault-tokens-create-interactive.ts +7 -1
  56. package/src/commands/vault.ts +3 -0
  57. package/src/help.ts +77 -25
  58. package/src/hub-control.ts +6 -1
  59. package/src/hub-db.ts +31 -0
  60. package/src/hub-server.ts +155 -24
  61. package/src/hub-settings.ts +50 -1
  62. package/src/hub.ts +85 -14
  63. package/src/install-source.ts +7 -6
  64. package/src/module-manifest.ts +22 -17
  65. package/src/notes-redirect.ts +121 -0
  66. package/src/oauth-handlers.ts +297 -33
  67. package/src/oauth-ui.ts +154 -10
  68. package/src/origin-check.ts +12 -0
  69. package/src/rate-limit.ts +170 -81
  70. package/src/scope-explanations.ts +11 -0
  71. package/src/service-spec.ts +353 -166
  72. package/src/services-manifest.ts +369 -3
  73. package/src/setup-wizard.ts +210 -13
  74. package/src/supervisor.ts +4 -0
  75. package/src/tailscale/run.ts +7 -1
  76. package/src/well-known.ts +145 -16
  77. package/web/ui/dist/assets/index-CGPyOfGK.css +1 -0
  78. package/web/ui/dist/assets/index-DNTukKZw.js +61 -0
  79. package/web/ui/dist/index.html +2 -2
  80. package/web/ui/dist/assets/index-C2vGcXFG.css +0 -1
  81. package/web/ui/dist/assets/index-DmUVTI8I.js +0 -61
package/README.md CHANGED
@@ -2,18 +2,39 @@
2
2
 
3
3
  `@openparachute/hub` — the local hub for the [Parachute](https://parachute.computer) ecosystem. The `parachute` binary is one of its surfaces.
4
4
 
5
- The hub coordinates the modules running on your machine: it installs them, runs them as background processes, exposes them over Tailscale, serves the discovery document at `/.well-known/parachute.json`, and (soon) issues OAuth tokens. Each module (vault, notes, scribe, channel, …) stays a standalone package; the hub stitches them together.
5
+ The hub coordinates the modules running on your machine: it installs them, runs them as background processes, exposes them over Tailscale, serves the discovery document at `/.well-known/parachute.json`, and (soon) issues OAuth tokens. Each module (vault, app, scribe, …) stays a standalone package; the hub stitches them together.
6
6
 
7
7
  > Previously published as `@openparachute/cli`. Renamed 2026-04-26 to better reflect the role — see [parachute-patterns/hub-as-issuer](https://github.com/ParachuteComputer/parachute-patterns/blob/main/patterns/hub-as-issuer.md). The `parachute` binary name is unchanged.
8
8
 
9
9
  ## Install
10
10
 
11
+ ### Local (Bun)
12
+
11
13
  ```sh
12
14
  bun add -g @openparachute/hub
13
15
  ```
14
16
 
15
17
  Prereqs: [Bun](https://bun.sh) 1.3.0 or later. `parachute expose` also requires [Tailscale](https://tailscale.com/download) **1.82 or newer** (installed + `tailscale up` run once); the `expose` path is under active polish for launch, so expect rough edges.
16
18
 
19
+ ### Hosted (Render)
20
+
21
+ [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/ParachuteComputer/parachute-hub)
22
+
23
+ One-click Render deploy via the `render.yaml` Blueprint in this repo. Provisions a $7/mo Starter service + 1 GiB persistent disk + auto-deploys from `main`. Comes pre-configured with `PARACHUTE_INSTALL_CHANNEL=latest` so modules you install via the admin SPA (vault, app, scribe, runner) pull stable releases by default.
24
+
25
+ **Want pre-release / rc modules?** Set `PARACHUTE_INSTALL_CHANNEL=rc` in your Render dashboard env vars (useful for dev/testing against the rc release line).
26
+
27
+ After deploy completes:
28
+
29
+ 1. Open Render Logs → search for `parachute-bootstrap-` to find your one-time admin setup token (printed in a prominent banner on first boot).
30
+ 2. Visit your Render service URL's `/admin/setup` → paste the token → create your admin account.
31
+ 3. Set custom domain (optional) → set `PARACHUTE_HUB_ORIGIN` env to match.
32
+ 4. Install modules via the admin SPA at `/admin/modules` (or via the wizard).
33
+
34
+ Operators who want env-var-driven seeding (CI, scripted deploys) can still set `PARACHUTE_INITIAL_ADMIN_USERNAME` + `PARACHUTE_INITIAL_ADMIN_PASSWORD` manually in the Render dashboard — hub honors them when present.
35
+
36
+ Render's docs on Blueprints: <https://render.com/docs/blueprint-spec>
37
+
17
38
  ## First 5 minutes
18
39
 
19
40
  ```sh
@@ -160,16 +181,19 @@ Parachute services reserve a block of loopback ports in the canonical range **19
160
181
  | 1939 | parachute-hub (internal proxy + static) |
161
182
  | 1940 | parachute-vault |
162
183
  | 1941 | parachute-channel |
163
- | 1942 | parachute-notes |
184
+ | 1942 | parachute-notes *(deprecating — see [notes#154](https://github.com/ParachuteComputer/parachute-notes/issues/154); folds into parachute-app at 1946)* |
164
185
  | 1943 | parachute-scribe |
165
- | 1944–1949 | *unassigned (CLI fallback range)* |
186
+ | 1944 | *parachute-agent (retired 2026-05-20; slot held — see [`parachute-agent/DEPRECATED.md`](https://github.com/ParachuteComputer/parachute-agent/blob/main/DEPRECATED.md))* |
187
+ | 1945 | parachute-runner *(shipped; exploration-tier, not committed-core)* |
188
+ | 1946 | parachute-app *(committed core; UI host, ships Notes as canonical first app)* |
189
+ | 1947–1949 | *unassigned (CLI fallback range)* |
166
190
 
167
191
  The hub pins 1939 — no fallback. If something else is on 1939 when you run `parachute expose`, the command fails with a pointer to `lsof -iTCP:1939` rather than walking up into another service's slot.
168
192
 
169
193
  **The CLI is the port authority.** `parachute install <svc>` picks the port at install time and writes `PORT=<port>` into `~/.parachute/<svc>/.env`; lifecycle.start merges that .env into the spawn env so the next daemon boot binds the port the CLI assigned. The algorithm:
170
194
 
171
195
  1. Prefer the canonical slot (e.g. vault → 1940).
172
- 2. On collision, walk the unassigned range (1944–1949).
196
+ 2. On collision, walk the unassigned range (1947–1949).
173
197
  3. Range exhausted: assign past 1949 with a warning.
174
198
 
175
199
  Idempotent: an existing `PORT=` in `~/.parachute/<svc>/.env` wins, so re-installs and operator-edited ports survive across upgrades. Services keep their compiled-in fallbacks (vault → 1940 etc.) so a stand-alone `bun run` still works without a CLI-managed .env.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.5.13-rc.4",
3
+ "version": "0.5.13-rc.41",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -115,6 +115,32 @@ describe("handleGetClient", () => {
115
115
  expect(body.redirect_uris).toEqual(["https://app.example/cb"]);
116
116
  expect(body.scopes).toEqual(["vault:work:read"]);
117
117
  expect(typeof body.registered_at).toBe("string");
118
+ // hub#312 — same_hub surfaced for future SPA badging. Default false
119
+ // when the test registers via the helper (no operator-auth path).
120
+ expect(body.same_hub).toBe(false);
121
+ });
122
+
123
+ test("same_hub=true client surfaces same_hub: true in the response (hub#312)", async () => {
124
+ // The DCR path stamps same_hub=true on operator-authenticated
125
+ // registrations. Pin that the admin view exposes that flag so future
126
+ // SPA changes (per-client same-hub badge) can read it directly from
127
+ // /api/oauth/clients/<id>.
128
+ const { bearer } = await makeOperatorBearer();
129
+ const r = registerClient(harness.db, {
130
+ redirectUris: ["https://app.example/cb"],
131
+ scopes: ["vault:work:read"],
132
+ status: "approved",
133
+ sameHub: true,
134
+ clientName: "SameHubApp",
135
+ });
136
+ const id = r.client.clientId;
137
+ const res = await handleGetClient(getReq(id, bearer), id, {
138
+ db: harness.db,
139
+ issuer: ISSUER,
140
+ });
141
+ expect(res.status).toBe(200);
142
+ const body = (await res.json()) as Record<string, unknown>;
143
+ expect(body.same_hub).toBe(true);
118
144
  });
119
145
 
120
146
  test("returns the row's status after approval (so the SPA can short-circuit re-approve)", async () => {
@@ -272,4 +298,165 @@ describe("handleApproveClient", () => {
272
298
  const res = await handleApproveClient(req, id, { db: harness.db, issuer: ISSUER });
273
299
  expect(res.status).toBe(405);
274
300
  });
301
+
302
+ // Workstream D — OAuth resume via `return_to`. The SPA approve page
303
+ // can pass a hub-relative authorize URL as JSON body; the response
304
+ // echoes it as `redirect_to` so the SPA can navigate the browser there
305
+ // and resume the parked OAuth flow. The pre-D no-body shape continues
306
+ // to work (no `redirect_to` field, share-link dead-end case).
307
+ describe("workstream D — return_to / redirect_to", () => {
308
+ function jsonApproveReq(clientId: string, bearer: string, body: unknown): Request {
309
+ return new Request(`${ISSUER}/api/oauth/clients/${encodeURIComponent(clientId)}/approve`, {
310
+ method: "POST",
311
+ headers: {
312
+ authorization: `Bearer ${bearer}`,
313
+ "content-type": "application/json",
314
+ },
315
+ body: JSON.stringify(body),
316
+ });
317
+ }
318
+
319
+ test("echoes a same-origin /oauth/authorize?... return_to as redirect_to", async () => {
320
+ const { bearer } = await makeOperatorBearer();
321
+ const id = regPending();
322
+ const returnTo =
323
+ "/oauth/authorize?client_id=" +
324
+ encodeURIComponent(id) +
325
+ "&response_type=code&scope=vault%3Awork%3Aread";
326
+ const res = await handleApproveClient(
327
+ jsonApproveReq(id, bearer, { return_to: returnTo }),
328
+ id,
329
+ { db: harness.db, issuer: ISSUER },
330
+ );
331
+ expect(res.status).toBe(200);
332
+ const body = (await res.json()) as Record<string, unknown>;
333
+ expect(body.redirect_to).toBe(returnTo);
334
+ expect(body.status).toBe("approved");
335
+ expect(getClient(harness.db, id)?.status).toBe("approved");
336
+ });
337
+
338
+ test("omits redirect_to entirely when return_to is missing (share-link case preserved)", async () => {
339
+ const { bearer } = await makeOperatorBearer();
340
+ const id = regPending();
341
+ // No body — the pre-D shape. The endpoint must continue to work.
342
+ const res = await handleApproveClient(approveReq(id, bearer), id, {
343
+ db: harness.db,
344
+ issuer: ISSUER,
345
+ });
346
+ expect(res.status).toBe(200);
347
+ const body = (await res.json()) as Record<string, unknown>;
348
+ expect(body).not.toHaveProperty("redirect_to");
349
+ expect(body.status).toBe("approved");
350
+ });
351
+
352
+ test("drops an off-origin return_to (scheme-relative) silently, still approves", async () => {
353
+ const { bearer } = await makeOperatorBearer();
354
+ const id = regPending();
355
+ const res = await handleApproveClient(
356
+ jsonApproveReq(id, bearer, { return_to: "//evil.example/oauth/authorize?foo=1" }),
357
+ id,
358
+ { db: harness.db, issuer: ISSUER },
359
+ );
360
+ expect(res.status).toBe(200);
361
+ const body = (await res.json()) as Record<string, unknown>;
362
+ // No redirect_to — server refuses to echo a bad value. The client
363
+ // is still approved (we don't fail an otherwise-legitimate approve
364
+ // over a malformed return_to).
365
+ expect(body).not.toHaveProperty("redirect_to");
366
+ expect(getClient(harness.db, id)?.status).toBe("approved");
367
+ });
368
+
369
+ test("drops a non-authorize return_to (off-path) silently", async () => {
370
+ const { bearer } = await makeOperatorBearer();
371
+ const id = regPending();
372
+ const res = await handleApproveClient(
373
+ jsonApproveReq(id, bearer, { return_to: "/admin/vaults" }),
374
+ id,
375
+ { db: harness.db, issuer: ISSUER },
376
+ );
377
+ expect(res.status).toBe(200);
378
+ const body = (await res.json()) as Record<string, unknown>;
379
+ // `/admin/vaults` is same-origin but isn't a `/oauth/authorize?...`
380
+ // URL — the server-side gate is "authorize URL only" so the SPA
381
+ // can't be used as a redirect gadget for arbitrary in-SPA navigation.
382
+ expect(body).not.toHaveProperty("redirect_to");
383
+ });
384
+
385
+ test("drops absolute URL return_to silently", async () => {
386
+ const { bearer } = await makeOperatorBearer();
387
+ const id = regPending();
388
+ const res = await handleApproveClient(
389
+ jsonApproveReq(id, bearer, {
390
+ return_to: "https://evil.example/oauth/authorize?foo=1",
391
+ }),
392
+ id,
393
+ { db: harness.db, issuer: ISSUER },
394
+ );
395
+ expect(res.status).toBe(200);
396
+ const body = (await res.json()) as Record<string, unknown>;
397
+ expect(body).not.toHaveProperty("redirect_to");
398
+ });
399
+
400
+ test("non-JSON body is treated as 'no return_to' (no parser explosion)", async () => {
401
+ const { bearer } = await makeOperatorBearer();
402
+ const id = regPending();
403
+ // text/plain body — pre-D / unknown clients send anything. The
404
+ // endpoint must NOT throw on parse and must NOT echo a redirect_to.
405
+ const req = new Request(`${ISSUER}/api/oauth/clients/${id}/approve`, {
406
+ method: "POST",
407
+ headers: {
408
+ authorization: `Bearer ${bearer}`,
409
+ "content-type": "text/plain",
410
+ },
411
+ body: "garbage",
412
+ });
413
+ const res = await handleApproveClient(req, id, {
414
+ db: harness.db,
415
+ issuer: ISSUER,
416
+ });
417
+ expect(res.status).toBe(200);
418
+ const body = (await res.json()) as Record<string, unknown>;
419
+ expect(body).not.toHaveProperty("redirect_to");
420
+ });
421
+
422
+ test("malformed JSON body is treated as 'no return_to'", async () => {
423
+ const { bearer } = await makeOperatorBearer();
424
+ const id = regPending();
425
+ const req = new Request(`${ISSUER}/api/oauth/clients/${id}/approve`, {
426
+ method: "POST",
427
+ headers: {
428
+ authorization: `Bearer ${bearer}`,
429
+ "content-type": "application/json",
430
+ },
431
+ body: "{not json",
432
+ });
433
+ const res = await handleApproveClient(req, id, {
434
+ db: harness.db,
435
+ issuer: ISSUER,
436
+ });
437
+ expect(res.status).toBe(200);
438
+ const body = (await res.json()) as Record<string, unknown>;
439
+ expect(body).not.toHaveProperty("redirect_to");
440
+ });
441
+
442
+ test("re-approve with return_to echoes redirect_to (idempotent path)", async () => {
443
+ // The OAuth resume flow can legitimately race: operator opens the
444
+ // approve link, an automated path approves the same client, then
445
+ // operator clicks. We still want the redirect to fire so the
446
+ // operator's flow resumes — not dead-end on already_approved.
447
+ const { bearer } = await makeOperatorBearer();
448
+ const id = regPending();
449
+ approveClient(harness.db, id);
450
+ const returnTo = `/oauth/authorize?client_id=${encodeURIComponent(id)}&response_type=code&scope=vault%3Awork%3Aread`;
451
+ const res = await handleApproveClient(
452
+ jsonApproveReq(id, bearer, { return_to: returnTo }),
453
+ id,
454
+ { db: harness.db, issuer: ISSUER },
455
+ );
456
+ expect(res.status).toBe(200);
457
+ const body = (await res.json()) as Record<string, unknown>;
458
+ expect(body.already_approved).toBe(true);
459
+ expect(body.redirect_to).toBe(returnTo);
460
+ });
461
+ });
275
462
  });
@@ -29,6 +29,11 @@ import {
29
29
  } from "../api-account.ts";
30
30
  import { CSRF_COOKIE_NAME, CSRF_FIELD_NAME } from "../csrf.ts";
31
31
  import { hubDbPath, openHubDb } from "../hub-db.ts";
32
+ import {
33
+ CHANGE_PASSWORD_MAX_ATTEMPTS,
34
+ CHANGE_PASSWORD_WINDOW_MS,
35
+ changePasswordRateLimiter,
36
+ } from "../rate-limit.ts";
32
37
  import { SESSION_TTL_MS, buildSessionCookie, createSession } from "../sessions.ts";
33
38
  import { createUser, getUserById, verifyPassword } from "../users.ts";
34
39
 
@@ -84,6 +89,13 @@ function formBody(values: Record<string, string>): {
84
89
  let harness: Harness;
85
90
  beforeEach(() => {
86
91
  harness = makeHarness();
92
+ // Per-test rate-limit reset — change-password tests share the
93
+ // singleton `changePasswordRateLimiter`, and a test that exhausts a
94
+ // user-id bucket would 429-cascade into the next test if the user-id
95
+ // happened to collide. Per-harness DB → fresh user-ids, so in practice
96
+ // there's no collision, but the explicit reset matches `admin-handlers`
97
+ // discipline and pins the contract.
98
+ changePasswordRateLimiter.reset();
87
99
  });
88
100
  afterEach(() => {
89
101
  harness.cleanup();
@@ -438,6 +450,161 @@ describe("POST /account/change-password", () => {
438
450
  const setCookie = res.headers.get("set-cookie") ?? "";
439
451
  expect(setCookie).not.toContain("Max-Age=0");
440
452
  });
453
+
454
+ // hub#282 — per-user rate-limit on /account/change-password.
455
+ // CHANGE_PASSWORD_MAX_ATTEMPTS attempts per CHANGE_PASSWORD_WINDOW_MS;
456
+ // (CHANGE_PASSWORD_MAX_ATTEMPTS+1)th attempt within the window is 429.
457
+ test("rapid wrong-current_password attempts exhaust the bucket and 429 with Retry-After", async () => {
458
+ const { cookie } = await sessionCookieFor(harness.db, "newbie", "correct-pw", {
459
+ passwordChanged: false,
460
+ });
461
+ const buildReq = () => {
462
+ const { body, headers } = formBody({
463
+ [CSRF_FIELD_NAME]: TEST_CSRF,
464
+ current_password: "this-is-wrong",
465
+ new_password: "long-enough-passphrase",
466
+ new_password_confirm: "long-enough-passphrase",
467
+ });
468
+ return new Request("http://hub.test/account/change-password", {
469
+ method: "POST",
470
+ headers: { ...headers, cookie },
471
+ body,
472
+ });
473
+ };
474
+ // First N attempts: wrong current → 401 each (admitted by rate limiter,
475
+ // failed by argon2id verify).
476
+ for (let i = 0; i < CHANGE_PASSWORD_MAX_ATTEMPTS; i++) {
477
+ const r = await handleAccountChangePasswordPost(buildReq(), { db: harness.db });
478
+ expect(r.status).toBe(401);
479
+ }
480
+ // (N+1)th attempt: rate-limit fires before argon2id → 429 + Retry-After.
481
+ const denied = await handleAccountChangePasswordPost(buildReq(), { db: harness.db });
482
+ expect(denied.status).toBe(429);
483
+ const retryAfter = denied.headers.get("retry-after");
484
+ expect(retryAfter).not.toBeNull();
485
+ const seconds = Number(retryAfter);
486
+ expect(seconds).toBeGreaterThan(0);
487
+ // Window is CHANGE_PASSWORD_WINDOW_MS, so retry-after sits in (0, window].
488
+ expect(seconds).toBeLessThanOrEqual(CHANGE_PASSWORD_WINDOW_MS / 1000);
489
+ // Body should re-render the form with the rate-limit message.
490
+ const html = await denied.text();
491
+ expect(html).toContain("Too many password-change attempts");
492
+ });
493
+
494
+ test("rate-limit is per-user: two users have independent buckets", async () => {
495
+ const userA = await sessionCookieFor(harness.db, "user-a", "pw-a", {
496
+ passwordChanged: false,
497
+ });
498
+ const userB = await sessionCookieFor(harness.db, "user-b", "pw-b", {
499
+ passwordChanged: false,
500
+ allowMulti: true,
501
+ });
502
+ const buildReq = (cookie: string) => {
503
+ const { body, headers } = formBody({
504
+ [CSRF_FIELD_NAME]: TEST_CSRF,
505
+ current_password: "wrong",
506
+ new_password: "long-enough-passphrase",
507
+ new_password_confirm: "long-enough-passphrase",
508
+ });
509
+ return new Request("http://hub.test/account/change-password", {
510
+ method: "POST",
511
+ headers: { ...headers, cookie },
512
+ body,
513
+ });
514
+ };
515
+ // Exhaust user-a's bucket.
516
+ for (let i = 0; i < CHANGE_PASSWORD_MAX_ATTEMPTS; i++) {
517
+ await handleAccountChangePasswordPost(buildReq(userA.cookie), { db: harness.db });
518
+ }
519
+ const aDenied = await handleAccountChangePasswordPost(buildReq(userA.cookie), {
520
+ db: harness.db,
521
+ });
522
+ expect(aDenied.status).toBe(429);
523
+ // user-b's bucket is untouched — first attempt should be admitted
524
+ // (and reject for wrong current → 401, not 429).
525
+ const bAttempt = await handleAccountChangePasswordPost(buildReq(userB.cookie), {
526
+ db: harness.db,
527
+ });
528
+ expect(bAttempt.status).toBe(401);
529
+ });
530
+
531
+ test("rate-limit gate fires before argon2id verify (denied request is fast)", async () => {
532
+ // Pin the "fires before verifyPassword" property with an elapsed-time
533
+ // floor on the 429 response — argon2id verify would push elapsed
534
+ // into the hundreds of ms; the 429 path skips it.
535
+ const { cookie } = await sessionCookieFor(harness.db, "newbie", "correct-pw", {
536
+ passwordChanged: false,
537
+ });
538
+ const buildReq = () => {
539
+ const { body, headers } = formBody({
540
+ [CSRF_FIELD_NAME]: TEST_CSRF,
541
+ current_password: "wrong",
542
+ new_password: "long-enough-passphrase",
543
+ new_password_confirm: "long-enough-passphrase",
544
+ });
545
+ return new Request("http://hub.test/account/change-password", {
546
+ method: "POST",
547
+ headers: { ...headers, cookie },
548
+ body,
549
+ });
550
+ };
551
+ // Fill the bucket.
552
+ for (let i = 0; i < CHANGE_PASSWORD_MAX_ATTEMPTS; i++) {
553
+ await handleAccountChangePasswordPost(buildReq(), { db: harness.db });
554
+ }
555
+ // The (N+1)th attempt should 429-and-return without touching argon2id.
556
+ const t0 = Date.now();
557
+ const denied = await handleAccountChangePasswordPost(buildReq(), { db: harness.db });
558
+ const elapsed = Date.now() - t0;
559
+ expect(denied.status).toBe(429);
560
+ // 200ms is enough headroom even on a noisy runner; an argon2id verify
561
+ // would push elapsed into the hundreds of ms.
562
+ expect(elapsed).toBeLessThan(200);
563
+ });
564
+
565
+ test("CSRF failure does NOT burn a rate-limit slot", async () => {
566
+ // Gate-order invariant: rate-limit fires *after* CSRF, so a junk
567
+ // cross-site POST (which would never have a valid CSRF token) doesn't
568
+ // burn a bucket slot for the victim's session. Pin by sending
569
+ // (max+1) CSRF-broken requests and then confirming a fresh, valid
570
+ // attempt is admitted (would-be 401 for wrong current_password, not
571
+ // 429).
572
+ const { cookie } = await sessionCookieFor(harness.db, "newbie", "correct-pw", {
573
+ passwordChanged: false,
574
+ });
575
+ const csrfBroken = () => {
576
+ const { body, headers } = formBody({
577
+ [CSRF_FIELD_NAME]: "wrong-token",
578
+ current_password: "wrong",
579
+ new_password: "long-enough-passphrase",
580
+ new_password_confirm: "long-enough-passphrase",
581
+ });
582
+ return new Request("http://hub.test/account/change-password", {
583
+ method: "POST",
584
+ headers: { ...headers, cookie },
585
+ body,
586
+ });
587
+ };
588
+ for (let i = 0; i < CHANGE_PASSWORD_MAX_ATTEMPTS + 2; i++) {
589
+ const r = await handleAccountChangePasswordPost(csrfBroken(), { db: harness.db });
590
+ expect(r.status).toBe(400);
591
+ }
592
+ // Now send a CSRF-valid attempt — should NOT be 429 (CSRF-broken
593
+ // attempts never reached the rate limiter).
594
+ const { body, headers } = formBody({
595
+ [CSRF_FIELD_NAME]: TEST_CSRF,
596
+ current_password: "wrong",
597
+ new_password: "long-enough-passphrase",
598
+ new_password_confirm: "long-enough-passphrase",
599
+ });
600
+ const valid = new Request("http://hub.test/account/change-password", {
601
+ method: "POST",
602
+ headers: { ...headers, cookie },
603
+ body,
604
+ });
605
+ const res = await handleAccountChangePasswordPost(valid, { db: harness.db });
606
+ expect(res.status).toBe(401);
607
+ });
441
608
  });
442
609
 
443
610
  describe("markPasswordChanged", () => {