@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 +1 -1
- package/src/__tests__/csrf.test.ts +40 -1
- package/src/__tests__/hub-server.test.ts +43 -10
- package/src/__tests__/request-protocol.test.ts +54 -0
- package/src/__tests__/sessions.test.ts +25 -2
- package/src/__tests__/setup-gate.test.ts +37 -11
- package/src/__tests__/setup-wizard.test.ts +815 -0
- package/src/admin-handlers.ts +7 -2
- package/src/api-modules-ops.ts +32 -3
- package/src/csrf.ts +30 -12
- package/src/help.ts +2 -1
- package/src/hub-server.ts +76 -75
- package/src/oauth-handlers.ts +5 -2
- package/src/request-protocol.ts +48 -0
- package/src/sessions.ts +29 -17
- package/src/setup-wizard.ts +1083 -0
package/package.json
CHANGED
|
@@ -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
|
|
983
|
-
//
|
|
984
|
-
//
|
|
985
|
-
|
|
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("
|
|
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
|
|
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
|
-
|
|
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
|
-
// /
|
|
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(
|
|
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
|
-
|
|
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(
|
|
138
|
-
expect(res.headers.get("
|
|
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
|
|
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
|
|
152
|
-
//
|
|
153
|
-
//
|
|
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
|
|
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, {
|
|
190
|
-
|
|
191
|
-
|
|
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
|
}
|