@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
|
@@ -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
|
+
});
|