@openparachute/hub 0.5.7 → 0.5.10-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.
- package/package.json +1 -1
- package/src/__tests__/admin-clients.test.ts +275 -0
- package/src/__tests__/admin-handlers.test.ts +70 -323
- package/src/__tests__/admin-host-admin-token.test.ts +52 -4
- package/src/__tests__/api-me.test.ts +149 -0
- package/src/__tests__/api-mint-token.test.ts +381 -0
- package/src/__tests__/api-modules-ops.test.ts +658 -0
- package/src/__tests__/api-modules.test.ts +426 -0
- package/src/__tests__/api-revocation-list.test.ts +198 -0
- package/src/__tests__/api-revoke-token.test.ts +320 -0
- package/src/__tests__/api-tokens.test.ts +629 -0
- package/src/__tests__/auth.test.ts +680 -16
- package/src/__tests__/csrf.test.ts +40 -1
- package/src/__tests__/expose-2fa-warning.test.ts +3 -5
- package/src/__tests__/expose-cloudflare.test.ts +1 -1
- package/src/__tests__/expose.test.ts +2 -2
- package/src/__tests__/hub-server.test.ts +584 -67
- package/src/__tests__/hub-settings.test.ts +377 -0
- package/src/__tests__/hub.test.ts +123 -53
- package/src/__tests__/install-source.test.ts +249 -0
- package/src/__tests__/jwt-sign.test.ts +205 -0
- package/src/__tests__/module-manifest.test.ts +48 -0
- package/src/__tests__/oauth-handlers.test.ts +522 -5
- package/src/__tests__/operator-token.test.ts +427 -3
- package/src/__tests__/origin-check.test.ts +220 -0
- package/src/__tests__/request-protocol.test.ts +54 -0
- package/src/__tests__/serve-boot.test.ts +193 -0
- package/src/__tests__/serve.test.ts +100 -0
- package/src/__tests__/sessions.test.ts +25 -2
- package/src/__tests__/setup-gate.test.ts +222 -0
- package/src/__tests__/setup-wizard.test.ts +2089 -0
- package/src/__tests__/status.test.ts +199 -0
- package/src/__tests__/supervisor.test.ts +482 -0
- package/src/__tests__/upgrade.test.ts +247 -4
- package/src/__tests__/vault-name.test.ts +79 -0
- package/src/__tests__/well-known.test.ts +69 -0
- package/src/admin-clients.ts +139 -0
- package/src/admin-handlers.ts +37 -254
- package/src/admin-host-admin-token.ts +25 -10
- package/src/admin-login-ui.ts +256 -0
- package/src/admin-vault-admin-token.ts +1 -1
- package/src/api-me.ts +124 -0
- package/src/api-mint-token.ts +239 -0
- package/src/api-modules-ops.ts +585 -0
- package/src/api-modules.ts +367 -0
- package/src/api-revocation-list.ts +59 -0
- package/src/api-revoke-token.ts +153 -0
- package/src/api-tokens.ts +224 -0
- package/src/cli.ts +28 -0
- package/src/commands/auth.ts +408 -51
- package/src/commands/expose-2fa-warning.ts +6 -6
- package/src/commands/serve-boot.ts +133 -0
- package/src/commands/serve.ts +214 -0
- package/src/commands/status.ts +74 -10
- package/src/commands/upgrade.ts +33 -6
- package/src/csrf.ts +34 -13
- package/src/help.ts +55 -5
- package/src/hub-control.ts +1 -0
- package/src/hub-db.ts +87 -0
- package/src/hub-server.ts +767 -136
- package/src/hub-settings.ts +259 -0
- package/src/hub.ts +298 -150
- package/src/install-source.ts +291 -0
- package/src/jwt-sign.ts +265 -5
- package/src/module-manifest.ts +48 -10
- package/src/oauth-handlers.ts +262 -56
- package/src/oauth-ui.ts +23 -2
- package/src/operator-token.ts +349 -18
- package/src/origin-check.ts +127 -0
- package/src/rate-limit.ts +5 -2
- package/src/request-protocol.ts +48 -0
- package/src/scope-explanations.ts +33 -2
- package/src/sessions.ts +30 -18
- package/src/setup-wizard.ts +2009 -0
- package/src/supervisor.ts +411 -0
- package/src/vault-name.ts +71 -0
- package/src/well-known.ts +54 -1
- package/web/ui/dist/assets/index-BDSEsaBY.css +1 -0
- package/web/ui/dist/assets/index-CP07NbdF.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/admin-config.test.ts +0 -281
- package/src/admin-config-ui.ts +0 -534
- package/src/admin-config.ts +0 -226
- package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
- package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { bootSupervisedModules } from "../commands/serve-boot.ts";
|
|
6
|
+
import { type ServiceEntry, writeManifest } from "../services-manifest.ts";
|
|
7
|
+
import { type SpawnRequest, type SupervisedProc, Supervisor } from "../supervisor.ts";
|
|
8
|
+
|
|
9
|
+
interface Harness {
|
|
10
|
+
dir: string;
|
|
11
|
+
manifestPath: string;
|
|
12
|
+
cleanup: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function makeHarness(): Harness {
|
|
16
|
+
const dir = mkdtempSync(join(tmpdir(), "serve-boot-"));
|
|
17
|
+
const manifestPath = join(dir, "services.json");
|
|
18
|
+
return {
|
|
19
|
+
dir,
|
|
20
|
+
manifestPath,
|
|
21
|
+
cleanup: () => rmSync(dir, { recursive: true, force: true }),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function makeFakeProc(pid: number): SupervisedProc & { resolveExit: (c: number | null) => void } {
|
|
26
|
+
let resolveExit!: (c: number | null) => void;
|
|
27
|
+
const exited = new Promise<number | null>((r) => {
|
|
28
|
+
resolveExit = r;
|
|
29
|
+
});
|
|
30
|
+
return {
|
|
31
|
+
pid,
|
|
32
|
+
exited,
|
|
33
|
+
stdout: null,
|
|
34
|
+
stderr: null,
|
|
35
|
+
kill: () => {},
|
|
36
|
+
resolveExit,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function makeRecorder(): {
|
|
41
|
+
spawn: (req: SpawnRequest) => SupervisedProc;
|
|
42
|
+
calls: SpawnRequest[];
|
|
43
|
+
} {
|
|
44
|
+
const calls: SpawnRequest[] = [];
|
|
45
|
+
let nextPid = 1000;
|
|
46
|
+
return {
|
|
47
|
+
calls,
|
|
48
|
+
spawn: (req) => {
|
|
49
|
+
calls.push(req);
|
|
50
|
+
nextPid++;
|
|
51
|
+
return makeFakeProc(nextPid);
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const VAULT_ENTRY: ServiceEntry = {
|
|
57
|
+
name: "parachute-vault",
|
|
58
|
+
port: 1940,
|
|
59
|
+
paths: ["/vault/default"],
|
|
60
|
+
health: "/vault/default/health",
|
|
61
|
+
version: "0.4.5",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const NOTES_ENTRY: ServiceEntry = {
|
|
65
|
+
name: "parachute-notes",
|
|
66
|
+
port: 1941,
|
|
67
|
+
paths: ["/notes"],
|
|
68
|
+
health: "/notes/health",
|
|
69
|
+
version: "0.3.15",
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
describe("bootSupervisedModules", () => {
|
|
73
|
+
let h: Harness;
|
|
74
|
+
beforeEach(() => {
|
|
75
|
+
h = makeHarness();
|
|
76
|
+
});
|
|
77
|
+
afterEach(() => h.cleanup());
|
|
78
|
+
|
|
79
|
+
test("spawns one supervisor child per first-party services.json row", async () => {
|
|
80
|
+
writeManifest({ services: [VAULT_ENTRY, NOTES_ENTRY] }, h.manifestPath);
|
|
81
|
+
const recorder = makeRecorder();
|
|
82
|
+
const sup = new Supervisor({ spawnFn: recorder.spawn });
|
|
83
|
+
|
|
84
|
+
const results = await bootSupervisedModules(sup, {
|
|
85
|
+
manifestPath: h.manifestPath,
|
|
86
|
+
configDir: h.dir,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(results).toHaveLength(2);
|
|
90
|
+
expect(results.every((r) => r.status === "started")).toBe(true);
|
|
91
|
+
expect(recorder.calls).toHaveLength(2);
|
|
92
|
+
const shorts = recorder.calls.map((c) => c.short).sort();
|
|
93
|
+
expect(shorts).toEqual(["notes", "vault"]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("empty services.json is a no-op", async () => {
|
|
97
|
+
writeManifest({ services: [] }, h.manifestPath);
|
|
98
|
+
const recorder = makeRecorder();
|
|
99
|
+
const sup = new Supervisor({ spawnFn: recorder.spawn });
|
|
100
|
+
|
|
101
|
+
const results = await bootSupervisedModules(sup, {
|
|
102
|
+
manifestPath: h.manifestPath,
|
|
103
|
+
configDir: h.dir,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
expect(results).toEqual([]);
|
|
107
|
+
expect(recorder.calls).toEqual([]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("forwards PARACHUTE_HUB_ORIGIN to child env when set", async () => {
|
|
111
|
+
writeManifest({ services: [VAULT_ENTRY] }, h.manifestPath);
|
|
112
|
+
const recorder = makeRecorder();
|
|
113
|
+
const sup = new Supervisor({ spawnFn: recorder.spawn });
|
|
114
|
+
|
|
115
|
+
await bootSupervisedModules(sup, {
|
|
116
|
+
manifestPath: h.manifestPath,
|
|
117
|
+
configDir: h.dir,
|
|
118
|
+
hubOrigin: "https://hub.example",
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(recorder.calls[0]?.env?.PARACHUTE_HUB_ORIGIN).toBe("https://hub.example");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("merges per-module .env file into child env", async () => {
|
|
125
|
+
writeManifest({ services: [VAULT_ENTRY] }, h.manifestPath);
|
|
126
|
+
// Write a per-module .env at <configDir>/<short>/.env — what the
|
|
127
|
+
// on-box flow's install-time scribe-key prompt produces.
|
|
128
|
+
mkdirSync(join(h.dir, "vault"), { recursive: true });
|
|
129
|
+
writeFileSync(
|
|
130
|
+
join(h.dir, "vault", ".env"),
|
|
131
|
+
"SCRIBE_AUTH_TOKEN=secret-token\nSCRIBE_URL=http://127.0.0.1:3200\n",
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const recorder = makeRecorder();
|
|
135
|
+
const sup = new Supervisor({ spawnFn: recorder.spawn });
|
|
136
|
+
|
|
137
|
+
await bootSupervisedModules(sup, {
|
|
138
|
+
manifestPath: h.manifestPath,
|
|
139
|
+
configDir: h.dir,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
expect(recorder.calls[0]?.env?.SCRIBE_AUTH_TOKEN).toBe("secret-token");
|
|
143
|
+
expect(recorder.calls[0]?.env?.SCRIBE_URL).toBe("http://127.0.0.1:3200");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("hubOrigin wins over a stale .env entry on collision", async () => {
|
|
147
|
+
writeManifest({ services: [VAULT_ENTRY] }, h.manifestPath);
|
|
148
|
+
mkdirSync(join(h.dir, "vault"), { recursive: true });
|
|
149
|
+
writeFileSync(join(h.dir, "vault", ".env"), "PARACHUTE_HUB_ORIGIN=http://stale.local\n");
|
|
150
|
+
|
|
151
|
+
const recorder = makeRecorder();
|
|
152
|
+
const sup = new Supervisor({ spawnFn: recorder.spawn });
|
|
153
|
+
|
|
154
|
+
await bootSupervisedModules(sup, {
|
|
155
|
+
manifestPath: h.manifestPath,
|
|
156
|
+
configDir: h.dir,
|
|
157
|
+
hubOrigin: "https://live.example",
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// The live hub-origin from the runtime env beats the on-disk
|
|
161
|
+
// stale value — that's the resolveHubOrigin precedence carried
|
|
162
|
+
// over from lifecycle.ts.
|
|
163
|
+
expect(recorder.calls[0]?.env?.PARACHUTE_HUB_ORIGIN).toBe("https://live.example");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("rows with no resolvable startCmd are skipped with reason", async () => {
|
|
167
|
+
// A third-party services.json entry that ALSO doesn't match a
|
|
168
|
+
// first-party fallback AND has no installDir → no spec.
|
|
169
|
+
const orphan: ServiceEntry = {
|
|
170
|
+
name: "@third-party/unknown",
|
|
171
|
+
port: 4000,
|
|
172
|
+
paths: ["/unknown"],
|
|
173
|
+
health: "/unknown/health",
|
|
174
|
+
version: "0.1.0",
|
|
175
|
+
};
|
|
176
|
+
writeManifest({ services: [orphan] }, h.manifestPath);
|
|
177
|
+
const recorder = makeRecorder();
|
|
178
|
+
const sup = new Supervisor({ spawnFn: recorder.spawn });
|
|
179
|
+
const logs: string[] = [];
|
|
180
|
+
|
|
181
|
+
const results = await bootSupervisedModules(sup, {
|
|
182
|
+
manifestPath: h.manifestPath,
|
|
183
|
+
configDir: h.dir,
|
|
184
|
+
log: (l) => logs.push(l),
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
expect(results).toHaveLength(1);
|
|
188
|
+
expect(results[0]?.status).toBe("skipped");
|
|
189
|
+
expect(results[0]?.reason).toBe("no-spec");
|
|
190
|
+
expect(recorder.calls).toEqual([]);
|
|
191
|
+
expect(logs.some((l) => l.includes("no startCmd resolvable"))).toBe(true);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { seedInitialAdminIfNeeded } from "../commands/serve.ts";
|
|
6
|
+
import { openHubDb } from "../hub-db.ts";
|
|
7
|
+
import { userCount } from "../users.ts";
|
|
8
|
+
|
|
9
|
+
describe("seedInitialAdminIfNeeded", () => {
|
|
10
|
+
let dir: string;
|
|
11
|
+
let dbPath: string;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
dir = mkdtempSync(join(tmpdir(), "parachute-serve-"));
|
|
15
|
+
dbPath = join(dir, "hub.db");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
rmSync(dir, { recursive: true, force: true });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("returns 'needs-setup' on fresh state with no env vars", async () => {
|
|
23
|
+
const db = openHubDb(dbPath);
|
|
24
|
+
const result = await seedInitialAdminIfNeeded(db, {}, () => {});
|
|
25
|
+
expect(result).toBe("needs-setup");
|
|
26
|
+
expect(userCount(db)).toBe(0);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("seeds an admin from PARACHUTE_INITIAL_ADMIN_* on fresh state", async () => {
|
|
30
|
+
const db = openHubDb(dbPath);
|
|
31
|
+
const log = mock<(line: string) => void>(() => {});
|
|
32
|
+
const result = await seedInitialAdminIfNeeded(
|
|
33
|
+
db,
|
|
34
|
+
{
|
|
35
|
+
PARACHUTE_INITIAL_ADMIN_USERNAME: "ops",
|
|
36
|
+
PARACHUTE_INITIAL_ADMIN_PASSWORD: "correct horse battery staple",
|
|
37
|
+
},
|
|
38
|
+
log,
|
|
39
|
+
);
|
|
40
|
+
expect(result).toBe("seeded");
|
|
41
|
+
expect(userCount(db)).toBe(1);
|
|
42
|
+
// The log line carries the username so operators can grep container
|
|
43
|
+
// logs to verify the seed fired.
|
|
44
|
+
expect(log).toHaveBeenCalledTimes(1);
|
|
45
|
+
expect(log.mock.calls[0]?.[0] ?? "").toContain("seeded initial admin");
|
|
46
|
+
expect(log.mock.calls[0]?.[0] ?? "").toContain("ops");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("returns 'exists' when an admin already exists, even with env vars set", async () => {
|
|
50
|
+
// Seed once.
|
|
51
|
+
const db = openHubDb(dbPath);
|
|
52
|
+
await seedInitialAdminIfNeeded(
|
|
53
|
+
db,
|
|
54
|
+
{
|
|
55
|
+
PARACHUTE_INITIAL_ADMIN_USERNAME: "ops",
|
|
56
|
+
PARACHUTE_INITIAL_ADMIN_PASSWORD: "first-pw",
|
|
57
|
+
},
|
|
58
|
+
() => {},
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
// Same env on a second boot must NOT clobber the existing admin —
|
|
62
|
+
// the seed is first-boot only. (Container restart with the env still
|
|
63
|
+
// set from the Render dashboard is the canonical second-boot.)
|
|
64
|
+
const result = await seedInitialAdminIfNeeded(
|
|
65
|
+
db,
|
|
66
|
+
{
|
|
67
|
+
PARACHUTE_INITIAL_ADMIN_USERNAME: "ops",
|
|
68
|
+
PARACHUTE_INITIAL_ADMIN_PASSWORD: "different-pw",
|
|
69
|
+
},
|
|
70
|
+
() => {},
|
|
71
|
+
);
|
|
72
|
+
expect(result).toBe("exists");
|
|
73
|
+
expect(userCount(db)).toBe(1);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("treats whitespace-only username as missing (needs-setup)", async () => {
|
|
77
|
+
const db = openHubDb(dbPath);
|
|
78
|
+
const result = await seedInitialAdminIfNeeded(
|
|
79
|
+
db,
|
|
80
|
+
{
|
|
81
|
+
PARACHUTE_INITIAL_ADMIN_USERNAME: " ",
|
|
82
|
+
PARACHUTE_INITIAL_ADMIN_PASSWORD: "pw",
|
|
83
|
+
},
|
|
84
|
+
() => {},
|
|
85
|
+
);
|
|
86
|
+
expect(result).toBe("needs-setup");
|
|
87
|
+
expect(userCount(db)).toBe(0);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("requires both username and password — half-set env is needs-setup", async () => {
|
|
91
|
+
const db = openHubDb(dbPath);
|
|
92
|
+
const result = await seedInitialAdminIfNeeded(
|
|
93
|
+
db,
|
|
94
|
+
{ PARACHUTE_INITIAL_ADMIN_USERNAME: "ops" },
|
|
95
|
+
() => {},
|
|
96
|
+
);
|
|
97
|
+
expect(result).toBe("needs-setup");
|
|
98
|
+
expect(userCount(db)).toBe(0);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -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", () => {
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-admin setup gate (hub#258). When the hub boots with no admin row
|
|
3
|
+
* (the fresh container case), admin-onboarding-coupled surfaces 503
|
|
4
|
+
* with `{error: "setup_required", setup_url: "/admin/setup"}` so
|
|
5
|
+
* callers can branch on the shape rather than scrape an HTML page.
|
|
6
|
+
*
|
|
7
|
+
* Gated routes (require an admin to be useful): `/login`, `/logout`,
|
|
8
|
+
* `/admin/*` (except `/admin/setup`), `/api/*`.
|
|
9
|
+
*
|
|
10
|
+
* Routes that pass through (platform health, public discovery, OAuth
|
|
11
|
+
* third-party flows, content proxies, the setup page itself):
|
|
12
|
+
*
|
|
13
|
+
* /health, /, /hub.html, /.well-known/*, /admin/setup,
|
|
14
|
+
* /oauth/*, /vault/*, /<service>/*
|
|
15
|
+
*
|
|
16
|
+
* Once an admin row exists, the gate is a no-op — the rest of dispatch
|
|
17
|
+
* runs as normal. The `/admin/setup` route 301s to /login at that
|
|
18
|
+
* point so a stale bookmark still lands somewhere.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
22
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
23
|
+
import { tmpdir } from "node:os";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
26
|
+
import { hubFetch } from "../hub-server.ts";
|
|
27
|
+
import { writeManifest } from "../services-manifest.ts";
|
|
28
|
+
import { createUser } from "../users.ts";
|
|
29
|
+
|
|
30
|
+
interface Harness {
|
|
31
|
+
dir: string;
|
|
32
|
+
cleanup: () => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function makeHarness(): Harness {
|
|
36
|
+
const dir = mkdtempSync(join(tmpdir(), "setup-gate-"));
|
|
37
|
+
// Minimal hub.html so the `/` route resolves without a 404.
|
|
38
|
+
writeFileSync(join(dir, "hub.html"), "<html>discovery</html>");
|
|
39
|
+
writeManifest({ services: [] }, join(dir, "services.json"));
|
|
40
|
+
return {
|
|
41
|
+
dir,
|
|
42
|
+
cleanup: () => rmSync(dir, { recursive: true, force: true }),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function req(path: string, init: RequestInit = {}): Request {
|
|
47
|
+
return new Request(`http://127.0.0.1:1939${path}`, init);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe("setup gate (no admin yet)", () => {
|
|
51
|
+
let h: Harness;
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
h = makeHarness();
|
|
54
|
+
});
|
|
55
|
+
afterEach(() => h.cleanup());
|
|
56
|
+
|
|
57
|
+
test("503s gated admin/api routes with setup_required body", async () => {
|
|
58
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
59
|
+
try {
|
|
60
|
+
// `/api/me` is a representative gated route — admin SPA bootstrap
|
|
61
|
+
// can't function without an operator identity behind it.
|
|
62
|
+
const res = await hubFetch(h.dir, { getDb: () => db })(req("/api/me"));
|
|
63
|
+
expect(res.status).toBe(503);
|
|
64
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
65
|
+
expect(body.error).toBe("setup_required");
|
|
66
|
+
expect(body.setup_url).toBe("/admin/setup");
|
|
67
|
+
expect(typeof body.error_description).toBe("string");
|
|
68
|
+
} finally {
|
|
69
|
+
db.close();
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("503s /login when no admin exists", async () => {
|
|
74
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
75
|
+
try {
|
|
76
|
+
const res = await hubFetch(h.dir, { getDb: () => db })(req("/login"));
|
|
77
|
+
expect(res.status).toBe(503);
|
|
78
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
79
|
+
expect(body.error).toBe("setup_required");
|
|
80
|
+
} finally {
|
|
81
|
+
db.close();
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("/oauth/register passes through the gate (third-party DCR doesn't need admin)", async () => {
|
|
86
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
87
|
+
try {
|
|
88
|
+
const res = await hubFetch(h.dir, {
|
|
89
|
+
getDb: () => db,
|
|
90
|
+
issuer: "https://hub.example",
|
|
91
|
+
})(
|
|
92
|
+
req("/oauth/register", {
|
|
93
|
+
method: "POST",
|
|
94
|
+
headers: { "content-type": "application/json" },
|
|
95
|
+
body: JSON.stringify({ redirect_uris: ["https://app.example/cb"] }),
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
// Either 201 (registered) or 4xx (validation rejection) — the
|
|
99
|
+
// point is NOT 503-setup_required. OAuth surfaces operate
|
|
100
|
+
// independently of the admin-onboarding state.
|
|
101
|
+
expect(res.status).not.toBe(503);
|
|
102
|
+
} finally {
|
|
103
|
+
db.close();
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("/health passes through the gate", async () => {
|
|
108
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
109
|
+
try {
|
|
110
|
+
const res = await hubFetch(h.dir, { getDb: () => db })(req("/health"));
|
|
111
|
+
expect(res.status).toBe(200);
|
|
112
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
113
|
+
expect(body.status).toBe("ok");
|
|
114
|
+
} finally {
|
|
115
|
+
db.close();
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("/.well-known/jwks.json passes through the gate", async () => {
|
|
120
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
121
|
+
try {
|
|
122
|
+
const res = await hubFetch(h.dir, { getDb: () => db })(req("/.well-known/jwks.json"));
|
|
123
|
+
// Empty keys array is the no-rotation-yet shape, but the route is
|
|
124
|
+
// reachable — not gated to 503.
|
|
125
|
+
expect(res.status).toBe(200);
|
|
126
|
+
const body = (await res.json()) as { keys: unknown[] };
|
|
127
|
+
expect(Array.isArray(body.keys)).toBe(true);
|
|
128
|
+
} finally {
|
|
129
|
+
db.close();
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
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 () => {
|
|
139
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
140
|
+
try {
|
|
141
|
+
const res = await hubFetch(h.dir, { getDb: () => db })(req("/"));
|
|
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");
|
|
155
|
+
} finally {
|
|
156
|
+
db.close();
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("/admin/setup renders the wizard (account step) when no admin exists", async () => {
|
|
161
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
162
|
+
try {
|
|
163
|
+
const res = await hubFetch(h.dir, { getDb: () => db })(req("/admin/setup"));
|
|
164
|
+
expect(res.status).toBe(200);
|
|
165
|
+
expect(res.headers.get("content-type")).toContain("text/html");
|
|
166
|
+
const html = await res.text();
|
|
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"');
|
|
171
|
+
expect(html).toContain("PARACHUTE_INITIAL_ADMIN_USERNAME");
|
|
172
|
+
expect(html).toContain("PARACHUTE_INITIAL_ADMIN_PASSWORD");
|
|
173
|
+
} finally {
|
|
174
|
+
db.close();
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("setup gate (admin exists)", () => {
|
|
180
|
+
let h: Harness;
|
|
181
|
+
beforeEach(() => {
|
|
182
|
+
h = makeHarness();
|
|
183
|
+
});
|
|
184
|
+
afterEach(() => h.cleanup());
|
|
185
|
+
|
|
186
|
+
test("no-ops once an admin row exists — operator routes resume normal dispatch", async () => {
|
|
187
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
188
|
+
try {
|
|
189
|
+
await createUser(db, "owner", "pw");
|
|
190
|
+
// /oauth/token rejects GET with 405 in normal dispatch (it's a
|
|
191
|
+
// POST-only endpoint). If the gate were still firing this would
|
|
192
|
+
// come back 503; the 405 confirms regular dispatch resumed.
|
|
193
|
+
const res = await hubFetch(h.dir, { getDb: () => db })(
|
|
194
|
+
req("/oauth/token", { method: "GET" }),
|
|
195
|
+
);
|
|
196
|
+
expect(res.status).toBe(405);
|
|
197
|
+
} finally {
|
|
198
|
+
db.close();
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("/admin/setup resumes at the vault step when admin exists but vault doesn't (hub#259)", async () => {
|
|
203
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
204
|
+
try {
|
|
205
|
+
await createUser(db, "owner", "pw");
|
|
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"');
|
|
218
|
+
} finally {
|
|
219
|
+
db.close();
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
});
|