@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.
Files changed (69) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-auth.test.ts +128 -0
  3. package/src/__tests__/admin-clients.test.ts +103 -1
  4. package/src/__tests__/admin-handlers.test.ts +28 -0
  5. package/src/__tests__/admin-host-admin-token.test.ts +58 -1
  6. package/src/__tests__/admin-lock.test.ts +33 -1
  7. package/src/__tests__/admin-vaults.test.ts +52 -9
  8. package/src/__tests__/api-account-2fa.test.ts +453 -0
  9. package/src/__tests__/api-mint-token.test.ts +75 -0
  10. package/src/__tests__/api-modules.test.ts +143 -0
  11. package/src/__tests__/api-settings-root-redirect.test.ts +302 -0
  12. package/src/__tests__/auth.test.ts +336 -0
  13. package/src/__tests__/clients.test.ts +298 -0
  14. package/src/__tests__/cors.test.ts +138 -1
  15. package/src/__tests__/doctor.test.ts +755 -0
  16. package/src/__tests__/hub-command.test.ts +69 -2
  17. package/src/__tests__/hub-settings.test.ts +188 -0
  18. package/src/__tests__/jwt-sign.test.ts +27 -0
  19. package/src/__tests__/oauth-handlers.test.ts +207 -21
  20. package/src/__tests__/oauth-ui.test.ts +52 -0
  21. package/src/__tests__/scope-explanations.test.ts +20 -9
  22. package/src/__tests__/sessions.test.ts +80 -0
  23. package/src/__tests__/setup-gate.test.ts +111 -3
  24. package/src/__tests__/vault-remove.test.ts +40 -19
  25. package/src/account-setup.ts +2 -0
  26. package/src/admin-agent-grants.ts +16 -1
  27. package/src/admin-auth.ts +13 -4
  28. package/src/admin-clients.ts +66 -5
  29. package/src/admin-grants.ts +11 -2
  30. package/src/admin-handlers.ts +2 -0
  31. package/src/admin-host-admin-token.ts +24 -1
  32. package/src/admin-lock.ts +16 -0
  33. package/src/admin-vaults.ts +70 -15
  34. package/src/api-account-2fa.ts +395 -0
  35. package/src/api-admin-lock.ts +7 -0
  36. package/src/api-hub-upgrade.ts +14 -1
  37. package/src/api-hub.ts +10 -1
  38. package/src/api-invites.ts +18 -3
  39. package/src/api-me.ts +11 -2
  40. package/src/api-mint-token.ts +16 -1
  41. package/src/api-modules.ts +119 -1
  42. package/src/api-revoke-token.ts +14 -1
  43. package/src/api-settings-hub-origin.ts +14 -1
  44. package/src/api-settings-root-redirect.ts +201 -0
  45. package/src/api-tokens.ts +14 -1
  46. package/src/api-users.ts +15 -6
  47. package/src/api-vault-caps.ts +11 -2
  48. package/src/cli.ts +29 -0
  49. package/src/clients.ts +164 -0
  50. package/src/commands/auth.ts +263 -1
  51. package/src/commands/doctor.ts +1250 -0
  52. package/src/commands/hub.ts +102 -1
  53. package/src/commands/vault-remove.ts +16 -24
  54. package/src/cors.ts +7 -3
  55. package/src/help.ts +53 -0
  56. package/src/hub-db.ts +14 -0
  57. package/src/hub-server.ts +123 -19
  58. package/src/hub-settings.ts +163 -1
  59. package/src/jwt-sign.ts +25 -6
  60. package/src/oauth-handlers.ts +14 -1
  61. package/src/oauth-ui.ts +51 -0
  62. package/src/rate-limit.ts +28 -0
  63. package/src/scope-explanations.ts +23 -9
  64. package/src/sessions.ts +43 -2
  65. package/src/setup-wizard.ts +2 -0
  66. package/web/ui/dist/assets/{index--728BX3j.css → index-BcC4U5gM.css} +1 -1
  67. package/web/ui/dist/assets/index-CVqK1cV5.js +61 -0
  68. package/web/ui/dist/index.html +2 -2
  69. 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
- expect(scopesSupported).toContain("hub:admin");
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
- expect(scopesSupported).toContain("hub:admin");
173
- // NON_REQUESTABLE filter still applies even when the scope is declared
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
- expect(scopes).toContain("hub:admin");
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 scope consent screen (high-power sanity gate)", async () => {
7288
- // hub:admin is requestable via DCR (only `parachute:host:admin` and
7289
- // per-vault `vault:*:admin` are non-requestable). For same-hub
7290
- // clients we DO still show consent on admin scopes the operator
7291
- // who registered the client may not want to grant their own session
7292
- // hub-wide admin access without an explicit click.
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
- // Consent rendered, not silent-approve.
7316
- expect(res.status).toBe(200);
7317
- expect(res.headers.get("content-type")).toContain("text/html");
7318
- const html = await res.text();
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 admin+non-admin → consent screen (any admin scope shows consent)", async () => {
7326
- // Defensive: a request asking for `vault:default:read hub:admin` must
7327
- // NOT silent-approve on the strength of the non-admin scope. Any
7328
- // admin scope present forces consent.
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(200);
7352
- expect(res.headers.get("content-type")).toContain("text/html");
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 () => {