@openparachute/hub 0.5.10-rc.9 → 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.
Files changed (44) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-handlers.test.ts +141 -6
  3. package/src/__tests__/api-account.test.ts +463 -0
  4. package/src/__tests__/api-modules-ops.test.ts +74 -0
  5. package/src/__tests__/api-modules.test.ts +134 -0
  6. package/src/__tests__/api-users.test.ts +522 -0
  7. package/src/__tests__/cors.test.ts +587 -0
  8. package/src/__tests__/hub-db.test.ts +126 -1
  9. package/src/__tests__/hub-settings.test.ts +152 -0
  10. package/src/__tests__/jwt-sign.test.ts +59 -0
  11. package/src/__tests__/oauth-handlers.test.ts +912 -10
  12. package/src/__tests__/oauth-ui.test.ts +210 -0
  13. package/src/__tests__/scope-explanations.test.ts +23 -0
  14. package/src/__tests__/serve.test.ts +8 -1
  15. package/src/__tests__/setup-wizard.test.ts +216 -3
  16. package/src/__tests__/users.test.ts +196 -0
  17. package/src/__tests__/vault-names.test.ts +172 -0
  18. package/src/account-change-password-ui.ts +379 -0
  19. package/src/admin-handlers.ts +68 -2
  20. package/src/admin-host-admin-token.ts +5 -0
  21. package/src/admin-vault-admin-token.ts +7 -0
  22. package/src/api-account.ts +443 -0
  23. package/src/api-mint-token.ts +6 -0
  24. package/src/api-modules-ops.ts +15 -6
  25. package/src/api-modules.ts +101 -0
  26. package/src/api-users.ts +393 -0
  27. package/src/commands/auth.ts +10 -1
  28. package/src/commands/serve.ts +5 -1
  29. package/src/cors.ts +263 -0
  30. package/src/hub-db.ts +30 -0
  31. package/src/hub-server.ts +138 -18
  32. package/src/hub-settings.ts +98 -1
  33. package/src/jwt-sign.ts +17 -1
  34. package/src/oauth-handlers.ts +237 -29
  35. package/src/oauth-ui.ts +451 -38
  36. package/src/operator-token.ts +4 -0
  37. package/src/scope-explanations.ts +26 -1
  38. package/src/setup-wizard.ts +134 -16
  39. package/src/users.ts +210 -3
  40. package/src/vault-names.ts +57 -0
  41. package/web/ui/dist/assets/index-XhxYXDT5.js +61 -0
  42. package/web/ui/dist/assets/{index-D54otIhv.css → index-p6DkOcsk.css} +1 -1
  43. package/web/ui/dist/index.html +2 -2
  44. 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
  });
@@ -11,14 +11,20 @@ import { tmpdir } from "node:os";
11
11
  import { join } from "node:path";
12
12
  import { hubDbPath, openHubDb } from "../hub-db.ts";
13
13
  import {
14
+ DEFAULT_MODULE_INSTALL_CHANNEL,
14
15
  FIRST_CLIENT_AUTO_APPROVE_WINDOW_MS,
16
+ MODULE_INSTALL_CHANNELS,
17
+ PARACHUTE_MODULE_CHANNEL_ENV,
15
18
  SETUP_EXPOSE_MODES,
16
19
  consumeFirstClientAutoApproveWindow,
17
20
  deleteSetting,
21
+ getModuleInstallChannel,
18
22
  getSetting,
19
23
  isFirstClientAutoApproveWindowOpen,
24
+ isModuleInstallChannel,
20
25
  isSetupExposeMode,
21
26
  openFirstClientAutoApproveWindow,
27
+ setModuleInstallChannel,
22
28
  setSetting,
23
29
  } from "../hub-settings.ts";
24
30
 
@@ -223,3 +229,149 @@ describe("hub-settings — first-client auto-approve window", () => {
223
229
  }
224
230
  });
225
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
+ });
@@ -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", () => {