@openparachute/hub 0.7.3 → 0.7.4-rc.10

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 (39) hide show
  1. package/package.json +4 -11
  2. package/src/__tests__/admin-vaults.test.ts +164 -1
  3. package/src/__tests__/api-account-2fa.test.ts +381 -0
  4. package/src/__tests__/api-hub-upgrade.test.ts +59 -3
  5. package/src/__tests__/cli.test.ts +22 -0
  6. package/src/__tests__/clients.test.ts +28 -8
  7. package/src/__tests__/cloudflare-connector-service.test.ts +3 -1
  8. package/src/__tests__/hub-server.test.ts +127 -5
  9. package/src/__tests__/init.test.ts +507 -1
  10. package/src/__tests__/managed-unit.test.ts +62 -0
  11. package/src/__tests__/oauth-handlers.test.ts +488 -0
  12. package/src/__tests__/oauth-ui.test.ts +55 -1
  13. package/src/__tests__/scope-explanations.test.ts +19 -0
  14. package/src/__tests__/setup-wizard.test.ts +124 -7
  15. package/src/__tests__/status-supervisor.test.ts +152 -3
  16. package/src/__tests__/supervisor.test.ts +25 -0
  17. package/src/__tests__/vault-names.test.ts +32 -3
  18. package/src/__tests__/well-known.test.ts +37 -2
  19. package/src/admin-vaults.ts +7 -12
  20. package/src/api-account-2fa.ts +373 -0
  21. package/src/api-hub-upgrade.ts +38 -3
  22. package/src/api-me.ts +11 -2
  23. package/src/cli.ts +49 -5
  24. package/src/clients.ts +14 -0
  25. package/src/commands/init.ts +224 -37
  26. package/src/commands/status.ts +108 -5
  27. package/src/help.ts +17 -0
  28. package/src/hub-server.ts +72 -7
  29. package/src/managed-unit.ts +30 -1
  30. package/src/oauth-handlers.ts +98 -6
  31. package/src/oauth-ui.ts +123 -0
  32. package/src/scope-explanations.ts +2 -1
  33. package/src/setup-wizard.ts +40 -21
  34. package/src/supervisor.ts +46 -2
  35. package/src/vault-names.ts +15 -4
  36. package/src/well-known.ts +10 -1
  37. package/web/ui/dist/assets/{index--728BX3j.css → index-BcC4U5gM.css} +1 -1
  38. package/web/ui/dist/assets/{index-DZzX_Enf.js → index-DygKux-C.js} +13 -13
  39. package/web/ui/dist/index.html +2 -2
@@ -103,6 +103,28 @@ describe("cli", () => {
103
103
  expect(code).toBe(1);
104
104
  expect(stderr).toMatch(/invalid --hub-origin/);
105
105
  });
106
+
107
+ // hub#694 bug 2: `init --channel` mirrors `install --channel` — rejected at
108
+ // the arg layer (exit 1) before any daemon work, so these are hermetic.
109
+ test("init --channel without a value exits 1 (hub#694)", async () => {
110
+ const { code, stderr } = await runCli(["init", "--channel"]);
111
+ expect(code).toBe(1);
112
+ expect(stderr).toMatch(/--channel requires a value/);
113
+ });
114
+
115
+ test("init --channel with an invalid value exits 1 (hub#694)", async () => {
116
+ const { code, stderr } = await runCli(["init", "--channel", "banana"]);
117
+ expect(code).toBe(1);
118
+ expect(stderr).toMatch(/--channel must be "rc" or "latest"/);
119
+ expect(stderr).toMatch(/banana/);
120
+ });
121
+
122
+ test("init --help documents --channel (hub#694)", async () => {
123
+ const { code, stdout } = await runCli(["init", "--help"]);
124
+ expect(code).toBe(0);
125
+ expect(stdout).toMatch(/--channel/);
126
+ expect(stdout).toMatch(/rc\|latest/);
127
+ });
106
128
  });
107
129
 
108
130
  describe("cli per-subcommand help", () => {
@@ -151,7 +151,10 @@ describe("expandRedirectUrisForHubOrigins (surface#118 cross-hub-origin DCR expa
151
151
  const hubOrigins = [LOOPBACK, "http://localhost:1939", PUBLIC];
152
152
 
153
153
  test("expands a loopback-rooted URI onto every other hub origin", () => {
154
- const out = expandRedirectUrisForHubOrigins([`${LOOPBACK}/surface/notes/oauth/callback`], hubOrigins);
154
+ const out = expandRedirectUrisForHubOrigins(
155
+ [`${LOOPBACK}/surface/notes/oauth/callback`],
156
+ hubOrigins,
157
+ );
155
158
  // Original is preserved + the public + localhost variants are added.
156
159
  expect(out).toContain(`${LOOPBACK}/surface/notes/oauth/callback`);
157
160
  expect(out).toContain(`${PUBLIC}/surface/notes/oauth/callback`);
@@ -188,10 +191,7 @@ describe("expandRedirectUrisForHubOrigins (surface#118 cross-hub-origin DCR expa
188
191
  });
189
192
 
190
193
  test("single known hub origin → no expansion (submitted set returned as-is)", () => {
191
- const out = expandRedirectUrisForHubOrigins(
192
- [`${LOOPBACK}/surface/notes/`],
193
- [LOOPBACK],
194
- );
194
+ const out = expandRedirectUrisForHubOrigins([`${LOOPBACK}/surface/notes/`], [LOOPBACK]);
195
195
  expect(out).toEqual([`${LOOPBACK}/surface/notes/`]);
196
196
  });
197
197
 
@@ -222,9 +222,9 @@ describe("expandRedirectUrisForHubOrigins (surface#118 cross-hub-origin DCR expa
222
222
  const r = registerClient(db, { redirectUris: expanded });
223
223
  // The public-origin variant now matches exactly at authorize time — the
224
224
  // off-localhost sign-in that surface#118 broke.
225
- expect(
226
- requireRegisteredRedirectUri(r.client, `${PUBLIC}/surface/notes/oauth/callback`),
227
- ).toBe(`${PUBLIC}/surface/notes/oauth/callback`);
225
+ expect(requireRegisteredRedirectUri(r.client, `${PUBLIC}/surface/notes/oauth/callback`)).toBe(
226
+ `${PUBLIC}/surface/notes/oauth/callback`,
227
+ );
228
228
  // A truly-unregistered URI is still rejected — strict match unchanged.
229
229
  expect(() =>
230
230
  requireRegisteredRedirectUri(r.client, "https://evil.example/surface/notes/oauth/callback"),
@@ -352,4 +352,24 @@ describe("isValidRedirectUri", () => {
352
352
  expect(isValidRedirectUri("/relative")).toBe(false);
353
353
  expect(isValidRedirectUri("not a url")).toBe(false);
354
354
  });
355
+ // hub#663: spec-forbidden shapes that the protocol allowlist alone passed.
356
+ test("rejects userinfo-bearing redirect URIs (hub#663)", () => {
357
+ expect(isValidRedirectUri("https://x@evil.com/cb")).toBe(false);
358
+ expect(isValidRedirectUri("https://user:pass@evil.com/cb")).toBe(false);
359
+ expect(isValidRedirectUri("http://attacker@127.0.0.1:3000/cb")).toBe(false);
360
+ });
361
+ test("rejects control chars in the raw input (hub#663)", () => {
362
+ // Control chars must be caught on the RAW string — URL parsing would
363
+ // otherwise strip a trailing \r\n and the smuggled value would pass.
364
+ expect(isValidRedirectUri("https://example.com/cb\r\nSet-Cookie: x")).toBe(false);
365
+ expect(isValidRedirectUri("https://example.com/\x00cb")).toBe(false);
366
+ expect(isValidRedirectUri("https://example.com/cb\x7f")).toBe(false);
367
+ });
368
+ test("still accepts clean http(s) with ports, paths, and queries (regression guard)", () => {
369
+ // Legitimate clients (hub modules, self-built surfaces, Notes, Claude DCR)
370
+ // all register clean URIs — these must keep passing.
371
+ expect(isValidRedirectUri("https://claude.ai/api/mcp/auth_callback")).toBe(true);
372
+ expect(isValidRedirectUri("http://localhost:1939/admin/oauth/callback")).toBe(true);
373
+ expect(isValidRedirectUri("https://my-surface.github.io/cb?x=1")).toBe(true);
374
+ });
355
375
  });
@@ -258,8 +258,10 @@ describe("installConnectorService — Linux systemd", () => {
258
258
  platform: "linux",
259
259
  getuid: () => 1000,
260
260
  userName: () => "op",
261
- // enable-linger FAIL, then daemon-reload OK, enable --now OK.
261
+ // #528 probe: show-user → Linger=no (off, so we proceed to enable);
262
+ // then enable-linger FAIL, daemon-reload OK, enable --now OK.
262
263
  runResults: [
264
+ { code: 0, stdout: "Linger=no\n", stderr: "" },
263
265
  { code: 1, stdout: "", stderr: "Failed to enable linger" },
264
266
  { code: 0, stdout: "", stderr: "" },
265
267
  { code: 0, stdout: "", stderr: "" },
@@ -3,10 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import {
7
- _resetBootstrapTokenForTests,
8
- generateBootstrapToken,
9
- } from "../bootstrap-token.ts";
6
+ import { _resetBootstrapTokenForTests, generateBootstrapToken } from "../bootstrap-token.ts";
10
7
  import { buildCsrfCookie, generateCsrfToken } from "../csrf.ts";
11
8
  import { HUB_SVC, hubPortPath } from "../hub-control.ts";
12
9
  import { createDbHolder } from "../hub-db-liveness.ts";
@@ -3715,7 +3712,9 @@ describe("layerOf — classify trust layer from proxy headers + peer (item E / #
3715
3712
  // flipped an empty XFF back to loopback would re-open the Caddy-direct leak.
3716
3713
  test("loopback peer + empty X-Forwarded-For → public (errs safe, not loopback) [#704]", () => {
3717
3714
  expect(layerOf(req("/", { headers: { "X-Forwarded-For": "" } }), "127.0.0.1")).toBe("public");
3718
- expect(layerOf(req("/", { headers: { "X-Forwarded-For": " " } }), "127.0.0.1")).toBe("public");
3715
+ expect(layerOf(req("/", { headers: { "X-Forwarded-For": " " } }), "127.0.0.1")).toBe(
3716
+ "public",
3717
+ );
3719
3718
  });
3720
3719
 
3721
3720
  // The genuine on-box caller (CLI, health probe, init bootstrap-token loopback
@@ -6090,3 +6089,126 @@ describe("GET /admin/setup bootstrap-token probe — loopback-gated (hub#576 + C
6090
6089
  }
6091
6090
  });
6092
6091
  });
6092
+
6093
+ // hub#643 (Tier-1): non-script security headers on proxied module/surface
6094
+ // text/html pages. The vault content proxy and the generic services-mount
6095
+ // proxy both flow through `decorateWithChrome`, so the headers land on both.
6096
+ // DELIBERATELY no `script-src` — a strict script-src would white-screen
6097
+ // self-built GitHub-hosted surfaces + inline-script module pages (that's the
6098
+ // deferred Tier-2). Header-only: non-HTML proxied responses are NOT decorated.
6099
+ describe("hubFetch proxied-page security headers (hub#643 Tier-1)", () => {
6100
+ const TIER1_CSP = "frame-ancestors 'self'; object-src 'none'; base-uri 'self'";
6101
+
6102
+ // Live upstream that echoes a fixed content-type + body so the test can
6103
+ // exercise both the text/html (decorated) and JSON (untouched) branches.
6104
+ function startUpstream(contentType: string, body: string): { port: number; stop: () => void } {
6105
+ const server = Bun.serve({
6106
+ port: 0,
6107
+ hostname: "127.0.0.1",
6108
+ fetch: () => new Response(body, { status: 200, headers: { "content-type": contentType } }),
6109
+ });
6110
+ return { port: server.port as number, stop: () => server.stop(true) };
6111
+ }
6112
+
6113
+ test("decorates a proxied text/html generic-mount page with nosniff + the Tier-1 CSP", async () => {
6114
+ const h = makeHarness();
6115
+ const upstream = startUpstream(
6116
+ "text/html; charset=utf-8",
6117
+ "<html><body><h1>my surface</h1></body></html>",
6118
+ );
6119
+ try {
6120
+ writeManifest(
6121
+ {
6122
+ services: [
6123
+ {
6124
+ name: "parachute-surface",
6125
+ port: upstream.port,
6126
+ paths: ["/surface"],
6127
+ health: "/surface/health",
6128
+ version: "0.2.0",
6129
+ },
6130
+ ],
6131
+ },
6132
+ h.manifestPath,
6133
+ );
6134
+ const res = await hubFetch(h.dir, { manifestPath: h.manifestPath })(req("/surface/foo"));
6135
+ expect(res.status).toBe(200);
6136
+ expect(res.headers.get("content-type")).toContain("text/html");
6137
+ expect(res.headers.get("x-content-type-options")).toBe("nosniff");
6138
+ const csp = res.headers.get("content-security-policy");
6139
+ expect(csp).toBe(TIER1_CSP);
6140
+ // The critical Tier-1/Tier-2 boundary: NO script-src — self-built
6141
+ // GitHub-hosted surfaces + inline-script module pages must stay
6142
+ // unrestricted. A strict script-src is the deferred Tier-2.
6143
+ expect(csp).not.toContain("script-src");
6144
+ } finally {
6145
+ upstream.stop();
6146
+ h.cleanup();
6147
+ }
6148
+ });
6149
+
6150
+ test("decorates a proxied text/html per-vault page (the Notes-PWA path) with the same headers", async () => {
6151
+ const h = makeHarness();
6152
+ const upstream = startUpstream("text/html; charset=utf-8", "<html><body>notes</body></html>");
6153
+ try {
6154
+ writeManifest(
6155
+ {
6156
+ services: [
6157
+ {
6158
+ name: "parachute-vault",
6159
+ port: upstream.port,
6160
+ paths: ["/vault/default"],
6161
+ health: "/vault/default/health",
6162
+ version: "0.4.0",
6163
+ },
6164
+ ],
6165
+ },
6166
+ h.manifestPath,
6167
+ );
6168
+ const res = await hubFetch(h.dir, { manifestPath: h.manifestPath })(
6169
+ req("/vault/default/some-page"),
6170
+ );
6171
+ expect(res.status).toBe(200);
6172
+ expect(res.headers.get("x-content-type-options")).toBe("nosniff");
6173
+ expect(res.headers.get("content-security-policy")).toBe(TIER1_CSP);
6174
+ expect(res.headers.get("content-security-policy")).not.toContain("script-src");
6175
+ } finally {
6176
+ upstream.stop();
6177
+ h.cleanup();
6178
+ }
6179
+ });
6180
+
6181
+ test("leaves a proxied NON-HTML response (JSON) undecorated", async () => {
6182
+ const h = makeHarness();
6183
+ const upstream = startUpstream("application/json", JSON.stringify({ ok: true }));
6184
+ try {
6185
+ writeManifest(
6186
+ {
6187
+ services: [
6188
+ {
6189
+ name: "parachute-surface",
6190
+ port: upstream.port,
6191
+ paths: ["/surface"],
6192
+ health: "/surface/health",
6193
+ version: "0.2.0",
6194
+ },
6195
+ ],
6196
+ },
6197
+ h.manifestPath,
6198
+ );
6199
+ const res = await hubFetch(h.dir, { manifestPath: h.manifestPath })(req("/surface/api/data"));
6200
+ expect(res.status).toBe(200);
6201
+ expect(res.headers.get("content-type")).toContain("application/json");
6202
+ // No HTML CSP on a JSON API response (proves the header is gated on
6203
+ // content-type, so a `.js` asset proxied through the same path is also
6204
+ // left alone).
6205
+ expect(res.headers.get("content-security-policy")).toBeNull();
6206
+ expect(res.headers.get("x-content-type-options")).toBeNull();
6207
+ const body = (await res.json()) as { ok: boolean };
6208
+ expect(body.ok).toBe(true);
6209
+ } finally {
6210
+ upstream.stop();
6211
+ h.cleanup();
6212
+ }
6213
+ });
6214
+ });