@openparachute/hub 0.5.10-rc.4 → 0.5.10-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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.5.10-rc.4",
3
+ "version": "0.5.10-rc.6",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -29,7 +29,7 @@ describe("generateCsrfToken", () => {
29
29
  });
30
30
 
31
31
  describe("buildCsrfCookie", () => {
32
- test("emits the expected attributes", () => {
32
+ test("emits the expected attributes (default secure)", () => {
33
33
  const v = buildCsrfCookie("abc");
34
34
  expect(v).toContain(`${CSRF_COOKIE_NAME}=abc`);
35
35
  expect(v).toContain("HttpOnly");
@@ -38,6 +38,19 @@ describe("buildCsrfCookie", () => {
38
38
  expect(v).toContain("Path=/");
39
39
  expect(v).toContain("Max-Age=");
40
40
  });
41
+
42
+ test("omits Secure when secure: false (HTTP localhost)", () => {
43
+ const v = buildCsrfCookie("abc", { secure: false });
44
+ expect(v).toContain(`${CSRF_COOKIE_NAME}=abc`);
45
+ expect(v).toContain("HttpOnly");
46
+ expect(v).not.toContain("Secure");
47
+ expect(v).toContain("SameSite=Lax");
48
+ });
49
+
50
+ test("keeps Secure when secure: true (explicit)", () => {
51
+ const v = buildCsrfCookie("abc", { secure: true });
52
+ expect(v).toContain("Secure");
53
+ });
41
54
  });
42
55
 
43
56
  describe("parseCsrfCookie", () => {
@@ -71,6 +84,32 @@ describe("ensureCsrfToken", () => {
71
84
  expect(result.token.length).toBeGreaterThan(0);
72
85
  expect(result.setCookie).toBeDefined();
73
86
  });
87
+
88
+ // Bug 1 (rc.5 → rc.6) regression: cookies minted over plain HTTP must
89
+ // NOT carry the Secure attribute or browsers silently drop them on
90
+ // http://localhost:1939, breaking the very next double-submit POST.
91
+ test("sets Secure when the request URL is https://", () => {
92
+ const req = new Request("https://hub.example/admin/setup");
93
+ const result = ensureCsrfToken(req);
94
+ expect(result.setCookie).toContain("Secure");
95
+ });
96
+
97
+ test("omits Secure when the request URL is http://localhost", () => {
98
+ const req = new Request("http://localhost:1939/admin/setup");
99
+ const result = ensureCsrfToken(req);
100
+ expect(result.setCookie).not.toContain("Secure");
101
+ // The rest of the cookie shape stays intact — only Secure flips.
102
+ expect(result.setCookie).toContain("HttpOnly");
103
+ expect(result.setCookie).toContain("SameSite=Lax");
104
+ });
105
+
106
+ test("sets Secure when http:// request carries X-Forwarded-Proto: https", () => {
107
+ const req = new Request("http://hub.internal/admin/setup", {
108
+ headers: { "x-forwarded-proto": "https" },
109
+ });
110
+ const result = ensureCsrfToken(req);
111
+ expect(result.setCookie).toContain("Secure");
112
+ });
74
113
  });
75
114
 
76
115
  describe("verifyCsrfToken", () => {
@@ -74,10 +74,16 @@ describe("hubFetch routing", () => {
74
74
  // The dynamic path takes over from the static disk file the moment a
75
75
  // DB is configured. With no session cookie, we still render — just
76
76
  // with the "Sign in" affordance.
77
+ //
78
+ // hub#259 rc.6: requires an admin row to bypass the fresh-hub
79
+ // funnel redirect to /admin/setup (Bug 2 fix). Seed one so this
80
+ // test continues to exercise the signed-out-but-setup-done branch.
77
81
  const h = makeHarness();
78
82
  try {
79
83
  const db = openHubDb(hubDbPath(h.dir));
80
84
  try {
85
+ const { createUser } = await import("../users.ts");
86
+ await createUser(db, "owner", "pw");
81
87
  const res = await hubFetch(h.dir, { getDb: () => db })(req("/"));
82
88
  expect(res.status).toBe(200);
83
89
  expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
@@ -979,10 +985,12 @@ describe("hubFetch routing", () => {
979
985
  }
980
986
  });
981
987
 
982
- // First-boot setup placeholder (hub#258). When no admin exists the page
983
- // is the bootstrap onboarding surface; once an admin exists it 301s to
984
- // /login so a stale bookmark still lands somewhere useful.
985
- test("/admin/setup renders placeholder HTML when no admin exists", async () => {
988
+ // First-boot setup wizard (hub#259, expanding hub#258's static
989
+ // placeholder). When no admin exists, GET /admin/setup renders the
990
+ // wizard's account-step form. Once admin + vault both exist, it 301s
991
+ // to /login so a stale bookmark still lands somewhere useful. With
992
+ // admin but no vault, the wizard resumes at the vault step.
993
+ test("/admin/setup renders the wizard's account form when no admin exists", async () => {
986
994
  const h = makeHarness();
987
995
  try {
988
996
  const db = openHubDb(hubDbPath(h.dir));
@@ -991,7 +999,8 @@ describe("hubFetch routing", () => {
991
999
  expect(res.status).toBe(200);
992
1000
  expect(res.headers.get("content-type")).toContain("text/html");
993
1001
  const body = await res.text();
994
- expect(body).toContain("first-boot setup");
1002
+ expect(body).toContain('action="/admin/setup/account"');
1003
+ // Env-var seed path is still surfaced as the alt-path disclosure.
995
1004
  expect(body).toContain("PARACHUTE_INITIAL_ADMIN_USERNAME");
996
1005
  } finally {
997
1006
  db.close();
@@ -1001,13 +1010,35 @@ describe("hubFetch routing", () => {
1001
1010
  }
1002
1011
  });
1003
1012
 
1004
- test("/admin/setup 301s to /login when an admin already exists", async () => {
1013
+ test("/admin/setup 301s to /login once admin + vault both exist (hub#259)", async () => {
1005
1014
  const h = makeHarness();
1006
1015
  try {
1007
1016
  const db = openHubDb(hubDbPath(h.dir));
1008
1017
  try {
1009
1018
  await createUser(db, "owner", "pw");
1010
- const res = await hubFetch(h.dir, { getDb: () => db })(req("/admin/setup"));
1019
+ // Seed the vault entry so the wizard's state derives as "done"
1020
+ // and the GET 301s. Without this the wizard would still resume
1021
+ // at the vault step.
1022
+ const { writeManifest } = await import("../services-manifest.ts");
1023
+ const { join } = await import("node:path");
1024
+ writeManifest(
1025
+ {
1026
+ services: [
1027
+ {
1028
+ name: "parachute-vault",
1029
+ version: "0.1.0",
1030
+ port: 1940,
1031
+ paths: ["/vault/default"],
1032
+ health: "/health",
1033
+ },
1034
+ ],
1035
+ },
1036
+ join(h.dir, "services.json"),
1037
+ );
1038
+ const res = await hubFetch(h.dir, {
1039
+ getDb: () => db,
1040
+ manifestPath: join(h.dir, "services.json"),
1041
+ })(req("/admin/setup"));
1011
1042
  expect(res.status).toBe(301);
1012
1043
  expect(res.headers.get("location")).toBe("/login");
1013
1044
  } finally {
@@ -1058,7 +1089,7 @@ describe("hubFetch routing", () => {
1058
1089
  }
1059
1090
  });
1060
1091
 
1061
- test("pre-admin lockout: /login is gated, /admin/setup + /health + well-known stay open", async () => {
1092
+ test("pre-admin lockout: /login is gated, /admin/setup + /health + well-known stay open, / funnels to /admin/setup", async () => {
1062
1093
  const h = makeHarness();
1063
1094
  try {
1064
1095
  writeFileSync(join(h.dir, "hub.html"), "<html>discovery</html>");
@@ -1075,9 +1106,11 @@ describe("hubFetch routing", () => {
1075
1106
  // /health open
1076
1107
  const healthRes = await handler(req("/health"));
1077
1108
  expect(healthRes.status).toBe(200);
1078
- // / open
1109
+ // / funnels to the wizard (hub#259 rc.6 fix for Bug 2 — the
1110
+ // static portal pre-setup is useless; redirect to setup).
1079
1111
  const rootRes = await handler(req("/"));
1080
- expect(rootRes.status).toBe(200);
1112
+ expect(rootRes.status).toBe(302);
1113
+ expect(rootRes.headers.get("location")).toBe("/admin/setup");
1081
1114
  // /.well-known/parachute.json open
1082
1115
  const wkRes = await handler(req("/.well-known/parachute.json"));
1083
1116
  expect(wkRes.status).toBe(200);
@@ -0,0 +1,54 @@
1
+ /**
2
+ * `isHttpsRequest` is the single signal cookie-mint helpers use to
3
+ * decide whether to set the `Secure` attribute. Wrong answer here
4
+ * causes browsers to silently drop cookies on HTTP localhost (Bug 1
5
+ * from the rc.5 fresh-machine test) OR mint Secure-less cookies behind
6
+ * a TLS-terminating reverse proxy (the opposite failure mode).
7
+ *
8
+ * Three signals tested, in priority order: direct URL scheme,
9
+ * X-Forwarded-Proto header, plain HTTP default.
10
+ */
11
+ import { describe, expect, test } from "bun:test";
12
+ import { isHttpsRequest } from "../request-protocol.ts";
13
+
14
+ describe("isHttpsRequest", () => {
15
+ test("returns true for https:// request URL", () => {
16
+ expect(isHttpsRequest(new Request("https://hub.example/x"))).toBe(true);
17
+ });
18
+
19
+ test("returns false for http:// request URL", () => {
20
+ expect(isHttpsRequest(new Request("http://localhost:1939/x"))).toBe(false);
21
+ });
22
+
23
+ test("returns true when X-Forwarded-Proto: https on an http:// request", () => {
24
+ const req = new Request("http://hub.internal/x", {
25
+ headers: { "x-forwarded-proto": "https" },
26
+ });
27
+ expect(isHttpsRequest(req)).toBe(true);
28
+ });
29
+
30
+ test("returns false when X-Forwarded-Proto: http", () => {
31
+ const req = new Request("http://hub.internal/x", {
32
+ headers: { "x-forwarded-proto": "http" },
33
+ });
34
+ expect(isHttpsRequest(req)).toBe(false);
35
+ });
36
+
37
+ test("tolerates uppercase / whitespace / list shape in X-Forwarded-Proto", () => {
38
+ // Some proxies emit `https,http` (chain of two hops) or " HTTPS "
39
+ // with whitespace. The first token is what we honor.
40
+ expect(
41
+ isHttpsRequest(new Request("http://hub/x", { headers: { "x-forwarded-proto": " HTTPS " } })),
42
+ ).toBe(true);
43
+ expect(
44
+ isHttpsRequest(
45
+ new Request("http://hub/x", { headers: { "x-forwarded-proto": "https,http" } }),
46
+ ),
47
+ ).toBe(true);
48
+ expect(
49
+ isHttpsRequest(
50
+ new Request("http://hub/x", { headers: { "x-forwarded-proto": "http,https" } }),
51
+ ),
52
+ ).toBe(false);
53
+ });
54
+ });
@@ -81,7 +81,7 @@ describe("deleteSession", () => {
81
81
  });
82
82
 
83
83
  describe("buildSessionCookie", () => {
84
- test("emits the expected attributes", () => {
84
+ test("emits the expected attributes (default secure)", () => {
85
85
  const v = buildSessionCookie("abc", 86400);
86
86
  expect(v).toContain(`${SESSION_COOKIE_NAME}=abc`);
87
87
  expect(v).toContain("HttpOnly");
@@ -91,16 +91,39 @@ describe("buildSessionCookie", () => {
91
91
  expect(v).not.toContain("Path=/oauth");
92
92
  expect(v).toContain("Max-Age=86400");
93
93
  });
94
+
95
+ // Bug 1 (rc.5 → rc.6) regression: session cookies minted over plain
96
+ // HTTP must NOT carry Secure or browsers drop them, leaving the
97
+ // operator un-signed-in on the very next request.
98
+ test("omits Secure when secure: false (HTTP localhost)", () => {
99
+ const v = buildSessionCookie("abc", 86400, { secure: false });
100
+ expect(v).toContain(`${SESSION_COOKIE_NAME}=abc`);
101
+ expect(v).toContain("HttpOnly");
102
+ expect(v).not.toContain("Secure");
103
+ expect(v).toContain("SameSite=Lax");
104
+ });
105
+
106
+ test("keeps Secure when secure: true (explicit)", () => {
107
+ const v = buildSessionCookie("abc", 86400, { secure: true });
108
+ expect(v).toContain("Secure");
109
+ });
94
110
  });
95
111
 
96
112
  describe("buildSessionClearCookie", () => {
97
- test("emits Max-Age=0", () => {
113
+ test("emits Max-Age=0 (default secure)", () => {
98
114
  const v = buildSessionClearCookie();
99
115
  expect(v).toContain(`${SESSION_COOKIE_NAME}=`);
100
116
  expect(v).toContain("Max-Age=0");
117
+ expect(v).toContain("Secure");
101
118
  expect(v).toContain("Path=/");
102
119
  expect(v).not.toContain("Path=/oauth");
103
120
  });
121
+
122
+ test("omits Secure when secure: false (HTTP localhost)", () => {
123
+ const v = buildSessionClearCookie({ secure: false });
124
+ expect(v).not.toContain("Secure");
125
+ expect(v).toContain("Max-Age=0");
126
+ });
104
127
  });
105
128
 
106
129
  describe("parseSessionCookie", () => {
@@ -130,27 +130,44 @@ describe("setup gate (no admin yet)", () => {
130
130
  }
131
131
  });
132
132
 
133
- test("/ passes through the gate (renders discovery page)", async () => {
133
+ // Bug 2 (rc.5 rc.6) regression: on a fresh hub, GET `/` should
134
+ // funnel straight to the wizard rather than render the static
135
+ // portal — the operator otherwise has to manually navigate to
136
+ // `/admin/setup`. The portal pre-setup carries no usable signal
137
+ // (no installed services to discover, no admin to sign in as).
138
+ test("/ 302s to /admin/setup when no admin exists (fresh-hub funnel)", async () => {
134
139
  const db = openHubDb(hubDbPath(h.dir));
135
140
  try {
136
141
  const res = await hubFetch(h.dir, { getDb: () => db })(req("/"));
137
- expect(res.status).toBe(200);
138
- expect(res.headers.get("content-type")).toContain("text/html");
142
+ expect(res.status).toBe(302);
143
+ expect(res.headers.get("location")).toBe("/admin/setup");
144
+ } finally {
145
+ db.close();
146
+ }
147
+ });
148
+
149
+ test("/hub.html 302s to /admin/setup when no admin exists", async () => {
150
+ const db = openHubDb(hubDbPath(h.dir));
151
+ try {
152
+ const res = await hubFetch(h.dir, { getDb: () => db })(req("/hub.html"));
153
+ expect(res.status).toBe(302);
154
+ expect(res.headers.get("location")).toBe("/admin/setup");
139
155
  } finally {
140
156
  db.close();
141
157
  }
142
158
  });
143
159
 
144
- test("/admin/setup renders the placeholder HTML", async () => {
160
+ test("/admin/setup renders the wizard (account step) when no admin exists", async () => {
145
161
  const db = openHubDb(hubDbPath(h.dir));
146
162
  try {
147
163
  const res = await hubFetch(h.dir, { getDb: () => db })(req("/admin/setup"));
148
164
  expect(res.status).toBe(200);
149
165
  expect(res.headers.get("content-type")).toContain("text/html");
150
166
  const html = await res.text();
151
- // Spot-check the env-var seed is documented it's the canonical
152
- // bootstrap path for containers and we don't want a future
153
- // refactor to silently strip it.
167
+ // Spot-check the wizard is rendering its account-step form (hub#259
168
+ // replaced the env-var-only placeholder with a real wizard, but the
169
+ // env-var path is still surfaced as the "alt-path" disclosure).
170
+ expect(html).toContain('action="/admin/setup/account"');
154
171
  expect(html).toContain("PARACHUTE_INITIAL_ADMIN_USERNAME");
155
172
  expect(html).toContain("PARACHUTE_INITIAL_ADMIN_PASSWORD");
156
173
  } finally {
@@ -182,13 +199,22 @@ describe("setup gate (admin exists)", () => {
182
199
  }
183
200
  });
184
201
 
185
- test("/admin/setup 301s to /login once an admin exists", async () => {
202
+ test("/admin/setup resumes at the vault step when admin exists but vault doesn't (hub#259)", async () => {
186
203
  const db = openHubDb(hubDbPath(h.dir));
187
204
  try {
188
205
  await createUser(db, "owner", "pw");
189
- const res = await hubFetch(h.dir, { getDb: () => db })(req("/admin/setup"));
190
- expect(res.status).toBe(301);
191
- expect(res.headers.get("location")).toBe("/login");
206
+ const res = await hubFetch(h.dir, {
207
+ getDb: () => db,
208
+ manifestPath: join(h.dir, "services.json"),
209
+ })(req("/admin/setup"));
210
+ // With admin in place but no vault entry in services.json, the
211
+ // wizard's GET resumes at step 3 — the vault-name form — rather
212
+ // than 301-ing to /login. The 301-to-/login fires only once BOTH
213
+ // admin and vault are in place; that case is exercised in the
214
+ // setup-wizard suite where the manifest is seeded.
215
+ expect(res.status).toBe(200);
216
+ const html = await res.text();
217
+ expect(html).toContain('action="/admin/setup/vault"');
192
218
  } finally {
193
219
  db.close();
194
220
  }