@openparachute/hub 0.5.1 → 0.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-handlers.test.ts +92 -0
  3. package/src/__tests__/expose-2fa-warning.test.ts +125 -0
  4. package/src/__tests__/expose-cloudflare.test.ts +101 -0
  5. package/src/__tests__/expose.test.ts +199 -340
  6. package/src/__tests__/hub-server.test.ts +1227 -1
  7. package/src/__tests__/install.test.ts +50 -31
  8. package/src/__tests__/lifecycle.test.ts +97 -2
  9. package/src/__tests__/module-manifest.test.ts +13 -0
  10. package/src/__tests__/notes-serve.test.ts +154 -2
  11. package/src/__tests__/oauth-handlers.test.ts +737 -1
  12. package/src/__tests__/port-assign.test.ts +41 -52
  13. package/src/__tests__/rate-limit.test.ts +190 -0
  14. package/src/__tests__/services-manifest.test.ts +367 -0
  15. package/src/__tests__/setup.test.ts +12 -9
  16. package/src/__tests__/status.test.ts +173 -0
  17. package/src/admin-handlers.ts +38 -13
  18. package/src/commands/expose-2fa-warning.ts +82 -0
  19. package/src/commands/expose-cloudflare.ts +27 -0
  20. package/src/commands/expose-public-auto.ts +3 -7
  21. package/src/commands/expose.ts +88 -173
  22. package/src/commands/install.ts +11 -13
  23. package/src/commands/lifecycle.ts +53 -4
  24. package/src/commands/status.ts +28 -1
  25. package/src/help.ts +3 -3
  26. package/src/hub-server.ts +266 -32
  27. package/src/module-manifest.ts +19 -0
  28. package/src/notes-serve.ts +70 -9
  29. package/src/oauth-handlers.ts +249 -12
  30. package/src/oauth-ui.ts +167 -0
  31. package/src/port-assign.ts +28 -35
  32. package/src/rate-limit.ts +163 -0
  33. package/src/service-spec.ts +66 -13
  34. package/src/services-manifest.ts +83 -3
  35. package/src/sessions.ts +19 -0
@@ -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
+ });