@openparachute/hub 0.5.2 → 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.
@@ -74,103 +74,92 @@ describe("assignPort (pure)", () => {
74
74
  });
75
75
  });
76
76
 
77
- describe("assignServicePort (.env round-trip)", () => {
78
- test("preserves an existing PORT in .env (idempotent re-install)", () => {
77
+ describe("assignServicePort (hub#206 — services.json is authoritative)", () => {
78
+ // Post-hub#206 assignServicePort is a thin wrapper over assignPort: the
79
+ // install path no longer touches the service's .env, since services.json
80
+ // is the single source of truth and the duplicate state caused drift on
81
+ // re-install. These tests pin the new contract:
82
+ // 1. The function returns the assigned port + source/warning.
83
+ // 2. It does not write to .env. Pre-existing .env files are untouched
84
+ // (no PORT line added, no PORT line removed, no other lines mutated).
85
+ // 3. There's no "preserved" source — a stale .env PORT does NOT survive
86
+ // a re-install (operators edit services.json now).
87
+
88
+ test("returns canonical when free, does not touch .env", () => {
79
89
  const { dir, cleanup } = makeTempDir();
80
90
  try {
81
- const envPath = join(dir, ".env");
82
- writeFileSync(envPath, "PORT=1944\nOTHER=keepme\n");
91
+ const envPath = join(dir, "subdir", ".env");
83
92
  const result = assignServicePort({
84
- envPath,
85
93
  canonical: 1940,
86
- // Even though canonical is free, the existing .env wins.
87
94
  occupied: [],
88
95
  });
89
- expect(result.port).toBe(1944);
90
- expect(result.source).toBe("preserved");
91
- expect(result.written).toBe(false);
92
- // File untouched no rewrite means OTHER stays as-is.
93
- const text = readFileSync(envPath, "utf8");
94
- expect(text).toContain("PORT=1944");
95
- expect(text).toContain("OTHER=keepme");
96
+ expect(result.port).toBe(1940);
97
+ expect(result.source).toBe("canonical");
98
+ expect(result.warning).toBeUndefined();
99
+ // No .env file gets created subdir doesn't even exist.
100
+ expect(existsSync(envPath)).toBe(false);
96
101
  } finally {
97
102
  cleanup();
98
103
  }
99
104
  });
100
105
 
101
- test("writes PORT into a fresh .env when canonical is free", () => {
106
+ test("does NOT preserve a pre-existing PORT in .env (services.json is authoritative)", () => {
107
+ // Pre-hub#206 a stale `.env` PORT survived a re-install — operators
108
+ // editing services.json would get re-stamped by the .env. Post-#206
109
+ // services.json wins; the .env PORT is ignored at install time and
110
+ // also at boot (per the 4-tier ladder in scribe/agent).
102
111
  const { dir, cleanup } = makeTempDir();
103
112
  try {
104
- const envPath = join(dir, "subdir", ".env");
113
+ const envPath = join(dir, ".env");
114
+ const before = "PORT=1944\nOTHER=keepme\n";
115
+ writeFileSync(envPath, before);
105
116
  const result = assignServicePort({
106
- envPath,
107
117
  canonical: 1940,
108
118
  occupied: [],
109
119
  });
120
+ // We assigned the canonical port, NOT the stale 1944 from .env.
110
121
  expect(result.port).toBe(1940);
111
122
  expect(result.source).toBe("canonical");
112
- expect(result.written).toBe(true);
113
- expect(result.warning).toBeUndefined();
114
- expect(existsSync(envPath)).toBe(true);
115
- expect(readFileSync(envPath, "utf8")).toContain("PORT=1940");
123
+ // The .env file is bit-for-bit untouched — PORT line and other lines
124
+ // both stay. (No new PORT line written, no existing PORT rewritten.)
125
+ const after = readFileSync(envPath, "utf8");
126
+ expect(after).toBe(before);
116
127
  } finally {
117
128
  cleanup();
118
129
  }
119
130
  });
120
131
 
121
- test("writes a fallback PORT and surfaces the warning when canonical is occupied", () => {
132
+ test("returns fallback port and warning when canonical is occupied; .env untouched", () => {
122
133
  const { dir, cleanup } = makeTempDir();
123
134
  try {
124
135
  const envPath = join(dir, ".env");
136
+ // Pre-existing .env with non-PORT content.
137
+ const before = "FOO=bar\n";
138
+ writeFileSync(envPath, before);
125
139
  const result = assignServicePort({
126
- envPath,
127
140
  canonical: 1940,
128
141
  occupied: [1940],
129
142
  });
130
143
  expect(result.port).toBe(1944);
131
144
  expect(result.source).toBe("fallback-in-range");
132
- expect(result.written).toBe(true);
133
145
  expect(result.warning).toMatch(/canonical port 1940 is in use/);
134
- expect(readFileSync(envPath, "utf8")).toContain("PORT=1944");
135
- } finally {
136
- cleanup();
137
- }
138
- });
139
-
140
- test("ignores a non-numeric PORT and assigns a fresh one", () => {
141
- const { dir, cleanup } = makeTempDir();
142
- try {
143
- const envPath = join(dir, ".env");
144
- writeFileSync(envPath, "PORT=garbage\n");
145
- const result = assignServicePort({
146
- envPath,
147
- canonical: 1940,
148
- occupied: [],
149
- });
150
- expect(result.port).toBe(1940);
151
- expect(result.written).toBe(true);
152
- // The garbage value got upserted to a real number.
153
- expect(readFileSync(envPath, "utf8")).toContain("PORT=1940");
146
+ // .env stays bit-for-bit identical.
147
+ expect(readFileSync(envPath, "utf8")).toBe(before);
154
148
  } finally {
155
149
  cleanup();
156
150
  }
157
151
  });
158
152
 
159
- test("preserves surrounding lines on rewrite", () => {
153
+ test("third-party (no canonical) gets first reservation slot; no .env created", () => {
160
154
  const { dir, cleanup } = makeTempDir();
161
155
  try {
162
156
  const envPath = join(dir, ".env");
163
- writeFileSync(envPath, "FOO=bar\nBAZ=qux\n");
164
157
  const result = assignServicePort({
165
- envPath,
166
- canonical: 1940,
167
158
  occupied: [],
168
159
  });
169
- expect(result.written).toBe(true);
170
- const text = readFileSync(envPath, "utf8");
171
- expect(text).toContain("FOO=bar");
172
- expect(text).toContain("BAZ=qux");
173
- expect(text).toContain("PORT=1940");
160
+ expect(result.port).toBe(1944);
161
+ expect(result.source).toBe("fallback-in-range");
162
+ expect(existsSync(envPath)).toBe(false);
174
163
  } finally {
175
164
  cleanup();
176
165
  }
@@ -0,0 +1,190 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import {
3
+ MAX_ATTEMPTS,
4
+ UNKNOWN_IP_SENTINEL,
5
+ WINDOW_MS,
6
+ __resetForTests,
7
+ checkAndRecord,
8
+ clientIpFromRequest,
9
+ } from "../rate-limit.ts";
10
+
11
+ afterEach(() => {
12
+ __resetForTests();
13
+ });
14
+
15
+ describe("checkAndRecord — bucket fill / drain", () => {
16
+ test("admits the first MAX_ATTEMPTS attempts; denies the next one with Retry-After", () => {
17
+ const now = new Date("2026-05-08T12:00:00Z");
18
+ for (let i = 0; i < MAX_ATTEMPTS; i++) {
19
+ const r = checkAndRecord("ip-a", now);
20
+ expect(r.allowed).toBe(true);
21
+ expect(r.retryAfterSeconds).toBeUndefined();
22
+ }
23
+ const denied = checkAndRecord("ip-a", now);
24
+ expect(denied.allowed).toBe(false);
25
+ expect(denied.retryAfterSeconds).toBeDefined();
26
+ // 5 attempts at the same instant; window length 15 min = 900s. Reset is
27
+ // exactly WINDOW_MS later, so retry-after === WINDOW_MS / 1000.
28
+ expect(denied.retryAfterSeconds).toBe(WINDOW_MS / 1000);
29
+ });
30
+
31
+ test("bucket drains: attempt is admitted again once the window passes", () => {
32
+ const t0 = new Date("2026-05-08T12:00:00Z");
33
+ for (let i = 0; i < MAX_ATTEMPTS; i++) {
34
+ checkAndRecord("ip-a", t0);
35
+ }
36
+ const stillDenied = checkAndRecord("ip-a", new Date(t0.getTime() + WINDOW_MS - 1000));
37
+ expect(stillDenied.allowed).toBe(false);
38
+
39
+ // Advance past the window — all five timestamps fall off, slot opens.
40
+ const past = new Date(t0.getTime() + WINDOW_MS + 1000);
41
+ const allowed = checkAndRecord("ip-a", past);
42
+ expect(allowed.allowed).toBe(true);
43
+ });
44
+
45
+ test("partial drain: oldest entry falling off opens exactly one slot", () => {
46
+ const t0 = new Date("2026-05-08T12:00:00Z");
47
+ // Spread 5 attempts 1 minute apart so they fall off the window
48
+ // individually rather than as a cohort.
49
+ for (let i = 0; i < MAX_ATTEMPTS; i++) {
50
+ checkAndRecord("ip-a", new Date(t0.getTime() + i * 60_000));
51
+ }
52
+ // Right at the 5th-minute mark, all 5 are in window → denied.
53
+ const denied = checkAndRecord("ip-a", new Date(t0.getTime() + 5 * 60_000));
54
+ expect(denied.allowed).toBe(false);
55
+
56
+ // Step past WINDOW_MS from the *first* attempt (t0) → that one falls
57
+ // off, so we should be admitted.
58
+ const partial = new Date(t0.getTime() + WINDOW_MS + 1000);
59
+ const r = checkAndRecord("ip-a", partial);
60
+ expect(r.allowed).toBe(true);
61
+ });
62
+
63
+ test("denied attempts do not push the reset further into the future", () => {
64
+ const t0 = new Date("2026-05-08T12:00:00Z");
65
+ for (let i = 0; i < MAX_ATTEMPTS; i++) {
66
+ checkAndRecord("ip-a", t0);
67
+ }
68
+ // Five denials over 30 seconds. The reset moment must be anchored to the
69
+ // 5 admitted attempts at t0, NOT to the latest denial.
70
+ for (let i = 1; i <= 5; i++) {
71
+ checkAndRecord("ip-a", new Date(t0.getTime() + i * 6000));
72
+ }
73
+ const finalCheck = checkAndRecord("ip-a", new Date(t0.getTime() + 30_000));
74
+ expect(finalCheck.allowed).toBe(false);
75
+ // 30 seconds elapsed; expected ~870s remaining. Tolerance ±2s for
76
+ // ceil-rounding edge.
77
+ const expected = Math.ceil((WINDOW_MS - 30_000) / 1000);
78
+ expect(finalCheck.retryAfterSeconds).toBe(expected);
79
+ });
80
+
81
+ test("Retry-After is always at least 1 second at the boundary", () => {
82
+ const t0 = new Date("2026-05-08T12:00:00Z");
83
+ for (let i = 0; i < MAX_ATTEMPTS; i++) {
84
+ checkAndRecord("ip-a", t0);
85
+ }
86
+ // Exactly at the moment the oldest attempt would fall off — clamp to 1.
87
+ const r = checkAndRecord("ip-a", new Date(t0.getTime() + WINDOW_MS));
88
+ // At exactly WINDOW_MS, the oldest is gone → admitted, not denied.
89
+ expect(r.allowed).toBe(true);
90
+ });
91
+
92
+ test("Retry-After natural value is always >= 1 in the deny branch (1ms-remaining case)", () => {
93
+ // The `Math.max(1, ...)` clamp at rate-limit.ts:90 is defense-in-depth:
94
+ // the deny branch requires `pruned.length >= MAX_ATTEMPTS`, which means
95
+ // every retained timestamp is strictly inside the window, so
96
+ // `resetAtMs - now > 0` strictly, so `Math.ceil(positive / 1000) >= 1`.
97
+ // This test pins that invariant: at `WINDOW_MS - 1ms` after the cohort,
98
+ // 1ms remains until the oldest falls off → unclamped value is
99
+ // `Math.ceil(1 / 1000) = 1`, the minimum natural value.
100
+ const t0 = new Date("2026-05-08T12:00:00Z");
101
+ for (let i = 0; i < MAX_ATTEMPTS; i++) {
102
+ checkAndRecord("ip-a", t0);
103
+ }
104
+ const denied = checkAndRecord("ip-a", new Date(t0.getTime() + WINDOW_MS - 1));
105
+ expect(denied.allowed).toBe(false);
106
+ expect(denied.retryAfterSeconds).toBe(1);
107
+ });
108
+
109
+ test("Retry-After is >= 1 across every denied step from t0 to the boundary", () => {
110
+ // Belt-and-suspenders sweep: walk `now` from t0 up to (but not including)
111
+ // the boundary in 100ms steps and assert every denied response has
112
+ // `retryAfterSeconds >= 1`. Locks in the "natural value never drops to
113
+ // zero in the deny branch" invariant the clamp guards.
114
+ const t0 = new Date("2026-05-08T12:00:00Z");
115
+ for (let i = 0; i < MAX_ATTEMPTS; i++) {
116
+ checkAndRecord("ip-a", t0);
117
+ }
118
+ for (let dt = 0; dt < WINDOW_MS; dt += 100) {
119
+ const r = checkAndRecord("ip-a", new Date(t0.getTime() + dt));
120
+ expect(r.allowed).toBe(false);
121
+ expect(r.retryAfterSeconds).toBeDefined();
122
+ expect(r.retryAfterSeconds as number).toBeGreaterThanOrEqual(1);
123
+ }
124
+ });
125
+ });
126
+
127
+ describe("checkAndRecord — multi-IP independence", () => {
128
+ test("exhausting one IP's bucket does not affect another IP", () => {
129
+ const now = new Date("2026-05-08T12:00:00Z");
130
+ for (let i = 0; i < MAX_ATTEMPTS; i++) checkAndRecord("ip-a", now);
131
+ expect(checkAndRecord("ip-a", now).allowed).toBe(false);
132
+
133
+ // Different IP — fresh bucket.
134
+ expect(checkAndRecord("ip-b", now).allowed).toBe(true);
135
+ expect(checkAndRecord("ip-c", now).allowed).toBe(true);
136
+ });
137
+
138
+ test("IPv4 / IPv6 / sentinel are all distinct keys", () => {
139
+ const now = new Date("2026-05-08T12:00:00Z");
140
+ for (let i = 0; i < MAX_ATTEMPTS; i++) checkAndRecord("203.0.113.7", now);
141
+ expect(checkAndRecord("203.0.113.7", now).allowed).toBe(false);
142
+ expect(checkAndRecord("2001:db8::42", now).allowed).toBe(true);
143
+ expect(checkAndRecord(UNKNOWN_IP_SENTINEL, now).allowed).toBe(true);
144
+ });
145
+ });
146
+
147
+ describe("clientIpFromRequest — header priority", () => {
148
+ test("CF-Connecting-IP wins over X-Forwarded-For", () => {
149
+ const req = new Request("http://hub.test/admin/login", {
150
+ headers: {
151
+ "cf-connecting-ip": "203.0.113.7",
152
+ "x-forwarded-for": "198.51.100.99, 10.0.0.1",
153
+ },
154
+ });
155
+ expect(clientIpFromRequest(req)).toBe("203.0.113.7");
156
+ });
157
+
158
+ test("X-Forwarded-For first hop is used when CF-Connecting-IP is absent", () => {
159
+ const req = new Request("http://hub.test/admin/login", {
160
+ headers: { "x-forwarded-for": "198.51.100.99, 10.0.0.1, 10.0.0.2" },
161
+ });
162
+ expect(clientIpFromRequest(req)).toBe("198.51.100.99");
163
+ });
164
+
165
+ test("X-Forwarded-For with whitespace is trimmed", () => {
166
+ const req = new Request("http://hub.test/admin/login", {
167
+ headers: { "x-forwarded-for": " 198.51.100.99 , 10.0.0.1" },
168
+ });
169
+ expect(clientIpFromRequest(req)).toBe("198.51.100.99");
170
+ });
171
+
172
+ test("falls through to UNKNOWN_IP_SENTINEL when no headers are set", () => {
173
+ const req = new Request("http://hub.test/admin/login");
174
+ expect(clientIpFromRequest(req)).toBe(UNKNOWN_IP_SENTINEL);
175
+ });
176
+
177
+ test("empty / whitespace-only header values are treated as absent", () => {
178
+ const req = new Request("http://hub.test/admin/login", {
179
+ headers: { "cf-connecting-ip": " ", "x-forwarded-for": "" },
180
+ });
181
+ expect(clientIpFromRequest(req)).toBe(UNKNOWN_IP_SENTINEL);
182
+ });
183
+
184
+ test("empty CF-Connecting-IP falls through to X-Forwarded-For first hop", () => {
185
+ const req = new Request("http://hub.test/admin/login", {
186
+ headers: { "cf-connecting-ip": "", "x-forwarded-for": "198.51.100.99" },
187
+ });
188
+ expect(clientIpFromRequest(req)).toBe("198.51.100.99");
189
+ });
190
+ });
@@ -222,6 +222,347 @@ describe("services-manifest", () => {
222
222
  cleanup();
223
223
  }
224
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
+ });
225
566
  });
226
567
 
227
568
  describe("claw → agent migration", () => {