@openparachute/hub 0.6.1-rc.2 → 0.6.1-rc.3

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.1-rc.2",
3
+ "version": "0.6.1-rc.3",
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": {
@@ -95,7 +95,10 @@ function fixtureLoadServicesManifest(): ServicesManifest {
95
95
 
96
96
  describe("authorizationServerMetadata", () => {
97
97
  test("emits RFC 8414 fields rooted at the issuer", async () => {
98
- const res = authorizationServerMetadata({ issuer: ISSUER });
98
+ const res = authorizationServerMetadata({
99
+ issuer: ISSUER,
100
+ loadServicesManifest: fixtureLoadServicesManifest,
101
+ });
99
102
  expect(res.status).toBe(200);
100
103
  const body = (await res.json()) as Record<string, unknown>;
101
104
  expect(body.issuer).toBe(ISSUER);
@@ -110,15 +113,21 @@ describe("authorizationServerMetadata", () => {
110
113
  const scopesSupported = body.scopes_supported as string[];
111
114
  expect(scopesSupported).toContain("vault:read");
112
115
  expect(scopesSupported).toContain("vault:admin");
113
- expect(scopesSupported).toContain("scribe:transcribe");
116
+ expect(scopesSupported).toContain("scribe:transcribe"); // scribe is in the fixture manifest
114
117
  expect(scopesSupported).toContain("hub:admin");
118
+ // channel isn't in the fixture manifest → its scopes aren't advertised
119
+ // (hub#…: optional-module scopes only surface when the module is installed).
120
+ expect(scopesSupported).not.toContain("channel:send");
115
121
  });
116
122
 
117
123
  test("does NOT advertise non-requestable operator-only scopes", async () => {
118
124
  // #96: parachute:host:admin is operator-only. RFC 8414 §2 frames
119
125
  // scopes_supported as scopes a client *can* request — advertising what
120
126
  // we always reject would mislead clients.
121
- const res = authorizationServerMetadata({ issuer: ISSUER });
127
+ const res = authorizationServerMetadata({
128
+ issuer: ISSUER,
129
+ loadServicesManifest: fixtureLoadServicesManifest,
130
+ });
122
131
  const body = (await res.json()) as Record<string, unknown>;
123
132
  const scopesSupported = body.scopes_supported as string[];
124
133
  expect(scopesSupported).not.toContain("parachute:host:admin");
@@ -143,6 +152,7 @@ describe("authorizationServerMetadata", () => {
143
152
  const res = authorizationServerMetadata({
144
153
  issuer: ISSUER,
145
154
  loadDeclaredScopes: () => declared,
155
+ loadServicesManifest: fixtureLoadServicesManifest,
146
156
  });
147
157
  const body = (await res.json()) as Record<string, unknown>;
148
158
  const scopesSupported = body.scopes_supported as string[];
@@ -157,11 +167,84 @@ describe("authorizationServerMetadata", () => {
157
167
  // NON_REQUESTABLE filter still applies even when the scope is declared
158
168
  expect(scopesSupported).not.toContain("parachute:host:admin");
159
169
  });
170
+
171
+ test("advertises an optional module's scopes only when it's installed", async () => {
172
+ // FIRST_PARTY_SCOPES carries scribe:* + channel:send statically. On a
173
+ // vault-only hub they must NOT be advertised — a discovery client (e.g.
174
+ // claude.ai's connector UI) lists the catalog verbatim, so a friend
175
+ // connecting one vault was shown Scribe + Channel access the hub can't
176
+ // honor. Vault + hub are core and always advertised.
177
+ const declared = new Set<string>([
178
+ "vault:read",
179
+ "vault:write",
180
+ "vault:admin",
181
+ "scribe:transcribe",
182
+ "scribe:admin",
183
+ "channel:send",
184
+ "hub:admin",
185
+ ]);
186
+ const vaultOnly = {
187
+ services: [
188
+ {
189
+ name: "parachute-vault",
190
+ port: 1940,
191
+ paths: ["/vault/default"],
192
+ health: "/health",
193
+ version: "0.5.1",
194
+ },
195
+ ],
196
+ };
197
+ const res = authorizationServerMetadata({
198
+ issuer: ISSUER,
199
+ loadDeclaredScopes: () => declared,
200
+ loadServicesManifest: () => vaultOnly as unknown as ServicesManifest,
201
+ });
202
+ const scopes = ((await res.json()) as Record<string, unknown>).scopes_supported as string[];
203
+ // core scopes survive
204
+ expect(scopes).toContain("vault:read");
205
+ expect(scopes).toContain("vault:admin");
206
+ expect(scopes).toContain("hub:admin");
207
+ // uninstalled optional-module scopes are dropped
208
+ expect(scopes).not.toContain("scribe:transcribe");
209
+ expect(scopes).not.toContain("scribe:admin");
210
+ expect(scopes).not.toContain("channel:send");
211
+
212
+ // ...but once scribe is installed, its scopes ARE advertised again.
213
+ const withScribe = {
214
+ services: [
215
+ {
216
+ name: "parachute-vault",
217
+ port: 1940,
218
+ paths: ["/vault/default"],
219
+ health: "/health",
220
+ version: "0.5.1",
221
+ },
222
+ {
223
+ name: "parachute-scribe",
224
+ port: 1943,
225
+ paths: ["/scribe"],
226
+ health: "/health",
227
+ version: "0.4.5",
228
+ },
229
+ ],
230
+ };
231
+ const res2 = authorizationServerMetadata({
232
+ issuer: ISSUER,
233
+ loadDeclaredScopes: () => declared,
234
+ loadServicesManifest: () => withScribe as unknown as ServicesManifest,
235
+ });
236
+ const scopes2 = ((await res2.json()) as Record<string, unknown>).scopes_supported as string[];
237
+ expect(scopes2).toContain("scribe:transcribe");
238
+ expect(scopes2).not.toContain("channel:send"); // channel still not installed
239
+ });
160
240
  });
161
241
 
162
242
  describe("protectedResourceMetadata (RFC 9728, closes hub#393)", () => {
163
243
  test("emits the required RFC 9728 fields rooted at the issuer", async () => {
164
- const res = protectedResourceMetadata({ issuer: ISSUER });
244
+ const res = protectedResourceMetadata({
245
+ issuer: ISSUER,
246
+ loadServicesManifest: fixtureLoadServicesManifest,
247
+ });
165
248
  expect(res.status).toBe(200);
166
249
  expect(res.headers.get("content-type")).toMatch(/application\/json/);
167
250
  const body = (await res.json()) as Record<string, unknown>;
@@ -185,6 +268,7 @@ describe("protectedResourceMetadata (RFC 9728, closes hub#393)", () => {
185
268
  const res = protectedResourceMetadata({
186
269
  issuer: ISSUER,
187
270
  loadDeclaredScopes: () => declared,
271
+ loadServicesManifest: fixtureLoadServicesManifest,
188
272
  });
189
273
  const body = (await res.json()) as Record<string, unknown>;
190
274
  const scopes = body.scopes_supported as string[];
@@ -3904,7 +3988,10 @@ describe("refresh-token rotation + /oauth/revoke (#73)", () => {
3904
3988
  });
3905
3989
 
3906
3990
  test("authorizationServerMetadata advertises revocation_endpoint", async () => {
3907
- const res = authorizationServerMetadata({ issuer: ISSUER });
3991
+ const res = authorizationServerMetadata({
3992
+ issuer: ISSUER,
3993
+ loadServicesManifest: fixtureLoadServicesManifest,
3994
+ });
3908
3995
  const body = (await res.json()) as Record<string, unknown>;
3909
3996
  expect(body.revocation_endpoint).toBe(`${ISSUER}/oauth/revoke`);
3910
3997
  });
@@ -388,13 +388,53 @@ function oauthErrorRedirect(
388
388
  *
389
389
  * Closes hub#393.
390
390
  */
391
+
392
+ /**
393
+ * Optional first-party modules whose scopes `FIRST_PARTY_SCOPES` carries
394
+ * statically (it's `Object.keys(SCOPE_EXPLANATIONS)`), paired with the
395
+ * services.json entry that means "installed." Vault + hub are core and always
396
+ * advertised; these are the modules a hub may not have.
397
+ */
398
+ const OPTIONAL_MODULE_SCOPES: ReadonlyArray<readonly [prefix: string, service: string]> = [
399
+ ["scribe:", "parachute-scribe"],
400
+ ["channel:", "parachute-channel"],
401
+ ];
402
+
403
+ /**
404
+ * The scope set to advertise in `scopes_supported` (RFC 8414 + RFC 9728): the
405
+ * requestable declared scopes, minus any OPTIONAL module's scopes when that
406
+ * module isn't installed.
407
+ *
408
+ * Why: `FIRST_PARTY_SCOPES` is static, so a vault-only hub still advertised
409
+ * `scribe:*` + `channel:send`. Discovery clients list the advertised catalog
410
+ * verbatim — claude.ai's connector UI showed a friend connecting ONE vault a
411
+ * request for Scribe + Channel access the hub can't even honor. So advertise an
412
+ * optional module's scopes only when its service is present in services.json.
413
+ * (Trims the ADVERTISEMENT only; issuance/validation still use the full
414
+ * `loadDeclaredScopes` set, and the per-vault PRM stays vault-narrowed.)
415
+ */
416
+ function advertisedScopes(declared: ReadonlySet<string>, manifest: ServicesManifest): string[] {
417
+ const installed = new Set(manifest.services.map((s) => s.name));
418
+ return Array.from(declared)
419
+ .filter(isRequestableScope)
420
+ .filter((scope) => {
421
+ for (const [prefix, service] of OPTIONAL_MODULE_SCOPES) {
422
+ if (scope.startsWith(prefix) && !installed.has(service)) return false;
423
+ }
424
+ return true;
425
+ });
426
+ }
427
+
391
428
  export function protectedResourceMetadata(deps: OAuthDeps): Response {
392
429
  const iss = deps.issuer;
393
430
  const declared = (deps.loadDeclaredScopes ?? loadDeclaredScopes)();
394
431
  return jsonResponse({
395
432
  resource: iss,
396
433
  authorization_servers: [iss],
397
- scopes_supported: Array.from(declared).filter(isRequestableScope),
434
+ scopes_supported: advertisedScopes(
435
+ declared,
436
+ (deps.loadServicesManifest ?? readServicesManifest)(),
437
+ ),
398
438
  bearer_methods_supported: ["header"],
399
439
  resource_documentation: "https://parachute.computer",
400
440
  // Intentional omission: `resource_signing_alg_values_supported` +
@@ -435,7 +475,10 @@ export function authorizationServerMetadata(deps: OAuthDeps): Response {
435
475
  // — RFC 8414 §2 frames `scopes_supported` as "the OAuth 2.0 [...] scope
436
476
  // values that this authorization server supports" for clients to request.
437
477
  // Advertising what we always reject would mislead clients.
438
- scopes_supported: Array.from(declared).filter(isRequestableScope),
478
+ scopes_supported: advertisedScopes(
479
+ declared,
480
+ (deps.loadServicesManifest ?? readServicesManifest)(),
481
+ ),
439
482
  });
440
483
  }
441
484
 
@@ -45,6 +45,12 @@ export const SCOPE_EXPLANATIONS: Record<string, ScopeExplanation> = {
45
45
  label: "Full vault access plus configuration changes (rotate tokens, change settings).",
46
46
  level: "admin",
47
47
  },
48
+ // Optional-module scopes (scribe / channel). These are in FIRST_PARTY_SCOPES
49
+ // (= Object.keys(this map)) but the modules may not be installed — so they're
50
+ // GATED in `OPTIONAL_MODULE_SCOPES` (oauth-handlers.ts) and only advertised in
51
+ // `scopes_supported` when the service is in services.json. If you add scopes
52
+ // for another optional module here, add a matching gate there too, or a
53
+ // vault-only hub will over-advertise them (the bug behind hub#489).
48
54
  "scribe:transcribe": {
49
55
  label: "Send audio to Scribe for transcription.",
50
56
  level: "write",