@openparachute/hub 0.6.0 → 0.6.1-rc.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.6.0",
3
+ "version": "0.6.1-rc.1",
4
4
  "description": "parachute \u2014 the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -609,6 +609,207 @@ describe("handleAuthorizeGet — vault picker", () => {
609
609
  });
610
610
  });
611
611
 
612
+ describe("handleAuthorizeGet — RFC 8707 resource binding drops foreign scopes (scary-consent fix)", () => {
613
+ // claude.ai connecting to ONE vault reads the hub's whole-hub AS-metadata
614
+ // `scopes_supported` and over-requests the full catalog. Bound to the vault
615
+ // resource (`aud=vault.<name>`), scribe/channel/hub scopes are unusable, so
616
+ // they must be DROPPED before consent — Aaron hit them as "a fuck ton of
617
+ // privileges that don't make sense" (scribe isn't even installed here).
618
+ const FOREIGN_AND_VAULT =
619
+ "vault:read vault:write scribe:transcribe scribe:admin channel:send hub:admin";
620
+
621
+ test("session consent for a vault MCP resource drops scribe/channel/hub scopes", async () => {
622
+ const { db, cleanup } = await makeDb();
623
+ try {
624
+ const user = await createUser(db, "owner", "pw");
625
+ const session = createSession(db, { userId: user.id });
626
+ const reg = registerClient(db, {
627
+ redirectUris: ["https://app.example/cb"],
628
+ clientName: "Claude",
629
+ });
630
+ const { challenge } = makePkce();
631
+ const req = new Request(
632
+ authorizeUrl({
633
+ client_id: reg.client.clientId,
634
+ redirect_uri: "https://app.example/cb",
635
+ response_type: "code",
636
+ code_challenge: challenge,
637
+ code_challenge_method: "S256",
638
+ scope: FOREIGN_AND_VAULT,
639
+ resource: `${ISSUER}/vault/default/mcp`,
640
+ }),
641
+ {
642
+ headers: {
643
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
644
+ },
645
+ },
646
+ );
647
+ const res = handleAuthorizeGet(db, req, {
648
+ issuer: ISSUER,
649
+ loadServicesManifest: fixtureLoadServicesManifest,
650
+ });
651
+ // Renders consent (200) — NOT a 302 invalid_scope. Pre-fix the
652
+ // pass-through left non-requestable `hub:admin` + `scribe:admin` in the
653
+ // request, which the gate would reject; dropping them clears the gate.
654
+ expect(res.status).toBe(200);
655
+ const html = await res.text();
656
+ // Vault scopes survive, narrowed to the bound vault → picker is gone.
657
+ expect(html).not.toContain("Pick a vault");
658
+ expect(html).toContain("Create, edit, and delete notes, tags, and attachments."); // vault:write
659
+ // The foreign scopes are gone.
660
+ expect(html).not.toContain("Send audio to Scribe for transcription."); // scribe:transcribe
661
+ expect(html).not.toContain("Manage Scribe configuration"); // scribe:admin
662
+ expect(html).not.toContain("Post messages to your Channel."); // channel:send
663
+ expect(html).not.toContain("Manage hub identity"); // hub:admin
664
+ } finally {
665
+ cleanup();
666
+ }
667
+ });
668
+
669
+ test("session-less 'App not yet approved' page also drops foreign scopes", async () => {
670
+ const { db, cleanup } = await makeDb();
671
+ try {
672
+ const reg = registerClient(db, {
673
+ redirectUris: ["https://app.example/cb"],
674
+ clientName: "Claude",
675
+ status: "pending",
676
+ });
677
+ const { challenge } = makePkce();
678
+ const req = new Request(
679
+ authorizeUrl({
680
+ client_id: reg.client.clientId,
681
+ redirect_uri: "https://app.example/cb",
682
+ response_type: "code",
683
+ code_challenge: challenge,
684
+ code_challenge_method: "S256",
685
+ scope: "vault:read scribe:transcribe channel:send",
686
+ resource: `${ISSUER}/vault/default/mcp`,
687
+ }),
688
+ // No session cookie → the unauth pending page renders.
689
+ );
690
+ const res = handleAuthorizeGet(db, req, {
691
+ issuer: ISSUER,
692
+ loadServicesManifest: fixtureLoadServicesManifest,
693
+ });
694
+ expect(res.status).toBe(403);
695
+ const html = await res.text();
696
+ expect(html).toContain("App not yet approved");
697
+ // Foreign scopes absent from the rendered rows...
698
+ expect(html).not.toContain("Send audio to Scribe for transcription.");
699
+ expect(html).not.toContain("Post messages to your Channel.");
700
+ // ...and from the login round-trip URL embedded in the page (the
701
+ // narrowed scope was written back onto `url` before this render).
702
+ expect(html).not.toContain("scribe:transcribe");
703
+ expect(html).not.toContain("channel:send");
704
+ expect(html).not.toContain("scribe%3Atranscribe");
705
+ expect(html).not.toContain("channel%3Asend");
706
+ } finally {
707
+ cleanup();
708
+ }
709
+ });
710
+
711
+ test("a vault-bound request of ONLY non-vault scopes narrows to empty (consent, not invalid_scope)", async () => {
712
+ // Edge: a client over-asks but names zero vault scopes against a vault
713
+ // resource. Narrowing drops everything → empty scope. We render consent
714
+ // (zero scope rows) rather than a 302 invalid_scope; an empty grant is
715
+ // harmless and the operator can simply deny.
716
+ const { db, cleanup } = await makeDb();
717
+ try {
718
+ const user = await createUser(db, "owner", "pw");
719
+ const session = createSession(db, { userId: user.id });
720
+ const reg = registerClient(db, {
721
+ redirectUris: ["https://app.example/cb"],
722
+ clientName: "Claude",
723
+ });
724
+ const { challenge } = makePkce();
725
+ const req = new Request(
726
+ authorizeUrl({
727
+ client_id: reg.client.clientId,
728
+ redirect_uri: "https://app.example/cb",
729
+ response_type: "code",
730
+ code_challenge: challenge,
731
+ code_challenge_method: "S256",
732
+ scope: "scribe:transcribe channel:send",
733
+ resource: `${ISSUER}/vault/default/mcp`,
734
+ }),
735
+ {
736
+ headers: {
737
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
738
+ },
739
+ },
740
+ );
741
+ const res = handleAuthorizeGet(db, req, {
742
+ issuer: ISSUER,
743
+ loadServicesManifest: fixtureLoadServicesManifest,
744
+ });
745
+ expect(res.status).toBe(200);
746
+ const html = await res.text();
747
+ expect(html).not.toContain("Send audio to Scribe for transcription.");
748
+ expect(html).not.toContain("Post messages to your Channel.");
749
+ } finally {
750
+ cleanup();
751
+ }
752
+ });
753
+
754
+ test("trust-by-client_name no longer re-prompts when the request over-asks the whole-hub catalog", async () => {
755
+ // Before the narrowing was moved ahead of the status branch, the
756
+ // trust-by-client_name coverage check compared the RAW request
757
+ // (`vault:read scribe:transcribe channel:send`) against a vault-only prior
758
+ // grant — never matched — re-prompting consent every session for a client
759
+ // the operator had already approved. Narrowing first makes the comparison
760
+ // vault-only-vs-vault-only, so the silent re-link fires.
761
+ const { db, cleanup } = await makeDb();
762
+ try {
763
+ const user = await createUser(db, "owner", "pw");
764
+ const session = createSession(db, { userId: user.id });
765
+ // Prior approval under client_name "Claude" (an earlier DCR client_id).
766
+ const oldReg = registerClient(db, {
767
+ redirectUris: ["https://app.example/cb"],
768
+ clientName: "Claude",
769
+ });
770
+ const { recordGrant } = await import("../grants.ts");
771
+ recordGrant(db, user.id, oldReg.client.clientId, ["vault:default:read"]);
772
+ // Fresh per-session DCR: new client_id, same name, pending.
773
+ const newReg = registerClient(db, {
774
+ redirectUris: ["https://app.example/cb"],
775
+ clientName: "Claude",
776
+ status: "pending",
777
+ });
778
+ expect(newReg.client.clientId).not.toBe(oldReg.client.clientId);
779
+ const { challenge } = makePkce();
780
+ const req = new Request(
781
+ authorizeUrl({
782
+ client_id: newReg.client.clientId,
783
+ redirect_uri: "https://app.example/cb",
784
+ response_type: "code",
785
+ code_challenge: challenge,
786
+ code_challenge_method: "S256",
787
+ scope: "vault:read scribe:transcribe channel:send",
788
+ resource: `${ISSUER}/vault/default/mcp`,
789
+ }),
790
+ {
791
+ headers: {
792
+ // Same-origin → the trust-by-client_name carry-over is allowed.
793
+ origin: ISSUER,
794
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
795
+ },
796
+ },
797
+ );
798
+ const res = handleAuthorizeGet(db, req, {
799
+ issuer: ISSUER,
800
+ loadServicesManifest: fixtureLoadServicesManifest,
801
+ });
802
+ // Silent re-link: 302 back to the client with a code — NOT a 200 re-prompt.
803
+ expect(res.status).toBe(302);
804
+ const loc = res.headers.get("location") ?? "";
805
+ expect(loc).toContain("https://app.example/cb");
806
+ expect(loc).toContain("code=");
807
+ } finally {
808
+ cleanup();
809
+ }
810
+ });
811
+ });
812
+
612
813
  describe("handleAuthorizePost — vault picker", () => {
613
814
  test("approve with vault_pick narrows vault:read → vault:<picked>:read in the issued JWT", async () => {
614
815
  const { db, cleanup } = await makeDb();
@@ -77,16 +77,42 @@ describe("narrowResourceVaultScopes", () => {
77
77
  expect(narrowResourceVaultScopes(["vault:other:read"], "jon")).toEqual(["vault:other:read"]);
78
78
  });
79
79
 
80
- test("passes non-vault scopes through unchanged", () => {
81
- expect(narrowResourceVaultScopes(["scribe:transcribe", "vault:read"], "jon")).toEqual([
82
- "scribe:transcribe",
83
- "vault:jon:read",
84
- ]);
85
- });
86
-
87
- test("narrows the admin verb too (gate happens downstream)", () => {
88
- // narrowResourceVaultScopes only rewrites shape; the non-requestable gate
89
- // (`vault:<name>:admin`) blocks it afterward.
80
+ test("drops non-vault scopes unusable in a vault-audience token", () => {
81
+ // A vault-bound flow mints `aud=vault.jon`; scribe/channel/hub scopes
82
+ // inside that token are dead weight, so they're removed rather than shown
83
+ // on the consent screen.
84
+ expect(
85
+ narrowResourceVaultScopes(
86
+ ["scribe:transcribe", "channel:send", "hub:admin", "vault:read"],
87
+ "jon",
88
+ ),
89
+ ).toEqual(["vault:jon:read"]);
90
+ });
91
+
92
+ test("a one-vault connection drops the whole-hub catalog claude.ai over-requests", () => {
93
+ // claude.ai reads the hub AS-metadata `scopes_supported` (the full
94
+ // catalog) and requests all of it. Bound to one vault, only that vault's
95
+ // verbs survive — no scribe (uninstalled) or channel:send on the consent.
96
+ // Regression lock for the "scary consent" bug.
97
+ expect(
98
+ narrowResourceVaultScopes(
99
+ [
100
+ "vault:read",
101
+ "vault:write",
102
+ "vault:admin",
103
+ "scribe:admin",
104
+ "scribe:transcribe",
105
+ "channel:send",
106
+ "hub:admin",
107
+ ],
108
+ "default",
109
+ ),
110
+ ).toEqual(["vault:default:read", "vault:default:write", "vault:default:admin"]);
111
+ });
112
+
113
+ test("narrows the admin verb too (requestable-scope gate decides downstream)", () => {
114
+ // narrowResourceVaultScopes only rewrites shape; `vault:<name>:admin` is
115
+ // requestable post-#484, so this named form survives the downstream gate.
90
116
  expect(narrowResourceVaultScopes(["vault:admin"], "jon")).toEqual(["vault:jon:admin"]);
91
117
  });
92
118
 
@@ -844,6 +844,47 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
844
844
  // localStorage, the recovery is "clear that key and reload the SPA").
845
845
  return unknownClientResponse(parsed.clientId, parsed.redirectUri, deps);
846
846
  }
847
+
848
+ // RFC 8707 resource binding. When the client named a per-vault MCP resource
849
+ // (`<origin>/vault/<name>/mcp` or its PRM URL), narrow the requested scopes
850
+ // to that vault BEFORE the pending/consent branches below, so EVERY
851
+ // downstream consumer sees the narrowed set:
852
+ //
853
+ // 1. The consent screen — and the session-less "App not yet approved"
854
+ // page (`pendingClientResponse`) — shows ONLY that vault's scopes
855
+ // instead of the whole-hub catalog. Narrowing DROPS non-vault scopes
856
+ // (`scribe:*`, `channel:send`, `hub:admin`) outright: the token this
857
+ // flow mints is stamped `aud=vault.<name>`, so they're unusable inside
858
+ // it and only inflate the consent surface — the exact "scary consent"
859
+ // a friend hit connecting Claude to ONE vault (scribe isn't even
860
+ // installed; `channel:send` is meaningless to them).
861
+ // 2. The minted token carries the named scope, so `inferAudience` stamps
862
+ // `aud=vault.<name>` and a current-line vault accepts it (an unnamed
863
+ // `vault:read` token is rejected by `findBroadVaultScopes`).
864
+ // 3. The trust-by-client_name coverage checks (the auto-approve branch
865
+ // below + the one inside `pendingClientResponse`) compare the narrowed
866
+ // request against the narrowed prior grant. Before this ran early they
867
+ // compared the RAW whole-hub request against a vault-only grant, never
868
+ // matched, and re-prompted consent every session for a client the
869
+ // operator had already approved.
870
+ //
871
+ // We rewrite both `parsed.scope` (consent/grant/mint read it) AND the `url`
872
+ // scope param (`pendingClientResponse` + its login round-trip read it off
873
+ // the URL) so the two never diverge. Re-entry after login re-narrows
874
+ // idempotently (no foreign scopes left to drop).
875
+ //
876
+ // No resource, or one that isn't a per-vault MCP resource (off-origin,
877
+ // malformed, non-vault path) → `boundVault` is null and the flow is
878
+ // byte-for-byte the pre-#461 behavior (manual picker, etc.).
879
+ const boundVault = resolveResourceVault(parsed.resource, resolveBoundOrigins(deps));
880
+ if (boundVault) {
881
+ parsed.scope = narrowResourceVaultScopes(
882
+ parsed.scope.split(" ").filter((s) => s.length > 0),
883
+ boundVault,
884
+ ).join(" ");
885
+ url.searchParams.set("scope", parsed.scope);
886
+ }
887
+
847
888
  if (client.status !== "approved") {
848
889
  // Single-consent change (2026-05-29): the separate operator "approve this
849
890
  // client" gate is retired — the user's OAuth consent IS the authorization.
@@ -921,41 +962,6 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
921
962
  );
922
963
  }
923
964
 
924
- // RFC 8707 resource binding. When the client named a per-vault MCP
925
- // resource (`<origin>/vault/<name>/mcp` or its PRM URL), narrow the
926
- // requested vault verbs to the named `vault:<name>:<verb>` form BEFORE any
927
- // downstream processing. Two effects:
928
- //
929
- // 1. The consent screen shows ONLY that vault's scopes (the picker locks
930
- // to <name>) instead of the whole-hub catalog — a friend connecting to
931
- // one vault no longer sees `hub:admin`, `scribe:admin`, or every other
932
- // vault's verbs.
933
- // 2. The minted token carries the named scope, so `inferAudience` stamps
934
- // `aud=vault.<name>` and a current-line vault accepts it (an unnamed
935
- // `vault:read` token is rejected by `findBroadVaultScopes`).
936
- //
937
- // Narrowing happens before the non-requestable gate (below) on purpose: if
938
- // a resource-bound client somehow asked for `vault:admin`, narrowing makes
939
- // it `vault:<name>:admin`, which IS non-requestable — so the gate correctly
940
- // blocks it. Read/write narrow to the requestable named form. Non-vault
941
- // scopes and already-named scopes for other vaults pass through unchanged.
942
- //
943
- // No resource, or a resource that isn't one of our per-vault MCP resources
944
- // (off-origin, malformed, non-vault path) → `boundVault` is null and the
945
- // flow is byte-for-byte the pre-#461 behavior (manual picker, etc.).
946
- const boundVault = resolveResourceVault(parsed.resource, resolveBoundOrigins(deps));
947
- if (boundVault) {
948
- const narrowed = narrowResourceVaultScopes(
949
- parsed.scope.split(" ").filter((s) => s.length > 0),
950
- boundVault,
951
- );
952
- // Rewrite `parsed.scope` so the narrowed named scopes flow through every
953
- // downstream consumer: the login-redirect query round-trip, the consent
954
- // props + hidden inputs, the skip-consent grant lookup, and the
955
- // auth-code mint.
956
- parsed.scope = narrowed.join(" ");
957
- }
958
-
959
965
  // Operator-only scope gate (#96). Reject any request that names a scope
960
966
  // we'll never mint via this flow — `parachute:host:admin` and friends.
961
967
  // Per RFC 6749 §4.1.2.1, errors that aren't redirect-uri-related are
@@ -109,26 +109,41 @@ function decodeVaultName(segment: string): string | null {
109
109
  }
110
110
 
111
111
  /**
112
- * Rewrite the requested scope list for a resource-bound vault flow.
112
+ * Rewrite the requested scope list for a resource-bound vault flow, returning
113
+ * ONLY scopes usable in the resulting vault-audience token:
113
114
  *
114
115
  * - unnamed `vault:<verb>` → `vault:<name>:<verb>` (the narrow,
115
116
  * audience-correct shape vault accepts);
116
117
  * - already-named `vault:<other>:<verb>` is LEFT UNTOUCHED — a client that
117
118
  * explicitly named a different vault is not silently re-pointed; the
118
119
  * downstream picker / assignment defenses decide whether that's allowed.
119
- * - non-vault scopes (`scribe:transcribe`, `hub:admin`, …) pass through
120
- * unchanged resource-binding only narrows the vault verbs.
120
+ * - non-vault scopes (`scribe:*`, `channel:send`, `hub:admin`, …) are
121
+ * DROPPED. This flow mints a token stamped `aud=vault.<name>` (RFC 8707),
122
+ * so a scribe/channel/hub scope inside it is unusable — keeping it only
123
+ * inflates the consent surface a friend sees when connecting ONE vault.
124
+ * That "scary consent" is the failure mode this module exists to kill
125
+ * (see the header docstring): the verb-narrowing alone left the foreign
126
+ * scopes riding through, so a client that over-requests the whole-hub
127
+ * catalog (claude.ai reads it from the AS-metadata `scopes_supported`)
128
+ * still surfaced `scribe:admin` + `channel:send` on the consent screen.
129
+ * A client that genuinely wants a scribe token runs a separate flow
130
+ * naming the scribe resource.
121
131
  *
122
- * Idempotent: a scope already shaped `vault:<name>:<verb>` for THIS name is
123
- * returned as-is, so re-running over a narrowed list is a no-op.
132
+ * Idempotent: an already-narrowed list contains only `vault:` scopes, so a
133
+ * second pass has nothing left to drop and `vault:<name>:<verb>` for THIS name
134
+ * is returned as-is.
124
135
  */
125
136
  export function narrowResourceVaultScopes(scopes: readonly string[], vaultName: string): string[] {
126
- return scopes.map((s) => {
137
+ const out: string[] = [];
138
+ for (const s of scopes) {
127
139
  const parts = s.split(":");
140
+ if (parts[0] !== "vault") continue; // drop scribe:/channel:/hub:/… — foreign to a vault-audience token
128
141
  const verb = parts[1];
129
- if (parts.length === 2 && parts[0] === "vault" && verb && VAULT_VERBS.has(verb)) {
130
- return `vault:${vaultName}:${verb}`;
142
+ if (parts.length === 2 && verb && VAULT_VERBS.has(verb)) {
143
+ out.push(`vault:${vaultName}:${verb}`);
144
+ } else {
145
+ out.push(s); // already-named (incl. other vaults) or malformed vault scope — downstream defenses decide
131
146
  }
132
- return s;
133
- });
147
+ }
148
+ return out;
134
149
  }