@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.
- package/package.json +1 -1
- package/src/__tests__/admin-handlers.test.ts +92 -0
- package/src/__tests__/expose-2fa-warning.test.ts +125 -0
- package/src/__tests__/expose-cloudflare.test.ts +101 -0
- package/src/__tests__/expose.test.ts +199 -340
- package/src/__tests__/hub-server.test.ts +1227 -1
- package/src/__tests__/install.test.ts +50 -31
- package/src/__tests__/lifecycle.test.ts +97 -2
- package/src/__tests__/module-manifest.test.ts +13 -0
- package/src/__tests__/notes-serve.test.ts +154 -2
- package/src/__tests__/oauth-handlers.test.ts +737 -1
- package/src/__tests__/port-assign.test.ts +41 -52
- package/src/__tests__/rate-limit.test.ts +190 -0
- package/src/__tests__/services-manifest.test.ts +367 -0
- package/src/__tests__/setup.test.ts +12 -9
- package/src/__tests__/status.test.ts +173 -0
- package/src/admin-handlers.ts +38 -13
- package/src/commands/expose-2fa-warning.ts +82 -0
- package/src/commands/expose-cloudflare.ts +27 -0
- package/src/commands/expose-public-auto.ts +3 -7
- package/src/commands/expose.ts +88 -173
- package/src/commands/install.ts +11 -13
- package/src/commands/lifecycle.ts +53 -4
- package/src/commands/status.ts +28 -1
- package/src/help.ts +3 -3
- package/src/hub-server.ts +266 -32
- package/src/module-manifest.ts +19 -0
- package/src/notes-serve.ts +70 -9
- package/src/oauth-handlers.ts +249 -12
- package/src/oauth-ui.ts +167 -0
- package/src/port-assign.ts +28 -35
- package/src/rate-limit.ts +163 -0
- package/src/service-spec.ts +66 -13
- package/src/services-manifest.ts +83 -3
- 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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
"parachute-
|
|
122
|
-
"parachute-
|
|
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:
|
|
129
|
+
name: s.name,
|
|
127
130
|
version: "0.0.0",
|
|
128
|
-
port:
|
|
129
|
-
paths: [`/${
|
|
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 {
|
package/src/admin-handlers.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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 =
|
|
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 =
|
|
235
|
+
const session = findActiveSession(db, req);
|
|
211
236
|
if (!session) return loginRedirect(req);
|
|
212
237
|
|
|
213
238
|
const form = await req.formData();
|