@openparachute/hub 0.5.1 → 0.5.7

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 (35) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-handlers.test.ts +92 -0
  3. package/src/__tests__/expose-2fa-warning.test.ts +125 -0
  4. package/src/__tests__/expose-cloudflare.test.ts +101 -0
  5. package/src/__tests__/expose.test.ts +199 -340
  6. package/src/__tests__/hub-server.test.ts +1227 -1
  7. package/src/__tests__/install.test.ts +50 -31
  8. package/src/__tests__/lifecycle.test.ts +97 -2
  9. package/src/__tests__/module-manifest.test.ts +13 -0
  10. package/src/__tests__/notes-serve.test.ts +154 -2
  11. package/src/__tests__/oauth-handlers.test.ts +737 -1
  12. package/src/__tests__/port-assign.test.ts +41 -52
  13. package/src/__tests__/rate-limit.test.ts +190 -0
  14. package/src/__tests__/services-manifest.test.ts +367 -0
  15. package/src/__tests__/setup.test.ts +12 -9
  16. package/src/__tests__/status.test.ts +173 -0
  17. package/src/admin-handlers.ts +38 -13
  18. package/src/commands/expose-2fa-warning.ts +82 -0
  19. package/src/commands/expose-cloudflare.ts +27 -0
  20. package/src/commands/expose-public-auto.ts +3 -7
  21. package/src/commands/expose.ts +88 -173
  22. package/src/commands/install.ts +11 -13
  23. package/src/commands/lifecycle.ts +53 -4
  24. package/src/commands/status.ts +28 -1
  25. package/src/help.ts +3 -3
  26. package/src/hub-server.ts +266 -32
  27. package/src/module-manifest.ts +19 -0
  28. package/src/notes-serve.ts +70 -9
  29. package/src/oauth-handlers.ts +249 -12
  30. package/src/oauth-ui.ts +167 -0
  31. package/src/port-assign.ts +28 -35
  32. package/src/rate-limit.ts +163 -0
  33. package/src/service-spec.ts +66 -13
  34. package/src/services-manifest.ts +83 -3
  35. package/src/sessions.ts +19 -0
@@ -196,6 +196,373 @@ describe("services-manifest", () => {
196
196
  cleanup();
197
197
  }
198
198
  });
199
+
200
+ test("round-trips optional stripPrefix (true and false)", () => {
201
+ const { path, cleanup } = makeTempPath();
202
+ try {
203
+ const stripping: ServiceEntry = { ...vault, stripPrefix: true };
204
+ upsertService(stripping, path);
205
+ expect(readManifest(path).services[0]).toEqual(stripping);
206
+
207
+ const explicitFalse: ServiceEntry = { ...vault, stripPrefix: false };
208
+ upsertService(explicitFalse, path);
209
+ expect(readManifest(path).services[0]).toEqual(explicitFalse);
210
+ } finally {
211
+ cleanup();
212
+ }
213
+ });
214
+
215
+ test("rejects non-boolean stripPrefix", () => {
216
+ const { path, cleanup } = makeTempPath();
217
+ try {
218
+ expect(() =>
219
+ upsertService({ ...vault, stripPrefix: "yes" as unknown as boolean }, path),
220
+ ).toThrow(/stripPrefix/);
221
+ } finally {
222
+ cleanup();
223
+ }
224
+ });
225
+
226
+ // Duplicate-port detection (hub#195). The original collision had
227
+ // parachute-scribe and agent both at 1944 in services.json with no
228
+ // operator-visible warning. The OS lets only one service bind, the
229
+ // hub reverse-proxy quietly routes everyone to whoever won the race,
230
+ // and `/agent` requests silently land on scribe. Reject at parse time
231
+ // so the same shape can't recur silently. Underlying overwrite bugs
232
+ // were fixed in parachute-scribe#41 + parachute-agent#146; this is
233
+ // the hub-side gate.
234
+ describe("duplicate port rejection", () => {
235
+ test("rejects manifest where two entries share a port", () => {
236
+ const { path, cleanup } = makeTempPath();
237
+ try {
238
+ writeFileSync(
239
+ path,
240
+ JSON.stringify({
241
+ services: [
242
+ {
243
+ name: "parachute-scribe",
244
+ port: 1944,
245
+ paths: ["/scribe"],
246
+ health: "/scribe/health",
247
+ version: "0.4.0",
248
+ },
249
+ {
250
+ name: "agent",
251
+ port: 1944,
252
+ paths: ["/agent"],
253
+ health: "/agent/health",
254
+ version: "0.1.0",
255
+ },
256
+ ],
257
+ }),
258
+ );
259
+ expect(() => readManifest(path)).toThrow(ServicesManifestError);
260
+ } finally {
261
+ cleanup();
262
+ }
263
+ });
264
+
265
+ test("error message names both conflicting services and the colliding port", () => {
266
+ const { path, cleanup } = makeTempPath();
267
+ try {
268
+ writeFileSync(
269
+ path,
270
+ JSON.stringify({
271
+ services: [
272
+ {
273
+ name: "parachute-scribe",
274
+ port: 1944,
275
+ paths: ["/scribe"],
276
+ health: "/scribe/health",
277
+ version: "0.4.0",
278
+ },
279
+ {
280
+ name: "agent",
281
+ port: 1944,
282
+ paths: ["/agent"],
283
+ health: "/agent/health",
284
+ version: "0.1.0",
285
+ },
286
+ ],
287
+ }),
288
+ );
289
+ // The error names the conflicting port (so an operator scanning
290
+ // services.json knows where to look) and both service names (so
291
+ // they know which two rows to reconcile).
292
+ expect(() => readManifest(path)).toThrow(/duplicate port 1944/);
293
+ expect(() => readManifest(path)).toThrow(/parachute-scribe/);
294
+ expect(() => readManifest(path)).toThrow(/agent/);
295
+ } finally {
296
+ cleanup();
297
+ }
298
+ });
299
+
300
+ test("accepts manifest with all unique ports", () => {
301
+ const { path, cleanup } = makeTempPath();
302
+ try {
303
+ writeFileSync(
304
+ path,
305
+ JSON.stringify({
306
+ services: [
307
+ {
308
+ name: "parachute-vault",
309
+ port: 1940,
310
+ paths: ["/"],
311
+ health: "/health",
312
+ version: "0.2.4",
313
+ },
314
+ {
315
+ name: "parachute-scribe",
316
+ port: 1943,
317
+ paths: ["/scribe"],
318
+ health: "/scribe/health",
319
+ version: "0.4.0",
320
+ },
321
+ ],
322
+ }),
323
+ );
324
+ const m = readManifest(path);
325
+ expect(m.services).toHaveLength(2);
326
+ } finally {
327
+ cleanup();
328
+ }
329
+ });
330
+
331
+ test("allows multi-vault: parachute-vault-default + parachute-vault-techne on the same port", () => {
332
+ // Multi-vault is the deliberate exception. One parachute-vault process
333
+ // serves N vault instances on a single port at distinct mount paths.
334
+ // The duplicate-port gate must not break that shape.
335
+ const { path, cleanup } = makeTempPath();
336
+ try {
337
+ writeFileSync(
338
+ path,
339
+ JSON.stringify({
340
+ services: [
341
+ {
342
+ name: "parachute-vault-default",
343
+ port: 1940,
344
+ paths: ["/vault/default"],
345
+ health: "/vault/default/health",
346
+ version: "0.4.0",
347
+ },
348
+ {
349
+ name: "parachute-vault-techne",
350
+ port: 1940,
351
+ paths: ["/vault/techne"],
352
+ health: "/vault/techne/health",
353
+ version: "0.4.0",
354
+ },
355
+ ],
356
+ }),
357
+ );
358
+ const m = readManifest(path);
359
+ expect(m.services).toHaveLength(2);
360
+ } finally {
361
+ cleanup();
362
+ }
363
+ });
364
+
365
+ test("rejects vault sharing a port with a non-vault service", () => {
366
+ // The vault exception is narrow: same-port is allowed only between
367
+ // multi-vault rows. A vault sharing a port with anything else is the
368
+ // same silent-miswire shape we're guarding against.
369
+ const { path, cleanup } = makeTempPath();
370
+ try {
371
+ writeFileSync(
372
+ path,
373
+ JSON.stringify({
374
+ services: [
375
+ {
376
+ name: "parachute-vault-default",
377
+ port: 1940,
378
+ paths: ["/vault/default"],
379
+ health: "/vault/default/health",
380
+ version: "0.4.0",
381
+ },
382
+ {
383
+ name: "parachute-scribe",
384
+ port: 1940,
385
+ paths: ["/scribe"],
386
+ health: "/scribe/health",
387
+ version: "0.4.0",
388
+ },
389
+ ],
390
+ }),
391
+ );
392
+ expect(() => readManifest(path)).toThrow(/duplicate port 1940/);
393
+ } finally {
394
+ cleanup();
395
+ }
396
+ });
397
+
398
+ test("three-way collision still surfaces (first pair caught)", () => {
399
+ const { path, cleanup } = makeTempPath();
400
+ try {
401
+ writeFileSync(
402
+ path,
403
+ JSON.stringify({
404
+ services: [
405
+ {
406
+ name: "a",
407
+ port: 9000,
408
+ paths: ["/a"],
409
+ health: "/a/health",
410
+ version: "0.1.0",
411
+ },
412
+ {
413
+ name: "b",
414
+ port: 9000,
415
+ paths: ["/b"],
416
+ health: "/b/health",
417
+ version: "0.1.0",
418
+ },
419
+ {
420
+ name: "c",
421
+ port: 9000,
422
+ paths: ["/c"],
423
+ health: "/c/health",
424
+ version: "0.1.0",
425
+ },
426
+ ],
427
+ }),
428
+ );
429
+ expect(() => readManifest(path)).toThrow(/duplicate port 9000/);
430
+ } finally {
431
+ cleanup();
432
+ }
433
+ });
434
+ });
435
+
436
+ // Write-time port collision rejection (hub#205). The read-time gate above
437
+ // catches duplicate ports on the next `readManifest`, but without a
438
+ // matching write-side check `upsertService` happily writes a corrupt
439
+ // manifest to disk and only the next read surfaces the fault. A buggy
440
+ // service boot calling `upsertService({ name: "agent", port: 1944 })`
441
+ // while scribe is already at 1944 must fail before `writeManifest` runs.
442
+ // Same multi-vault carve-out applies.
443
+ describe("upsertService duplicate-port rejection (hub#205)", () => {
444
+ const scribe: ServiceEntry = {
445
+ name: "parachute-scribe",
446
+ port: 1944,
447
+ paths: ["/scribe"],
448
+ health: "/scribe/health",
449
+ version: "0.4.0",
450
+ };
451
+ const agent: ServiceEntry = {
452
+ name: "agent",
453
+ port: 1944,
454
+ paths: ["/agent"],
455
+ health: "/agent/health",
456
+ version: "0.1.0",
457
+ };
458
+
459
+ test("succeeds when adding a service at a non-conflicting port", () => {
460
+ const { path, cleanup } = makeTempPath();
461
+ try {
462
+ upsertService(scribe, path);
463
+ const m = upsertService({ ...agent, port: 1945 }, path);
464
+ expect(m.services).toHaveLength(2);
465
+ expect(m.services.map((s) => s.port).sort()).toEqual([1944, 1945]);
466
+ // And it actually wrote: a fresh read sees both rows.
467
+ expect(readManifest(path).services).toHaveLength(2);
468
+ } finally {
469
+ cleanup();
470
+ }
471
+ });
472
+
473
+ test("throws ServicesManifestError when adding a service at a port already claimed by a non-vault service", () => {
474
+ const { path, cleanup } = makeTempPath();
475
+ try {
476
+ upsertService(scribe, path);
477
+ expect(() => upsertService(agent, path)).toThrow(ServicesManifestError);
478
+ // Error names the colliding port and both services so an operator
479
+ // scanning logs knows which two rows to reconcile.
480
+ expect(() => upsertService(agent, path)).toThrow(/duplicate port 1944/);
481
+ expect(() => upsertService(agent, path)).toThrow(/parachute-scribe/);
482
+ expect(() => upsertService(agent, path)).toThrow(/agent/);
483
+ // Crucially: services.json was NOT corrupted on the failed write.
484
+ // The pre-existing row stays, and the agent row never lands.
485
+ const m = readManifest(path);
486
+ expect(m.services).toHaveLength(1);
487
+ expect(m.services[0]?.name).toBe("parachute-scribe");
488
+ } finally {
489
+ cleanup();
490
+ }
491
+ });
492
+
493
+ test("succeeds when adding a vault row at a port already used by another vault row (multi-vault carve-out)", () => {
494
+ const { path, cleanup } = makeTempPath();
495
+ try {
496
+ const vaultDefault: ServiceEntry = {
497
+ name: "parachute-vault-default",
498
+ port: 1940,
499
+ paths: ["/vault/default"],
500
+ health: "/vault/default/health",
501
+ version: "0.4.0",
502
+ };
503
+ const vaultTechne: ServiceEntry = {
504
+ name: "parachute-vault-techne",
505
+ port: 1940,
506
+ paths: ["/vault/techne"],
507
+ health: "/vault/techne/health",
508
+ version: "0.4.0",
509
+ };
510
+ upsertService(vaultDefault, path);
511
+ const m = upsertService(vaultTechne, path);
512
+ expect(m.services).toHaveLength(2);
513
+ expect(m.services.map((s) => s.port)).toEqual([1940, 1940]);
514
+ // And persisted: a fresh read sees both vault rows on the same port,
515
+ // confirming readManifest's multi-vault carve-out matches the write
516
+ // side's.
517
+ expect(readManifest(path).services).toHaveLength(2);
518
+ } finally {
519
+ cleanup();
520
+ }
521
+ });
522
+
523
+ test("succeeds when UPDATING an existing entry's port to a non-conflicting port", () => {
524
+ // The update path (idx >= 0 in upsertService) replaces the row in-place
525
+ // before the duplicate-port check. Updating an entry's port to a value
526
+ // that collides with a DIFFERENT row must still throw, but moving an
527
+ // entry to a free port must succeed — including off canonical, which is
528
+ // a legitimate operator move (e.g., to dodge a third-party clash).
529
+ const { path, cleanup } = makeTempPath();
530
+ try {
531
+ upsertService(scribe, path); // port 1944
532
+ upsertService({ ...agent, port: 1945 }, path); // port 1945
533
+ // Move scribe from 1944 to 1948 (free): succeeds.
534
+ const m = upsertService({ ...scribe, port: 1948 }, path);
535
+ expect(m.services).toHaveLength(2);
536
+ const scribeRow = m.services.find((s) => s.name === "parachute-scribe");
537
+ expect(scribeRow?.port).toBe(1948);
538
+ // Fresh read: persisted state matches.
539
+ const persisted = readManifest(path);
540
+ expect(persisted.services.find((s) => s.name === "parachute-scribe")?.port).toBe(1948);
541
+ } finally {
542
+ cleanup();
543
+ }
544
+ });
545
+
546
+ test("throws when UPDATING an existing entry's port to one that collides with another row", () => {
547
+ // Companion to the above: the update path must NOT bypass the gate
548
+ // when the moved row's new port now collides with a different row.
549
+ const { path, cleanup } = makeTempPath();
550
+ try {
551
+ upsertService(scribe, path); // port 1944
552
+ upsertService({ ...agent, port: 1945 }, path); // port 1945
553
+ // Move scribe to 1945, where agent already lives: must throw.
554
+ expect(() => upsertService({ ...scribe, port: 1945 }, path)).toThrow(ServicesManifestError);
555
+ expect(() => upsertService({ ...scribe, port: 1945 }, path)).toThrow(/duplicate port 1945/);
556
+ // And the on-disk state stayed coherent — scribe at 1944, agent at
557
+ // 1945 — because the gate fires before writeManifest.
558
+ const persisted = readManifest(path);
559
+ expect(persisted.services.find((s) => s.name === "parachute-scribe")?.port).toBe(1944);
560
+ expect(persisted.services.find((s) => s.name === "agent")?.port).toBe(1945);
561
+ } finally {
562
+ cleanup();
563
+ }
564
+ });
565
+ });
199
566
  });
200
567
 
201
568
  describe("claw → agent migration", () => {
@@ -115,18 +115,21 @@ describe("setup", () => {
115
115
  const h = makeHarness();
116
116
  try {
117
117
  // Pre-seed every first-party shortname so survey returns all-installed.
118
- for (const m of [
119
- "parachute-vault",
120
- "parachute-notes",
121
- "parachute-scribe",
122
- "parachute-channel",
123
- ]) {
118
+ // Distinct canonical ports per service — services-manifest.ts now
119
+ // rejects duplicate ports between distinct services (hub#195).
120
+ const seeds: Array<{ name: string; port: number }> = [
121
+ { name: "parachute-vault", port: 1940 },
122
+ { name: "parachute-notes", port: 1942 },
123
+ { name: "parachute-scribe", port: 1943 },
124
+ { name: "parachute-channel", port: 1941 },
125
+ ];
126
+ for (const s of seeds) {
124
127
  upsertService(
125
128
  {
126
- name: m,
129
+ name: s.name,
127
130
  version: "0.0.0",
128
- port: 1940,
129
- paths: [`/${m.replace(/^parachute-/, "")}`],
131
+ port: s.port,
132
+ paths: [`/${s.name.replace(/^parachute-/, "")}`],
130
133
  health: "/health",
131
134
  },
132
135
  h.manifestPath,
@@ -317,6 +317,179 @@ describe("status", () => {
317
317
  }
318
318
  });
319
319
 
320
+ // Canonical-port drift warning (hub#195). When a known service ends up at
321
+ // a non-canonical port (because of an upgrade rewrite, a port-walk fallback,
322
+ // or an operator edit), surface it in `parachute status` so a silent miswire
323
+ // is operator-visible. Warning, not error — operators may have moved the
324
+ // service deliberately to dodge a third-party clash.
325
+ describe("canonical-port drift warning", () => {
326
+ test("warns when scribe is at non-canonical port (1944 instead of 1943)", async () => {
327
+ const { path, cleanup } = makeTempPath();
328
+ try {
329
+ upsertService(
330
+ {
331
+ name: "parachute-scribe",
332
+ port: 1944,
333
+ paths: ["/scribe"],
334
+ health: "/scribe/health",
335
+ version: "0.4.0",
336
+ },
337
+ path,
338
+ );
339
+ const lines: string[] = [];
340
+ await status({
341
+ manifestPath: path,
342
+ fetchImpl: async () => new Response(null, { status: 200 }),
343
+ print: (l) => lines.push(l),
344
+ });
345
+ expect(lines.some((l) => l.includes("canonical port is 1943"))).toBe(true);
346
+ } finally {
347
+ cleanup();
348
+ }
349
+ });
350
+
351
+ test("does not warn when service is on its canonical port", async () => {
352
+ const { path, cleanup } = makeTempPath();
353
+ try {
354
+ upsertService(
355
+ {
356
+ name: "parachute-scribe",
357
+ port: 1943,
358
+ paths: ["/scribe"],
359
+ health: "/scribe/health",
360
+ version: "0.4.0",
361
+ },
362
+ path,
363
+ );
364
+ const lines: string[] = [];
365
+ await status({
366
+ manifestPath: path,
367
+ fetchImpl: async () => new Response(null, { status: 200 }),
368
+ print: (l) => lines.push(l),
369
+ });
370
+ expect(lines.some((l) => l.includes("canonical port"))).toBe(false);
371
+ } finally {
372
+ cleanup();
373
+ }
374
+ });
375
+
376
+ test("does not warn for third-party services with no canonical port", async () => {
377
+ const { path, cleanup } = makeTempPath();
378
+ try {
379
+ upsertService(
380
+ {
381
+ name: "third-party-thing",
382
+ port: 9000,
383
+ paths: ["/widget"],
384
+ health: "/health",
385
+ version: "1.0.0",
386
+ },
387
+ path,
388
+ );
389
+ const lines: string[] = [];
390
+ await status({
391
+ manifestPath: path,
392
+ fetchImpl: async () => new Response(null, { status: 200 }),
393
+ print: (l) => lines.push(l),
394
+ });
395
+ expect(lines.some((l) => l.includes("canonical port"))).toBe(false);
396
+ } finally {
397
+ cleanup();
398
+ }
399
+ });
400
+
401
+ test("warning does not affect exit code (status stays 0 when healthy)", async () => {
402
+ const { path, cleanup } = makeTempPath();
403
+ try {
404
+ upsertService(
405
+ {
406
+ name: "parachute-scribe",
407
+ port: 1944,
408
+ paths: ["/scribe"],
409
+ health: "/scribe/health",
410
+ version: "0.4.0",
411
+ },
412
+ path,
413
+ );
414
+ const code = await status({
415
+ manifestPath: path,
416
+ fetchImpl: async () => new Response(null, { status: 200 }),
417
+ print: () => {},
418
+ });
419
+ // Drift is informational. A healthy probed service still returns 0
420
+ // even when the port has drifted off canonical.
421
+ expect(code).toBe(0);
422
+ } finally {
423
+ cleanup();
424
+ }
425
+ });
426
+
427
+ test("warning still fires when service is stopped (probe skipped)", async () => {
428
+ const { path, configDir, cleanup } = makeTempPath();
429
+ try {
430
+ upsertService(
431
+ {
432
+ name: "parachute-scribe",
433
+ port: 1944,
434
+ paths: ["/scribe"],
435
+ health: "/scribe/health",
436
+ version: "0.4.0",
437
+ },
438
+ path,
439
+ );
440
+ writePid("scribe", 4242, configDir);
441
+ const lines: string[] = [];
442
+ await status({
443
+ manifestPath: path,
444
+ configDir,
445
+ alive: () => false,
446
+ fetchImpl: async () => new Response(null, { status: 200 }),
447
+ print: (l) => lines.push(l),
448
+ });
449
+ // Drift is computed from services.json, not from the probe — a
450
+ // stopped service with a drifted port should still surface the
451
+ // warning so operators see the miswire even before they start it.
452
+ expect(lines.some((l) => l.includes("canonical port is 1943"))).toBe(true);
453
+ } finally {
454
+ cleanup();
455
+ }
456
+ });
457
+
458
+ test("multi-vault instance rows do not surface a drift warning (intentional gap)", async () => {
459
+ // Pinning the documented gap: `parachute-vault-default` is not
460
+ // a canonical manifest name in FIRST_PARTY_FALLBACKS, so
461
+ // `canonicalPortForManifest` returns undefined and no drift
462
+ // warning fires — even when the row's port differs from the
463
+ // canonical `parachute-vault` port (1940). Rationale lives on
464
+ // `canonicalPortForManifest` in service-spec.ts; this test pins
465
+ // the behavior so a future change to the lookup shape doesn't
466
+ // accidentally start emitting drift on every multi-vault row
467
+ // without an explicit decision.
468
+ const { path, cleanup } = makeTempPath();
469
+ try {
470
+ upsertService(
471
+ {
472
+ name: "parachute-vault-default",
473
+ port: 1944,
474
+ paths: ["/vault/default"],
475
+ health: "/vault/default/health",
476
+ version: "0.2.4",
477
+ },
478
+ path,
479
+ );
480
+ const lines: string[] = [];
481
+ await status({
482
+ manifestPath: path,
483
+ fetchImpl: async () => new Response(null, { status: 200 }),
484
+ print: (l) => lines.push(l),
485
+ });
486
+ expect(lines.some((l) => l.includes("canonical port"))).toBe(false);
487
+ } finally {
488
+ cleanup();
489
+ }
490
+ });
491
+ });
492
+
320
493
  test("stopped services still render a URL line so the user knows where to point clients post-start", async () => {
321
494
  const { path, configDir, cleanup } = makeTempPath();
322
495
  try {
@@ -31,6 +31,7 @@ import { restart as lifecycleRestart } from "./commands/lifecycle.ts";
31
31
  import { CONFIG_DIR } from "./config.ts";
32
32
  import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
33
33
  import type { ModuleManifest } from "./module-manifest.ts";
34
+ import { checkAndRecord, clientIpFromRequest } from "./rate-limit.ts";
34
35
  import {
35
36
  type ServicesManifest,
36
37
  readManifest as readServicesManifest,
@@ -41,7 +42,7 @@ import {
41
42
  buildSessionCookie,
42
43
  createSession,
43
44
  deleteSession,
44
- findSession,
45
+ findActiveSession,
45
46
  parseSessionCookie,
46
47
  } from "./sessions.ts";
47
48
  import { getUserByUsername, verifyPassword } from "./users.ts";
@@ -74,15 +75,6 @@ function redirect(location: string, extra: Record<string, string> = {}): Respons
74
75
 
75
76
  // --- session gate ----------------------------------------------------------
76
77
 
77
- /**
78
- * Return the active session for this request, or null. Caller decides what
79
- * to do on null — most paths should redirect to `/admin/login?next=<path>`.
80
- */
81
- function activeSession(db: Database, req: Request) {
82
- const sid = parseSessionCookie(req.headers.get("cookie"));
83
- return sid ? findSession(db, sid) : null;
84
- }
85
-
86
78
  function loginRedirect(req: Request, extra: Record<string, string> = {}): Response {
87
79
  const url = new URL(req.url);
88
80
  const next = `${url.pathname}${url.search}`;
@@ -106,7 +98,11 @@ export function handleAdminLoginGet(_db: Database, req: Request): Response {
106
98
  return htmlResponse(renderAdminLogin({ next, csrfToken: csrf.token }), 200, extra);
107
99
  }
108
100
 
109
- export async function handleAdminLoginPost(db: Database, req: Request): Promise<Response> {
101
+ export async function handleAdminLoginPost(
102
+ db: Database,
103
+ req: Request,
104
+ deps: AdminLoginDeps = {},
105
+ ): Promise<Response> {
110
106
  const form = await req.formData();
111
107
  const formCsrf = form.get(CSRF_FIELD_NAME);
112
108
  if (!verifyCsrfToken(req, typeof formCsrf === "string" ? formCsrf : null)) {
@@ -118,6 +114,24 @@ export async function handleAdminLoginPost(db: Database, req: Request): Promise<
118
114
  400,
119
115
  );
120
116
  }
117
+ // Rate-limit gate fires *after* CSRF (so a junk cross-site POST doesn't
118
+ // burn a bucket slot for the victim's IP) but *before* credential check.
119
+ // Every legitimate login attempt — wrong password, missing user, eventually
120
+ // failed-2FA (#186) — counts toward the same bucket so an attacker can't
121
+ // partition the cooldown across stages.
122
+ const clientIp = clientIpFromRequest(req);
123
+ const now = deps.now ? deps.now() : new Date();
124
+ const gate = checkAndRecord(clientIp, now);
125
+ if (!gate.allowed) {
126
+ return htmlResponse(
127
+ renderAdminError({
128
+ title: "Too many login attempts",
129
+ message: `Too many login attempts from this IP. Try again in ${gate.retryAfterSeconds ?? 1} seconds.`,
130
+ }),
131
+ 429,
132
+ { "retry-after": String(gate.retryAfterSeconds ?? 1) },
133
+ );
134
+ }
121
135
  const username = String(form.get("username") ?? "");
122
136
  const password = String(form.get("password") ?? "");
123
137
  const next = safeNext(String(form.get("next") ?? ""));
@@ -147,6 +161,17 @@ export async function handleAdminLoginPost(db: Database, req: Request): Promise<
147
161
  return redirect(next, { "set-cookie": cookie });
148
162
  }
149
163
 
164
+ /**
165
+ * Test-injection seam for `handleAdminLoginPost`. Production callers omit
166
+ * `deps`; tests pass a deterministic clock so the rate-limit assertions
167
+ * don't race wall-clock time. Kept narrow — login doesn't share the wider
168
+ * `AdminDeps` because it doesn't load services / module manifests.
169
+ */
170
+ export interface AdminLoginDeps {
171
+ /** Test seam — defaults to real clock. */
172
+ now?: () => Date;
173
+ }
174
+
150
175
  // --- /admin/logout ---------------------------------------------------------
151
176
 
152
177
  /**
@@ -183,7 +208,7 @@ export async function handleAdminConfigGet(
183
208
  req: Request,
184
209
  deps: AdminDeps = {},
185
210
  ): Promise<Response> {
186
- const session = activeSession(db, req);
211
+ const session = findActiveSession(db, req);
187
212
  if (!session) return loginRedirect(req);
188
213
 
189
214
  const csrf = ensureCsrfToken(req);
@@ -207,7 +232,7 @@ export async function handleAdminConfigPost(
207
232
  moduleName: string,
208
233
  deps: AdminDeps = {},
209
234
  ): Promise<Response> {
210
- const session = activeSession(db, req);
235
+ const session = findActiveSession(db, req);
211
236
  if (!session) return loginRedirect(req);
212
237
 
213
238
  const form = await req.formData();