@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.
- 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 +74 -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-settings.test.ts +152 -0
- package/src/__tests__/jwt-sign.test.ts +59 -0
- package/src/__tests__/oauth-handlers.test.ts +912 -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 +216 -3
- package/src/__tests__/users.test.ts +196 -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 +15 -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 +30 -0
- package/src/hub-server.ts +138 -18
- package/src/hub-settings.ts +98 -1
- package/src/jwt-sign.ts +17 -1
- package/src/oauth-handlers.ts +237 -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 +134 -16
- package/src/users.ts +210 -3
- 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 () => {
|
|
@@ -37,7 +37,7 @@ import {
|
|
|
37
37
|
handleSetupVaultPost,
|
|
38
38
|
} from "../setup-wizard.ts";
|
|
39
39
|
import { Supervisor } from "../supervisor.ts";
|
|
40
|
-
import { createUser, userCount } from "../users.ts";
|
|
40
|
+
import { createUser, getUserByUsername, userCount } from "../users.ts";
|
|
41
41
|
|
|
42
42
|
interface Harness {
|
|
43
43
|
dir: string;
|
|
@@ -577,6 +577,13 @@ describe("handleSetupAccountPost", () => {
|
|
|
577
577
|
const sessionCookie = setCookie(post, SESSION_COOKIE_NAME);
|
|
578
578
|
expect(sessionCookie).toBeDefined();
|
|
579
579
|
expect(userCount(db)).toBe(1);
|
|
580
|
+
// Multi-user Phase 1: the wizard's first admin chose their password
|
|
581
|
+
// via this very form, so skip the force-change-password redirect on
|
|
582
|
+
// first sign-in (`password_changed=1`). `assigned_vault` stays NULL
|
|
583
|
+
// — admin posture (no per-vault restriction).
|
|
584
|
+
const created = getUserByUsername(db, "ops");
|
|
585
|
+
expect(created?.passwordChanged).toBe(true);
|
|
586
|
+
expect(created?.assignedVault).toBeNull();
|
|
580
587
|
} finally {
|
|
581
588
|
db.close();
|
|
582
589
|
}
|
|
@@ -1335,9 +1342,28 @@ describe("done screen auto-minted token (hub#272 Item A)", () => {
|
|
|
1335
1342
|
);
|
|
1336
1343
|
expect(res.status).toBe(200);
|
|
1337
1344
|
const html = await res.text();
|
|
1338
|
-
|
|
1339
|
-
|
|
1345
|
+
// Real token rides in the hidden script-tag stash as JSON-encoded
|
|
1346
|
+
// text — script element content is raw-text per the HTML spec
|
|
1347
|
+
// (entities aren't parsed), so JSON encoding round-trips through
|
|
1348
|
+
// textContent + JSON.parse without `"` polluting the copied
|
|
1349
|
+
// command. Verify the JSON-encoded form appears in the document.
|
|
1350
|
+
expect(html).toContain(
|
|
1351
|
+
'"claude mcp add --transport http parachute-default https://hub.example/vault/default/mcp --header \\"Authorization: Bearer test-jwt-token-abc\\""',
|
|
1352
|
+
);
|
|
1340
1353
|
expect(html).toContain('id="mcp-cmd"');
|
|
1354
|
+
expect(html).toContain('id="mcp-cmd-real"');
|
|
1355
|
+
// The hidden stash is `<script type="application/json">` so the
|
|
1356
|
+
// browser doesn't execute it but textContent is still readable.
|
|
1357
|
+
expect(html).toContain('<script type="application/json" id="mcp-cmd-real">');
|
|
1358
|
+
// The visible default state is masked: the <pre> body is wrapped
|
|
1359
|
+
// with data-state="masked" and renders • placeholder characters
|
|
1360
|
+
// rather than the live token. Verified by the masked Bearer
|
|
1361
|
+
// header substring (• repeated).
|
|
1362
|
+
expect(html).toContain('data-state="masked"');
|
|
1363
|
+
expect(html).toMatch(/Bearer •+/);
|
|
1364
|
+
// Show button + Copy button both present.
|
|
1365
|
+
expect(html).toContain('id="mcp-cmd-show"');
|
|
1366
|
+
expect(html).toContain('id="mcp-cmd-copy"');
|
|
1341
1367
|
expect(html).toContain("/admin/tokens");
|
|
1342
1368
|
// The token is single-use — consumed on first render.
|
|
1343
1369
|
expect(getSetting(db, "setup_minted_token")).toBeUndefined();
|
|
@@ -1439,6 +1465,193 @@ describe("done screen auto-minted token (hub#272 Item A)", () => {
|
|
|
1439
1465
|
}
|
|
1440
1466
|
});
|
|
1441
1467
|
|
|
1468
|
+
// rc.11 — token visible by default on the done screen was a
|
|
1469
|
+
// shoulder-surf hazard. The fix: render the visible command with
|
|
1470
|
+
// a masked Bearer token, stash the real command in a
|
|
1471
|
+
// hidden script tag, and surface a Show button + Copy button. Copy
|
|
1472
|
+
// ALWAYS pulls the real command from the script tag so the
|
|
1473
|
+
// operator's terminal paste never breaks regardless of mask state.
|
|
1474
|
+
test("done screen masks the Bearer token in the visible <pre> by default", async () => {
|
|
1475
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1476
|
+
try {
|
|
1477
|
+
const user = await createUser(db, "owner", "pw");
|
|
1478
|
+
writeManifest(
|
|
1479
|
+
{
|
|
1480
|
+
services: [
|
|
1481
|
+
{
|
|
1482
|
+
name: "parachute-vault",
|
|
1483
|
+
version: "0.1.0",
|
|
1484
|
+
port: 1940,
|
|
1485
|
+
paths: ["/vault/default"],
|
|
1486
|
+
health: "/health",
|
|
1487
|
+
},
|
|
1488
|
+
],
|
|
1489
|
+
},
|
|
1490
|
+
h.manifestPath,
|
|
1491
|
+
);
|
|
1492
|
+
setSetting(db, "setup_expose_mode", "localhost");
|
|
1493
|
+
setSetting(db, "setup_minted_token", "pvt_super_secret_token_payload");
|
|
1494
|
+
const { createSession } = await import("../sessions.ts");
|
|
1495
|
+
const session = createSession(db, { userId: user.id });
|
|
1496
|
+
const res = handleSetupGet(
|
|
1497
|
+
req("/admin/setup?just_finished=1", {
|
|
1498
|
+
headers: { cookie: `${SESSION_COOKIE_NAME}=${session.id}` },
|
|
1499
|
+
}),
|
|
1500
|
+
{
|
|
1501
|
+
db,
|
|
1502
|
+
manifestPath: h.manifestPath,
|
|
1503
|
+
configDir: h.dir,
|
|
1504
|
+
issuer: "https://hub.example",
|
|
1505
|
+
registry: getDefaultOperationsRegistry(),
|
|
1506
|
+
},
|
|
1507
|
+
);
|
|
1508
|
+
const html = await res.text();
|
|
1509
|
+
// Extract the visible <pre id="mcp-cmd"> text only — the masked
|
|
1510
|
+
// shape must live there, with no occurrence of the literal token
|
|
1511
|
+
// string. The real token still appears elsewhere (the hidden
|
|
1512
|
+
// script tag) so a plain `toContain` would miss the leak.
|
|
1513
|
+
const preMatch = html.match(/<pre id="mcp-cmd">([^<]*)<\/pre>/);
|
|
1514
|
+
expect(preMatch).not.toBeNull();
|
|
1515
|
+
const preBody = preMatch?.[1] ?? "";
|
|
1516
|
+
expect(preBody).not.toContain("pvt_super_secret_token_payload");
|
|
1517
|
+
// Masked Bearer header is present in the <pre> text.
|
|
1518
|
+
expect(preBody).toMatch(/Bearer •+/);
|
|
1519
|
+
// Real command still in the document (hidden JSON stash) so the
|
|
1520
|
+
// Copy handler can read it.
|
|
1521
|
+
expect(html).toContain('<script type="application/json" id="mcp-cmd-real">');
|
|
1522
|
+
expect(html).toContain("pvt_super_secret_token_payload");
|
|
1523
|
+
// Default state is masked.
|
|
1524
|
+
expect(html).toContain('data-state="masked"');
|
|
1525
|
+
} finally {
|
|
1526
|
+
db.close();
|
|
1527
|
+
}
|
|
1528
|
+
});
|
|
1529
|
+
|
|
1530
|
+
test("done screen JSON-encodes the stashed command so `</script>` in a token can't break out", async () => {
|
|
1531
|
+
// Defense-in-depth: an attacker-shaped token containing `</script>`
|
|
1532
|
+
// would prematurely close the stash tag if we just dropped it into
|
|
1533
|
+
// the HTML. The renderer JSON-encodes the command AND replaces
|
|
1534
|
+
// `</` with `<\/` inside the encoded string so the sequence can't
|
|
1535
|
+
// appear in the document. Decode round-trips via JSON.parse.
|
|
1536
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1537
|
+
try {
|
|
1538
|
+
const user = await createUser(db, "owner", "pw");
|
|
1539
|
+
writeManifest(
|
|
1540
|
+
{
|
|
1541
|
+
services: [
|
|
1542
|
+
{
|
|
1543
|
+
name: "parachute-vault",
|
|
1544
|
+
version: "0.1.0",
|
|
1545
|
+
port: 1940,
|
|
1546
|
+
paths: ["/vault/default"],
|
|
1547
|
+
health: "/health",
|
|
1548
|
+
},
|
|
1549
|
+
],
|
|
1550
|
+
},
|
|
1551
|
+
h.manifestPath,
|
|
1552
|
+
);
|
|
1553
|
+
setSetting(db, "setup_expose_mode", "localhost");
|
|
1554
|
+
// Token contains characters that would be load-bearing in the
|
|
1555
|
+
// HTML/JS layer if mis-encoded: a quote (would close the JSON
|
|
1556
|
+
// string) and `</script>` (would close the stash tag).
|
|
1557
|
+
const hostileToken = `weird-token-with-"-and-</script>-inside`;
|
|
1558
|
+
setSetting(db, "setup_minted_token", hostileToken);
|
|
1559
|
+
const { createSession } = await import("../sessions.ts");
|
|
1560
|
+
const session = createSession(db, { userId: user.id });
|
|
1561
|
+
const res = handleSetupGet(
|
|
1562
|
+
req("/admin/setup?just_finished=1", {
|
|
1563
|
+
headers: { cookie: `${SESSION_COOKIE_NAME}=${session.id}` },
|
|
1564
|
+
}),
|
|
1565
|
+
{
|
|
1566
|
+
db,
|
|
1567
|
+
manifestPath: h.manifestPath,
|
|
1568
|
+
configDir: h.dir,
|
|
1569
|
+
issuer: "https://hub.example",
|
|
1570
|
+
registry: getDefaultOperationsRegistry(),
|
|
1571
|
+
},
|
|
1572
|
+
);
|
|
1573
|
+
const html = await res.text();
|
|
1574
|
+
// `</script>` must NOT appear inside the stash element. We
|
|
1575
|
+
// verify by extracting the stash text via the literal HTML
|
|
1576
|
+
// boundaries and asserting no close-tag escape escaped the
|
|
1577
|
+
// encoder.
|
|
1578
|
+
const stashMatch = html.match(
|
|
1579
|
+
/<script type="application\/json" id="mcp-cmd-real">([\s\S]*?)<\/script>/,
|
|
1580
|
+
);
|
|
1581
|
+
expect(stashMatch).not.toBeNull();
|
|
1582
|
+
const stashBody = stashMatch?.[1] ?? "";
|
|
1583
|
+
// The encoder replaces `</` with `<\/` inside the JSON, so the
|
|
1584
|
+
// raw bytes between the opening and the first `</script>` should
|
|
1585
|
+
// not contain `</`.
|
|
1586
|
+
expect(stashBody).not.toContain("</");
|
|
1587
|
+
// Round-trips: `<\/` decodes back to `</` after JSON.parse +
|
|
1588
|
+
// the script-end-sequence escape — the operator's clipboard
|
|
1589
|
+
// gets the original bytes.
|
|
1590
|
+
const decoded = JSON.parse(stashBody) as string;
|
|
1591
|
+
expect(decoded).toContain(hostileToken);
|
|
1592
|
+
} finally {
|
|
1593
|
+
db.close();
|
|
1594
|
+
}
|
|
1595
|
+
});
|
|
1596
|
+
|
|
1597
|
+
test("done screen wires Show + Copy buttons that read from the hidden real-command stash", async () => {
|
|
1598
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1599
|
+
try {
|
|
1600
|
+
const user = await createUser(db, "owner", "pw");
|
|
1601
|
+
writeManifest(
|
|
1602
|
+
{
|
|
1603
|
+
services: [
|
|
1604
|
+
{
|
|
1605
|
+
name: "parachute-vault",
|
|
1606
|
+
version: "0.1.0",
|
|
1607
|
+
port: 1940,
|
|
1608
|
+
paths: ["/vault/default"],
|
|
1609
|
+
health: "/health",
|
|
1610
|
+
},
|
|
1611
|
+
],
|
|
1612
|
+
},
|
|
1613
|
+
h.manifestPath,
|
|
1614
|
+
);
|
|
1615
|
+
setSetting(db, "setup_expose_mode", "localhost");
|
|
1616
|
+
setSetting(db, "setup_minted_token", "live-token-AAA");
|
|
1617
|
+
const { createSession } = await import("../sessions.ts");
|
|
1618
|
+
const session = createSession(db, { userId: user.id });
|
|
1619
|
+
const res = handleSetupGet(
|
|
1620
|
+
req("/admin/setup?just_finished=1", {
|
|
1621
|
+
headers: { cookie: `${SESSION_COOKIE_NAME}=${session.id}` },
|
|
1622
|
+
}),
|
|
1623
|
+
{
|
|
1624
|
+
db,
|
|
1625
|
+
manifestPath: h.manifestPath,
|
|
1626
|
+
configDir: h.dir,
|
|
1627
|
+
issuer: "https://hub.example",
|
|
1628
|
+
registry: getDefaultOperationsRegistry(),
|
|
1629
|
+
},
|
|
1630
|
+
);
|
|
1631
|
+
const html = await res.text();
|
|
1632
|
+
// Both buttons present, both wired via addEventListener (no
|
|
1633
|
+
// inline onclick — the script runs in a single IIFE).
|
|
1634
|
+
expect(html).toContain('id="mcp-cmd-show"');
|
|
1635
|
+
expect(html).toContain('id="mcp-cmd-copy"');
|
|
1636
|
+
expect(html).toContain("'click'");
|
|
1637
|
+
// The Copy handler reads from the hidden script tag, not from
|
|
1638
|
+
// the visible <pre>. Regression: this was the load-bearing
|
|
1639
|
+
// contract Aaron called out ("Copy still works without reveal").
|
|
1640
|
+
expect(html).toContain("getElementById('mcp-cmd-real')");
|
|
1641
|
+
// The stash holds JSON-encoded text and the handler decodes via
|
|
1642
|
+
// JSON.parse so the clipboard receives the exact byte sequence of
|
|
1643
|
+
// the command — `"`-style HTML entities can't survive into
|
|
1644
|
+
// the operator's shell because script-element content is raw text
|
|
1645
|
+
// (the HTML parser doesn't decode entities inside <script>).
|
|
1646
|
+
expect(html).toContain("JSON.parse(real.textContent");
|
|
1647
|
+
// Auto-hide timer present so a stray reveal doesn't leak into a
|
|
1648
|
+
// subsequent screencast capture.
|
|
1649
|
+
expect(html).toContain("setTimeout(setMasked, 10000)");
|
|
1650
|
+
} finally {
|
|
1651
|
+
db.close();
|
|
1652
|
+
}
|
|
1653
|
+
});
|
|
1654
|
+
|
|
1442
1655
|
test("GET /admin/setup?just_finished=1 without a session does NOT consume the minted token (hub#274 security fold)", async () => {
|
|
1443
1656
|
// Regression — without the session gate, any HTTP client racing the
|
|
1444
1657
|
// operator's browser between the expose POST (which mints + stores)
|