@openparachute/hub 0.7.4-rc.9 → 0.7.4
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-auth.test.ts +128 -0
- package/src/__tests__/admin-clients.test.ts +103 -1
- package/src/__tests__/admin-handlers.test.ts +28 -0
- package/src/__tests__/admin-host-admin-token.test.ts +58 -1
- package/src/__tests__/admin-lock.test.ts +33 -1
- package/src/__tests__/admin-vaults.test.ts +52 -9
- package/src/__tests__/api-account-2fa.test.ts +453 -0
- package/src/__tests__/api-mint-token.test.ts +75 -0
- package/src/__tests__/api-modules.test.ts +143 -0
- package/src/__tests__/api-settings-root-redirect.test.ts +302 -0
- package/src/__tests__/auth.test.ts +336 -0
- package/src/__tests__/clients.test.ts +298 -0
- package/src/__tests__/cors.test.ts +138 -1
- package/src/__tests__/doctor.test.ts +755 -0
- package/src/__tests__/hub-command.test.ts +69 -2
- package/src/__tests__/hub-settings.test.ts +188 -0
- package/src/__tests__/jwt-sign.test.ts +27 -0
- package/src/__tests__/oauth-handlers.test.ts +207 -21
- package/src/__tests__/oauth-ui.test.ts +52 -0
- package/src/__tests__/scope-explanations.test.ts +20 -9
- package/src/__tests__/sessions.test.ts +80 -0
- package/src/__tests__/setup-gate.test.ts +111 -3
- package/src/__tests__/vault-remove.test.ts +40 -19
- package/src/account-setup.ts +2 -0
- package/src/admin-agent-grants.ts +16 -1
- package/src/admin-auth.ts +13 -4
- package/src/admin-clients.ts +66 -5
- package/src/admin-grants.ts +11 -2
- package/src/admin-handlers.ts +2 -0
- package/src/admin-host-admin-token.ts +24 -1
- package/src/admin-lock.ts +16 -0
- package/src/admin-vaults.ts +70 -15
- package/src/api-account-2fa.ts +395 -0
- package/src/api-admin-lock.ts +7 -0
- package/src/api-hub-upgrade.ts +14 -1
- package/src/api-hub.ts +10 -1
- package/src/api-invites.ts +18 -3
- package/src/api-me.ts +11 -2
- package/src/api-mint-token.ts +16 -1
- package/src/api-modules.ts +119 -1
- package/src/api-revoke-token.ts +14 -1
- package/src/api-settings-hub-origin.ts +14 -1
- package/src/api-settings-root-redirect.ts +201 -0
- package/src/api-tokens.ts +14 -1
- package/src/api-users.ts +15 -6
- package/src/api-vault-caps.ts +11 -2
- package/src/cli.ts +29 -0
- package/src/clients.ts +164 -0
- package/src/commands/auth.ts +263 -1
- package/src/commands/doctor.ts +1250 -0
- package/src/commands/hub.ts +102 -1
- package/src/commands/vault-remove.ts +16 -24
- package/src/cors.ts +7 -3
- package/src/help.ts +53 -0
- package/src/hub-db.ts +14 -0
- package/src/hub-server.ts +123 -19
- package/src/hub-settings.ts +163 -1
- package/src/jwt-sign.ts +25 -6
- package/src/oauth-handlers.ts +14 -1
- package/src/oauth-ui.ts +51 -0
- package/src/rate-limit.ts +28 -0
- package/src/scope-explanations.ts +23 -9
- package/src/sessions.ts +43 -2
- package/src/setup-wizard.ts +2 -0
- package/web/ui/dist/assets/{index--728BX3j.css → index-BcC4U5gM.css} +1 -1
- package/web/ui/dist/assets/index-CVqK1cV5.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DZzX_Enf.js +0 -61
|
@@ -12,9 +12,9 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
|
12
12
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
13
13
|
import { tmpdir } from "node:os";
|
|
14
14
|
import { join } from "node:path";
|
|
15
|
-
import { hub, hubSetOrigin, rewriteCaddyfileHost } from "../commands/hub.ts";
|
|
15
|
+
import { hub, hubSetOrigin, hubSetRootRedirect, rewriteCaddyfileHost } from "../commands/hub.ts";
|
|
16
16
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
17
|
-
import { getHubOrigin } from "../hub-settings.ts";
|
|
17
|
+
import { getHubOrigin, getRootRedirect } from "../hub-settings.ts";
|
|
18
18
|
import type { CommandResult } from "../tailscale/run.ts";
|
|
19
19
|
|
|
20
20
|
describe("parachute hub set-origin", () => {
|
|
@@ -431,3 +431,70 @@ describe("parachute hub set-origin — Caddy automation", () => {
|
|
|
431
431
|
expect(log.join("\n")).toContain("already points at old.example.com");
|
|
432
432
|
});
|
|
433
433
|
});
|
|
434
|
+
|
|
435
|
+
describe("parachute hub set-root-redirect", () => {
|
|
436
|
+
let dir: string;
|
|
437
|
+
let log: string[];
|
|
438
|
+
const collect = (line: string) => log.push(line);
|
|
439
|
+
|
|
440
|
+
beforeEach(() => {
|
|
441
|
+
dir = mkdtempSync(join(tmpdir(), "hub-set-root-redirect-"));
|
|
442
|
+
log = [];
|
|
443
|
+
});
|
|
444
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
445
|
+
|
|
446
|
+
/** Open the configDir's hub.db and read the persisted root_redirect. */
|
|
447
|
+
function persisted(): string | null {
|
|
448
|
+
const db = openHubDb(hubDbPath(dir));
|
|
449
|
+
try {
|
|
450
|
+
return getRootRedirect(db);
|
|
451
|
+
} finally {
|
|
452
|
+
db.close();
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
test("persists a safe same-origin path to hub_settings.root_redirect", async () => {
|
|
457
|
+
const code = await hubSetRootRedirect(["/surface/reading-room"], {
|
|
458
|
+
configDir: dir,
|
|
459
|
+
log: collect,
|
|
460
|
+
});
|
|
461
|
+
expect(code).toBe(0);
|
|
462
|
+
expect(persisted()).toBe("/surface/reading-room");
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
test("--clear deletes the row", async () => {
|
|
466
|
+
await hubSetRootRedirect(["/surface/x"], { configDir: dir, log: collect });
|
|
467
|
+
const code = await hubSetRootRedirect(["--clear"], { configDir: dir, log: collect });
|
|
468
|
+
expect(code).toBe(0);
|
|
469
|
+
expect(persisted()).toBeNull();
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
test("rejects an open-redirect path without writing", async () => {
|
|
473
|
+
for (const bad of ["//evil.com", "https://evil.com", "/\\evil.com", "/"]) {
|
|
474
|
+
const code = await hubSetRootRedirect([bad], { configDir: dir, log: collect });
|
|
475
|
+
expect(code).toBe(1);
|
|
476
|
+
expect(persisted()).toBeNull();
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test("rejects a path with no leading slash without writing", async () => {
|
|
481
|
+
const code = await hubSetRootRedirect(["surface/x"], { configDir: dir, log: collect });
|
|
482
|
+
expect(code).toBe(1);
|
|
483
|
+
expect(persisted()).toBeNull();
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test("usage error (exit 1) when no path + no --clear", async () => {
|
|
487
|
+
const code = await hubSetRootRedirect([], { configDir: dir, log: collect });
|
|
488
|
+
expect(code).toBe(1);
|
|
489
|
+
expect(persisted()).toBeNull();
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test("routed through the `hub` dispatcher", async () => {
|
|
493
|
+
const code = await hub(["set-root-redirect", "/surface/via-dispatcher"], {
|
|
494
|
+
configDir: dir,
|
|
495
|
+
log: collect,
|
|
496
|
+
});
|
|
497
|
+
expect(code).toBe(0);
|
|
498
|
+
expect(persisted()).toBe("/surface/via-dispatcher");
|
|
499
|
+
});
|
|
500
|
+
});
|
|
@@ -12,8 +12,10 @@ import { join } from "node:path";
|
|
|
12
12
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
13
13
|
import {
|
|
14
14
|
DEFAULT_MODULE_INSTALL_CHANNEL,
|
|
15
|
+
DEFAULT_ROOT_REDIRECT,
|
|
15
16
|
FIRST_CLIENT_AUTO_APPROVE_WINDOW_MS,
|
|
16
17
|
MODULE_INSTALL_CHANNELS,
|
|
18
|
+
PARACHUTE_HUB_ROOT_REDIRECT_ENV,
|
|
17
19
|
PARACHUTE_INSTALL_CHANNEL_ENV,
|
|
18
20
|
PARACHUTE_MODULE_CHANNEL_ENV,
|
|
19
21
|
SETUP_EXPOSE_MODES,
|
|
@@ -21,15 +23,20 @@ import {
|
|
|
21
23
|
deleteSetting,
|
|
22
24
|
getHubOrigin,
|
|
23
25
|
getModuleInstallChannel,
|
|
26
|
+
getRootRedirect,
|
|
24
27
|
getSetting,
|
|
25
28
|
isFirstClientAutoApproveWindowOpen,
|
|
26
29
|
isModuleInstallChannel,
|
|
27
30
|
isNotesRedirectDisabled,
|
|
31
|
+
isSafeRedirectPath,
|
|
28
32
|
isSetupExposeMode,
|
|
29
33
|
openFirstClientAutoApproveWindow,
|
|
34
|
+
resolveRootRedirect,
|
|
35
|
+
resolveRootRedirectDetailed,
|
|
30
36
|
setHubOrigin,
|
|
31
37
|
setModuleInstallChannel,
|
|
32
38
|
setNotesRedirectDisabled,
|
|
39
|
+
setRootRedirect,
|
|
33
40
|
setSetting,
|
|
34
41
|
} from "../hub-settings.ts";
|
|
35
42
|
|
|
@@ -613,3 +620,184 @@ describe("hub-settings — notes_redirect_disabled (parachute-app §16)", () =>
|
|
|
613
620
|
}
|
|
614
621
|
});
|
|
615
622
|
});
|
|
623
|
+
|
|
624
|
+
describe("hub-settings — isSafeRedirectPath (open-redirect guard)", () => {
|
|
625
|
+
test("accepts plain same-origin relative paths", () => {
|
|
626
|
+
expect(isSafeRedirectPath("/admin")).toBe(true);
|
|
627
|
+
expect(isSafeRedirectPath("/surface/reading-room")).toBe(true);
|
|
628
|
+
expect(isSafeRedirectPath("/vault/default/")).toBe(true);
|
|
629
|
+
// Query + fragment stay same-origin → allowed.
|
|
630
|
+
expect(isSafeRedirectPath("/surface/x?view=reading#top")).toBe(true);
|
|
631
|
+
// Deep paths with hyphens/dots/underscores (regression: a botched
|
|
632
|
+
// whitespace regex once rejected `-`).
|
|
633
|
+
expect(isSafeRedirectPath("/a-b_c.d/e")).toBe(true);
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
test("rejects protocol-relative + backslash authority tricks", () => {
|
|
637
|
+
expect(isSafeRedirectPath("//evil.com")).toBe(false);
|
|
638
|
+
expect(isSafeRedirectPath("//evil.com/path")).toBe(false);
|
|
639
|
+
expect(isSafeRedirectPath("/\\evil.com")).toBe(false);
|
|
640
|
+
expect(isSafeRedirectPath("/\\\\evil.com")).toBe(false);
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
test("rejects absolute URLs + scheme payloads", () => {
|
|
644
|
+
expect(isSafeRedirectPath("https://evil.com")).toBe(false);
|
|
645
|
+
expect(isSafeRedirectPath("http://evil.com/x")).toBe(false);
|
|
646
|
+
// biome-ignore lint/suspicious/noExplicitAny: testing the runtime guard with a hostile string
|
|
647
|
+
expect(isSafeRedirectPath("javascript:alert(1)" as any)).toBe(false);
|
|
648
|
+
expect(isSafeRedirectPath("data:text/html,<script>1</script>")).toBe(false);
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
test("rejects values missing a leading slash", () => {
|
|
652
|
+
expect(isSafeRedirectPath("admin")).toBe(false);
|
|
653
|
+
expect(isSafeRedirectPath("evil.com")).toBe(false);
|
|
654
|
+
expect(isSafeRedirectPath("")).toBe(false);
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
test("rejects pathname-`/` targets (would 302-loop the bare-`/` route)", () => {
|
|
658
|
+
expect(isSafeRedirectPath("/")).toBe(false);
|
|
659
|
+
expect(isSafeRedirectPath("/?next=x")).toBe(false);
|
|
660
|
+
expect(isSafeRedirectPath("/#frag")).toBe(false);
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
test("rejects whitespace + control chars (header-injection / normalization)", () => {
|
|
664
|
+
expect(isSafeRedirectPath("/admin\r\nSet-Cookie: x=1")).toBe(false);
|
|
665
|
+
expect(isSafeRedirectPath("/ad min")).toBe(false);
|
|
666
|
+
expect(isSafeRedirectPath("/admin\t")).toBe(false);
|
|
667
|
+
expect(isSafeRedirectPath("/\tadmin")).toBe(false);
|
|
668
|
+
expect(isSafeRedirectPath("/admin ")).toBe(false);
|
|
669
|
+
// U+2028 line separator (stripped by some parsers) — built via charCode so
|
|
670
|
+
// the source file carries no irregular-whitespace literal.
|
|
671
|
+
expect(isSafeRedirectPath(`/admin${String.fromCharCode(0x2028)}x`)).toBe(false);
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
test("rejects non-string inputs", () => {
|
|
675
|
+
// biome-ignore lint/suspicious/noExplicitAny: exercising the runtime type guard
|
|
676
|
+
expect(isSafeRedirectPath(null as any)).toBe(false);
|
|
677
|
+
// biome-ignore lint/suspicious/noExplicitAny: exercising the runtime type guard
|
|
678
|
+
expect(isSafeRedirectPath(undefined as any)).toBe(false);
|
|
679
|
+
// biome-ignore lint/suspicious/noExplicitAny: exercising the runtime type guard
|
|
680
|
+
expect(isSafeRedirectPath(42 as any)).toBe(false);
|
|
681
|
+
// biome-ignore lint/suspicious/noExplicitAny: exercising the runtime type guard
|
|
682
|
+
expect(isSafeRedirectPath({} as any)).toBe(false);
|
|
683
|
+
});
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
describe("hub-settings — root_redirect storage + resolution", () => {
|
|
687
|
+
let dir: string;
|
|
688
|
+
beforeEach(() => {
|
|
689
|
+
dir = mkdtempSync(join(tmpdir(), "hub-settings-root-redirect-"));
|
|
690
|
+
});
|
|
691
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
692
|
+
|
|
693
|
+
// An empty env so the resolver's env layer is deterministic (the host's real
|
|
694
|
+
// PARACHUTE_HUB_ROOT_REDIRECT, if any, must not leak in).
|
|
695
|
+
const noEnv: NodeJS.ProcessEnv = {};
|
|
696
|
+
const silent = () => {};
|
|
697
|
+
|
|
698
|
+
test("getRootRedirect round-trips via setRootRedirect", () => {
|
|
699
|
+
const db = openHubDb(hubDbPath(dir));
|
|
700
|
+
try {
|
|
701
|
+
expect(getRootRedirect(db)).toBeNull();
|
|
702
|
+
setRootRedirect(db, "/surface/reading-room");
|
|
703
|
+
expect(getRootRedirect(db)).toBe("/surface/reading-room");
|
|
704
|
+
} finally {
|
|
705
|
+
db.close();
|
|
706
|
+
}
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
test("setRootRedirect(null) / empty clears the row", () => {
|
|
710
|
+
const db = openHubDb(hubDbPath(dir));
|
|
711
|
+
try {
|
|
712
|
+
setRootRedirect(db, "/surface/x");
|
|
713
|
+
setRootRedirect(db, null);
|
|
714
|
+
expect(getRootRedirect(db)).toBeNull();
|
|
715
|
+
setRootRedirect(db, "/surface/x");
|
|
716
|
+
setRootRedirect(db, "");
|
|
717
|
+
expect(getRootRedirect(db)).toBeNull();
|
|
718
|
+
} finally {
|
|
719
|
+
db.close();
|
|
720
|
+
}
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
test("resolves to /admin default when neither DB nor env is set", () => {
|
|
724
|
+
const db = openHubDb(hubDbPath(dir));
|
|
725
|
+
try {
|
|
726
|
+
expect(resolveRootRedirect(db, { env: noEnv })).toBe(DEFAULT_ROOT_REDIRECT);
|
|
727
|
+
expect(resolveRootRedirectDetailed(db, { env: noEnv })).toEqual({
|
|
728
|
+
value: "/admin",
|
|
729
|
+
source: "default",
|
|
730
|
+
});
|
|
731
|
+
} finally {
|
|
732
|
+
db.close();
|
|
733
|
+
}
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
test("env override applies when no DB row", () => {
|
|
737
|
+
const db = openHubDb(hubDbPath(dir));
|
|
738
|
+
try {
|
|
739
|
+
const env = { [PARACHUTE_HUB_ROOT_REDIRECT_ENV]: "/surface/from-env" };
|
|
740
|
+
expect(resolveRootRedirectDetailed(db, { env })).toEqual({
|
|
741
|
+
value: "/surface/from-env",
|
|
742
|
+
source: "env",
|
|
743
|
+
});
|
|
744
|
+
} finally {
|
|
745
|
+
db.close();
|
|
746
|
+
}
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
test("DB row overrides env (DB is tier-1)", () => {
|
|
750
|
+
const db = openHubDb(hubDbPath(dir));
|
|
751
|
+
try {
|
|
752
|
+
setRootRedirect(db, "/surface/from-db");
|
|
753
|
+
const env = { [PARACHUTE_HUB_ROOT_REDIRECT_ENV]: "/surface/from-env" };
|
|
754
|
+
expect(resolveRootRedirectDetailed(db, { env })).toEqual({
|
|
755
|
+
value: "/surface/from-db",
|
|
756
|
+
source: "db",
|
|
757
|
+
});
|
|
758
|
+
} finally {
|
|
759
|
+
db.close();
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
test("an unsafe DB row is ignored → falls through to env", () => {
|
|
764
|
+
const db = openHubDb(hubDbPath(dir));
|
|
765
|
+
try {
|
|
766
|
+
// Simulate a hand-edited sqlite row that bypassed write-side validation.
|
|
767
|
+
setSetting(db, "root_redirect", "//evil.com");
|
|
768
|
+
const env = { [PARACHUTE_HUB_ROOT_REDIRECT_ENV]: "/surface/from-env" };
|
|
769
|
+
expect(resolveRootRedirect(db, { env, warn: silent })).toBe("/surface/from-env");
|
|
770
|
+
} finally {
|
|
771
|
+
db.close();
|
|
772
|
+
}
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
test("an unsafe DB row with no env → falls all the way back to /admin", () => {
|
|
776
|
+
const db = openHubDb(hubDbPath(dir));
|
|
777
|
+
try {
|
|
778
|
+
setSetting(db, "root_redirect", "https://evil.com");
|
|
779
|
+
expect(resolveRootRedirect(db, { env: noEnv, warn: silent })).toBe("/admin");
|
|
780
|
+
} finally {
|
|
781
|
+
db.close();
|
|
782
|
+
}
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
test("an unsafe env value is ignored → falls back to /admin", () => {
|
|
786
|
+
const db = openHubDb(hubDbPath(dir));
|
|
787
|
+
try {
|
|
788
|
+
const env = { [PARACHUTE_HUB_ROOT_REDIRECT_ENV]: "//evil.com" };
|
|
789
|
+
expect(resolveRootRedirectDetailed(db, { env, warn: silent })).toEqual({
|
|
790
|
+
value: "/admin",
|
|
791
|
+
source: "default",
|
|
792
|
+
});
|
|
793
|
+
} finally {
|
|
794
|
+
db.close();
|
|
795
|
+
}
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
test("a null db (no state) resolves from env / default only", () => {
|
|
799
|
+
expect(resolveRootRedirect(null, { env: noEnv })).toBe("/admin");
|
|
800
|
+
const env = { [PARACHUTE_HUB_ROOT_REDIRECT_ENV]: "/surface/from-env" };
|
|
801
|
+
expect(resolveRootRedirect(null, { env })).toBe("/surface/from-env");
|
|
802
|
+
});
|
|
803
|
+
});
|
|
@@ -370,6 +370,33 @@ describe("validateAccessToken", () => {
|
|
|
370
370
|
}
|
|
371
371
|
});
|
|
372
372
|
|
|
373
|
+
test("expectedIssuer accepts a SET — iss in the set validates, outside rejects", async () => {
|
|
374
|
+
const { db, cleanup } = makeDb();
|
|
375
|
+
try {
|
|
376
|
+
const { token } = await signAccessToken(db, {
|
|
377
|
+
sub: "u",
|
|
378
|
+
scopes: ["s"],
|
|
379
|
+
audience: "operator",
|
|
380
|
+
clientId: "c",
|
|
381
|
+
issuer: "https://tunnel.example",
|
|
382
|
+
});
|
|
383
|
+
// In-set (≠ first element) validates — additive membership on iss.
|
|
384
|
+
const { payload } = await validateAccessToken(db, token, [
|
|
385
|
+
"http://127.0.0.1:1939",
|
|
386
|
+
"https://tunnel.example",
|
|
387
|
+
]);
|
|
388
|
+
expect(payload.iss).toBe("https://tunnel.example");
|
|
389
|
+
// Outside the set rejects.
|
|
390
|
+
await expect(
|
|
391
|
+
validateAccessToken(db, token, ["http://127.0.0.1:1939", "https://other.example"]),
|
|
392
|
+
).rejects.toThrow();
|
|
393
|
+
// A single-element set that doesn't match also rejects (no widening).
|
|
394
|
+
await expect(validateAccessToken(db, token, ["http://127.0.0.1:1939"])).rejects.toThrow();
|
|
395
|
+
} finally {
|
|
396
|
+
cleanup();
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
|
|
373
400
|
test("verifies a token signed by a recently-retired key (rotation tolerance)", async () => {
|
|
374
401
|
const { db, cleanup } = makeDb();
|
|
375
402
|
try {
|
|
@@ -119,7 +119,9 @@ describe("authorizationServerMetadata", () => {
|
|
|
119
119
|
expect(scopesSupported).toContain("vault:read");
|
|
120
120
|
expect(scopesSupported).toContain("vault:admin");
|
|
121
121
|
expect(scopesSupported).toContain("scribe:transcribe"); // scribe is in the fixture manifest
|
|
122
|
-
|
|
122
|
+
// hub:admin + scribe:admin are operator-only (non-requestable) — never advertised (2026-06-30)
|
|
123
|
+
expect(scopesSupported).not.toContain("hub:admin");
|
|
124
|
+
expect(scopesSupported).not.toContain("scribe:admin");
|
|
123
125
|
// agent isn't in the fixture manifest → its scopes aren't advertised
|
|
124
126
|
// (hub#…: optional-module scopes only surface when the module is installed).
|
|
125
127
|
expect(scopesSupported).not.toContain("agent:send");
|
|
@@ -169,9 +171,10 @@ describe("authorizationServerMetadata", () => {
|
|
|
169
171
|
// First-party still advertised — no regression
|
|
170
172
|
expect(scopesSupported).toContain("vault:read");
|
|
171
173
|
expect(scopesSupported).toContain("vault:admin");
|
|
172
|
-
|
|
173
|
-
//
|
|
174
|
+
// NON_REQUESTABLE filter applies even when the scope is declared:
|
|
175
|
+
// host-* AND the service-admin scopes (hub:admin/scribe:admin) are filtered.
|
|
174
176
|
expect(scopesSupported).not.toContain("parachute:host:admin");
|
|
177
|
+
expect(scopesSupported).not.toContain("hub:admin");
|
|
175
178
|
});
|
|
176
179
|
|
|
177
180
|
test("advertises an optional module's scopes only when it's installed", async () => {
|
|
@@ -209,7 +212,8 @@ describe("authorizationServerMetadata", () => {
|
|
|
209
212
|
// core scopes survive
|
|
210
213
|
expect(scopes).toContain("vault:read");
|
|
211
214
|
expect(scopes).toContain("vault:admin");
|
|
212
|
-
|
|
215
|
+
// hub:admin is operator-only (non-requestable) — never advertised
|
|
216
|
+
expect(scopes).not.toContain("hub:admin");
|
|
213
217
|
// uninstalled optional-module scopes are dropped
|
|
214
218
|
expect(scopes).not.toContain("scribe:transcribe");
|
|
215
219
|
expect(scopes).not.toContain("scribe:admin");
|
|
@@ -241,6 +245,10 @@ describe("authorizationServerMetadata", () => {
|
|
|
241
245
|
});
|
|
242
246
|
const scopes2 = ((await res2.json()) as Record<string, unknown>).scopes_supported as string[];
|
|
243
247
|
expect(scopes2).toContain("scribe:transcribe");
|
|
248
|
+
// scribe:admin stays dropped EVEN WITH scribe installed — proving it's the
|
|
249
|
+
// requestability gate (non-requestable, 2026-06-30) doing the work here, not
|
|
250
|
+
// the optional-module-not-installed gate that drops scribe:transcribe above.
|
|
251
|
+
expect(scopes2).not.toContain("scribe:admin");
|
|
244
252
|
expect(scopes2).not.toContain("agent:send"); // agent still not installed
|
|
245
253
|
});
|
|
246
254
|
});
|
|
@@ -353,6 +361,88 @@ describe("handleAuthorizeGet", () => {
|
|
|
353
361
|
}
|
|
354
362
|
});
|
|
355
363
|
|
|
364
|
+
// hub#314 — same-hub vs external trust marker reaches the rendered consent
|
|
365
|
+
// screen via `client.sameHub`. An unnamed `vault:read` request from a
|
|
366
|
+
// same-hub client falls through to consent (the auto-approve gate requires
|
|
367
|
+
// `!hasUnnamedVault`), so we can assert the marker on the GET render.
|
|
368
|
+
test("renders the EXTERNAL trust marker for a third-party DCR client", async () => {
|
|
369
|
+
const { db, cleanup } = await makeDb();
|
|
370
|
+
try {
|
|
371
|
+
const user = await createUser(db, "owner", "pw");
|
|
372
|
+
const session = createSession(db, { userId: user.id });
|
|
373
|
+
// registerClient defaults sameHub:false → external (third-party DCR).
|
|
374
|
+
const reg = registerClient(db, {
|
|
375
|
+
redirectUris: ["https://app.example/cb"],
|
|
376
|
+
clientName: "ThirdPartyApp",
|
|
377
|
+
});
|
|
378
|
+
const { challenge } = makePkce();
|
|
379
|
+
const req = new Request(
|
|
380
|
+
authorizeUrl({
|
|
381
|
+
client_id: reg.client.clientId,
|
|
382
|
+
redirect_uri: "https://app.example/cb",
|
|
383
|
+
response_type: "code",
|
|
384
|
+
code_challenge: challenge,
|
|
385
|
+
code_challenge_method: "S256",
|
|
386
|
+
scope: "vault:read",
|
|
387
|
+
}),
|
|
388
|
+
{
|
|
389
|
+
headers: {
|
|
390
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
);
|
|
394
|
+
const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
|
|
395
|
+
expect(res.status).toBe(200);
|
|
396
|
+
const html = await res.text();
|
|
397
|
+
// Element form — the `.badge-trust-*` CSS class names are always in the
|
|
398
|
+
// inlined <style>; the rendered ELEMENT only appears when the marker fires.
|
|
399
|
+
expect(html).toContain('class="badge badge-trust-external"');
|
|
400
|
+
expect(html).toContain("third-party app that registered itself");
|
|
401
|
+
expect(html).not.toContain('class="badge badge-trust-same-hub"');
|
|
402
|
+
} finally {
|
|
403
|
+
cleanup();
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test("renders the FIRST-PARTY trust marker for a same-hub client", async () => {
|
|
408
|
+
const { db, cleanup } = await makeDb();
|
|
409
|
+
try {
|
|
410
|
+
const user = await createUser(db, "owner", "pw");
|
|
411
|
+
const session = createSession(db, { userId: user.id });
|
|
412
|
+
const reg = registerClient(db, {
|
|
413
|
+
redirectUris: ["https://app.example/cb"],
|
|
414
|
+
clientName: "FirstPartyApp",
|
|
415
|
+
sameHub: true,
|
|
416
|
+
});
|
|
417
|
+
const { challenge } = makePkce();
|
|
418
|
+
const req = new Request(
|
|
419
|
+
authorizeUrl({
|
|
420
|
+
client_id: reg.client.clientId,
|
|
421
|
+
redirect_uri: "https://app.example/cb",
|
|
422
|
+
response_type: "code",
|
|
423
|
+
code_challenge: challenge,
|
|
424
|
+
code_challenge_method: "S256",
|
|
425
|
+
// Unnamed vault verb → bypasses the same-hub auto-approve gate
|
|
426
|
+
// (`!hasUnnamedVault`) and falls through to the consent render.
|
|
427
|
+
scope: "vault:read",
|
|
428
|
+
}),
|
|
429
|
+
{
|
|
430
|
+
headers: {
|
|
431
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
);
|
|
435
|
+
const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
|
|
436
|
+
expect(res.status).toBe(200);
|
|
437
|
+
const html = await res.text();
|
|
438
|
+
expect(html).toContain('class="badge badge-trust-same-hub"');
|
|
439
|
+
expect(html).toContain("Registered through this hub");
|
|
440
|
+
expect(html).not.toContain('class="badge badge-trust-external"');
|
|
441
|
+
} finally {
|
|
442
|
+
cleanup();
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
|
|
356
446
|
test("rejects unknown client_id with 400", async () => {
|
|
357
447
|
const { db, cleanup } = await makeDb();
|
|
358
448
|
try {
|
|
@@ -7284,12 +7374,12 @@ describe("DCR same-hub auto-trust (hub#312)", () => {
|
|
|
7284
7374
|
}
|
|
7285
7375
|
});
|
|
7286
7376
|
|
|
7287
|
-
test("authorize: same_hub=true + admin
|
|
7288
|
-
// hub:admin is requestable via
|
|
7289
|
-
//
|
|
7290
|
-
//
|
|
7291
|
-
//
|
|
7292
|
-
// hub-
|
|
7377
|
+
test("authorize: same_hub=true + hub:admin → invalid_scope (operator-only, even for same-hub) (2026-06-30)", async () => {
|
|
7378
|
+
// hub:admin is now non-requestable via /oauth/authorize (a vault MCP
|
|
7379
|
+
// connector pointed at the hub-level AS would otherwise be offered
|
|
7380
|
+
// hub-wide admin). Even a trusted same-hub client with an owner session
|
|
7381
|
+
// is rejected with invalid_scope — fails closed before consent. The
|
|
7382
|
+
// legit hub-admin paths are operator-bearer/session, not authorize-flow.
|
|
7293
7383
|
const { db, cleanup } = await makeDb();
|
|
7294
7384
|
try {
|
|
7295
7385
|
const user = await createUser(db, "owner", "pw");
|
|
@@ -7308,24 +7398,24 @@ describe("DCR same-hub auto-trust (hub#312)", () => {
|
|
|
7308
7398
|
scope: "hub:admin",
|
|
7309
7399
|
code_challenge: challenge,
|
|
7310
7400
|
code_challenge_method: "S256",
|
|
7401
|
+
state: "abc",
|
|
7311
7402
|
}),
|
|
7312
7403
|
{ headers: { cookie: buildSessionCookie(session.id, SESSION_COOKIE_TTL_S) } },
|
|
7313
7404
|
);
|
|
7314
7405
|
const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
|
|
7315
|
-
|
|
7316
|
-
|
|
7317
|
-
expect(
|
|
7318
|
-
|
|
7319
|
-
expect(html).toContain("hub:admin");
|
|
7406
|
+
expect(res.status).toBe(302);
|
|
7407
|
+
const loc = new URL(res.headers.get("location") ?? "");
|
|
7408
|
+
expect(loc.searchParams.get("error")).toBe("invalid_scope");
|
|
7409
|
+
expect(loc.searchParams.get("error_description")).toContain("hub:admin");
|
|
7320
7410
|
} finally {
|
|
7321
7411
|
cleanup();
|
|
7322
7412
|
}
|
|
7323
7413
|
});
|
|
7324
7414
|
|
|
7325
|
-
test("authorize: same_hub=true + mixed
|
|
7326
|
-
//
|
|
7327
|
-
//
|
|
7328
|
-
//
|
|
7415
|
+
test("authorize: same_hub=true + mixed vault + hub:admin → invalid_scope (a non-requestable scope rejects the whole request) (2026-06-30)", async () => {
|
|
7416
|
+
// A request mixing a requestable scope with hub:admin must NOT slip the
|
|
7417
|
+
// non-requestable scope through on the strength of the others — the whole
|
|
7418
|
+
// request is rejected with invalid_scope (same as parachute:host:admin).
|
|
7329
7419
|
const { db, cleanup } = await makeDb();
|
|
7330
7420
|
try {
|
|
7331
7421
|
const user = await createUser(db, "owner", "pw");
|
|
@@ -7344,12 +7434,52 @@ describe("DCR same-hub auto-trust (hub#312)", () => {
|
|
|
7344
7434
|
scope: "vault:default:read hub:admin",
|
|
7345
7435
|
code_challenge: challenge,
|
|
7346
7436
|
code_challenge_method: "S256",
|
|
7437
|
+
state: "abc",
|
|
7347
7438
|
}),
|
|
7348
7439
|
{ headers: { cookie: buildSessionCookie(session.id, SESSION_COOKIE_TTL_S) } },
|
|
7349
7440
|
);
|
|
7350
7441
|
const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
|
|
7351
|
-
expect(res.status).toBe(
|
|
7352
|
-
|
|
7442
|
+
expect(res.status).toBe(302);
|
|
7443
|
+
const loc = new URL(res.headers.get("location") ?? "");
|
|
7444
|
+
expect(loc.searchParams.get("error")).toBe("invalid_scope");
|
|
7445
|
+
expect(loc.searchParams.get("error_description")).toContain("hub:admin");
|
|
7446
|
+
} finally {
|
|
7447
|
+
cleanup();
|
|
7448
|
+
}
|
|
7449
|
+
});
|
|
7450
|
+
|
|
7451
|
+
test("authorize: explicit scribe:admin → invalid_scope (service-admin, operator-only) (2026-06-30)", async () => {
|
|
7452
|
+
// Symmetry with hub:admin: the other service-admin scope is non-requestable
|
|
7453
|
+
// too. Scribe's admin UI gets scribe:admin via the cookie-gated
|
|
7454
|
+
// /admin/module-token/scribe path, never /oauth/authorize — so rejecting it
|
|
7455
|
+
// here breaks no first-party path.
|
|
7456
|
+
const { db, cleanup } = await makeDb();
|
|
7457
|
+
try {
|
|
7458
|
+
const user = await createUser(db, "owner", "pw");
|
|
7459
|
+
const session = createSession(db, { userId: user.id });
|
|
7460
|
+
const reg = registerClient(db, {
|
|
7461
|
+
redirectUris: ["https://app.example/cb"],
|
|
7462
|
+
status: "approved",
|
|
7463
|
+
sameHub: true,
|
|
7464
|
+
});
|
|
7465
|
+
const { challenge } = makePkce();
|
|
7466
|
+
const req = new Request(
|
|
7467
|
+
authorizeUrl({
|
|
7468
|
+
client_id: reg.client.clientId,
|
|
7469
|
+
redirect_uri: "https://app.example/cb",
|
|
7470
|
+
response_type: "code",
|
|
7471
|
+
scope: "scribe:admin",
|
|
7472
|
+
code_challenge: challenge,
|
|
7473
|
+
code_challenge_method: "S256",
|
|
7474
|
+
state: "abc",
|
|
7475
|
+
}),
|
|
7476
|
+
{ headers: { cookie: buildSessionCookie(session.id, SESSION_COOKIE_TTL_S) } },
|
|
7477
|
+
);
|
|
7478
|
+
const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
|
|
7479
|
+
expect(res.status).toBe(302);
|
|
7480
|
+
const loc = new URL(res.headers.get("location") ?? "");
|
|
7481
|
+
expect(loc.searchParams.get("error")).toBe("invalid_scope");
|
|
7482
|
+
expect(loc.searchParams.get("error_description")).toContain("scribe:admin");
|
|
7353
7483
|
} finally {
|
|
7354
7484
|
cleanup();
|
|
7355
7485
|
}
|
|
@@ -10142,6 +10272,62 @@ describe("hub#689 — owner-on-own-vault verb selector + widening", () => {
|
|
|
10142
10272
|
}
|
|
10143
10273
|
});
|
|
10144
10274
|
|
|
10275
|
+
// GET render (hub#703, folded into hub#314): a user with MIXED authority —
|
|
10276
|
+
// admin on vault A (role=write → holds admin) but only read on vault B
|
|
10277
|
+
// (direct INSERT role=read) — does NOT see the selector. The user could pick
|
|
10278
|
+
// either vault, but doesn't own (hold admin on) EVERY pickable vault, so the
|
|
10279
|
+
// `userHoldsAdminOnPickable` predicate (`assignedVaults.every(v => verbs
|
|
10280
|
+
// includes "admin")`) fails on vault B and the selector is suppressed. The
|
|
10281
|
+
// suppression logic already ships + is correct (oauth-handlers.ts ~2963 +
|
|
10282
|
+
// ~1226); this test closes the coverage gap with no code change.
|
|
10283
|
+
test("selector NOT rendered for a mixed-authority user (admin on A, read-only on B)", async () => {
|
|
10284
|
+
const { db, cleanup } = await makeDb();
|
|
10285
|
+
try {
|
|
10286
|
+
await createUser(db, "owner", "pw");
|
|
10287
|
+
const mixed = await createUser(db, "mixed", "pw", { allowMulti: true });
|
|
10288
|
+
// Vault A ("work"): role=write → vaultVerbsForRole maps to [read,write,
|
|
10289
|
+
// admin], so the user holds admin on A. (setUserVaults hardcodes write.)
|
|
10290
|
+
setUserVaults(db, mixed.id, ["work"]);
|
|
10291
|
+
// Vault B ("other"): direct INSERT role=read → holds read only, NOT admin.
|
|
10292
|
+
// setUserVaults DELETEs first, so this INSERT must come after it to keep A.
|
|
10293
|
+
db.prepare(
|
|
10294
|
+
"INSERT INTO user_vaults (user_id, vault_name, role, created_at) VALUES (?, ?, 'read', ?)",
|
|
10295
|
+
).run(mixed.id, "other", new Date().toISOString());
|
|
10296
|
+
const session = createSession(db, { userId: mixed.id });
|
|
10297
|
+
const reg = registerClient(db, {
|
|
10298
|
+
redirectUris: ["https://app.example/cb"],
|
|
10299
|
+
status: "approved",
|
|
10300
|
+
});
|
|
10301
|
+
const { challenge } = makePkce();
|
|
10302
|
+
const res = handleAuthorizeGet(
|
|
10303
|
+
db,
|
|
10304
|
+
new Request(
|
|
10305
|
+
authorizeUrl({
|
|
10306
|
+
client_id: reg.client.clientId,
|
|
10307
|
+
redirect_uri: "https://app.example/cb",
|
|
10308
|
+
response_type: "code",
|
|
10309
|
+
code_challenge: challenge,
|
|
10310
|
+
code_challenge_method: "S256",
|
|
10311
|
+
scope: "vault:read",
|
|
10312
|
+
}),
|
|
10313
|
+
{ headers: { cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, TTL_S)}` } },
|
|
10314
|
+
),
|
|
10315
|
+
selDeps,
|
|
10316
|
+
);
|
|
10317
|
+
expect(res.status).toBe(200);
|
|
10318
|
+
const html = await res.text();
|
|
10319
|
+
// Suppressed: the `.every(v => verbs includes "admin")` check fails on B.
|
|
10320
|
+
expect(html).not.toContain("Access level");
|
|
10321
|
+
expect(html).not.toContain('name="verb_select"');
|
|
10322
|
+
// Sanity: the multi-vault picker DID render (two assigned vaults), so the
|
|
10323
|
+
// suppression is specifically the verb selector, not the whole flow.
|
|
10324
|
+
expect(html).toContain('name="vault_pick" value="work"');
|
|
10325
|
+
expect(html).toContain('name="vault_pick" value="other"');
|
|
10326
|
+
} finally {
|
|
10327
|
+
cleanup();
|
|
10328
|
+
}
|
|
10329
|
+
});
|
|
10330
|
+
|
|
10145
10331
|
// GET render: a non-owner (non-admin with ZERO assigned vaults) does NOT
|
|
10146
10332
|
// see the selector — they can't authorize a vault scope at all.
|
|
10147
10333
|
test("selector NOT rendered for a non-owner (zero-vault non-admin)", async () => {
|