@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,10 +2,13 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import {
|
|
3
3
|
type AuthorizeFormParams,
|
|
4
4
|
escapeHtml,
|
|
5
|
+
renderApprovePending,
|
|
5
6
|
renderConsent,
|
|
6
7
|
renderError,
|
|
7
8
|
renderHiddenInputs,
|
|
8
9
|
renderLogin,
|
|
10
|
+
renderUnknownClient,
|
|
11
|
+
substituteVaultDisplay,
|
|
9
12
|
} from "../oauth-ui.ts";
|
|
10
13
|
|
|
11
14
|
const PARAMS: AuthorizeFormParams = {
|
|
@@ -232,6 +235,213 @@ describe("renderError", () => {
|
|
|
232
235
|
});
|
|
233
236
|
});
|
|
234
237
|
|
|
238
|
+
describe("renderUnknownClient", () => {
|
|
239
|
+
test("escapes the client_id into the page", () => {
|
|
240
|
+
const html = renderUnknownClient({
|
|
241
|
+
clientId: "<img src=x>",
|
|
242
|
+
selfOriginRedirectPath: null,
|
|
243
|
+
});
|
|
244
|
+
expect(html).toContain("<img src=x>");
|
|
245
|
+
expect(html).not.toContain("<img src=x>");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("renders the recovery button when selfOriginRedirectPath is set", () => {
|
|
249
|
+
const html = renderUnknownClient({
|
|
250
|
+
clientId: "stale-id",
|
|
251
|
+
selfOriginRedirectPath: "/notes/oauth/callback",
|
|
252
|
+
});
|
|
253
|
+
expect(html).toContain('id="unknown-client-reset"');
|
|
254
|
+
expect(html).toContain('data-target="/notes/oauth/callback"');
|
|
255
|
+
// The inline JS clears the Notes-side DCR cache prefix.
|
|
256
|
+
expect(html).toContain("'lens:dcr:'");
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("escapes selfOriginRedirectPath into the data-target attribute", () => {
|
|
260
|
+
const html = renderUnknownClient({
|
|
261
|
+
clientId: "id",
|
|
262
|
+
selfOriginRedirectPath: '/x"><script>alert(1)</script>',
|
|
263
|
+
});
|
|
264
|
+
expect(html).not.toContain("><script>alert(1)</script>");
|
|
265
|
+
expect(html).toContain(""");
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("omits the recovery button when selfOriginRedirectPath is null", () => {
|
|
269
|
+
const html = renderUnknownClient({
|
|
270
|
+
clientId: "stale-id",
|
|
271
|
+
selfOriginRedirectPath: null,
|
|
272
|
+
});
|
|
273
|
+
expect(html).not.toContain('id="unknown-client-reset"');
|
|
274
|
+
expect(html).not.toContain("'lens:dcr:'");
|
|
275
|
+
// Static fallback help text still surfaces.
|
|
276
|
+
expect(html).toContain("close this window");
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe("substituteVaultDisplay", () => {
|
|
281
|
+
test("undefined → leaves the scope untouched", () => {
|
|
282
|
+
expect(substituteVaultDisplay("vault:read", undefined)).toBe("vault:read");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("named vault → substitutes vault:<name>:<verb>", () => {
|
|
286
|
+
expect(substituteVaultDisplay("vault:read", "work")).toBe("vault:work:read");
|
|
287
|
+
expect(substituteVaultDisplay("vault:write", "default")).toBe("vault:default:write");
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("null → renders a <TBD> placeholder for the consent picker", () => {
|
|
291
|
+
expect(substituteVaultDisplay("vault:read", null)).toBe("vault:<TBD>:read");
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("non-vault scope passes through regardless of displayVault", () => {
|
|
295
|
+
expect(substituteVaultDisplay("scribe:transcribe", "work")).toBe("scribe:transcribe");
|
|
296
|
+
expect(substituteVaultDisplay("channel:send", null)).toBe("channel:send");
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("already-named vault scope passes through (caller specified the vault)", () => {
|
|
300
|
+
expect(substituteVaultDisplay("vault:other:read", "work")).toBe("vault:other:read");
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("vault admin (vault:admin) doesn't get narrowed — admin verb stays unnamed", () => {
|
|
304
|
+
// vault:admin is a full-vault scope; we don't narrow it the same way
|
|
305
|
+
// because per-vault admin is `vault:<name>:admin` (non-requestable) and
|
|
306
|
+
// the unnamed vault:admin form is the legacy full-vault grant. Keep the
|
|
307
|
+
// displayed shape as-is so the operator sees the scope they're
|
|
308
|
+
// consenting to literally.
|
|
309
|
+
expect(substituteVaultDisplay("vault:admin", "work")).toBe("vault:admin");
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
describe("renderConsent displayVault substitution", () => {
|
|
314
|
+
test("non-admin user (lockedVault) → consent shows vault:<assigned>:<verb>", () => {
|
|
315
|
+
const html = renderConsent({
|
|
316
|
+
params: PARAMS,
|
|
317
|
+
csrfToken: CSRF,
|
|
318
|
+
clientId: "c",
|
|
319
|
+
clientName: "App",
|
|
320
|
+
scopes: ["vault:read", "vault:write"],
|
|
321
|
+
vaultPicker: {
|
|
322
|
+
unnamedVerbs: ["read", "write"],
|
|
323
|
+
availableVaults: ["my-vault"],
|
|
324
|
+
lockedVault: "my-vault",
|
|
325
|
+
},
|
|
326
|
+
displayVault: "my-vault",
|
|
327
|
+
});
|
|
328
|
+
expect(html).toContain("vault:my-vault:read");
|
|
329
|
+
expect(html).toContain("vault:my-vault:write");
|
|
330
|
+
// Raw unnamed form (the thing this PR fixes) must NOT appear in the
|
|
331
|
+
// rendered scope-row code blocks. Scope name shows up inside
|
|
332
|
+
// `<code class="scope-name">…</code>` so check the row substring.
|
|
333
|
+
expect(html).not.toMatch(/<code class="scope-name">vault:read<\/code>/);
|
|
334
|
+
expect(html).not.toMatch(/<code class="scope-name">vault:write<\/code>/);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("admin user, single-vault hub → picker pre-checks the only vault, consent shows it", () => {
|
|
338
|
+
const html = renderConsent({
|
|
339
|
+
params: PARAMS,
|
|
340
|
+
csrfToken: CSRF,
|
|
341
|
+
clientId: "c",
|
|
342
|
+
clientName: "App",
|
|
343
|
+
scopes: ["vault:read"],
|
|
344
|
+
vaultPicker: { unnamedVerbs: ["read"], availableVaults: ["default"] },
|
|
345
|
+
displayVault: "default",
|
|
346
|
+
});
|
|
347
|
+
expect(html).toContain("vault:default:read");
|
|
348
|
+
expect(html).not.toMatch(/<code class="scope-name">vault:read<\/code>/);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("admin user, multi-vault hub → displayVault=null renders <TBD> + picker hint", () => {
|
|
352
|
+
const html = renderConsent({
|
|
353
|
+
params: PARAMS,
|
|
354
|
+
csrfToken: CSRF,
|
|
355
|
+
clientId: "c",
|
|
356
|
+
clientName: "App",
|
|
357
|
+
scopes: ["vault:read"],
|
|
358
|
+
vaultPicker: { unnamedVerbs: ["read"], availableVaults: ["work", "personal"] },
|
|
359
|
+
displayVault: null,
|
|
360
|
+
});
|
|
361
|
+
expect(html).toContain("vault:<TBD>:read");
|
|
362
|
+
expect(html).toContain("scope-pending-note");
|
|
363
|
+
expect(html).toContain("A specific vault is picked below");
|
|
364
|
+
// explainScope label still resolves via the verb-form lookup.
|
|
365
|
+
expect(html).toContain("Read your notes");
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test("displayVault undefined preserves the legacy raw form (no substitution)", () => {
|
|
369
|
+
// The existing oauth-ui.test.ts cases call renderConsent without
|
|
370
|
+
// displayVault — confirming the back-compat shape stays.
|
|
371
|
+
const html = renderConsent({
|
|
372
|
+
params: PARAMS,
|
|
373
|
+
csrfToken: CSRF,
|
|
374
|
+
clientId: "c",
|
|
375
|
+
clientName: "App",
|
|
376
|
+
scopes: ["vault:read"],
|
|
377
|
+
});
|
|
378
|
+
expect(html).toContain("vault:read");
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
describe("renderApprovePending unauthenticated CTAs", () => {
|
|
383
|
+
const COMMON = {
|
|
384
|
+
clientName: "MyApp",
|
|
385
|
+
clientId: "client-xyz",
|
|
386
|
+
redirectUris: ["https://app.example/cb"],
|
|
387
|
+
requestedScopes: ["vault:read"],
|
|
388
|
+
hubOrigin: "https://hub.example.com",
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
test("renders Sign in CTA wired to /login?next=/admin/approve-client/<id>", () => {
|
|
392
|
+
const html = renderApprovePending(COMMON);
|
|
393
|
+
expect(html).toContain("Sign in as admin to approve");
|
|
394
|
+
const expectedHref = `/login?next=${encodeURIComponent("/admin/approve-client/client-xyz")}`;
|
|
395
|
+
expect(html).toContain(`href="${expectedHref}"`);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test("renders fully-qualified shareable deep link + Copy button + clipboard JS", () => {
|
|
399
|
+
const html = renderApprovePending(COMMON);
|
|
400
|
+
expect(html).toContain("https://hub.example.com/admin/approve-client/client-xyz");
|
|
401
|
+
expect(html).toContain('id="approve-share-copy"');
|
|
402
|
+
expect(html).toContain('data-link="https://hub.example.com/admin/approve-client/client-xyz"');
|
|
403
|
+
// Inline JS uses navigator.clipboard.writeText with visual feedback.
|
|
404
|
+
expect(html).toContain("navigator.clipboard");
|
|
405
|
+
expect(html).toContain("writeText");
|
|
406
|
+
expect(html).toContain("Copied!");
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("trims a trailing slash on hubOrigin so the deep link doesn't double-slash", () => {
|
|
410
|
+
const html = renderApprovePending({ ...COMMON, hubOrigin: "https://hub.example.com/" });
|
|
411
|
+
expect(html).toContain("https://hub.example.com/admin/approve-client/client-xyz");
|
|
412
|
+
expect(html).not.toContain("https://hub.example.com//admin/");
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test("retired CLI hint does NOT appear in the unauthenticated branch", () => {
|
|
416
|
+
const html = renderApprovePending(COMMON);
|
|
417
|
+
expect(html).not.toContain("parachute auth approve-client");
|
|
418
|
+
expect(html).not.toContain("from a terminal");
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test("escapes hostile client_id into href, data-link, and the visible deep link", () => {
|
|
422
|
+
const html = renderApprovePending({
|
|
423
|
+
...COMMON,
|
|
424
|
+
clientId: "<img src=x onerror=alert(1)>",
|
|
425
|
+
});
|
|
426
|
+
expect(html).not.toContain("<img src=x");
|
|
427
|
+
// encodeURIComponent in both the href and the deep-link URL produces
|
|
428
|
+
// %3Cimg…; that lives inside escapeHtml-wrapped attribute values.
|
|
429
|
+
expect(html).toContain("%3Cimg");
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("admin-authenticated branch hides the unauth CTAs and renders the inline approve form", () => {
|
|
433
|
+
const html = renderApprovePending({
|
|
434
|
+
...COMMON,
|
|
435
|
+
approveForm: { csrfToken: CSRF, returnTo: "/oauth/authorize?client_id=client-xyz" },
|
|
436
|
+
});
|
|
437
|
+
expect(html).toContain('action="/oauth/authorize/approve"');
|
|
438
|
+
expect(html).toContain("Approve and continue");
|
|
439
|
+
expect(html).not.toContain("Sign in as admin to approve");
|
|
440
|
+
expect(html).not.toContain("Or send this link to your hub admin");
|
|
441
|
+
expect(html).not.toContain("parachute auth approve-client");
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
|
|
235
445
|
describe("CSS / styling guarantees", () => {
|
|
236
446
|
test("does not load fonts from a third-party CDN (privacy)", () => {
|
|
237
447
|
const html = renderLogin({ params: PARAMS, csrfToken: CSRF });
|
|
@@ -41,6 +41,29 @@ describe("explainScope", () => {
|
|
|
41
41
|
test("returns null for an unknown scope", () => {
|
|
42
42
|
expect(explainScope("notes:weird-thing")).toBeNull();
|
|
43
43
|
});
|
|
44
|
+
|
|
45
|
+
// Approval-UX rc.19: the consent screen renders the *resolved* scope
|
|
46
|
+
// shape (`vault:<name>:read`) rather than the raw OAuth request
|
|
47
|
+
// (`vault:read`) so the operator sees the form the token will carry.
|
|
48
|
+
// explainScope falls back to the unnamed verb's entry for both the
|
|
49
|
+
// narrowed (`vault:work:read`) and wildcard-display (`vault:*:read`)
|
|
50
|
+
// forms so the consent UI keeps the same label + level styling.
|
|
51
|
+
test("named vault scopes (vault:<name>:<verb>) reuse the unnamed-verb explanation", () => {
|
|
52
|
+
expect(explainScope("vault:work:read")?.label).toBe(SCOPE_EXPLANATIONS["vault:read"]?.label);
|
|
53
|
+
expect(explainScope("vault:work:read")?.level).toBe("read");
|
|
54
|
+
expect(explainScope("vault:my-techne_2:write")?.level).toBe("write");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("wildcard vault display form (vault:*:<verb>) explains via the unnamed verb", () => {
|
|
58
|
+
expect(explainScope("vault:*:read")?.level).toBe("read");
|
|
59
|
+
expect(explainScope("vault:*:write")?.level).toBe("write");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("doesn't promote a per-vault admin (vault:<name>:admin) into an explained scope", () => {
|
|
63
|
+
// vault:<name>:admin is NON_REQUESTABLE — never appears on the consent
|
|
64
|
+
// screen. Explicitly not in the verb-pattern, so explainScope returns null.
|
|
65
|
+
expect(explainScope("vault:default:admin")).toBeNull();
|
|
66
|
+
});
|
|
44
67
|
});
|
|
45
68
|
|
|
46
69
|
describe("scopeIsAdmin", () => {
|
|
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { seedInitialAdminIfNeeded } from "../commands/serve.ts";
|
|
6
6
|
import { openHubDb } from "../hub-db.ts";
|
|
7
|
-
import { userCount } from "../users.ts";
|
|
7
|
+
import { getUserByUsername, userCount } from "../users.ts";
|
|
8
8
|
|
|
9
9
|
describe("seedInitialAdminIfNeeded", () => {
|
|
10
10
|
let dir: string;
|
|
@@ -44,6 +44,13 @@ describe("seedInitialAdminIfNeeded", () => {
|
|
|
44
44
|
expect(log).toHaveBeenCalledTimes(1);
|
|
45
45
|
expect(log.mock.calls[0]?.[0] ?? "").toContain("seeded initial admin");
|
|
46
46
|
expect(log.mock.calls[0]?.[0] ?? "").toContain("ops");
|
|
47
|
+
// Multi-user Phase 1 (design 2026-05-20-multi-user-phase-1.md §wizard
|
|
48
|
+
// interaction): env-seeded admin chose their password via env vars, so
|
|
49
|
+
// skip the force-change-password redirect. `assignedVault` stays null
|
|
50
|
+
// — admin posture.
|
|
51
|
+
const seeded = getUserByUsername(db, "ops");
|
|
52
|
+
expect(seeded?.passwordChanged).toBe(true);
|
|
53
|
+
expect(seeded?.assignedVault).toBeNull();
|
|
47
54
|
});
|
|
48
55
|
|
|
49
56
|
test("returns 'exists' when an admin already exists, even with env vars set", async () => {
|