@openparachute/hub 0.5.10-rc.5 → 0.5.10-rc.9
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__/api-modules-ops.test.ts +65 -0
- package/src/__tests__/csrf.test.ts +40 -1
- package/src/__tests__/hub-server.test.ts +40 -7
- package/src/__tests__/hub-settings.test.ts +225 -0
- package/src/__tests__/hub.test.ts +17 -0
- package/src/__tests__/oauth-handlers.test.ts +147 -0
- package/src/__tests__/request-protocol.test.ts +54 -0
- package/src/__tests__/sessions.test.ts +25 -2
- package/src/__tests__/setup-gate.test.ts +19 -3
- package/src/__tests__/setup-wizard.test.ts +1286 -12
- package/src/__tests__/supervisor.test.ts +76 -2
- package/src/__tests__/vault-name.test.ts +79 -0
- package/src/admin-handlers.ts +7 -2
- package/src/api-modules-ops.ts +15 -0
- package/src/csrf.ts +30 -12
- package/src/hub-db.ts +24 -0
- package/src/hub-server.ts +46 -2
- package/src/hub-settings.ts +162 -0
- package/src/hub.ts +34 -9
- package/src/oauth-handlers.ts +24 -2
- package/src/request-protocol.ts +48 -0
- package/src/sessions.ts +29 -17
- package/src/setup-wizard.ts +985 -56
- package/src/supervisor.ts +66 -14
- package/src/vault-name.ts +71 -0
package/package.json
CHANGED
|
@@ -415,6 +415,71 @@ describe("POST /api/modules/:short/upgrade", () => {
|
|
|
415
415
|
await new Promise((r) => setTimeout(r, 10));
|
|
416
416
|
expect(calls).toContainEqual(["bun", "add", "-g", "@openparachute/vault@latest"]);
|
|
417
417
|
});
|
|
418
|
+
|
|
419
|
+
test("fails with 'try install first' when module is installed but never supervised", async () => {
|
|
420
|
+
// Module has a services.json row (e.g. seeded by `parachute install`
|
|
421
|
+
// pre-supervisor era) but the supervisor never spawned it.
|
|
422
|
+
// `bun add -g` succeeds, then `supervisor.restart()` returns
|
|
423
|
+
// undefined because there's no entry in the Map. The operation
|
|
424
|
+
// should land in `failed` with the canonical "try install first"
|
|
425
|
+
// message rather than silently succeed (hub#265).
|
|
426
|
+
writeManifest(h.manifestPath, [
|
|
427
|
+
{
|
|
428
|
+
name: "parachute-vault",
|
|
429
|
+
port: 1940,
|
|
430
|
+
paths: ["/vault/default"],
|
|
431
|
+
health: "/vault/default/health",
|
|
432
|
+
version: "0.4.5",
|
|
433
|
+
},
|
|
434
|
+
]);
|
|
435
|
+
const { supervisor, spawns } = makeIdleSupervisor();
|
|
436
|
+
// Intentionally do NOT call supervisor.start(...) — that's the
|
|
437
|
+
// path under test.
|
|
438
|
+
|
|
439
|
+
const { run, calls } = alwaysOkRun();
|
|
440
|
+
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
441
|
+
const deps = {
|
|
442
|
+
db: h.db,
|
|
443
|
+
issuer: ISSUER,
|
|
444
|
+
manifestPath: h.manifestPath,
|
|
445
|
+
configDir: h.dir,
|
|
446
|
+
supervisor,
|
|
447
|
+
run,
|
|
448
|
+
};
|
|
449
|
+
const res = await handleUpgrade(
|
|
450
|
+
postReq("/api/modules/vault/upgrade", { authorization: `Bearer ${bearer}` }),
|
|
451
|
+
"vault",
|
|
452
|
+
deps,
|
|
453
|
+
);
|
|
454
|
+
expect(res.status).toBe(202);
|
|
455
|
+
const body = (await res.json()) as { operation_id: string };
|
|
456
|
+
// Give the async runUpgrade chain a tick to settle.
|
|
457
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
458
|
+
|
|
459
|
+
// bun add was still attempted (it's the first step).
|
|
460
|
+
expect(calls).toContainEqual(["bun", "add", "-g", "@openparachute/vault@latest"]);
|
|
461
|
+
// No supervisor spawn ever happened — confirms the missing
|
|
462
|
+
// supervisor entry is what we exercised, not some other branch.
|
|
463
|
+
expect(spawns).toEqual([]);
|
|
464
|
+
|
|
465
|
+
// Poll the operation: status `failed`, message points the
|
|
466
|
+
// operator at the install path.
|
|
467
|
+
const opRes = await handleOperationGet(
|
|
468
|
+
getReq(`/api/modules/operations/${body.operation_id}`, {
|
|
469
|
+
authorization: `Bearer ${bearer}`,
|
|
470
|
+
}),
|
|
471
|
+
body.operation_id,
|
|
472
|
+
deps,
|
|
473
|
+
);
|
|
474
|
+
const op = (await opRes.json()) as {
|
|
475
|
+
status: string;
|
|
476
|
+
error?: string;
|
|
477
|
+
log: string[];
|
|
478
|
+
};
|
|
479
|
+
expect(op.status).toBe("failed");
|
|
480
|
+
expect(op.error).toMatch(/supervisor restart found no module/);
|
|
481
|
+
expect(op.log.join(" ")).toMatch(/try install first/);
|
|
482
|
+
});
|
|
418
483
|
});
|
|
419
484
|
|
|
420
485
|
describe("POST /api/modules/:short/uninstall", () => {
|
|
@@ -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");
|
|
@@ -159,6 +165,29 @@ describe("hubFetch routing", () => {
|
|
|
159
165
|
}
|
|
160
166
|
});
|
|
161
167
|
|
|
168
|
+
test("/.well-known/parachute.json sets cache-control: no-store (hub#268 Item 1)", async () => {
|
|
169
|
+
// The discovery page (/) fetches this doc and renders Service tiles
|
|
170
|
+
// from it. Without no-store, the browser HTTP cache returns the
|
|
171
|
+
// stale services list the next time the operator clicks back to /
|
|
172
|
+
// after installing a module via /admin/modules. The doc is built
|
|
173
|
+
// per-request anyway, so giving up cacheability has no real cost.
|
|
174
|
+
const h = makeHarness();
|
|
175
|
+
try {
|
|
176
|
+
writeManifest({ services: [] }, h.manifestPath);
|
|
177
|
+
const getRes = await hubFetch(h.dir, { manifestPath: h.manifestPath })(
|
|
178
|
+
req("/.well-known/parachute.json"),
|
|
179
|
+
);
|
|
180
|
+
expect(getRes.headers.get("cache-control")).toBe("no-store");
|
|
181
|
+
// Preflight gets the same header (same corsHeaders object).
|
|
182
|
+
const optRes = await hubFetch(h.dir, { manifestPath: h.manifestPath })(
|
|
183
|
+
req("/.well-known/parachute.json", { method: "OPTIONS" }),
|
|
184
|
+
);
|
|
185
|
+
expect(optRes.headers.get("cache-control")).toBe("no-store");
|
|
186
|
+
} finally {
|
|
187
|
+
h.cleanup();
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
162
191
|
test("OPTIONS preflight on /.well-known/parachute.json returns 204 + CORS", async () => {
|
|
163
192
|
const h = makeHarness();
|
|
164
193
|
try {
|
|
@@ -1004,16 +1033,17 @@ describe("hubFetch routing", () => {
|
|
|
1004
1033
|
}
|
|
1005
1034
|
});
|
|
1006
1035
|
|
|
1007
|
-
test("/admin/setup 301s to /login once admin + vault
|
|
1036
|
+
test("/admin/setup 301s to /login once admin + vault + expose mode all exist (hub#259, hub#268)", async () => {
|
|
1008
1037
|
const h = makeHarness();
|
|
1009
1038
|
try {
|
|
1010
1039
|
const db = openHubDb(hubDbPath(h.dir));
|
|
1011
1040
|
try {
|
|
1012
1041
|
await createUser(db, "owner", "pw");
|
|
1013
|
-
// Seed the vault entry so the wizard's
|
|
1014
|
-
// and the GET 301s. Without
|
|
1015
|
-
//
|
|
1042
|
+
// Seed the vault entry + expose-mode answer so the wizard's
|
|
1043
|
+
// state derives as "done" and the GET 301s. Without expose
|
|
1044
|
+
// (hub#268 Item 2) the wizard would resume on the expose step.
|
|
1016
1045
|
const { writeManifest } = await import("../services-manifest.ts");
|
|
1046
|
+
const { setSetting } = await import("../hub-settings.ts");
|
|
1017
1047
|
const { join } = await import("node:path");
|
|
1018
1048
|
writeManifest(
|
|
1019
1049
|
{
|
|
@@ -1029,6 +1059,7 @@ describe("hubFetch routing", () => {
|
|
|
1029
1059
|
},
|
|
1030
1060
|
join(h.dir, "services.json"),
|
|
1031
1061
|
);
|
|
1062
|
+
setSetting(db, "setup_expose_mode", "localhost");
|
|
1032
1063
|
const res = await hubFetch(h.dir, {
|
|
1033
1064
|
getDb: () => db,
|
|
1034
1065
|
manifestPath: join(h.dir, "services.json"),
|
|
@@ -1083,7 +1114,7 @@ describe("hubFetch routing", () => {
|
|
|
1083
1114
|
}
|
|
1084
1115
|
});
|
|
1085
1116
|
|
|
1086
|
-
test("pre-admin lockout: /login is gated, /admin/setup + /health + well-known stay open", async () => {
|
|
1117
|
+
test("pre-admin lockout: /login is gated, /admin/setup + /health + well-known stay open, / funnels to /admin/setup", async () => {
|
|
1087
1118
|
const h = makeHarness();
|
|
1088
1119
|
try {
|
|
1089
1120
|
writeFileSync(join(h.dir, "hub.html"), "<html>discovery</html>");
|
|
@@ -1100,9 +1131,11 @@ describe("hubFetch routing", () => {
|
|
|
1100
1131
|
// /health open
|
|
1101
1132
|
const healthRes = await handler(req("/health"));
|
|
1102
1133
|
expect(healthRes.status).toBe(200);
|
|
1103
|
-
// /
|
|
1134
|
+
// / funnels to the wizard (hub#259 rc.6 fix for Bug 2 — the
|
|
1135
|
+
// static portal pre-setup is useless; redirect to setup).
|
|
1104
1136
|
const rootRes = await handler(req("/"));
|
|
1105
|
-
expect(rootRes.status).toBe(
|
|
1137
|
+
expect(rootRes.status).toBe(302);
|
|
1138
|
+
expect(rootRes.headers.get("location")).toBe("/admin/setup");
|
|
1106
1139
|
// /.well-known/parachute.json open
|
|
1107
1140
|
const wkRes = await handler(req("/.well-known/parachute.json"));
|
|
1108
1141
|
expect(wkRes.status).toBe(200);
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for hub-local key/value settings (hub#268).
|
|
3
|
+
*
|
|
4
|
+
* Covers the bare KV API (get/set/delete) and the two domain helpers
|
|
5
|
+
* the wizard + oauth handlers consume: setup_expose_mode validation and
|
|
6
|
+
* the first-client auto-approve window (open + check + consume + clear).
|
|
7
|
+
*/
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
13
|
+
import {
|
|
14
|
+
FIRST_CLIENT_AUTO_APPROVE_WINDOW_MS,
|
|
15
|
+
SETUP_EXPOSE_MODES,
|
|
16
|
+
consumeFirstClientAutoApproveWindow,
|
|
17
|
+
deleteSetting,
|
|
18
|
+
getSetting,
|
|
19
|
+
isFirstClientAutoApproveWindowOpen,
|
|
20
|
+
isSetupExposeMode,
|
|
21
|
+
openFirstClientAutoApproveWindow,
|
|
22
|
+
setSetting,
|
|
23
|
+
} from "../hub-settings.ts";
|
|
24
|
+
|
|
25
|
+
describe("hub-settings — bare KV", () => {
|
|
26
|
+
let dir: string;
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
dir = mkdtempSync(join(tmpdir(), "hub-settings-"));
|
|
29
|
+
});
|
|
30
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
31
|
+
|
|
32
|
+
test("getSetting returns undefined for an absent key", () => {
|
|
33
|
+
const db = openHubDb(hubDbPath(dir));
|
|
34
|
+
try {
|
|
35
|
+
expect(getSetting(db, "setup_expose_mode")).toBeUndefined();
|
|
36
|
+
} finally {
|
|
37
|
+
db.close();
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("setSetting writes a value getSetting reads back", () => {
|
|
42
|
+
const db = openHubDb(hubDbPath(dir));
|
|
43
|
+
try {
|
|
44
|
+
setSetting(db, "setup_expose_mode", "tailnet");
|
|
45
|
+
expect(getSetting(db, "setup_expose_mode")).toBe("tailnet");
|
|
46
|
+
} finally {
|
|
47
|
+
db.close();
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("setSetting overwrites an existing value (UPSERT)", () => {
|
|
52
|
+
const db = openHubDb(hubDbPath(dir));
|
|
53
|
+
try {
|
|
54
|
+
setSetting(db, "setup_expose_mode", "tailnet");
|
|
55
|
+
setSetting(db, "setup_expose_mode", "public");
|
|
56
|
+
expect(getSetting(db, "setup_expose_mode")).toBe("public");
|
|
57
|
+
} finally {
|
|
58
|
+
db.close();
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("deleteSetting removes a row + idempotently no-ops on a missing key", () => {
|
|
63
|
+
const db = openHubDb(hubDbPath(dir));
|
|
64
|
+
try {
|
|
65
|
+
setSetting(db, "setup_expose_mode", "localhost");
|
|
66
|
+
deleteSetting(db, "setup_expose_mode");
|
|
67
|
+
expect(getSetting(db, "setup_expose_mode")).toBeUndefined();
|
|
68
|
+
// Second delete is a no-op.
|
|
69
|
+
deleteSetting(db, "setup_expose_mode");
|
|
70
|
+
expect(getSetting(db, "setup_expose_mode")).toBeUndefined();
|
|
71
|
+
} finally {
|
|
72
|
+
db.close();
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("setSetting updates the updated_at column on every write", () => {
|
|
77
|
+
const db = openHubDb(hubDbPath(dir));
|
|
78
|
+
try {
|
|
79
|
+
const t1 = new Date("2026-05-19T00:00:00.000Z");
|
|
80
|
+
const t2 = new Date("2026-05-19T00:01:00.000Z");
|
|
81
|
+
setSetting(db, "setup_expose_mode", "localhost", () => t1);
|
|
82
|
+
setSetting(db, "setup_expose_mode", "localhost", () => t2);
|
|
83
|
+
// Re-write with the same value still bumps updated_at — useful
|
|
84
|
+
// for operational polling that wants to distinguish stale vs
|
|
85
|
+
// fresh state.
|
|
86
|
+
const row = db
|
|
87
|
+
.query<{ updated_at: string }, []>(
|
|
88
|
+
"SELECT updated_at FROM hub_settings WHERE key = 'setup_expose_mode'",
|
|
89
|
+
)
|
|
90
|
+
.get();
|
|
91
|
+
expect(row?.updated_at).toBe(t2.toISOString());
|
|
92
|
+
} finally {
|
|
93
|
+
db.close();
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("hub-settings — isSetupExposeMode", () => {
|
|
99
|
+
test("accepts the three canonical values", () => {
|
|
100
|
+
for (const m of SETUP_EXPOSE_MODES) {
|
|
101
|
+
expect(isSetupExposeMode(m)).toBe(true);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("rejects anything else (typos, empty, non-string)", () => {
|
|
106
|
+
expect(isSetupExposeMode("local")).toBe(false);
|
|
107
|
+
expect(isSetupExposeMode("LOCALHOST")).toBe(false);
|
|
108
|
+
expect(isSetupExposeMode("")).toBe(false);
|
|
109
|
+
expect(isSetupExposeMode(null)).toBe(false);
|
|
110
|
+
expect(isSetupExposeMode(undefined)).toBe(false);
|
|
111
|
+
expect(isSetupExposeMode(42)).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("hub-settings — first-client auto-approve window", () => {
|
|
116
|
+
let dir: string;
|
|
117
|
+
beforeEach(() => {
|
|
118
|
+
dir = mkdtempSync(join(tmpdir(), "hub-settings-"));
|
|
119
|
+
});
|
|
120
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
121
|
+
|
|
122
|
+
test("window is closed by default (no row)", () => {
|
|
123
|
+
const db = openHubDb(hubDbPath(dir));
|
|
124
|
+
try {
|
|
125
|
+
expect(isFirstClientAutoApproveWindowOpen(db)).toBe(false);
|
|
126
|
+
} finally {
|
|
127
|
+
db.close();
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("openFirstClientAutoApproveWindow opens a window 60 minutes long", () => {
|
|
132
|
+
const db = openHubDb(hubDbPath(dir));
|
|
133
|
+
try {
|
|
134
|
+
const now = new Date("2026-05-19T00:00:00.000Z");
|
|
135
|
+
openFirstClientAutoApproveWindow(db, () => now);
|
|
136
|
+
const stored = getSetting(db, "pending_first_client_auto_approve_until");
|
|
137
|
+
expect(stored).toBeDefined();
|
|
138
|
+
const parsed = new Date(stored ?? "");
|
|
139
|
+
expect(parsed.getTime() - now.getTime()).toBe(FIRST_CLIENT_AUTO_APPROVE_WINDOW_MS);
|
|
140
|
+
} finally {
|
|
141
|
+
db.close();
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("isFirstClientAutoApproveWindowOpen → true within window, false after expiry", () => {
|
|
146
|
+
const db = openHubDb(hubDbPath(dir));
|
|
147
|
+
try {
|
|
148
|
+
const t0 = new Date("2026-05-19T00:00:00.000Z");
|
|
149
|
+
openFirstClientAutoApproveWindow(db, () => t0);
|
|
150
|
+
// 30 minutes in → still open.
|
|
151
|
+
expect(
|
|
152
|
+
isFirstClientAutoApproveWindowOpen(db, () => new Date(t0.getTime() + 30 * 60 * 1000)),
|
|
153
|
+
).toBe(true);
|
|
154
|
+
// 60 minutes + 1 ms in → closed.
|
|
155
|
+
expect(
|
|
156
|
+
isFirstClientAutoApproveWindowOpen(
|
|
157
|
+
db,
|
|
158
|
+
() => new Date(t0.getTime() + FIRST_CLIENT_AUTO_APPROVE_WINDOW_MS + 1),
|
|
159
|
+
),
|
|
160
|
+
).toBe(false);
|
|
161
|
+
} finally {
|
|
162
|
+
db.close();
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("consumeFirstClientAutoApproveWindow consumes the window once, then returns false", () => {
|
|
167
|
+
const db = openHubDb(hubDbPath(dir));
|
|
168
|
+
try {
|
|
169
|
+
const now = new Date("2026-05-19T00:00:00.000Z");
|
|
170
|
+
openFirstClientAutoApproveWindow(db, () => now);
|
|
171
|
+
// First call consumes.
|
|
172
|
+
expect(consumeFirstClientAutoApproveWindow(db, () => now)).toBe(true);
|
|
173
|
+
// Second call sees no window.
|
|
174
|
+
expect(consumeFirstClientAutoApproveWindow(db, () => now)).toBe(false);
|
|
175
|
+
// The row is cleared.
|
|
176
|
+
expect(getSetting(db, "pending_first_client_auto_approve_until")).toBeUndefined();
|
|
177
|
+
} finally {
|
|
178
|
+
db.close();
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("consume returns false when the window has expired (and clears nothing)", () => {
|
|
183
|
+
const db = openHubDb(hubDbPath(dir));
|
|
184
|
+
try {
|
|
185
|
+
const t0 = new Date("2026-05-19T00:00:00.000Z");
|
|
186
|
+
openFirstClientAutoApproveWindow(db, () => t0);
|
|
187
|
+
const past = new Date(t0.getTime() + FIRST_CLIENT_AUTO_APPROVE_WINDOW_MS + 1);
|
|
188
|
+
expect(consumeFirstClientAutoApproveWindow(db, () => past)).toBe(false);
|
|
189
|
+
// Row is still there (no implicit cleanup on expiry — the setting
|
|
190
|
+
// just stops being "open"). A future open() resets it.
|
|
191
|
+
expect(getSetting(db, "pending_first_client_auto_approve_until")).toBeDefined();
|
|
192
|
+
} finally {
|
|
193
|
+
db.close();
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("malformed timestamp string is treated as closed (not parseable → not open)", () => {
|
|
198
|
+
const db = openHubDb(hubDbPath(dir));
|
|
199
|
+
try {
|
|
200
|
+
setSetting(db, "pending_first_client_auto_approve_until", "not-a-date");
|
|
201
|
+
expect(isFirstClientAutoApproveWindowOpen(db)).toBe(false);
|
|
202
|
+
expect(consumeFirstClientAutoApproveWindow(db)).toBe(false);
|
|
203
|
+
} finally {
|
|
204
|
+
db.close();
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("reopening the window after consume restarts the 60-minute clock", () => {
|
|
209
|
+
const db = openHubDb(hubDbPath(dir));
|
|
210
|
+
try {
|
|
211
|
+
const t0 = new Date("2026-05-19T00:00:00.000Z");
|
|
212
|
+
openFirstClientAutoApproveWindow(db, () => t0);
|
|
213
|
+
consumeFirstClientAutoApproveWindow(db, () => t0);
|
|
214
|
+
// Re-open at t0 + 90min.
|
|
215
|
+
const t1 = new Date(t0.getTime() + 90 * 60 * 1000);
|
|
216
|
+
openFirstClientAutoApproveWindow(db, () => t1);
|
|
217
|
+
// The new window's expiry is t1 + 60min, not t0 + 60min.
|
|
218
|
+
const stored = getSetting(db, "pending_first_client_auto_approve_until");
|
|
219
|
+
const parsed = new Date(stored ?? "");
|
|
220
|
+
expect(parsed.getTime()).toBe(t1.getTime() + FIRST_CLIENT_AUTO_APPROVE_WINDOW_MS);
|
|
221
|
+
} finally {
|
|
222
|
+
db.close();
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
});
|
|
@@ -103,6 +103,23 @@ describe("renderHub", () => {
|
|
|
103
103
|
expect(html).toContain("Could not load services");
|
|
104
104
|
});
|
|
105
105
|
|
|
106
|
+
test("discovery page fetches with cache: 'no-store' (hub#268 Item 1)", () => {
|
|
107
|
+
// Without `cache: 'no-store'` the browser's HTTP cache can return
|
|
108
|
+
// a stale services list when the operator clicks back to / after
|
|
109
|
+
// installing a module via /admin/modules. Server-side also sets
|
|
110
|
+
// cache-control: no-store on the well-known doc.
|
|
111
|
+
expect(html).toContain("cache: 'no-store'");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("discovery page re-fetches on bfcache restore via pageshow (hub#268 Item 1)", () => {
|
|
115
|
+
// When the operator clicks back from /admin/modules to / the
|
|
116
|
+
// browser may restore the prior DOM without re-running the IIFE.
|
|
117
|
+
// The pageshow handler re-runs loadServices() when the page was
|
|
118
|
+
// restored from cache (`e.persisted === true`).
|
|
119
|
+
expect(html).toContain("addEventListener('pageshow'");
|
|
120
|
+
expect(html).toContain("e.persisted");
|
|
121
|
+
});
|
|
122
|
+
|
|
106
123
|
test("does not retain the old aggregate-by-module-type code", () => {
|
|
107
124
|
// The Vault collapse + per-module aggregation pattern is gone — Use
|
|
108
125
|
// entries are direct service-path → label lookups; Admin is hardcoded.
|
|
@@ -6,6 +6,12 @@ import { join } from "node:path";
|
|
|
6
6
|
import { getClient, registerClient } from "../clients.ts";
|
|
7
7
|
import { CSRF_COOKIE_NAME } from "../csrf.ts";
|
|
8
8
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
9
|
+
import {
|
|
10
|
+
FIRST_CLIENT_AUTO_APPROVE_WINDOW_MS,
|
|
11
|
+
getSetting,
|
|
12
|
+
openFirstClientAutoApproveWindow,
|
|
13
|
+
setSetting,
|
|
14
|
+
} from "../hub-settings.ts";
|
|
9
15
|
import { findTokenRowByJti, validateAccessToken } from "../jwt-sign.ts";
|
|
10
16
|
import {
|
|
11
17
|
authorizationServerMetadata,
|
|
@@ -4216,3 +4222,144 @@ describe("inline approve button on pending /oauth/authorize (#208)", () => {
|
|
|
4216
4222
|
}
|
|
4217
4223
|
});
|
|
4218
4224
|
});
|
|
4225
|
+
|
|
4226
|
+
// DCR first-client auto-approve window (hub#268 Item 3). The wizard's
|
|
4227
|
+
// expose-step POST opens a 60-minute window where the very next
|
|
4228
|
+
// `/oauth/register` registration is auto-approved + the window cleared.
|
|
4229
|
+
// Single-use: client #2 within the same window falls through to the
|
|
4230
|
+
// standard pending-approval flow.
|
|
4231
|
+
describe("DCR first-client auto-approve window (hub#268 Item 3)", () => {
|
|
4232
|
+
function registerRequest(): Request {
|
|
4233
|
+
return new Request(`${ISSUER}/oauth/register`, {
|
|
4234
|
+
method: "POST",
|
|
4235
|
+
body: JSON.stringify({
|
|
4236
|
+
redirect_uris: ["https://app.example/cb"],
|
|
4237
|
+
client_name: "first-client",
|
|
4238
|
+
}),
|
|
4239
|
+
headers: { "content-type": "application/json" },
|
|
4240
|
+
});
|
|
4241
|
+
}
|
|
4242
|
+
|
|
4243
|
+
test("client registered within the open window → status approved + window cleared", async () => {
|
|
4244
|
+
const { db, cleanup } = await makeDb();
|
|
4245
|
+
try {
|
|
4246
|
+
const t0 = new Date("2026-05-19T00:00:00.000Z");
|
|
4247
|
+
openFirstClientAutoApproveWindow(db, () => t0);
|
|
4248
|
+
const res = await handleRegister(db, registerRequest(), {
|
|
4249
|
+
issuer: ISSUER,
|
|
4250
|
+
now: () => t0,
|
|
4251
|
+
});
|
|
4252
|
+
expect(res.status).toBe(201);
|
|
4253
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
4254
|
+
expect(body.status).toBe("approved");
|
|
4255
|
+
// Persisted, not just response-shaped.
|
|
4256
|
+
const row = getClient(db, body.client_id as string);
|
|
4257
|
+
expect(row?.status).toBe("approved");
|
|
4258
|
+
// Window cleared on consume (single-use).
|
|
4259
|
+
expect(getSetting(db, "pending_first_client_auto_approve_until")).toBeUndefined();
|
|
4260
|
+
} finally {
|
|
4261
|
+
cleanup();
|
|
4262
|
+
}
|
|
4263
|
+
});
|
|
4264
|
+
|
|
4265
|
+
test("client registered AFTER the window has expired → status pending", async () => {
|
|
4266
|
+
const { db, cleanup } = await makeDb();
|
|
4267
|
+
try {
|
|
4268
|
+
const t0 = new Date("2026-05-19T00:00:00.000Z");
|
|
4269
|
+
openFirstClientAutoApproveWindow(db, () => t0);
|
|
4270
|
+
const past = new Date(t0.getTime() + FIRST_CLIENT_AUTO_APPROVE_WINDOW_MS + 1);
|
|
4271
|
+
const res = await handleRegister(db, registerRequest(), {
|
|
4272
|
+
issuer: ISSUER,
|
|
4273
|
+
now: () => past,
|
|
4274
|
+
});
|
|
4275
|
+
expect(res.status).toBe(201);
|
|
4276
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
4277
|
+
expect(body.status).toBe("pending");
|
|
4278
|
+
} finally {
|
|
4279
|
+
cleanup();
|
|
4280
|
+
}
|
|
4281
|
+
});
|
|
4282
|
+
|
|
4283
|
+
test("second client within window after first auto-approved → status pending (single-use)", async () => {
|
|
4284
|
+
const { db, cleanup } = await makeDb();
|
|
4285
|
+
try {
|
|
4286
|
+
const t0 = new Date("2026-05-19T00:00:00.000Z");
|
|
4287
|
+
openFirstClientAutoApproveWindow(db, () => t0);
|
|
4288
|
+
// Client #1: approved.
|
|
4289
|
+
const res1 = await handleRegister(db, registerRequest(), {
|
|
4290
|
+
issuer: ISSUER,
|
|
4291
|
+
now: () => t0,
|
|
4292
|
+
});
|
|
4293
|
+
const body1 = (await res1.json()) as Record<string, unknown>;
|
|
4294
|
+
expect(body1.status).toBe("approved");
|
|
4295
|
+
// Client #2 within the (still-not-expired) window: pending.
|
|
4296
|
+
const stillWithinWindow = new Date(t0.getTime() + 30 * 60 * 1000);
|
|
4297
|
+
const res2 = await handleRegister(db, registerRequest(), {
|
|
4298
|
+
issuer: ISSUER,
|
|
4299
|
+
now: () => stillWithinWindow,
|
|
4300
|
+
});
|
|
4301
|
+
const body2 = (await res2.json()) as Record<string, unknown>;
|
|
4302
|
+
expect(body2.status).toBe("pending");
|
|
4303
|
+
} finally {
|
|
4304
|
+
cleanup();
|
|
4305
|
+
}
|
|
4306
|
+
});
|
|
4307
|
+
|
|
4308
|
+
test("no window set → status pending (default public-DCR flow)", async () => {
|
|
4309
|
+
const { db, cleanup } = await makeDb();
|
|
4310
|
+
try {
|
|
4311
|
+
const res = await handleRegister(db, registerRequest(), { issuer: ISSUER });
|
|
4312
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
4313
|
+
expect(body.status).toBe("pending");
|
|
4314
|
+
// Settings row untouched.
|
|
4315
|
+
expect(getSetting(db, "pending_first_client_auto_approve_until")).toBeUndefined();
|
|
4316
|
+
} finally {
|
|
4317
|
+
cleanup();
|
|
4318
|
+
}
|
|
4319
|
+
});
|
|
4320
|
+
|
|
4321
|
+
test("operator-bearer auto-approve still takes precedence over the window (no double-consume)", async () => {
|
|
4322
|
+
// Bearer-authenticated registration approves directly; the
|
|
4323
|
+
// auto-approve window should NOT be consumed in that case — it's
|
|
4324
|
+
// still available for the first un-authenticated client.
|
|
4325
|
+
const { db, cleanup } = await makeDb();
|
|
4326
|
+
try {
|
|
4327
|
+
const t0 = new Date("2026-05-19T00:00:00.000Z");
|
|
4328
|
+
openFirstClientAutoApproveWindow(db, () => t0);
|
|
4329
|
+
// We can't easily mint an operator bearer in this test layer, so
|
|
4330
|
+
// simulate by using the session-cookie path (issuer-trusted) which
|
|
4331
|
+
// also auto-approves before falling through to the window check.
|
|
4332
|
+
const user = await createUser(db, "owner", "pw");
|
|
4333
|
+
const session = createSession(db, { userId: user.id });
|
|
4334
|
+
const req = new Request(`${ISSUER}/oauth/register`, {
|
|
4335
|
+
method: "POST",
|
|
4336
|
+
body: JSON.stringify({ redirect_uris: ["https://app.example/cb"] }),
|
|
4337
|
+
headers: {
|
|
4338
|
+
"content-type": "application/json",
|
|
4339
|
+
cookie: buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000)),
|
|
4340
|
+
origin: ISSUER,
|
|
4341
|
+
},
|
|
4342
|
+
});
|
|
4343
|
+
const res = await handleRegister(db, req, { issuer: ISSUER, now: () => t0 });
|
|
4344
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
4345
|
+
expect(body.status).toBe("approved");
|
|
4346
|
+
// Window NOT consumed — still set, still open. The session-cookie
|
|
4347
|
+
// path approved first, never reaching the window-consume code.
|
|
4348
|
+
expect(getSetting(db, "pending_first_client_auto_approve_until")).toBeDefined();
|
|
4349
|
+
} finally {
|
|
4350
|
+
cleanup();
|
|
4351
|
+
}
|
|
4352
|
+
});
|
|
4353
|
+
|
|
4354
|
+
test("malformed timestamp in the setting → treated as no-window, status pending", async () => {
|
|
4355
|
+
const { db, cleanup } = await makeDb();
|
|
4356
|
+
try {
|
|
4357
|
+
setSetting(db, "pending_first_client_auto_approve_until", "not-a-real-iso-string");
|
|
4358
|
+
const res = await handleRegister(db, registerRequest(), { issuer: ISSUER });
|
|
4359
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
4360
|
+
expect(body.status).toBe("pending");
|
|
4361
|
+
} finally {
|
|
4362
|
+
cleanup();
|
|
4363
|
+
}
|
|
4364
|
+
});
|
|
4365
|
+
});
|