@openparachute/hub 0.5.2 → 0.5.9-rc.6
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-clients.test.ts +275 -0
- package/src/__tests__/admin-handlers.test.ts +159 -320
- package/src/__tests__/admin-host-admin-token.test.ts +52 -4
- package/src/__tests__/api-me.test.ts +149 -0
- package/src/__tests__/api-mint-token.test.ts +381 -0
- package/src/__tests__/api-revocation-list.test.ts +198 -0
- package/src/__tests__/api-revoke-token.test.ts +320 -0
- package/src/__tests__/api-tokens.test.ts +629 -0
- package/src/__tests__/auth.test.ts +680 -16
- package/src/__tests__/expose-2fa-warning.test.ts +123 -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 +986 -66
- package/src/__tests__/hub.test.ts +108 -55
- package/src/__tests__/install-source.test.ts +249 -0
- package/src/__tests__/install.test.ts +50 -31
- package/src/__tests__/jwt-sign.test.ts +205 -0
- package/src/__tests__/lifecycle.test.ts +97 -2
- package/src/__tests__/module-manifest.test.ts +48 -0
- package/src/__tests__/notes-serve.test.ts +154 -2
- package/src/__tests__/oauth-handlers.test.ts +1000 -3
- package/src/__tests__/operator-token.test.ts +379 -3
- package/src/__tests__/origin-check.test.ts +220 -0
- 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 +341 -0
- package/src/__tests__/setup.test.ts +12 -9
- package/src/__tests__/status.test.ts +372 -0
- package/src/__tests__/well-known.test.ts +69 -0
- package/src/admin-clients.ts +139 -0
- package/src/admin-handlers.ts +63 -260
- package/src/admin-host-admin-token.ts +25 -10
- package/src/admin-login-ui.ts +256 -0
- package/src/admin-vault-admin-token.ts +1 -1
- package/src/api-me.ts +124 -0
- package/src/api-mint-token.ts +239 -0
- package/src/api-revocation-list.ts +59 -0
- package/src/api-revoke-token.ts +153 -0
- package/src/api-tokens.ts +224 -0
- package/src/commands/auth.ts +408 -51
- 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 +99 -8
- package/src/csrf.ts +6 -3
- package/src/help.ts +13 -7
- package/src/hub-db.ts +63 -0
- package/src/hub-server.ts +572 -106
- package/src/hub.ts +272 -149
- package/src/install-source.ts +291 -0
- package/src/jwt-sign.ts +265 -5
- package/src/module-manifest.ts +48 -10
- package/src/notes-serve.ts +70 -9
- package/src/oauth-handlers.ts +395 -29
- package/src/oauth-ui.ts +188 -0
- package/src/operator-token.ts +272 -18
- package/src/origin-check.ts +127 -0
- package/src/port-assign.ts +28 -35
- package/src/rate-limit.ts +166 -0
- package/src/scope-explanations.ts +33 -2
- package/src/service-spec.ts +58 -13
- package/src/services-manifest.ts +62 -3
- package/src/sessions.ts +19 -0
- package/src/well-known.ts +54 -1
- package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
- package/web/ui/dist/assets/index-D54otIhv.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/admin-config.test.ts +0 -281
- package/src/admin-config-ui.ts +0 -534
- package/src/admin-config.ts +0 -226
- package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
- package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
|
@@ -74,103 +74,92 @@ describe("assignPort (pure)", () => {
|
|
|
74
74
|
});
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
-
describe("assignServicePort (.
|
|
78
|
-
|
|
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(
|
|
90
|
-
expect(result.source).toBe("
|
|
91
|
-
expect(result.
|
|
92
|
-
//
|
|
93
|
-
|
|
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("
|
|
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, "
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
expect(
|
|
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("
|
|
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
|
-
|
|
135
|
-
|
|
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("
|
|
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.
|
|
170
|
-
|
|
171
|
-
expect(
|
|
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", () => {
|