@openparachute/hub 0.5.10-rc.6 → 0.5.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-handlers.test.ts +141 -6
- package/src/__tests__/api-account.test.ts +463 -0
- package/src/__tests__/api-modules-ops.test.ts +139 -0
- package/src/__tests__/api-modules.test.ts +134 -0
- package/src/__tests__/api-users.test.ts +522 -0
- package/src/__tests__/cors.test.ts +587 -0
- package/src/__tests__/hub-db.test.ts +126 -1
- package/src/__tests__/hub-server.test.ts +29 -4
- package/src/__tests__/hub-settings.test.ts +377 -0
- package/src/__tests__/hub.test.ts +17 -0
- package/src/__tests__/jwt-sign.test.ts +59 -0
- package/src/__tests__/oauth-handlers.test.ts +1059 -10
- package/src/__tests__/oauth-ui.test.ts +210 -0
- package/src/__tests__/scope-explanations.test.ts +23 -0
- package/src/__tests__/serve.test.ts +8 -1
- package/src/__tests__/setup-wizard.test.ts +1500 -13
- package/src/__tests__/supervisor.test.ts +76 -2
- package/src/__tests__/users.test.ts +196 -0
- package/src/__tests__/vault-name.test.ts +79 -0
- package/src/__tests__/vault-names.test.ts +172 -0
- package/src/account-change-password-ui.ts +379 -0
- package/src/admin-handlers.ts +68 -2
- package/src/admin-host-admin-token.ts +5 -0
- package/src/admin-vault-admin-token.ts +7 -0
- package/src/api-account.ts +443 -0
- package/src/api-mint-token.ts +6 -0
- package/src/api-modules-ops.ts +30 -6
- package/src/api-modules.ts +101 -0
- package/src/api-users.ts +393 -0
- package/src/commands/auth.ts +10 -1
- package/src/commands/serve.ts +5 -1
- package/src/cors.ts +263 -0
- package/src/hub-db.ts +54 -0
- package/src/hub-server.ts +162 -18
- package/src/hub-settings.ts +259 -0
- package/src/hub.ts +34 -9
- package/src/jwt-sign.ts +17 -1
- package/src/oauth-handlers.ts +256 -29
- package/src/oauth-ui.ts +451 -38
- package/src/operator-token.ts +4 -0
- package/src/scope-explanations.ts +26 -1
- package/src/setup-wizard.ts +1100 -56
- package/src/supervisor.ts +66 -14
- package/src/users.ts +210 -3
- package/src/vault-name.ts +71 -0
- package/src/vault-names.ts +57 -0
- package/web/ui/dist/assets/index-XhxYXDT5.js +61 -0
- package/web/ui/dist/assets/{index-D54otIhv.css → index-p6DkOcsk.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-AX_UHJ5e.js +0 -61
|
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
5
|
+
import { hubDbPath, migrate, openHubDb } from "../hub-db.ts";
|
|
6
6
|
|
|
7
7
|
interface Harness {
|
|
8
8
|
configDir: string;
|
|
@@ -150,4 +150,129 @@ describe("openHubDb + migrate", () => {
|
|
|
150
150
|
h.cleanup();
|
|
151
151
|
}
|
|
152
152
|
});
|
|
153
|
+
|
|
154
|
+
test("v8 adds password_changed + assigned_vault columns on a fresh DB", () => {
|
|
155
|
+
const h = makeHarness();
|
|
156
|
+
try {
|
|
157
|
+
const db = openHubDb(h.dbPath);
|
|
158
|
+
try {
|
|
159
|
+
const versions = (
|
|
160
|
+
db.query<{ version: number }, []>("SELECT version FROM schema_version").all() ?? []
|
|
161
|
+
).map((r) => r.version);
|
|
162
|
+
expect(versions).toContain(8);
|
|
163
|
+
// PRAGMA table_info returns the column shape; we want both new
|
|
164
|
+
// columns present with the right defaults / nullability.
|
|
165
|
+
interface ColInfo {
|
|
166
|
+
name: string;
|
|
167
|
+
type: string;
|
|
168
|
+
notnull: number;
|
|
169
|
+
dflt_value: string | null;
|
|
170
|
+
}
|
|
171
|
+
const cols = db
|
|
172
|
+
.query<ColInfo, []>(
|
|
173
|
+
"SELECT name, type, \"notnull\", dflt_value FROM pragma_table_info('users')",
|
|
174
|
+
)
|
|
175
|
+
.all();
|
|
176
|
+
const byName = new Map(cols.map((c) => [c.name, c]));
|
|
177
|
+
const pc = byName.get("password_changed");
|
|
178
|
+
expect(pc).toBeDefined();
|
|
179
|
+
expect(pc?.type).toBe("INTEGER");
|
|
180
|
+
expect(pc?.notnull).toBe(1);
|
|
181
|
+
// Default literal — SQLite returns it as a string "0".
|
|
182
|
+
expect(pc?.dflt_value).toBe("0");
|
|
183
|
+
const av = byName.get("assigned_vault");
|
|
184
|
+
expect(av).toBeDefined();
|
|
185
|
+
expect(av?.type).toBe("TEXT");
|
|
186
|
+
expect(av?.notnull).toBe(0);
|
|
187
|
+
} finally {
|
|
188
|
+
db.close();
|
|
189
|
+
}
|
|
190
|
+
} finally {
|
|
191
|
+
h.cleanup();
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("v8 backfills password_changed=1 for users that pre-date the migration", () => {
|
|
196
|
+
const h = makeHarness();
|
|
197
|
+
try {
|
|
198
|
+
// Stand up a DB at the v7 state by partially-applying migrations:
|
|
199
|
+
// open Database directly, call migrate after stripping the v8 entry
|
|
200
|
+
// would be invasive. Instead, drive the same migration shape by hand
|
|
201
|
+
// for v1-v7 then insert a row, then call migrate() to apply v8.
|
|
202
|
+
// Cleanest path: openHubDb runs everything, but we want a v7 snapshot.
|
|
203
|
+
// Approach: open with openHubDb (runs all migrations), drop the v8
|
|
204
|
+
// changes, mark v8 unapplied, insert a user with password_changed=0
|
|
205
|
+
// (simulating a row from before the backfill), then re-run migrate.
|
|
206
|
+
// SQLite doesn't have DROP COLUMN pre-3.35 universally, so we do the
|
|
207
|
+
// recreate-and-rename: drop v8's columns by recreating users without
|
|
208
|
+
// them, then delete the v8 schema_version row, then call migrate().
|
|
209
|
+
const db = openHubDb(h.dbPath);
|
|
210
|
+
try {
|
|
211
|
+
// Build a v7-shape users table and copy the v8-shape rows.
|
|
212
|
+
db.exec(`
|
|
213
|
+
CREATE TABLE users_v7 (
|
|
214
|
+
id TEXT PRIMARY KEY,
|
|
215
|
+
username TEXT UNIQUE NOT NULL,
|
|
216
|
+
password_hash TEXT NOT NULL,
|
|
217
|
+
created_at TEXT NOT NULL,
|
|
218
|
+
updated_at TEXT NOT NULL
|
|
219
|
+
);
|
|
220
|
+
INSERT INTO users_v7 (id, username, password_hash, created_at, updated_at)
|
|
221
|
+
SELECT id, username, password_hash, created_at, updated_at FROM users;
|
|
222
|
+
DROP TABLE users;
|
|
223
|
+
ALTER TABLE users_v7 RENAME TO users;
|
|
224
|
+
`);
|
|
225
|
+
db.exec("DELETE FROM schema_version WHERE version = 8");
|
|
226
|
+
// Insert a row that pre-dates v8 (no password_changed column yet).
|
|
227
|
+
db.prepare(
|
|
228
|
+
`INSERT INTO users (id, username, password_hash, created_at, updated_at)
|
|
229
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
230
|
+
).run("legacy-user", "owner", "h", "2026-01-01", "2026-01-01");
|
|
231
|
+
// Now re-run migrations — v8 should ALTER the table and backfill.
|
|
232
|
+
migrate(db);
|
|
233
|
+
const row = db
|
|
234
|
+
.query<{ password_changed: number; assigned_vault: string | null }, [string]>(
|
|
235
|
+
"SELECT password_changed, assigned_vault FROM users WHERE id = ?",
|
|
236
|
+
)
|
|
237
|
+
.get("legacy-user");
|
|
238
|
+
expect(row).not.toBeNull();
|
|
239
|
+
expect(row?.password_changed).toBe(1);
|
|
240
|
+
expect(row?.assigned_vault).toBeNull();
|
|
241
|
+
const versions = (
|
|
242
|
+
db.query<{ version: number }, []>("SELECT version FROM schema_version").all() ?? []
|
|
243
|
+
).map((r) => r.version);
|
|
244
|
+
expect(versions).toContain(8);
|
|
245
|
+
} finally {
|
|
246
|
+
db.close();
|
|
247
|
+
}
|
|
248
|
+
} finally {
|
|
249
|
+
h.cleanup();
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("v8 — fresh inserts default password_changed=0 and assigned_vault NULL", () => {
|
|
254
|
+
const h = makeHarness();
|
|
255
|
+
try {
|
|
256
|
+
const db = openHubDb(h.dbPath);
|
|
257
|
+
try {
|
|
258
|
+
// Insert via the bare-columns SQL (mirrors what a pre-v8 caller
|
|
259
|
+
// would emit) to confirm the column DEFAULTs work.
|
|
260
|
+
db.prepare(
|
|
261
|
+
`INSERT INTO users (id, username, password_hash, created_at, updated_at)
|
|
262
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
263
|
+
).run("u-default", "owner", "h", "2026-01-01", "2026-01-01");
|
|
264
|
+
const row = db
|
|
265
|
+
.query<{ password_changed: number; assigned_vault: string | null }, [string]>(
|
|
266
|
+
"SELECT password_changed, assigned_vault FROM users WHERE id = ?",
|
|
267
|
+
)
|
|
268
|
+
.get("u-default");
|
|
269
|
+
expect(row?.password_changed).toBe(0);
|
|
270
|
+
expect(row?.assigned_vault).toBeNull();
|
|
271
|
+
} finally {
|
|
272
|
+
db.close();
|
|
273
|
+
}
|
|
274
|
+
} finally {
|
|
275
|
+
h.cleanup();
|
|
276
|
+
}
|
|
277
|
+
});
|
|
153
278
|
});
|
|
@@ -165,6 +165,29 @@ describe("hubFetch routing", () => {
|
|
|
165
165
|
}
|
|
166
166
|
});
|
|
167
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
|
+
|
|
168
191
|
test("OPTIONS preflight on /.well-known/parachute.json returns 204 + CORS", async () => {
|
|
169
192
|
const h = makeHarness();
|
|
170
193
|
try {
|
|
@@ -1010,16 +1033,17 @@ describe("hubFetch routing", () => {
|
|
|
1010
1033
|
}
|
|
1011
1034
|
});
|
|
1012
1035
|
|
|
1013
|
-
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 () => {
|
|
1014
1037
|
const h = makeHarness();
|
|
1015
1038
|
try {
|
|
1016
1039
|
const db = openHubDb(hubDbPath(h.dir));
|
|
1017
1040
|
try {
|
|
1018
1041
|
await createUser(db, "owner", "pw");
|
|
1019
|
-
// Seed the vault entry so the wizard's
|
|
1020
|
-
// and the GET 301s. Without
|
|
1021
|
-
//
|
|
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.
|
|
1022
1045
|
const { writeManifest } = await import("../services-manifest.ts");
|
|
1046
|
+
const { setSetting } = await import("../hub-settings.ts");
|
|
1023
1047
|
const { join } = await import("node:path");
|
|
1024
1048
|
writeManifest(
|
|
1025
1049
|
{
|
|
@@ -1035,6 +1059,7 @@ describe("hubFetch routing", () => {
|
|
|
1035
1059
|
},
|
|
1036
1060
|
join(h.dir, "services.json"),
|
|
1037
1061
|
);
|
|
1062
|
+
setSetting(db, "setup_expose_mode", "localhost");
|
|
1038
1063
|
const res = await hubFetch(h.dir, {
|
|
1039
1064
|
getDb: () => db,
|
|
1040
1065
|
manifestPath: join(h.dir, "services.json"),
|
|
@@ -0,0 +1,377 @@
|
|
|
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
|
+
DEFAULT_MODULE_INSTALL_CHANNEL,
|
|
15
|
+
FIRST_CLIENT_AUTO_APPROVE_WINDOW_MS,
|
|
16
|
+
MODULE_INSTALL_CHANNELS,
|
|
17
|
+
PARACHUTE_MODULE_CHANNEL_ENV,
|
|
18
|
+
SETUP_EXPOSE_MODES,
|
|
19
|
+
consumeFirstClientAutoApproveWindow,
|
|
20
|
+
deleteSetting,
|
|
21
|
+
getModuleInstallChannel,
|
|
22
|
+
getSetting,
|
|
23
|
+
isFirstClientAutoApproveWindowOpen,
|
|
24
|
+
isModuleInstallChannel,
|
|
25
|
+
isSetupExposeMode,
|
|
26
|
+
openFirstClientAutoApproveWindow,
|
|
27
|
+
setModuleInstallChannel,
|
|
28
|
+
setSetting,
|
|
29
|
+
} from "../hub-settings.ts";
|
|
30
|
+
|
|
31
|
+
describe("hub-settings — bare KV", () => {
|
|
32
|
+
let dir: string;
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
dir = mkdtempSync(join(tmpdir(), "hub-settings-"));
|
|
35
|
+
});
|
|
36
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
37
|
+
|
|
38
|
+
test("getSetting returns undefined for an absent key", () => {
|
|
39
|
+
const db = openHubDb(hubDbPath(dir));
|
|
40
|
+
try {
|
|
41
|
+
expect(getSetting(db, "setup_expose_mode")).toBeUndefined();
|
|
42
|
+
} finally {
|
|
43
|
+
db.close();
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("setSetting writes a value getSetting reads back", () => {
|
|
48
|
+
const db = openHubDb(hubDbPath(dir));
|
|
49
|
+
try {
|
|
50
|
+
setSetting(db, "setup_expose_mode", "tailnet");
|
|
51
|
+
expect(getSetting(db, "setup_expose_mode")).toBe("tailnet");
|
|
52
|
+
} finally {
|
|
53
|
+
db.close();
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("setSetting overwrites an existing value (UPSERT)", () => {
|
|
58
|
+
const db = openHubDb(hubDbPath(dir));
|
|
59
|
+
try {
|
|
60
|
+
setSetting(db, "setup_expose_mode", "tailnet");
|
|
61
|
+
setSetting(db, "setup_expose_mode", "public");
|
|
62
|
+
expect(getSetting(db, "setup_expose_mode")).toBe("public");
|
|
63
|
+
} finally {
|
|
64
|
+
db.close();
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("deleteSetting removes a row + idempotently no-ops on a missing key", () => {
|
|
69
|
+
const db = openHubDb(hubDbPath(dir));
|
|
70
|
+
try {
|
|
71
|
+
setSetting(db, "setup_expose_mode", "localhost");
|
|
72
|
+
deleteSetting(db, "setup_expose_mode");
|
|
73
|
+
expect(getSetting(db, "setup_expose_mode")).toBeUndefined();
|
|
74
|
+
// Second delete is a no-op.
|
|
75
|
+
deleteSetting(db, "setup_expose_mode");
|
|
76
|
+
expect(getSetting(db, "setup_expose_mode")).toBeUndefined();
|
|
77
|
+
} finally {
|
|
78
|
+
db.close();
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("setSetting updates the updated_at column on every write", () => {
|
|
83
|
+
const db = openHubDb(hubDbPath(dir));
|
|
84
|
+
try {
|
|
85
|
+
const t1 = new Date("2026-05-19T00:00:00.000Z");
|
|
86
|
+
const t2 = new Date("2026-05-19T00:01:00.000Z");
|
|
87
|
+
setSetting(db, "setup_expose_mode", "localhost", () => t1);
|
|
88
|
+
setSetting(db, "setup_expose_mode", "localhost", () => t2);
|
|
89
|
+
// Re-write with the same value still bumps updated_at — useful
|
|
90
|
+
// for operational polling that wants to distinguish stale vs
|
|
91
|
+
// fresh state.
|
|
92
|
+
const row = db
|
|
93
|
+
.query<{ updated_at: string }, []>(
|
|
94
|
+
"SELECT updated_at FROM hub_settings WHERE key = 'setup_expose_mode'",
|
|
95
|
+
)
|
|
96
|
+
.get();
|
|
97
|
+
expect(row?.updated_at).toBe(t2.toISOString());
|
|
98
|
+
} finally {
|
|
99
|
+
db.close();
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("hub-settings — isSetupExposeMode", () => {
|
|
105
|
+
test("accepts the three canonical values", () => {
|
|
106
|
+
for (const m of SETUP_EXPOSE_MODES) {
|
|
107
|
+
expect(isSetupExposeMode(m)).toBe(true);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("rejects anything else (typos, empty, non-string)", () => {
|
|
112
|
+
expect(isSetupExposeMode("local")).toBe(false);
|
|
113
|
+
expect(isSetupExposeMode("LOCALHOST")).toBe(false);
|
|
114
|
+
expect(isSetupExposeMode("")).toBe(false);
|
|
115
|
+
expect(isSetupExposeMode(null)).toBe(false);
|
|
116
|
+
expect(isSetupExposeMode(undefined)).toBe(false);
|
|
117
|
+
expect(isSetupExposeMode(42)).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("hub-settings — first-client auto-approve window", () => {
|
|
122
|
+
let dir: string;
|
|
123
|
+
beforeEach(() => {
|
|
124
|
+
dir = mkdtempSync(join(tmpdir(), "hub-settings-"));
|
|
125
|
+
});
|
|
126
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
127
|
+
|
|
128
|
+
test("window is closed by default (no row)", () => {
|
|
129
|
+
const db = openHubDb(hubDbPath(dir));
|
|
130
|
+
try {
|
|
131
|
+
expect(isFirstClientAutoApproveWindowOpen(db)).toBe(false);
|
|
132
|
+
} finally {
|
|
133
|
+
db.close();
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("openFirstClientAutoApproveWindow opens a window 60 minutes long", () => {
|
|
138
|
+
const db = openHubDb(hubDbPath(dir));
|
|
139
|
+
try {
|
|
140
|
+
const now = new Date("2026-05-19T00:00:00.000Z");
|
|
141
|
+
openFirstClientAutoApproveWindow(db, () => now);
|
|
142
|
+
const stored = getSetting(db, "pending_first_client_auto_approve_until");
|
|
143
|
+
expect(stored).toBeDefined();
|
|
144
|
+
const parsed = new Date(stored ?? "");
|
|
145
|
+
expect(parsed.getTime() - now.getTime()).toBe(FIRST_CLIENT_AUTO_APPROVE_WINDOW_MS);
|
|
146
|
+
} finally {
|
|
147
|
+
db.close();
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("isFirstClientAutoApproveWindowOpen → true within window, false after expiry", () => {
|
|
152
|
+
const db = openHubDb(hubDbPath(dir));
|
|
153
|
+
try {
|
|
154
|
+
const t0 = new Date("2026-05-19T00:00:00.000Z");
|
|
155
|
+
openFirstClientAutoApproveWindow(db, () => t0);
|
|
156
|
+
// 30 minutes in → still open.
|
|
157
|
+
expect(
|
|
158
|
+
isFirstClientAutoApproveWindowOpen(db, () => new Date(t0.getTime() + 30 * 60 * 1000)),
|
|
159
|
+
).toBe(true);
|
|
160
|
+
// 60 minutes + 1 ms in → closed.
|
|
161
|
+
expect(
|
|
162
|
+
isFirstClientAutoApproveWindowOpen(
|
|
163
|
+
db,
|
|
164
|
+
() => new Date(t0.getTime() + FIRST_CLIENT_AUTO_APPROVE_WINDOW_MS + 1),
|
|
165
|
+
),
|
|
166
|
+
).toBe(false);
|
|
167
|
+
} finally {
|
|
168
|
+
db.close();
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("consumeFirstClientAutoApproveWindow consumes the window once, then returns false", () => {
|
|
173
|
+
const db = openHubDb(hubDbPath(dir));
|
|
174
|
+
try {
|
|
175
|
+
const now = new Date("2026-05-19T00:00:00.000Z");
|
|
176
|
+
openFirstClientAutoApproveWindow(db, () => now);
|
|
177
|
+
// First call consumes.
|
|
178
|
+
expect(consumeFirstClientAutoApproveWindow(db, () => now)).toBe(true);
|
|
179
|
+
// Second call sees no window.
|
|
180
|
+
expect(consumeFirstClientAutoApproveWindow(db, () => now)).toBe(false);
|
|
181
|
+
// The row is cleared.
|
|
182
|
+
expect(getSetting(db, "pending_first_client_auto_approve_until")).toBeUndefined();
|
|
183
|
+
} finally {
|
|
184
|
+
db.close();
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("consume returns false when the window has expired (and clears nothing)", () => {
|
|
189
|
+
const db = openHubDb(hubDbPath(dir));
|
|
190
|
+
try {
|
|
191
|
+
const t0 = new Date("2026-05-19T00:00:00.000Z");
|
|
192
|
+
openFirstClientAutoApproveWindow(db, () => t0);
|
|
193
|
+
const past = new Date(t0.getTime() + FIRST_CLIENT_AUTO_APPROVE_WINDOW_MS + 1);
|
|
194
|
+
expect(consumeFirstClientAutoApproveWindow(db, () => past)).toBe(false);
|
|
195
|
+
// Row is still there (no implicit cleanup on expiry — the setting
|
|
196
|
+
// just stops being "open"). A future open() resets it.
|
|
197
|
+
expect(getSetting(db, "pending_first_client_auto_approve_until")).toBeDefined();
|
|
198
|
+
} finally {
|
|
199
|
+
db.close();
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("malformed timestamp string is treated as closed (not parseable → not open)", () => {
|
|
204
|
+
const db = openHubDb(hubDbPath(dir));
|
|
205
|
+
try {
|
|
206
|
+
setSetting(db, "pending_first_client_auto_approve_until", "not-a-date");
|
|
207
|
+
expect(isFirstClientAutoApproveWindowOpen(db)).toBe(false);
|
|
208
|
+
expect(consumeFirstClientAutoApproveWindow(db)).toBe(false);
|
|
209
|
+
} finally {
|
|
210
|
+
db.close();
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("reopening the window after consume restarts the 60-minute clock", () => {
|
|
215
|
+
const db = openHubDb(hubDbPath(dir));
|
|
216
|
+
try {
|
|
217
|
+
const t0 = new Date("2026-05-19T00:00:00.000Z");
|
|
218
|
+
openFirstClientAutoApproveWindow(db, () => t0);
|
|
219
|
+
consumeFirstClientAutoApproveWindow(db, () => t0);
|
|
220
|
+
// Re-open at t0 + 90min.
|
|
221
|
+
const t1 = new Date(t0.getTime() + 90 * 60 * 1000);
|
|
222
|
+
openFirstClientAutoApproveWindow(db, () => t1);
|
|
223
|
+
// The new window's expiry is t1 + 60min, not t0 + 60min.
|
|
224
|
+
const stored = getSetting(db, "pending_first_client_auto_approve_until");
|
|
225
|
+
const parsed = new Date(stored ?? "");
|
|
226
|
+
expect(parsed.getTime()).toBe(t1.getTime() + FIRST_CLIENT_AUTO_APPROVE_WINDOW_MS);
|
|
227
|
+
} finally {
|
|
228
|
+
db.close();
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe("hub-settings — isModuleInstallChannel", () => {
|
|
234
|
+
test("accepts the two canonical values", () => {
|
|
235
|
+
for (const c of MODULE_INSTALL_CHANNELS) {
|
|
236
|
+
expect(isModuleInstallChannel(c)).toBe(true);
|
|
237
|
+
}
|
|
238
|
+
expect(isModuleInstallChannel("latest")).toBe(true);
|
|
239
|
+
expect(isModuleInstallChannel("rc")).toBe(true);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("rejects anything else (typos, empty, non-string, case-mismatch)", () => {
|
|
243
|
+
expect(isModuleInstallChannel("LATEST")).toBe(false);
|
|
244
|
+
expect(isModuleInstallChannel("Latest")).toBe(false);
|
|
245
|
+
expect(isModuleInstallChannel("stable")).toBe(false);
|
|
246
|
+
expect(isModuleInstallChannel("beta")).toBe(false);
|
|
247
|
+
expect(isModuleInstallChannel("")).toBe(false);
|
|
248
|
+
expect(isModuleInstallChannel(null)).toBe(false);
|
|
249
|
+
expect(isModuleInstallChannel(undefined)).toBe(false);
|
|
250
|
+
expect(isModuleInstallChannel(42)).toBe(false);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
describe("hub-settings — module install channel bootstrap", () => {
|
|
255
|
+
let dir: string;
|
|
256
|
+
beforeEach(() => {
|
|
257
|
+
dir = mkdtempSync(join(tmpdir(), "hub-settings-channel-"));
|
|
258
|
+
});
|
|
259
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
260
|
+
|
|
261
|
+
test("first read with no env + no row seeds + returns the default (latest)", () => {
|
|
262
|
+
const db = openHubDb(hubDbPath(dir));
|
|
263
|
+
try {
|
|
264
|
+
// Empty env — no PARACHUTE_MODULE_CHANNEL.
|
|
265
|
+
const channel = getModuleInstallChannel(db, { env: {} });
|
|
266
|
+
expect(channel).toBe(DEFAULT_MODULE_INSTALL_CHANNEL);
|
|
267
|
+
expect(channel).toBe("latest");
|
|
268
|
+
// The row is now seeded.
|
|
269
|
+
expect(getSetting(db, "module_install_channel")).toBe("latest");
|
|
270
|
+
} finally {
|
|
271
|
+
db.close();
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("first read with PARACHUTE_MODULE_CHANNEL=rc seeds with rc", () => {
|
|
276
|
+
const db = openHubDb(hubDbPath(dir));
|
|
277
|
+
try {
|
|
278
|
+
const channel = getModuleInstallChannel(db, {
|
|
279
|
+
env: { [PARACHUTE_MODULE_CHANNEL_ENV]: "rc" },
|
|
280
|
+
});
|
|
281
|
+
expect(channel).toBe("rc");
|
|
282
|
+
expect(getSetting(db, "module_install_channel")).toBe("rc");
|
|
283
|
+
} finally {
|
|
284
|
+
db.close();
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("first read with PARACHUTE_MODULE_CHANNEL=latest seeds with latest", () => {
|
|
289
|
+
const db = openHubDb(hubDbPath(dir));
|
|
290
|
+
try {
|
|
291
|
+
const channel = getModuleInstallChannel(db, {
|
|
292
|
+
env: { [PARACHUTE_MODULE_CHANNEL_ENV]: "latest" },
|
|
293
|
+
});
|
|
294
|
+
expect(channel).toBe("latest");
|
|
295
|
+
expect(getSetting(db, "module_install_channel")).toBe("latest");
|
|
296
|
+
} finally {
|
|
297
|
+
db.close();
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("invalid PARACHUTE_MODULE_CHANNEL warns + falls back to latest", () => {
|
|
302
|
+
const db = openHubDb(hubDbPath(dir));
|
|
303
|
+
try {
|
|
304
|
+
const warns: string[] = [];
|
|
305
|
+
const channel = getModuleInstallChannel(db, {
|
|
306
|
+
env: { [PARACHUTE_MODULE_CHANNEL_ENV]: "stable" },
|
|
307
|
+
warn: (msg) => warns.push(msg),
|
|
308
|
+
});
|
|
309
|
+
expect(channel).toBe("latest");
|
|
310
|
+
expect(getSetting(db, "module_install_channel")).toBe("latest");
|
|
311
|
+
expect(warns.length).toBe(1);
|
|
312
|
+
expect(warns[0]).toMatch(/PARACHUTE_MODULE_CHANNEL="stable"/);
|
|
313
|
+
expect(warns[0]).toMatch(/not a valid channel/);
|
|
314
|
+
} finally {
|
|
315
|
+
db.close();
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("empty PARACHUTE_MODULE_CHANNEL is treated as unset (no warn)", () => {
|
|
320
|
+
const db = openHubDb(hubDbPath(dir));
|
|
321
|
+
try {
|
|
322
|
+
const warns: string[] = [];
|
|
323
|
+
const channel = getModuleInstallChannel(db, {
|
|
324
|
+
env: { [PARACHUTE_MODULE_CHANNEL_ENV]: "" },
|
|
325
|
+
warn: (msg) => warns.push(msg),
|
|
326
|
+
});
|
|
327
|
+
expect(channel).toBe("latest");
|
|
328
|
+
expect(warns).toEqual([]);
|
|
329
|
+
} finally {
|
|
330
|
+
db.close();
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("once seeded, env var is ignored on subsequent reads", () => {
|
|
335
|
+
const db = openHubDb(hubDbPath(dir));
|
|
336
|
+
try {
|
|
337
|
+
// First read seeds with rc.
|
|
338
|
+
getModuleInstallChannel(db, { env: { [PARACHUTE_MODULE_CHANNEL_ENV]: "rc" } });
|
|
339
|
+
// Second read with a different env value still returns the seeded value.
|
|
340
|
+
const channel = getModuleInstallChannel(db, {
|
|
341
|
+
env: { [PARACHUTE_MODULE_CHANNEL_ENV]: "latest" },
|
|
342
|
+
});
|
|
343
|
+
expect(channel).toBe("rc");
|
|
344
|
+
// And with no env at all.
|
|
345
|
+
expect(getModuleInstallChannel(db, { env: {} })).toBe("rc");
|
|
346
|
+
} finally {
|
|
347
|
+
db.close();
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("setModuleInstallChannel persists the new value, subsequent reads return it", () => {
|
|
352
|
+
const db = openHubDb(hubDbPath(dir));
|
|
353
|
+
try {
|
|
354
|
+
// Seed with rc via env.
|
|
355
|
+
getModuleInstallChannel(db, { env: { [PARACHUTE_MODULE_CHANNEL_ENV]: "rc" } });
|
|
356
|
+
// Admin toggles to latest.
|
|
357
|
+
setModuleInstallChannel(db, "latest");
|
|
358
|
+
expect(getModuleInstallChannel(db, { env: {} })).toBe("latest");
|
|
359
|
+
// And back to rc — no env needed.
|
|
360
|
+
setModuleInstallChannel(db, "rc");
|
|
361
|
+
expect(getModuleInstallChannel(db, { env: {} })).toBe("rc");
|
|
362
|
+
} finally {
|
|
363
|
+
db.close();
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("corrupted row falls back to latest silently (no throw)", () => {
|
|
368
|
+
const db = openHubDb(hubDbPath(dir));
|
|
369
|
+
try {
|
|
370
|
+
// Simulate a manual sqlite edit / schema drift / external write.
|
|
371
|
+
setSetting(db, "module_install_channel", "bogus");
|
|
372
|
+
expect(getModuleInstallChannel(db, { env: {} })).toBe("latest");
|
|
373
|
+
} finally {
|
|
374
|
+
db.close();
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
});
|
|
@@ -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.
|
|
@@ -121,6 +121,65 @@ describe("signAccessToken", () => {
|
|
|
121
121
|
cleanup();
|
|
122
122
|
}
|
|
123
123
|
});
|
|
124
|
+
|
|
125
|
+
// Multi-user Phase 1, PR 4 (design 2026-05-20-multi-user-phase-1.md):
|
|
126
|
+
// the `vault_scope` claim is emitted unconditionally so a downstream
|
|
127
|
+
// consumer (PR 5's scope-guard) doesn't have to distinguish "absent" from
|
|
128
|
+
// "empty." Callers pass `[]` for admin / unpinned, `[<assigned_vault>]`
|
|
129
|
+
// for non-admin pinned users.
|
|
130
|
+
test("vault_scope=[] is emitted as the empty-array claim", async () => {
|
|
131
|
+
const { db, cleanup } = makeDb();
|
|
132
|
+
try {
|
|
133
|
+
const { token } = await signAccessToken(db, {
|
|
134
|
+
sub: "admin-aaron",
|
|
135
|
+
scopes: ["vault:default:read"],
|
|
136
|
+
audience: "vault.default",
|
|
137
|
+
clientId: "c",
|
|
138
|
+
issuer: "https://hub.example",
|
|
139
|
+
vaultScope: [],
|
|
140
|
+
});
|
|
141
|
+
const payload = decodeJwt(token);
|
|
142
|
+
expect(payload.vault_scope).toEqual([]);
|
|
143
|
+
} finally {
|
|
144
|
+
cleanup();
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("vault_scope=['bob'] is emitted as the single-element claim", async () => {
|
|
149
|
+
const { db, cleanup } = makeDb();
|
|
150
|
+
try {
|
|
151
|
+
const { token } = await signAccessToken(db, {
|
|
152
|
+
sub: "bob",
|
|
153
|
+
scopes: ["vault:bob:read"],
|
|
154
|
+
audience: "vault.bob",
|
|
155
|
+
clientId: "c",
|
|
156
|
+
issuer: "https://hub.example",
|
|
157
|
+
vaultScope: ["bob"],
|
|
158
|
+
});
|
|
159
|
+
const payload = decodeJwt(token);
|
|
160
|
+
expect(payload.vault_scope).toEqual(["bob"]);
|
|
161
|
+
} finally {
|
|
162
|
+
cleanup();
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("vault_scope defaults to [] when caller omits the field (back-compat sentinel)", async () => {
|
|
167
|
+
const { db, cleanup } = makeDb();
|
|
168
|
+
try {
|
|
169
|
+
const { token } = await signAccessToken(db, {
|
|
170
|
+
sub: "operator",
|
|
171
|
+
scopes: ["parachute:host:admin"],
|
|
172
|
+
audience: "hub",
|
|
173
|
+
clientId: "c",
|
|
174
|
+
issuer: "https://hub.example",
|
|
175
|
+
});
|
|
176
|
+
const payload = decodeJwt(token);
|
|
177
|
+
// Claim is present (not undefined), set to the empty-array sentinel.
|
|
178
|
+
expect(payload.vault_scope).toEqual([]);
|
|
179
|
+
} finally {
|
|
180
|
+
cleanup();
|
|
181
|
+
}
|
|
182
|
+
});
|
|
124
183
|
});
|
|
125
184
|
|
|
126
185
|
describe("signRefreshToken", () => {
|