@openparachute/hub 0.5.13 → 0.5.14-rc.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/README.md +109 -15
- package/package.json +2 -2
- package/src/__tests__/account-home-ui.test.ts +205 -0
- package/src/__tests__/admin-handlers.test.ts +74 -0
- package/src/__tests__/admin-host-admin-token.test.ts +62 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-account.test.ts +191 -1
- package/src/__tests__/api-mint-token.test.ts +682 -3
- package/src/__tests__/api-modules-config.test.ts +16 -10
- package/src/__tests__/api-modules-ops.test.ts +97 -0
- package/src/__tests__/api-modules.test.ts +100 -83
- package/src/__tests__/api-ready.test.ts +135 -0
- package/src/__tests__/api-revoke-token.test.ts +384 -0
- package/src/__tests__/api-users.test.ts +390 -13
- package/src/__tests__/chrome-strip.test.ts +15 -15
- package/src/__tests__/cli.test.ts +7 -5
- package/src/__tests__/cloudflare-detect.test.ts +60 -5
- package/src/__tests__/expose-auth-preflight.test.ts +58 -50
- package/src/__tests__/expose-cloudflare.test.ts +114 -3
- package/src/__tests__/expose-interactive.test.ts +10 -4
- package/src/__tests__/expose-public-auto.test.ts +5 -1
- package/src/__tests__/expose.test.ts +49 -1
- package/src/__tests__/hub-db.test.ts +194 -29
- package/src/__tests__/hub-server.test.ts +322 -33
- package/src/__tests__/hub.test.ts +11 -0
- package/src/__tests__/init.test.ts +827 -0
- package/src/__tests__/lifecycle.test.ts +33 -1
- package/src/__tests__/migrate.test.ts +433 -51
- package/src/__tests__/notes-redirect.test.ts +20 -20
- package/src/__tests__/oauth-handlers.test.ts +1060 -29
- package/src/__tests__/oauth-ui.test.ts +12 -1
- package/src/__tests__/proxy-error-ui.test.ts +212 -0
- package/src/__tests__/proxy-state.test.ts +192 -0
- package/src/__tests__/resource-binding.test.ts +97 -0
- package/src/__tests__/scope-explanations.test.ts +36 -0
- package/src/__tests__/serve.test.ts +9 -9
- package/src/__tests__/services-manifest.test.ts +40 -40
- package/src/__tests__/setup-wizard.test.ts +1114 -66
- package/src/__tests__/setup.test.ts +1 -1
- package/src/__tests__/status.test.ts +39 -0
- package/src/__tests__/users.test.ts +396 -9
- package/src/__tests__/vault-auth-status.test.ts +271 -11
- package/src/__tests__/vault-hub-origin-env.test.ts +126 -0
- package/src/__tests__/well-known.test.ts +9 -9
- package/src/__tests__/wizard.test.ts +372 -0
- package/src/account-home-ui.ts +547 -0
- package/src/admin-handlers.ts +49 -17
- package/src/admin-host-admin-token.ts +25 -0
- package/src/admin-login-ui.ts +4 -4
- package/src/admin-vault-admin-token.ts +17 -0
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +72 -6
- package/src/api-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +52 -16
- package/src/api-modules.ts +31 -14
- package/src/api-ready.ts +102 -0
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +497 -58
- package/src/bun-link.ts +55 -0
- package/src/chrome-strip.ts +6 -6
- package/src/cli.ts +93 -24
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +73 -6
- package/src/commands/expose-auth-preflight.ts +55 -63
- package/src/commands/expose-cloudflare.ts +114 -10
- package/src/commands/expose-interactive.ts +10 -11
- package/src/commands/expose-public-auto.ts +6 -4
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +563 -0
- package/src/commands/install.ts +41 -23
- package/src/commands/lifecycle.ts +12 -0
- package/src/commands/migrate.ts +293 -41
- package/src/commands/status.ts +10 -1
- package/src/commands/wizard.ts +843 -0
- package/src/env-file.ts +10 -0
- package/src/help.ts +157 -17
- package/src/hub-db.ts +42 -0
- package/src/hub-server.ts +136 -23
- package/src/hub-settings.ts +13 -2
- package/src/hub.ts +16 -9
- package/src/notes-redirect.ts +5 -5
- package/src/oauth-handlers.ts +342 -173
- package/src/oauth-ui.ts +28 -2
- package/src/proxy-error-ui.ts +506 -0
- package/src/proxy-state.ts +131 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +94 -5
- package/src/service-spec.ts +39 -18
- package/src/setup-wizard.ts +1173 -117
- package/src/users.ts +307 -29
- package/src/vault/auth-status.ts +152 -25
- package/src/vault-hub-origin-env.ts +100 -0
- package/web/ui/dist/assets/index-2SSK7JbM.js +61 -0
- package/web/ui/dist/assets/index-B28SdMSz.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
- package/src/commands/vault-tokens-create-interactive.ts +0 -143
- package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
- package/web/ui/dist/assets/index-Dzrbe6EP.js +0 -61
|
@@ -308,7 +308,7 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
|
|
|
308
308
|
const body = (await resp.json()) as { error: string; error_description: string };
|
|
309
309
|
expect(body.error).toBe("invalid_scope");
|
|
310
310
|
expect(body.error_description).toContain("parachute:host:auth");
|
|
311
|
-
expect(body.error_description).toContain("not
|
|
311
|
+
expect(body.error_description).toContain("not grantable");
|
|
312
312
|
} finally {
|
|
313
313
|
db.close();
|
|
314
314
|
}
|
|
@@ -342,19 +342,98 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
|
|
|
342
342
|
}
|
|
343
343
|
});
|
|
344
344
|
|
|
345
|
-
|
|
345
|
+
// De-escalation (PR-A): a `parachute:host:admin` bearer MAY mint a
|
|
346
|
+
// vault-pinned admin token — host:admin already implies box-wide vault
|
|
347
|
+
// administration, so narrowing it to one vault is a privilege reduction.
|
|
348
|
+
// This is the canonical headless path replacing deprecated pvt_* (vault#282).
|
|
349
|
+
test("200 when host:admin bearer mints vault:<name>:admin (de-escalation)", async () => {
|
|
346
350
|
const h = makeHarness();
|
|
347
351
|
try {
|
|
348
352
|
const { db, userId } = await bootstrap(h.dir);
|
|
349
353
|
try {
|
|
354
|
+
// The default admin operator scope-set carries parachute:host:admin.
|
|
350
355
|
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
351
356
|
const resp = await handleApiMintToken(
|
|
352
357
|
jsonRequest({ scope: "vault:work:admin" }, { authorization: `Bearer ${op.token}` }),
|
|
353
358
|
{ db, issuer: ISSUER },
|
|
354
359
|
);
|
|
360
|
+
expect(resp.status).toBe(200);
|
|
361
|
+
const body = (await resp.json()) as { jti: string; token: string; scope: string };
|
|
362
|
+
expect(body.scope).toBe("vault:work:admin");
|
|
363
|
+
const validated = await validateAccessToken(db, body.token, ISSUER);
|
|
364
|
+
// Audience must be the per-vault resource so vault's strict-equality
|
|
365
|
+
// audience check accepts it.
|
|
366
|
+
expect(validated.payload.aud).toBe("vault.work");
|
|
367
|
+
expect(validated.payload.scope).toBe("vault:work:admin");
|
|
368
|
+
// vault_scope is pinned to the named vault (defense-in-depth — the
|
|
369
|
+
// token can ONLY be used against `work`), matching the canonical
|
|
370
|
+
// session-path mint in admin-vault-admin-token.ts.
|
|
371
|
+
expect(validated.payload.vault_scope).toEqual(["work"]);
|
|
372
|
+
// Registry row written → revocable like any operator mint.
|
|
373
|
+
const row = db
|
|
374
|
+
.query<{ jti: string }, [string]>("SELECT jti FROM tokens WHERE jti = ?")
|
|
375
|
+
.get(body.jti);
|
|
376
|
+
expect(row).not.toBeNull();
|
|
377
|
+
} finally {
|
|
378
|
+
db.close();
|
|
379
|
+
}
|
|
380
|
+
} finally {
|
|
381
|
+
h.cleanup();
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
// The de-escalation exception is gated on host:admin specifically. A
|
|
386
|
+
// bearer that only holds `parachute:host:auth` (the narrow auth scope-set)
|
|
387
|
+
// can mint verb scopes but NOT vault-admin — that would be an escalation.
|
|
388
|
+
test("400 invalid_scope when auth-only bearer mints vault:<name>:admin", async () => {
|
|
389
|
+
const h = makeHarness();
|
|
390
|
+
try {
|
|
391
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
392
|
+
try {
|
|
393
|
+
const op = await mintOperatorToken(db, userId, {
|
|
394
|
+
issuer: ISSUER,
|
|
395
|
+
scopeSet: "auth",
|
|
396
|
+
});
|
|
397
|
+
const resp = await handleApiMintToken(
|
|
398
|
+
jsonRequest({ scope: "vault:work:admin" }, { authorization: `Bearer ${op.token}` }),
|
|
399
|
+
{ db, issuer: ISSUER },
|
|
400
|
+
);
|
|
355
401
|
expect(resp.status).toBe(400);
|
|
356
|
-
const body = (await resp.json()) as { error: string };
|
|
402
|
+
const body = (await resp.json()) as { error: string; error_description: string };
|
|
357
403
|
expect(body.error).toBe("invalid_scope");
|
|
404
|
+
expect(body.error_description).toContain("vault:work:admin");
|
|
405
|
+
} finally {
|
|
406
|
+
db.close();
|
|
407
|
+
}
|
|
408
|
+
} finally {
|
|
409
|
+
h.cleanup();
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
// A bare `vault:admin` (no vault name) is NOT a per-vault admin scope —
|
|
414
|
+
// the de-escalation exception only covers `vault:<name>:admin`. It isn't
|
|
415
|
+
// in the non-requestable set either, so it's treated as an ordinary
|
|
416
|
+
// (unnamed) scope and mints — but with the `vault` fallback audience, not
|
|
417
|
+
// a per-vault one. Pinned so a future regex loosening can't silently let
|
|
418
|
+
// an unnamed admin through the named-vault exemption.
|
|
419
|
+
test("bare vault:admin (no name) is not caught by the de-escalation exemption", async () => {
|
|
420
|
+
const h = makeHarness();
|
|
421
|
+
try {
|
|
422
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
423
|
+
try {
|
|
424
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
425
|
+
const resp = await handleApiMintToken(
|
|
426
|
+
jsonRequest({ scope: "vault:admin" }, { authorization: `Bearer ${op.token}` }),
|
|
427
|
+
{ db, issuer: ISSUER },
|
|
428
|
+
);
|
|
429
|
+
// `vault:admin` isn't a per-vault admin scope and isn't in the
|
|
430
|
+
// non-requestable set, so it mints as an ordinary scope. The point
|
|
431
|
+
// of this test is that it does NOT get a per-vault audience/pin.
|
|
432
|
+
expect(resp.status).toBe(200);
|
|
433
|
+
const body = (await resp.json()) as { token: string };
|
|
434
|
+
const validated = await validateAccessToken(db, body.token, ISSUER);
|
|
435
|
+
expect(validated.payload.aud).toBe("vault");
|
|
436
|
+
expect(validated.payload.vault_scope).toEqual([]);
|
|
358
437
|
} finally {
|
|
359
438
|
db.close();
|
|
360
439
|
}
|
|
@@ -363,6 +442,606 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
|
|
|
363
442
|
}
|
|
364
443
|
});
|
|
365
444
|
|
|
445
|
+
// ── Capability attenuation (hub PR — subsumes hub#449's PR-A carve-out) ──
|
|
446
|
+
//
|
|
447
|
+
// A `vault:<name>:admin` bearer may mint a token whose authority is a
|
|
448
|
+
// SUBSET of its own: same-vault read/write/admin. It can NEVER mint a
|
|
449
|
+
// cross-vault scope or any host-level authority. We hand-mint the bearer
|
|
450
|
+
// via signAccessToken (scope `vault:work:admin`, aud `vault.work`,
|
|
451
|
+
// vaultScope `["work"]`) — mirroring how the SPA / mcp-install obtain one.
|
|
452
|
+
async function mintVaultAdminBearer(
|
|
453
|
+
db: ReturnType<typeof openHubDb>,
|
|
454
|
+
userId: string,
|
|
455
|
+
vault: string,
|
|
456
|
+
): Promise<string> {
|
|
457
|
+
const signed = await signAccessToken(db, {
|
|
458
|
+
sub: userId,
|
|
459
|
+
scopes: [`vault:${vault}:admin`],
|
|
460
|
+
audience: `vault.${vault}`,
|
|
461
|
+
clientId: "parachute-hub",
|
|
462
|
+
issuer: ISSUER,
|
|
463
|
+
ttlSeconds: 3600,
|
|
464
|
+
vaultScope: [vault],
|
|
465
|
+
});
|
|
466
|
+
return signed.token;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
describe("capability attenuation — vault:<name>:admin bearer", () => {
|
|
470
|
+
test("mints vault:work:write → 200, aud=vault.work, vault_scope=[work]", async () => {
|
|
471
|
+
const h = makeHarness();
|
|
472
|
+
try {
|
|
473
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
474
|
+
try {
|
|
475
|
+
const bearer = await mintVaultAdminBearer(db, userId, "work");
|
|
476
|
+
const resp = await handleApiMintToken(
|
|
477
|
+
jsonRequest({ scope: "vault:work:write" }, { authorization: `Bearer ${bearer}` }),
|
|
478
|
+
{ db, issuer: ISSUER },
|
|
479
|
+
);
|
|
480
|
+
expect(resp.status).toBe(200);
|
|
481
|
+
const body = (await resp.json()) as { token: string };
|
|
482
|
+
const validated = await validateAccessToken(db, body.token, ISSUER);
|
|
483
|
+
expect(validated.payload.aud).toBe("vault.work");
|
|
484
|
+
expect(validated.payload.scope).toBe("vault:work:write");
|
|
485
|
+
expect(validated.payload.vault_scope).toEqual(["work"]);
|
|
486
|
+
} finally {
|
|
487
|
+
db.close();
|
|
488
|
+
}
|
|
489
|
+
} finally {
|
|
490
|
+
h.cleanup();
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
test("mints vault:work:read → 200", async () => {
|
|
495
|
+
const h = makeHarness();
|
|
496
|
+
try {
|
|
497
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
498
|
+
try {
|
|
499
|
+
const bearer = await mintVaultAdminBearer(db, userId, "work");
|
|
500
|
+
const resp = await handleApiMintToken(
|
|
501
|
+
jsonRequest({ scope: "vault:work:read" }, { authorization: `Bearer ${bearer}` }),
|
|
502
|
+
{ db, issuer: ISSUER },
|
|
503
|
+
);
|
|
504
|
+
expect(resp.status).toBe(200);
|
|
505
|
+
const body = (await resp.json()) as { token: string };
|
|
506
|
+
const validated = await validateAccessToken(db, body.token, ISSUER);
|
|
507
|
+
expect(validated.payload.vault_scope).toEqual(["work"]);
|
|
508
|
+
} finally {
|
|
509
|
+
db.close();
|
|
510
|
+
}
|
|
511
|
+
} finally {
|
|
512
|
+
h.cleanup();
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
test("mints vault:work:admin → 200 (same-level allowed)", async () => {
|
|
517
|
+
const h = makeHarness();
|
|
518
|
+
try {
|
|
519
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
520
|
+
try {
|
|
521
|
+
const bearer = await mintVaultAdminBearer(db, userId, "work");
|
|
522
|
+
const resp = await handleApiMintToken(
|
|
523
|
+
jsonRequest({ scope: "vault:work:admin" }, { authorization: `Bearer ${bearer}` }),
|
|
524
|
+
{ db, issuer: ISSUER },
|
|
525
|
+
);
|
|
526
|
+
expect(resp.status).toBe(200);
|
|
527
|
+
const body = (await resp.json()) as { token: string };
|
|
528
|
+
const validated = await validateAccessToken(db, body.token, ISSUER);
|
|
529
|
+
expect(validated.payload.aud).toBe("vault.work");
|
|
530
|
+
expect(validated.payload.scope).toBe("vault:work:admin");
|
|
531
|
+
expect(validated.payload.vault_scope).toEqual(["work"]);
|
|
532
|
+
} finally {
|
|
533
|
+
db.close();
|
|
534
|
+
}
|
|
535
|
+
} finally {
|
|
536
|
+
h.cleanup();
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
// THE CRUX: cross-vault is the security boundary. A work-admin bearer
|
|
541
|
+
// MUST NOT be able to mint authority over any other vault.
|
|
542
|
+
test("mints vault:other:write → 400 (cross-vault BLOCKED)", async () => {
|
|
543
|
+
const h = makeHarness();
|
|
544
|
+
try {
|
|
545
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
546
|
+
try {
|
|
547
|
+
const bearer = await mintVaultAdminBearer(db, userId, "work");
|
|
548
|
+
const resp = await handleApiMintToken(
|
|
549
|
+
jsonRequest({ scope: "vault:other:write" }, { authorization: `Bearer ${bearer}` }),
|
|
550
|
+
{ db, issuer: ISSUER },
|
|
551
|
+
);
|
|
552
|
+
expect(resp.status).toBe(400);
|
|
553
|
+
const body = (await resp.json()) as { error: string; error_description: string };
|
|
554
|
+
expect(body.error).toBe("invalid_scope");
|
|
555
|
+
expect(body.error_description).toContain("vault:other:write");
|
|
556
|
+
} finally {
|
|
557
|
+
db.close();
|
|
558
|
+
}
|
|
559
|
+
} finally {
|
|
560
|
+
h.cleanup();
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
test("mints vault:other:admin → 400 (cross-vault BLOCKED)", async () => {
|
|
565
|
+
const h = makeHarness();
|
|
566
|
+
try {
|
|
567
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
568
|
+
try {
|
|
569
|
+
const bearer = await mintVaultAdminBearer(db, userId, "work");
|
|
570
|
+
const resp = await handleApiMintToken(
|
|
571
|
+
jsonRequest({ scope: "vault:other:admin" }, { authorization: `Bearer ${bearer}` }),
|
|
572
|
+
{ db, issuer: ISSUER },
|
|
573
|
+
);
|
|
574
|
+
expect(resp.status).toBe(400);
|
|
575
|
+
const body = (await resp.json()) as { error: string };
|
|
576
|
+
expect(body.error).toBe("invalid_scope");
|
|
577
|
+
} finally {
|
|
578
|
+
db.close();
|
|
579
|
+
}
|
|
580
|
+
} finally {
|
|
581
|
+
h.cleanup();
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
test("mints parachute:host:auth → 400 (no escalation to host authority)", async () => {
|
|
586
|
+
const h = makeHarness();
|
|
587
|
+
try {
|
|
588
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
589
|
+
try {
|
|
590
|
+
const bearer = await mintVaultAdminBearer(db, userId, "work");
|
|
591
|
+
const resp = await handleApiMintToken(
|
|
592
|
+
jsonRequest({ scope: "parachute:host:auth" }, { authorization: `Bearer ${bearer}` }),
|
|
593
|
+
{ db, issuer: ISSUER },
|
|
594
|
+
);
|
|
595
|
+
expect(resp.status).toBe(400);
|
|
596
|
+
const body = (await resp.json()) as { error: string };
|
|
597
|
+
expect(body.error).toBe("invalid_scope");
|
|
598
|
+
} finally {
|
|
599
|
+
db.close();
|
|
600
|
+
}
|
|
601
|
+
} finally {
|
|
602
|
+
h.cleanup();
|
|
603
|
+
}
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
test("mints parachute:host:admin → 400 (no escalation to host authority)", async () => {
|
|
607
|
+
const h = makeHarness();
|
|
608
|
+
try {
|
|
609
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
610
|
+
try {
|
|
611
|
+
const bearer = await mintVaultAdminBearer(db, userId, "work");
|
|
612
|
+
const resp = await handleApiMintToken(
|
|
613
|
+
jsonRequest({ scope: "parachute:host:admin" }, { authorization: `Bearer ${bearer}` }),
|
|
614
|
+
{ db, issuer: ISSUER },
|
|
615
|
+
);
|
|
616
|
+
expect(resp.status).toBe(400);
|
|
617
|
+
const body = (await resp.json()) as { error: string };
|
|
618
|
+
expect(body.error).toBe("invalid_scope");
|
|
619
|
+
} finally {
|
|
620
|
+
db.close();
|
|
621
|
+
}
|
|
622
|
+
} finally {
|
|
623
|
+
h.cleanup();
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
// No host:auth, and scribe:transcribe is not a vault:work scope → blocked.
|
|
628
|
+
test("mints scribe:transcribe → 400 (not a vault:work scope, no host:auth)", async () => {
|
|
629
|
+
const h = makeHarness();
|
|
630
|
+
try {
|
|
631
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
632
|
+
try {
|
|
633
|
+
const bearer = await mintVaultAdminBearer(db, userId, "work");
|
|
634
|
+
const resp = await handleApiMintToken(
|
|
635
|
+
jsonRequest({ scope: "scribe:transcribe" }, { authorization: `Bearer ${bearer}` }),
|
|
636
|
+
{ db, issuer: ISSUER },
|
|
637
|
+
);
|
|
638
|
+
expect(resp.status).toBe(400);
|
|
639
|
+
const body = (await resp.json()) as { error: string };
|
|
640
|
+
expect(body.error).toBe("invalid_scope");
|
|
641
|
+
} finally {
|
|
642
|
+
db.close();
|
|
643
|
+
}
|
|
644
|
+
} finally {
|
|
645
|
+
h.cleanup();
|
|
646
|
+
}
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
test("multi-scope vault:work:read vault:work:write → 200, vault_scope=[work]", async () => {
|
|
650
|
+
const h = makeHarness();
|
|
651
|
+
try {
|
|
652
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
653
|
+
try {
|
|
654
|
+
const bearer = await mintVaultAdminBearer(db, userId, "work");
|
|
655
|
+
const resp = await handleApiMintToken(
|
|
656
|
+
jsonRequest(
|
|
657
|
+
{ scope: "vault:work:read vault:work:write" },
|
|
658
|
+
{ authorization: `Bearer ${bearer}` },
|
|
659
|
+
),
|
|
660
|
+
{ db, issuer: ISSUER },
|
|
661
|
+
);
|
|
662
|
+
expect(resp.status).toBe(200);
|
|
663
|
+
const body = (await resp.json()) as { token: string };
|
|
664
|
+
const validated = await validateAccessToken(db, body.token, ISSUER);
|
|
665
|
+
expect(validated.payload.vault_scope).toEqual(["work"]);
|
|
666
|
+
} finally {
|
|
667
|
+
db.close();
|
|
668
|
+
}
|
|
669
|
+
} finally {
|
|
670
|
+
h.cleanup();
|
|
671
|
+
}
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
// Realistic headless-runner shape: a bearer holding admin over MULTIPLE
|
|
675
|
+
// vaults composes rule 3 across them. Minting same-vault subsets of both
|
|
676
|
+
// succeeds; vault_scope collects every authorized vault name (order-
|
|
677
|
+
// insensitive). aud is first-wins (vault.work), which is fine — a
|
|
678
|
+
// multi-vault token only authenticates against its single aud, the pin is
|
|
679
|
+
// defense-in-depth.
|
|
680
|
+
test("multi-vault-admin bearer mints vault:work:read vault:other:read → 200, vault_scope=[work,other]", async () => {
|
|
681
|
+
const h = makeHarness();
|
|
682
|
+
try {
|
|
683
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
684
|
+
try {
|
|
685
|
+
const bearer = await signAccessToken(db, {
|
|
686
|
+
sub: userId,
|
|
687
|
+
scopes: ["vault:work:admin", "vault:other:admin"],
|
|
688
|
+
audience: "vault.work",
|
|
689
|
+
clientId: "parachute-hub",
|
|
690
|
+
issuer: ISSUER,
|
|
691
|
+
ttlSeconds: 3600,
|
|
692
|
+
vaultScope: ["work", "other"],
|
|
693
|
+
});
|
|
694
|
+
const resp = await handleApiMintToken(
|
|
695
|
+
jsonRequest(
|
|
696
|
+
{ scope: "vault:work:read vault:other:read" },
|
|
697
|
+
{ authorization: `Bearer ${bearer.token}` },
|
|
698
|
+
),
|
|
699
|
+
{ db, issuer: ISSUER },
|
|
700
|
+
);
|
|
701
|
+
expect(resp.status).toBe(200);
|
|
702
|
+
const body = (await resp.json()) as { token: string };
|
|
703
|
+
const validated = await validateAccessToken(db, body.token, ISSUER);
|
|
704
|
+
expect(validated.payload.aud).toBe("vault.work");
|
|
705
|
+
const pin = validated.payload.vault_scope as string[];
|
|
706
|
+
expect(pin).toContain("work");
|
|
707
|
+
expect(pin).toContain("other");
|
|
708
|
+
expect(pin).toHaveLength(2);
|
|
709
|
+
} finally {
|
|
710
|
+
db.close();
|
|
711
|
+
}
|
|
712
|
+
} finally {
|
|
713
|
+
h.cleanup();
|
|
714
|
+
}
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
// One blocked scope rejects the whole request (no partial mint).
|
|
718
|
+
test("multi-scope vault:work:read vault:other:read → 400 (one blocked → all rejected)", async () => {
|
|
719
|
+
const h = makeHarness();
|
|
720
|
+
try {
|
|
721
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
722
|
+
try {
|
|
723
|
+
const bearer = await mintVaultAdminBearer(db, userId, "work");
|
|
724
|
+
const resp = await handleApiMintToken(
|
|
725
|
+
jsonRequest(
|
|
726
|
+
{ scope: "vault:work:read vault:other:read" },
|
|
727
|
+
{ authorization: `Bearer ${bearer}` },
|
|
728
|
+
),
|
|
729
|
+
{ db, issuer: ISSUER },
|
|
730
|
+
);
|
|
731
|
+
expect(resp.status).toBe(400);
|
|
732
|
+
const body = (await resp.json()) as { error: string; error_description: string };
|
|
733
|
+
expect(body.error).toBe("invalid_scope");
|
|
734
|
+
expect(body.error_description).toContain("vault:other:read");
|
|
735
|
+
} finally {
|
|
736
|
+
db.close();
|
|
737
|
+
}
|
|
738
|
+
} finally {
|
|
739
|
+
h.cleanup();
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
describe("capability attenuation — entry gate + regression", () => {
|
|
745
|
+
test("host:auth-only bearer mints vault:work:read → 200 (rule 1, preserved)", async () => {
|
|
746
|
+
const h = makeHarness();
|
|
747
|
+
try {
|
|
748
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
749
|
+
try {
|
|
750
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER, scopeSet: "auth" });
|
|
751
|
+
const resp = await handleApiMintToken(
|
|
752
|
+
jsonRequest({ scope: "vault:work:read" }, { authorization: `Bearer ${op.token}` }),
|
|
753
|
+
{ db, issuer: ISSUER },
|
|
754
|
+
);
|
|
755
|
+
expect(resp.status).toBe(200);
|
|
756
|
+
const body = (await resp.json()) as { token: string };
|
|
757
|
+
const validated = await validateAccessToken(db, body.token, ISSUER);
|
|
758
|
+
// Pure host:auth requestable mint → no vault pin.
|
|
759
|
+
expect(validated.payload.vault_scope).toEqual([]);
|
|
760
|
+
} finally {
|
|
761
|
+
db.close();
|
|
762
|
+
}
|
|
763
|
+
} finally {
|
|
764
|
+
h.cleanup();
|
|
765
|
+
}
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
test("host:auth-only bearer mints vault:work:admin → 400 (rule 1 doesn't cover admin)", async () => {
|
|
769
|
+
const h = makeHarness();
|
|
770
|
+
try {
|
|
771
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
772
|
+
try {
|
|
773
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER, scopeSet: "auth" });
|
|
774
|
+
const resp = await handleApiMintToken(
|
|
775
|
+
jsonRequest({ scope: "vault:work:admin" }, { authorization: `Bearer ${op.token}` }),
|
|
776
|
+
{ db, issuer: ISSUER },
|
|
777
|
+
);
|
|
778
|
+
expect(resp.status).toBe(400);
|
|
779
|
+
const body = (await resp.json()) as { error: string };
|
|
780
|
+
expect(body.error).toBe("invalid_scope");
|
|
781
|
+
} finally {
|
|
782
|
+
db.close();
|
|
783
|
+
}
|
|
784
|
+
} finally {
|
|
785
|
+
h.cleanup();
|
|
786
|
+
}
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
test("host:admin bearer mints vault:work:admin → 200 (rule 2, PR-A preserved)", async () => {
|
|
790
|
+
const h = makeHarness();
|
|
791
|
+
try {
|
|
792
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
793
|
+
try {
|
|
794
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
795
|
+
const resp = await handleApiMintToken(
|
|
796
|
+
jsonRequest({ scope: "vault:work:admin" }, { authorization: `Bearer ${op.token}` }),
|
|
797
|
+
{ db, issuer: ISSUER },
|
|
798
|
+
);
|
|
799
|
+
expect(resp.status).toBe(200);
|
|
800
|
+
const body = (await resp.json()) as { token: string };
|
|
801
|
+
const validated = await validateAccessToken(db, body.token, ISSUER);
|
|
802
|
+
expect(validated.payload.aud).toBe("vault.work");
|
|
803
|
+
expect(validated.payload.vault_scope).toEqual(["work"]);
|
|
804
|
+
} finally {
|
|
805
|
+
db.close();
|
|
806
|
+
}
|
|
807
|
+
} finally {
|
|
808
|
+
h.cleanup();
|
|
809
|
+
}
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
// Entry gate: a bearer with no host:* and no vault-admin holds no minting
|
|
813
|
+
// authority → 403 before any per-scope check.
|
|
814
|
+
test("403 entry gate when bearer holds no minting authority", async () => {
|
|
815
|
+
const h = makeHarness();
|
|
816
|
+
try {
|
|
817
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
818
|
+
try {
|
|
819
|
+
const noAuthority = await signAccessToken(db, {
|
|
820
|
+
sub: userId,
|
|
821
|
+
scopes: ["hub:admin", "scribe:transcribe"],
|
|
822
|
+
audience: "hub",
|
|
823
|
+
clientId: "parachute-hub",
|
|
824
|
+
issuer: ISSUER,
|
|
825
|
+
ttlSeconds: 3600,
|
|
826
|
+
});
|
|
827
|
+
const resp = await handleApiMintToken(
|
|
828
|
+
jsonRequest(
|
|
829
|
+
{ scope: "vault:work:read" },
|
|
830
|
+
{ authorization: `Bearer ${noAuthority.token}` },
|
|
831
|
+
),
|
|
832
|
+
{ db, issuer: ISSUER },
|
|
833
|
+
);
|
|
834
|
+
expect(resp.status).toBe(403);
|
|
835
|
+
const body = (await resp.json()) as { error: string };
|
|
836
|
+
expect(body.error).toBe("insufficient_scope");
|
|
837
|
+
} finally {
|
|
838
|
+
db.close();
|
|
839
|
+
}
|
|
840
|
+
} finally {
|
|
841
|
+
h.cleanup();
|
|
842
|
+
}
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
// A read-only token used AS A BEARER is not minting authority → 403.
|
|
846
|
+
test("403 entry gate when bearer is a vault:work:read token (read is not minting authority)", async () => {
|
|
847
|
+
const h = makeHarness();
|
|
848
|
+
try {
|
|
849
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
850
|
+
try {
|
|
851
|
+
const readOnly = await signAccessToken(db, {
|
|
852
|
+
sub: userId,
|
|
853
|
+
scopes: ["vault:work:read"],
|
|
854
|
+
audience: "vault.work",
|
|
855
|
+
clientId: "parachute-hub",
|
|
856
|
+
issuer: ISSUER,
|
|
857
|
+
ttlSeconds: 3600,
|
|
858
|
+
vaultScope: ["work"],
|
|
859
|
+
});
|
|
860
|
+
const resp = await handleApiMintToken(
|
|
861
|
+
jsonRequest(
|
|
862
|
+
{ scope: "vault:work:read" },
|
|
863
|
+
{ authorization: `Bearer ${readOnly.token}` },
|
|
864
|
+
),
|
|
865
|
+
{ db, issuer: ISSUER },
|
|
866
|
+
);
|
|
867
|
+
expect(resp.status).toBe(403);
|
|
868
|
+
const body = (await resp.json()) as { error: string };
|
|
869
|
+
expect(body.error).toBe("insufficient_scope");
|
|
870
|
+
} finally {
|
|
871
|
+
db.close();
|
|
872
|
+
}
|
|
873
|
+
} finally {
|
|
874
|
+
h.cleanup();
|
|
875
|
+
}
|
|
876
|
+
});
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
// ── Malformed vault-shaped scope guard (defensive hygiene, audit 2026-05-28) ──
|
|
880
|
+
//
|
|
881
|
+
// A `parachute:host:auth` bearer can craft scope strings that LOOK like a
|
|
882
|
+
// named per-vault scope but slip past `isNonRequestableScope`'s strict
|
|
883
|
+
// regexes, so `canGrant` rule 1 would admit them as "requestable" and mint a
|
|
884
|
+
// junk registry row. They grant zero access today (the vault consumer rejects
|
|
885
|
+
// them), so this isn't exploitable now — the mint-time shape check is a
|
|
886
|
+
// backstop against a future consumer-normalization regression + registry
|
|
887
|
+
// hygiene. It's an input-shape check, orthogonal to authority.
|
|
888
|
+
describe("malformed vault-shaped scope rejection", () => {
|
|
889
|
+
const MALFORMED = [
|
|
890
|
+
"vault:work:ADMIN", // uppercase verb
|
|
891
|
+
"vault::admin", // empty name
|
|
892
|
+
"vault:work:read:admin", // extra segment
|
|
893
|
+
"VAULT:work:admin", // uppercase resource
|
|
894
|
+
];
|
|
895
|
+
|
|
896
|
+
for (const scope of MALFORMED) {
|
|
897
|
+
test(`host:auth bearer minting ${scope} → 400 invalid_scope (malformed)`, async () => {
|
|
898
|
+
const h = makeHarness();
|
|
899
|
+
try {
|
|
900
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
901
|
+
try {
|
|
902
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
903
|
+
const resp = await handleApiMintToken(
|
|
904
|
+
jsonRequest({ scope }, { authorization: `Bearer ${op.token}` }),
|
|
905
|
+
{ db, issuer: ISSUER },
|
|
906
|
+
);
|
|
907
|
+
expect(resp.status).toBe(400);
|
|
908
|
+
const body = (await resp.json()) as { error: string; error_description: string };
|
|
909
|
+
expect(body.error).toBe("invalid_scope");
|
|
910
|
+
expect(body.error_description).toContain("malformed vault scope");
|
|
911
|
+
expect(body.error_description).toContain(scope);
|
|
912
|
+
// No junk registry row written — the request was rejected before
|
|
913
|
+
// mint. The only `cli_mint` provenance comes from this endpoint;
|
|
914
|
+
// the operator bearer's own row is `operator` provenance, so a
|
|
915
|
+
// count of zero proves the malformed mint never landed.
|
|
916
|
+
const row = db
|
|
917
|
+
.query<{ n: number }, []>(
|
|
918
|
+
"SELECT COUNT(*) AS n FROM tokens WHERE created_via = 'cli_mint'",
|
|
919
|
+
)
|
|
920
|
+
.get();
|
|
921
|
+
expect(row?.n ?? 0).toBe(0);
|
|
922
|
+
} finally {
|
|
923
|
+
db.close();
|
|
924
|
+
}
|
|
925
|
+
} finally {
|
|
926
|
+
h.cleanup();
|
|
927
|
+
}
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// Regression: well-formed named scopes still mint (host:auth → read/write
|
|
932
|
+
// via rule 1; host:admin → admin via rule 2). The guard is shape-only.
|
|
933
|
+
test("host:auth bearer minting vault:work:read → 200 (well-formed, regression)", async () => {
|
|
934
|
+
const h = makeHarness();
|
|
935
|
+
try {
|
|
936
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
937
|
+
try {
|
|
938
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER, scopeSet: "auth" });
|
|
939
|
+
const resp = await handleApiMintToken(
|
|
940
|
+
jsonRequest({ scope: "vault:work:read" }, { authorization: `Bearer ${op.token}` }),
|
|
941
|
+
{ db, issuer: ISSUER },
|
|
942
|
+
);
|
|
943
|
+
expect(resp.status).toBe(200);
|
|
944
|
+
const body = (await resp.json()) as { scope: string };
|
|
945
|
+
expect(body.scope).toBe("vault:work:read");
|
|
946
|
+
} finally {
|
|
947
|
+
db.close();
|
|
948
|
+
}
|
|
949
|
+
} finally {
|
|
950
|
+
h.cleanup();
|
|
951
|
+
}
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
test("host:auth bearer minting vault:work:write → 200 (well-formed, regression)", async () => {
|
|
955
|
+
const h = makeHarness();
|
|
956
|
+
try {
|
|
957
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
958
|
+
try {
|
|
959
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER, scopeSet: "auth" });
|
|
960
|
+
const resp = await handleApiMintToken(
|
|
961
|
+
jsonRequest({ scope: "vault:work:write" }, { authorization: `Bearer ${op.token}` }),
|
|
962
|
+
{ db, issuer: ISSUER },
|
|
963
|
+
);
|
|
964
|
+
expect(resp.status).toBe(200);
|
|
965
|
+
const body = (await resp.json()) as { scope: string };
|
|
966
|
+
expect(body.scope).toBe("vault:work:write");
|
|
967
|
+
} finally {
|
|
968
|
+
db.close();
|
|
969
|
+
}
|
|
970
|
+
} finally {
|
|
971
|
+
h.cleanup();
|
|
972
|
+
}
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
test("host:admin bearer minting vault:work:admin → 200 (well-formed, attenuation path)", async () => {
|
|
976
|
+
const h = makeHarness();
|
|
977
|
+
try {
|
|
978
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
979
|
+
try {
|
|
980
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
981
|
+
const resp = await handleApiMintToken(
|
|
982
|
+
jsonRequest({ scope: "vault:work:admin" }, { authorization: `Bearer ${op.token}` }),
|
|
983
|
+
{ db, issuer: ISSUER },
|
|
984
|
+
);
|
|
985
|
+
expect(resp.status).toBe(200);
|
|
986
|
+
const body = (await resp.json()) as { scope: string };
|
|
987
|
+
expect(body.scope).toBe("vault:work:admin");
|
|
988
|
+
} finally {
|
|
989
|
+
db.close();
|
|
990
|
+
}
|
|
991
|
+
} finally {
|
|
992
|
+
h.cleanup();
|
|
993
|
+
}
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
// The existing non-requestable behaviour is unchanged: a host:auth-only
|
|
997
|
+
// bearer minting the well-formed `vault:work:admin` is still 400 (rule 1
|
|
998
|
+
// doesn't cover admin) — this path is reached AFTER the shape guard passes.
|
|
999
|
+
test("host:auth-only bearer minting vault:work:admin → 400 (non-requestable, unchanged)", async () => {
|
|
1000
|
+
const h = makeHarness();
|
|
1001
|
+
try {
|
|
1002
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
1003
|
+
try {
|
|
1004
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER, scopeSet: "auth" });
|
|
1005
|
+
const resp = await handleApiMintToken(
|
|
1006
|
+
jsonRequest({ scope: "vault:work:admin" }, { authorization: `Bearer ${op.token}` }),
|
|
1007
|
+
{ db, issuer: ISSUER },
|
|
1008
|
+
);
|
|
1009
|
+
expect(resp.status).toBe(400);
|
|
1010
|
+
const body = (await resp.json()) as { error: string; error_description: string };
|
|
1011
|
+
expect(body.error).toBe("invalid_scope");
|
|
1012
|
+
// Not the malformed-shape message — it cleared the shape guard.
|
|
1013
|
+
expect(body.error_description).toContain("not grantable");
|
|
1014
|
+
} finally {
|
|
1015
|
+
db.close();
|
|
1016
|
+
}
|
|
1017
|
+
} finally {
|
|
1018
|
+
h.cleanup();
|
|
1019
|
+
}
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
// Non-vault scopes are entirely unaffected by the shape guard.
|
|
1023
|
+
test("host:auth bearer minting scribe:transcribe → 200 (non-vault, unaffected)", async () => {
|
|
1024
|
+
const h = makeHarness();
|
|
1025
|
+
try {
|
|
1026
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
1027
|
+
try {
|
|
1028
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER, scopeSet: "auth" });
|
|
1029
|
+
const resp = await handleApiMintToken(
|
|
1030
|
+
jsonRequest({ scope: "scribe:transcribe" }, { authorization: `Bearer ${op.token}` }),
|
|
1031
|
+
{ db, issuer: ISSUER },
|
|
1032
|
+
);
|
|
1033
|
+
expect(resp.status).toBe(200);
|
|
1034
|
+
const body = (await resp.json()) as { scope: string };
|
|
1035
|
+
expect(body.scope).toBe("scribe:transcribe");
|
|
1036
|
+
} finally {
|
|
1037
|
+
db.close();
|
|
1038
|
+
}
|
|
1039
|
+
} finally {
|
|
1040
|
+
h.cleanup();
|
|
1041
|
+
}
|
|
1042
|
+
});
|
|
1043
|
+
});
|
|
1044
|
+
|
|
366
1045
|
test("405 on non-POST", async () => {
|
|
367
1046
|
const h = makeHarness();
|
|
368
1047
|
try {
|