@openparachute/hub 0.7.4-rc.20 → 0.7.4-rc.22

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.7.4-rc.20",
3
+ "version": "0.7.4-rc.22",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -172,6 +172,134 @@ describe("requireScope", () => {
172
172
  });
173
173
  });
174
174
 
175
+ describe("requireScope multi-origin issuer set (hub#516 parity)", () => {
176
+ // The hub answers on several legitimate origins after `parachute hub
177
+ // set-origin` / `expose` (loopback ∪ tunnel ∪ public). A credential minted
178
+ // under a still-valid prior origin must keep validating at admin-auth even
179
+ // when the per-request canonical issuer is now a different member of the set.
180
+ const LOOPBACK = "http://127.0.0.1:1939";
181
+ const TUNNEL = "https://brain.gitcoin.co";
182
+ const FOREIGN = "https://evil.example.com";
183
+
184
+ test("accepts a token whose iss is in the set but ≠ the per-request canonical", async () => {
185
+ const h = makeHarness();
186
+ try {
187
+ const db = openHubDb(hubDbPath(h.dir));
188
+ try {
189
+ rotateSigningKey(db);
190
+ // Token minted under the TUNNEL origin (e.g. before `set-origin`)…
191
+ const token = await mintToken(db, ["parachute:host:admin"], { issuer: TUNNEL });
192
+ // …presented to admin-auth where the canonical per-request issuer is now
193
+ // LOOPBACK, but the bound-origin set still includes TUNNEL.
194
+ const ctx = await requireScope(db, reqWithAuth(`Bearer ${token}`), "parachute:host:admin", [
195
+ LOOPBACK,
196
+ TUNNEL,
197
+ ]);
198
+ expect(ctx.sub).toBe("user-test");
199
+ expect(ctx.scopes).toContain("parachute:host:admin");
200
+ } finally {
201
+ db.close();
202
+ }
203
+ } finally {
204
+ h.cleanup();
205
+ }
206
+ });
207
+
208
+ test("rejects 401 when iss is OUTSIDE the set", async () => {
209
+ const h = makeHarness();
210
+ try {
211
+ const db = openHubDb(hubDbPath(h.dir));
212
+ try {
213
+ rotateSigningKey(db);
214
+ const token = await mintToken(db, ["parachute:host:admin"], { issuer: FOREIGN });
215
+ let caught: AdminAuthError | null = null;
216
+ try {
217
+ await requireScope(db, reqWithAuth(`Bearer ${token}`), "parachute:host:admin", [
218
+ LOOPBACK,
219
+ TUNNEL,
220
+ ]);
221
+ } catch (err) {
222
+ caught = err as AdminAuthError;
223
+ }
224
+ expect(caught?.status).toBe(401);
225
+ } finally {
226
+ db.close();
227
+ }
228
+ } finally {
229
+ h.cleanup();
230
+ }
231
+ });
232
+
233
+ test("rejects 401 on signature failure regardless of the set (signature-first)", async () => {
234
+ const h = makeHarness();
235
+ try {
236
+ const db = openHubDb(hubDbPath(h.dir));
237
+ try {
238
+ rotateSigningKey(db);
239
+ // A hub-signed token, then rotate keys + retire so the original kid no
240
+ // longer verifies — the JWKS signature gate must fire before any iss
241
+ // membership check, so an in-set iss can't rescue an unverifiable token.
242
+ let caught: AdminAuthError | null = null;
243
+ try {
244
+ await requireScope(db, reqWithAuth("Bearer not-a-real-jwt"), "parachute:host:admin", [
245
+ LOOPBACK,
246
+ TUNNEL,
247
+ ]);
248
+ } catch (err) {
249
+ caught = err as AdminAuthError;
250
+ }
251
+ expect(caught?.status).toBe(401);
252
+ } finally {
253
+ db.close();
254
+ }
255
+ } finally {
256
+ h.cleanup();
257
+ }
258
+ });
259
+
260
+ test("single-string back-compat: in-set iss as a lone string still validates", async () => {
261
+ const h = makeHarness();
262
+ try {
263
+ const db = openHubDb(hubDbPath(h.dir));
264
+ try {
265
+ rotateSigningKey(db);
266
+ const token = await mintToken(db, ["parachute:host:admin"], { issuer: ISSUER });
267
+ // A single-element set behaves exactly like the prior single-string form.
268
+ const ctx = await requireScope(db, reqWithAuth(`Bearer ${token}`), "parachute:host:admin", [
269
+ ISSUER,
270
+ ]);
271
+ expect(ctx.sub).toBe("user-test");
272
+ } finally {
273
+ db.close();
274
+ }
275
+ } finally {
276
+ h.cleanup();
277
+ }
278
+ });
279
+
280
+ test("single-element set rejects a non-matching iss (no accidental widening)", async () => {
281
+ const h = makeHarness();
282
+ try {
283
+ const db = openHubDb(hubDbPath(h.dir));
284
+ try {
285
+ rotateSigningKey(db);
286
+ const token = await mintToken(db, ["parachute:host:admin"], { issuer: TUNNEL });
287
+ let caught: AdminAuthError | null = null;
288
+ try {
289
+ await requireScope(db, reqWithAuth(`Bearer ${token}`), "parachute:host:admin", [ISSUER]);
290
+ } catch (err) {
291
+ caught = err as AdminAuthError;
292
+ }
293
+ expect(caught?.status).toBe(401);
294
+ } finally {
295
+ db.close();
296
+ }
297
+ } finally {
298
+ h.cleanup();
299
+ }
300
+ });
301
+ });
302
+
175
303
  describe("adminAuthErrorResponse", () => {
176
304
  test("403 → insufficient_scope with WWW-Authenticate", async () => {
177
305
  const res = adminAuthErrorResponse(new AdminAuthError(403, "needs admin"));
@@ -8,6 +8,7 @@ import {
8
8
  handleAdminLoginPost,
9
9
  handleAdminLogoutPost,
10
10
  } from "../admin-handlers.ts";
11
+ import { _resetUnlockStateForTest, requireUnlocked, setPin } from "../admin-lock.ts";
11
12
  import { CSRF_COOKIE_NAME, CSRF_FIELD_NAME } from "../csrf.ts";
12
13
  import { hubDbPath, openHubDb } from "../hub-db.ts";
13
14
  import { __resetForTests as resetRateLimit } from "../rate-limit.ts";
@@ -169,6 +170,33 @@ describe("handleAdminLoginPost", () => {
169
170
  expect(res.headers.get("set-cookie") ?? "").toContain("parachute_hub_session=");
170
171
  });
171
172
 
173
+ test("Fix B: a fresh login with a PIN configured records an unlock (no immediate lock)", async () => {
174
+ // The admin-lock PIN guards the idle/grabbed tab — NOT the instant after a
175
+ // full login. The operator just proved their password; re-gating on the PIN
176
+ // the moment after is pure friction. A freshly-minted session must land
177
+ // within an unlock window so the SPA doesn't show the lock screen.
178
+ _resetUnlockStateForTest();
179
+ await createUser(harness.db, "admin", "pw", { passwordChanged: true });
180
+ await setPin(harness.db, "4827"); // lock feature ON
181
+ const { body, headers } = formBody({
182
+ [CSRF_FIELD_NAME]: TEST_CSRF,
183
+ username: "admin",
184
+ password: "pw",
185
+ next: "/admin/permissions",
186
+ });
187
+ const req = new Request("http://hub.test/admin/login", {
188
+ method: "POST",
189
+ headers: { ...headers, cookie: CSRF_COOKIE },
190
+ body,
191
+ });
192
+ const res = await handleAdminLoginPost(harness.db, req);
193
+ expect(res.status).toBe(302);
194
+ const sid = (res.headers.get("set-cookie") ?? "").match(/parachute_hub_session=([^;]+)/)?.[1];
195
+ expect(sid?.length).toBeTruthy();
196
+ // The just-minted session is unlocked — recordLoginUnlock ran during login.
197
+ expect(requireUnlocked(harness.db, sid ?? "").ok).toBe(true);
198
+ });
199
+
172
200
  test("ignores an absolute-URL next= from the form", async () => {
173
201
  await createUser(harness.db, "admin", "pw", { passwordChanged: true });
174
202
  const { body, headers } = formBody({
@@ -18,7 +18,13 @@ import { HOST_ADMIN_TOKEN_TTL_SECONDS, handleHostAdminToken } from "../admin-hos
18
18
  import { handleApiTokens } from "../api-tokens.ts";
19
19
  import { hubDbPath, openHubDb } from "../hub-db.ts";
20
20
  import { validateAccessToken } from "../jwt-sign.ts";
21
- import { SESSION_TTL_MS, buildSessionCookie, createSession, deleteSession } from "../sessions.ts";
21
+ import {
22
+ SESSION_TTL_MS,
23
+ buildSessionCookie,
24
+ createSession,
25
+ deleteSession,
26
+ findSession,
27
+ } from "../sessions.ts";
22
28
  import { rotateSigningKey } from "../signing-keys.ts";
23
29
  import { createUser } from "../users.ts";
24
30
 
@@ -186,6 +192,57 @@ describe("handleHostAdminToken", () => {
186
192
  expect(validated.payload.sub).toBe(adminId);
187
193
  });
188
194
 
195
+ // Sliding session renewal (Fix A) — a successful mint pushes the session's
196
+ // expiry forward and re-issues the cookie, so an active operator (the SPA
197
+ // re-mints ~every 10 min) isn't hard-logged-out at the 24h mark. The cookie
198
+ // must keep the EXACT attributes creation uses — not broadened.
199
+ test("200 renews the session cookie (HttpOnly/Secure/SameSite/host-only) and slides expiry", async () => {
200
+ const user = await createUser(harness.db, "operator", "hunter2");
201
+ // Create the session 12h in the past so the forward slide is observable.
202
+ const twelveHoursAgo = new Date(Date.now() - 12 * 60 * 60 * 1000);
203
+ const session = createSession(harness.db, { userId: user.id, now: () => twelveHoursAgo });
204
+ const originalExpiry = new Date(session.expiresAt).getTime();
205
+ const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
206
+ rotateSigningKey(harness.db);
207
+
208
+ const res = await handleHostAdminToken(
209
+ new Request(`${ISSUER}/admin/host-admin-token`, { headers: { cookie } }),
210
+ { db: harness.db, issuer: ISSUER },
211
+ );
212
+ expect(res.status).toBe(200);
213
+
214
+ const setCookie = res.headers.get("set-cookie") ?? "";
215
+ expect(setCookie).toContain("parachute_hub_session=");
216
+ expect(setCookie).toContain("HttpOnly");
217
+ expect(setCookie).toContain("Secure"); // ISSUER is https → Secure kept
218
+ expect(setCookie).toContain("SameSite=Lax");
219
+ expect(setCookie).toContain("Path=/");
220
+ expect(setCookie).not.toContain("Path=/oauth");
221
+ // Host-only: the renewed cookie must NOT add a Domain (no broadening).
222
+ expect(setCookie.toLowerCase()).not.toContain("domain=");
223
+
224
+ // The session's expiry slid forward (touchSession ran on the success path).
225
+ const found = findSession(harness.db, session.id);
226
+ expect(new Date(found?.expiresAt ?? 0).getTime()).toBeGreaterThan(originalExpiry);
227
+ });
228
+
229
+ test("renewed cookie omits Secure over plain HTTP (protocol-correct, not broadened)", async () => {
230
+ const { cookie } = await withSession();
231
+ rotateSigningKey(harness.db);
232
+ // HTTP origin → isHttpsRequest false → no Secure, so the browser keeps the
233
+ // cookie on http://localhost:1939 — mirrors how the session cookie is minted.
234
+ const res = await handleHostAdminToken(
235
+ new Request("http://hub.test/admin/host-admin-token", { headers: { cookie } }),
236
+ { db: harness.db, issuer: ISSUER },
237
+ );
238
+ expect(res.status).toBe(200);
239
+ const setCookie = res.headers.get("set-cookie") ?? "";
240
+ expect(setCookie).toContain("parachute_hub_session=");
241
+ expect(setCookie).not.toContain("Secure");
242
+ expect(setCookie).toContain("HttpOnly");
243
+ expect(setCookie).toContain("SameSite=Lax");
244
+ });
245
+
189
246
  // Regression for the end-to-end bug that motivated adding `:host:auth`
190
247
  // here: the SPA's session-bearer was rejected by `/api/auth/tokens` (and
191
248
  // its peers) because it carried `:host:admin` only. This test mints
@@ -30,6 +30,7 @@ import {
30
30
  getIdleSeconds,
31
31
  isLockConfigured,
32
32
  isSessionUnlocked,
33
+ recordLoginUnlock,
33
34
  recordUnlock,
34
35
  refreshActivity,
35
36
  requireUnlocked,
@@ -212,6 +213,31 @@ describe("requireUnlocked gate", () => {
212
213
  });
213
214
  });
214
215
 
216
+ // ---------------------------------------------------------------------------
217
+ // recordLoginUnlock (Fix B) — unlock at the auth boundary so a fresh login
218
+ // doesn't immediately hit the PIN lock screen.
219
+ // ---------------------------------------------------------------------------
220
+
221
+ describe("recordLoginUnlock", () => {
222
+ test("opens an unlock window when a PIN is configured", async () => {
223
+ await setPin(harness.db, "4827");
224
+ // A fresh session with a PIN set but no unlock is locked.
225
+ expect(requireUnlocked(harness.db, "sid-login").ok).toBe(false);
226
+ recordLoginUnlock(harness.db, "sid-login");
227
+ // After login → the freshly-authenticated session is unlocked.
228
+ expect(requireUnlocked(harness.db, "sid-login").ok).toBe(true);
229
+ expect(isSessionUnlocked("sid-login")).toBe(true);
230
+ });
231
+
232
+ test("no-op when the lock feature is OFF (no PIN) — records nothing", () => {
233
+ // Feature off → requireUnlocked is always ok anyway; the helper must not
234
+ // record a spurious window (meaningless, and would grow the map).
235
+ recordLoginUnlock(harness.db, "sid-no-pin");
236
+ expect(isSessionUnlocked("sid-no-pin")).toBe(false);
237
+ expect(requireUnlocked(harness.db, "sid-no-pin").ok).toBe(true);
238
+ });
239
+ });
240
+
215
241
  // ---------------------------------------------------------------------------
216
242
  // The cascade: a locked session refuses ALL four admin-token mints.
217
243
  // ---------------------------------------------------------------------------
@@ -172,6 +172,81 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
172
172
  }
173
173
  });
174
174
 
175
+ // hub#516 parity — the live "mint refused" after `parachute hub set-origin`.
176
+ // An operator/agent credential minted under a PRIOR origin (still a member of
177
+ // the hub's bound-origin set) must keep minting after the canonical issuer
178
+ // switches; the minted token still carries the new canonical issuer.
179
+ describe("multi-origin issuer set (set-origin parity)", () => {
180
+ const TUNNEL = "https://brain.gitcoin.co";
181
+
182
+ test("mints when the bearer's iss is in knownIssuers but ≠ the canonical issuer", async () => {
183
+ const h = makeHarness();
184
+ try {
185
+ const { db, userId } = await bootstrap(h.dir);
186
+ try {
187
+ // Operator token minted under the TUNNEL origin (pre-`set-origin`).
188
+ const op = await mintOperatorToken(db, userId, { issuer: TUNNEL });
189
+ const resp = await handleApiMintToken(
190
+ jsonRequest(
191
+ { scope: "scribe:transcribe", expires_in: 3600 },
192
+ { authorization: `Bearer ${op.token}` },
193
+ ),
194
+ // Canonical issuer is now ISSUER (loopback), but the bound set still
195
+ // includes TUNNEL — the still-valid prior origin.
196
+ { db, issuer: ISSUER, knownIssuers: [ISSUER, TUNNEL] },
197
+ );
198
+ expect(resp.status).toBe(200);
199
+ const body = (await resp.json()) as { token: string };
200
+ // The MINTED token carries the canonical issuer, not the bearer's.
201
+ const validated = await validateAccessToken(db, body.token, ISSUER);
202
+ expect(validated.payload.iss).toBe(ISSUER);
203
+ } finally {
204
+ db.close();
205
+ }
206
+ } finally {
207
+ h.cleanup();
208
+ }
209
+ });
210
+
211
+ test("rejects 401 when the bearer's iss is OUTSIDE knownIssuers", async () => {
212
+ const h = makeHarness();
213
+ try {
214
+ const { db, userId } = await bootstrap(h.dir);
215
+ try {
216
+ const op = await mintOperatorToken(db, userId, { issuer: "https://evil.example.com" });
217
+ const resp = await handleApiMintToken(
218
+ jsonRequest({ scope: "scribe:transcribe" }, { authorization: `Bearer ${op.token}` }),
219
+ { db, issuer: ISSUER, knownIssuers: [ISSUER, TUNNEL] },
220
+ );
221
+ expect(resp.status).toBe(401);
222
+ } finally {
223
+ db.close();
224
+ }
225
+ } finally {
226
+ h.cleanup();
227
+ }
228
+ });
229
+
230
+ test("back-compat: without knownIssuers, a non-canonical iss is still rejected", async () => {
231
+ const h = makeHarness();
232
+ try {
233
+ const { db, userId } = await bootstrap(h.dir);
234
+ try {
235
+ const op = await mintOperatorToken(db, userId, { issuer: TUNNEL });
236
+ const resp = await handleApiMintToken(
237
+ jsonRequest({ scope: "scribe:transcribe" }, { authorization: `Bearer ${op.token}` }),
238
+ { db, issuer: ISSUER }, // no knownIssuers → falls back to [ISSUER]
239
+ );
240
+ expect(resp.status).toBe(401);
241
+ } finally {
242
+ db.close();
243
+ }
244
+ } finally {
245
+ h.cleanup();
246
+ }
247
+ });
248
+ });
249
+
175
250
  test("happy path: --scope-set=auth narrow operator token also passes the scope gate", async () => {
176
251
  const h = makeHarness();
177
252
  try {
@@ -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
  });
@@ -7366,12 +7374,12 @@ describe("DCR same-hub auto-trust (hub#312)", () => {
7366
7374
  }
7367
7375
  });
7368
7376
 
7369
- test("authorize: same_hub=true + admin scope consent screen (high-power sanity gate)", async () => {
7370
- // hub:admin is requestable via DCR (only `parachute:host:admin` and
7371
- // per-vault `vault:*:admin` are non-requestable). For same-hub
7372
- // clients we DO still show consent on admin scopes the operator
7373
- // who registered the client may not want to grant their own session
7374
- // 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.
7375
7383
  const { db, cleanup } = await makeDb();
7376
7384
  try {
7377
7385
  const user = await createUser(db, "owner", "pw");
@@ -7390,24 +7398,24 @@ describe("DCR same-hub auto-trust (hub#312)", () => {
7390
7398
  scope: "hub:admin",
7391
7399
  code_challenge: challenge,
7392
7400
  code_challenge_method: "S256",
7401
+ state: "abc",
7393
7402
  }),
7394
7403
  { headers: { cookie: buildSessionCookie(session.id, SESSION_COOKIE_TTL_S) } },
7395
7404
  );
7396
7405
  const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
7397
- // Consent rendered, not silent-approve.
7398
- expect(res.status).toBe(200);
7399
- expect(res.headers.get("content-type")).toContain("text/html");
7400
- const html = await res.text();
7401
- 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");
7402
7410
  } finally {
7403
7411
  cleanup();
7404
7412
  }
7405
7413
  });
7406
7414
 
7407
- test("authorize: same_hub=true + mixed admin+non-admin → consent screen (any admin scope shows consent)", async () => {
7408
- // Defensive: a request asking for `vault:default:read hub:admin` must
7409
- // NOT silent-approve on the strength of the non-admin scope. Any
7410
- // 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).
7411
7419
  const { db, cleanup } = await makeDb();
7412
7420
  try {
7413
7421
  const user = await createUser(db, "owner", "pw");
@@ -7426,12 +7434,52 @@ describe("DCR same-hub auto-trust (hub#312)", () => {
7426
7434
  scope: "vault:default:read hub:admin",
7427
7435
  code_challenge: challenge,
7428
7436
  code_challenge_method: "S256",
7437
+ state: "abc",
7429
7438
  }),
7430
7439
  { headers: { cookie: buildSessionCookie(session.id, SESSION_COOKIE_TTL_S) } },
7431
7440
  );
7432
7441
  const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
7433
- expect(res.status).toBe(200);
7434
- 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");
7435
7483
  } finally {
7436
7484
  cleanup();
7437
7485
  }
@@ -129,11 +129,15 @@ describe("NON_REQUESTABLE_SCOPES (#96)", () => {
129
129
  expect(NON_REQUESTABLE_SCOPES.has("parachute:host:admin")).toBe(true);
130
130
  });
131
131
 
132
- test("does NOT contain hub:admin (intentional asymmetry)", () => {
133
- // hub:admin is service management an operator may legitimately delegate
134
- // to a tooling app. parachute:host:admin is cross-vault data sovereignty
135
- // and stays operator-only-mintable.
136
- expect(NON_REQUESTABLE_SCOPES.has("hub:admin")).toBe(false);
132
+ test("contains the service-admin scopes hub:admin and scribe:admin (2026-06-30 over-permissioning fix)", () => {
133
+ // A vault MCP connector (e.g. Claude) is pointed at the hub-level AS by the
134
+ // vault's protected-resource metadata, so hub:admin/scribe:admin would be
135
+ // advertised on its consent screen + minted if approved — wildly
136
+ // over-privileged for a vault reader. Every legit use is operator-bearer /
137
+ // session (operator token, DCR self-registration, admin SPA), never
138
+ // /oauth/authorize — so these fail closed without breaking operator paths.
139
+ expect(NON_REQUESTABLE_SCOPES.has("hub:admin")).toBe(true);
140
+ expect(NON_REQUESTABLE_SCOPES.has("scribe:admin")).toBe(true);
137
141
  });
138
142
 
139
143
  test("every non-requestable scope is a known first-party scope", () => {
@@ -148,11 +152,16 @@ describe("isRequestableScope", () => {
148
152
  expect(isRequestableScope("parachute:host:admin")).toBe(false);
149
153
  });
150
154
 
151
- test("true for hub:admin and other first-party scopes", () => {
152
- expect(isRequestableScope("hub:admin")).toBe(true);
155
+ test("false for service-admin scopes hub:admin and scribe:admin (operator-only, 2026-06-30)", () => {
156
+ expect(isRequestableScope("hub:admin")).toBe(false);
157
+ expect(isRequestableScope("scribe:admin")).toBe(false);
158
+ });
159
+
160
+ test("true for non-admin first-party scopes", () => {
153
161
  expect(isRequestableScope("vault:read")).toBe(true);
154
162
  expect(isRequestableScope("vault:admin")).toBe(true);
155
163
  expect(isRequestableScope("agent:send")).toBe(true);
164
+ expect(isRequestableScope("scribe:transcribe")).toBe(true);
156
165
  });
157
166
 
158
167
  test("true for unknown scopes (third-party module scopes pass through)", () => {
@@ -194,8 +203,10 @@ describe("isRequestableScope", () => {
194
203
  expect(isNonRequestableScope("parachute:Host:Install")).toBe(true);
195
204
  // Canonical lowercase still works unchanged.
196
205
  expect(isNonRequestableScope("parachute:host:auth")).toBe(true);
197
- // A non-host scope (even uppercased) stays requestable.
198
- expect(isNonRequestableScope("HUB:ADMIN")).toBe(false);
206
+ // Service-admin scopes are non-requestable too, case-insensitively (2026-06-30).
207
+ expect(isNonRequestableScope("HUB:ADMIN")).toBe(true);
208
+ // A genuinely requestable scope (even uppercased) stays requestable.
209
+ expect(isNonRequestableScope("VAULT:READ")).toBe(false);
199
210
  });
200
211
  });
201
212