@openparachute/hub 0.7.4-rc.8 → 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 (71) 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 +276 -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/__tests__/well-known.test.ts +37 -2
  26. package/src/account-setup.ts +2 -0
  27. package/src/admin-agent-grants.ts +16 -1
  28. package/src/admin-auth.ts +13 -4
  29. package/src/admin-clients.ts +66 -5
  30. package/src/admin-grants.ts +11 -2
  31. package/src/admin-handlers.ts +2 -0
  32. package/src/admin-host-admin-token.ts +24 -1
  33. package/src/admin-lock.ts +16 -0
  34. package/src/admin-vaults.ts +70 -15
  35. package/src/api-account-2fa.ts +395 -0
  36. package/src/api-admin-lock.ts +7 -0
  37. package/src/api-hub-upgrade.ts +14 -1
  38. package/src/api-hub.ts +10 -1
  39. package/src/api-invites.ts +18 -3
  40. package/src/api-me.ts +11 -2
  41. package/src/api-mint-token.ts +16 -1
  42. package/src/api-modules.ts +119 -1
  43. package/src/api-revoke-token.ts +14 -1
  44. package/src/api-settings-hub-origin.ts +14 -1
  45. package/src/api-settings-root-redirect.ts +201 -0
  46. package/src/api-tokens.ts +14 -1
  47. package/src/api-users.ts +15 -6
  48. package/src/api-vault-caps.ts +11 -2
  49. package/src/cli.ts +29 -0
  50. package/src/clients.ts +164 -0
  51. package/src/commands/auth.ts +263 -1
  52. package/src/commands/doctor.ts +1250 -0
  53. package/src/commands/hub.ts +102 -1
  54. package/src/commands/vault-remove.ts +16 -24
  55. package/src/cors.ts +7 -3
  56. package/src/help.ts +53 -0
  57. package/src/hub-db.ts +14 -0
  58. package/src/hub-server.ts +123 -19
  59. package/src/hub-settings.ts +163 -1
  60. package/src/jwt-sign.ts +25 -6
  61. package/src/oauth-handlers.ts +25 -5
  62. package/src/oauth-ui.ts +51 -0
  63. package/src/rate-limit.ts +28 -0
  64. package/src/scope-explanations.ts +23 -9
  65. package/src/sessions.ts +43 -2
  66. package/src/setup-wizard.ts +2 -0
  67. package/src/well-known.ts +10 -1
  68. package/web/ui/dist/assets/{index--728BX3j.css → index-BcC4U5gM.css} +1 -1
  69. package/web/ui/dist/assets/index-CVqK1cV5.js +61 -0
  70. package/web/ui/dist/index.html +2 -2
  71. package/web/ui/dist/assets/index-DZzX_Enf.js +0 -61
@@ -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 {
@@ -2914,6 +3004,75 @@ describe("handleToken — full OAuth dance", () => {
2914
3004
  notes: { url: `${ISSUER}/notes`, version: "0.3.0" },
2915
3005
  });
2916
3006
  });
3007
+
3008
+ // closes #478 — an empty-paths vault row ("installed but no servable
3009
+ // instance"; vault's self-register emits `paths: []` at zero vaults) must
3010
+ // NOT synthesize a phantom `vault` / `vault:default` entry pointing at
3011
+ // root in the /oauth/token services catalog. Pre-fix the `["/"]` fallback
3012
+ // resolved `vaultInstanceNameFor(name, "/")` → "default" and advertised
3013
+ // `${ISSUER}/` as the vault. Mirrors the skip in well-known.ts /
3014
+ // admin-vaults.ts / vault-names.ts.
3015
+ test("empty-paths vault row produces NO catalog entry — no phantom default (#478)", () => {
3016
+ const emptyPathsManifest: ServicesManifest = {
3017
+ services: [
3018
+ {
3019
+ name: "parachute-vault",
3020
+ port: 1940,
3021
+ paths: [],
3022
+ health: "/vault/default/health",
3023
+ version: "0.7.0",
3024
+ },
3025
+ ],
3026
+ };
3027
+ // Broad scope: would have leaked `vault` + `vault:default` at `/`.
3028
+ expect(buildServicesCatalog(emptyPathsManifest, ISSUER, ["vault:read"])).toEqual({});
3029
+ // Per-vault-narrowed scope for the phantom name: also nothing.
3030
+ expect(buildServicesCatalog(emptyPathsManifest, ISSUER, ["vault:default:read"])).toEqual({});
3031
+ });
3032
+
3033
+ test("positive control: a vault row WITH a path is still cataloged (#478)", () => {
3034
+ const realManifest: ServicesManifest = {
3035
+ services: [
3036
+ {
3037
+ name: "parachute-vault",
3038
+ port: 1940,
3039
+ paths: ["/vault/default"],
3040
+ health: "/vault/default/health",
3041
+ version: "0.7.0",
3042
+ },
3043
+ ],
3044
+ };
3045
+ expect(buildServicesCatalog(realManifest, ISSUER, ["vault:read"])).toEqual({
3046
+ vault: { url: `${ISSUER}/vault/default`, version: "0.7.0" },
3047
+ });
3048
+ });
3049
+
3050
+ test("empty-paths vault row alongside a real vault: only the real one is cataloged (#478)", () => {
3051
+ // A transitional manifest could carry both a path-less bare row and a
3052
+ // real instance row. The empty-paths row must contribute nothing; the
3053
+ // real vault is unaffected.
3054
+ const mixedManifest: ServicesManifest = {
3055
+ services: [
3056
+ {
3057
+ name: "parachute-vault",
3058
+ port: 1940,
3059
+ paths: [],
3060
+ health: "/vault/default/health",
3061
+ version: "0.7.0",
3062
+ },
3063
+ {
3064
+ name: "parachute-vault-work",
3065
+ port: 1941,
3066
+ paths: ["/vault/work"],
3067
+ health: "/vault/work/health",
3068
+ version: "0.7.0",
3069
+ },
3070
+ ],
3071
+ };
3072
+ expect(buildServicesCatalog(mixedManifest, ISSUER, ["vault:read"])).toEqual({
3073
+ vault: { url: `${ISSUER}/vault/work`, version: "0.7.0" },
3074
+ });
3075
+ });
2917
3076
  });
2918
3077
  });
2919
3078
 
@@ -7215,12 +7374,12 @@ describe("DCR same-hub auto-trust (hub#312)", () => {
7215
7374
  }
7216
7375
  });
7217
7376
 
7218
- test("authorize: same_hub=true + admin scope consent screen (high-power sanity gate)", async () => {
7219
- // hub:admin is requestable via DCR (only `parachute:host:admin` and
7220
- // per-vault `vault:*:admin` are non-requestable). For same-hub
7221
- // clients we DO still show consent on admin scopes the operator
7222
- // who registered the client may not want to grant their own session
7223
- // 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.
7224
7383
  const { db, cleanup } = await makeDb();
7225
7384
  try {
7226
7385
  const user = await createUser(db, "owner", "pw");
@@ -7239,24 +7398,24 @@ describe("DCR same-hub auto-trust (hub#312)", () => {
7239
7398
  scope: "hub:admin",
7240
7399
  code_challenge: challenge,
7241
7400
  code_challenge_method: "S256",
7401
+ state: "abc",
7242
7402
  }),
7243
7403
  { headers: { cookie: buildSessionCookie(session.id, SESSION_COOKIE_TTL_S) } },
7244
7404
  );
7245
7405
  const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
7246
- // Consent rendered, not silent-approve.
7247
- expect(res.status).toBe(200);
7248
- expect(res.headers.get("content-type")).toContain("text/html");
7249
- const html = await res.text();
7250
- 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");
7251
7410
  } finally {
7252
7411
  cleanup();
7253
7412
  }
7254
7413
  });
7255
7414
 
7256
- test("authorize: same_hub=true + mixed admin+non-admin → consent screen (any admin scope shows consent)", async () => {
7257
- // Defensive: a request asking for `vault:default:read hub:admin` must
7258
- // NOT silent-approve on the strength of the non-admin scope. Any
7259
- // 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).
7260
7419
  const { db, cleanup } = await makeDb();
7261
7420
  try {
7262
7421
  const user = await createUser(db, "owner", "pw");
@@ -7275,12 +7434,52 @@ describe("DCR same-hub auto-trust (hub#312)", () => {
7275
7434
  scope: "vault:default:read hub:admin",
7276
7435
  code_challenge: challenge,
7277
7436
  code_challenge_method: "S256",
7437
+ state: "abc",
7278
7438
  }),
7279
7439
  { headers: { cookie: buildSessionCookie(session.id, SESSION_COOKIE_TTL_S) } },
7280
7440
  );
7281
7441
  const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
7282
- expect(res.status).toBe(200);
7283
- 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");
7284
7483
  } finally {
7285
7484
  cleanup();
7286
7485
  }
@@ -10073,6 +10272,62 @@ describe("hub#689 — owner-on-own-vault verb selector + widening", () => {
10073
10272
  }
10074
10273
  });
10075
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
+
10076
10331
  // GET render: a non-owner (non-admin with ZERO assigned vaults) does NOT
10077
10332
  // see the selector — they can't authorize a vault scope at all.
10078
10333
  test("selector NOT rendered for a non-owner (zero-vault non-admin)", async () => {
@@ -306,6 +306,58 @@ describe("renderConsent", () => {
306
306
  expect(html).not.toContain("Access level");
307
307
  expect(html).not.toContain('name="verb_select"');
308
308
  });
309
+
310
+ // hub#314 — same-hub vs external trust marker. The `.badge-trust-*` class
311
+ // names are always present in the inlined <style> block, so assertions target
312
+ // the RENDERED ELEMENT form (`class="badge badge-trust-*"`) + the copy text,
313
+ // which only appear in the consent body when the marker actually renders.
314
+ test("renders the first-party trust marker for a same-hub client", () => {
315
+ const html = renderConsent({
316
+ params: PARAMS,
317
+ csrfToken: CSRF,
318
+ clientId: "c",
319
+ clientName: "App",
320
+ scopes: ["vault:read"],
321
+ sameHub: true,
322
+ });
323
+ expect(html).toContain('class="badge badge-trust-same-hub"');
324
+ expect(html).toContain(">First-party<");
325
+ expect(html).toContain("Registered through this hub");
326
+ // The external badge / copy must NOT appear for a same-hub client.
327
+ expect(html).not.toContain('class="badge badge-trust-external"');
328
+ expect(html).not.toContain("third-party app that registered itself");
329
+ });
330
+
331
+ test("renders the external trust marker for a third-party DCR client", () => {
332
+ const html = renderConsent({
333
+ params: PARAMS,
334
+ csrfToken: CSRF,
335
+ clientId: "c",
336
+ clientName: "App",
337
+ scopes: ["vault:read"],
338
+ sameHub: false,
339
+ });
340
+ expect(html).toContain('class="badge badge-trust-external"');
341
+ expect(html).toContain(">External<");
342
+ expect(html).toContain("third-party app that registered itself");
343
+ // The first-party badge / copy must NOT appear for an external client.
344
+ expect(html).not.toContain('class="badge badge-trust-same-hub"');
345
+ expect(html).not.toContain("Registered through this hub");
346
+ });
347
+
348
+ test("renders no trust marker when provenance is unknown (sameHub omitted)", () => {
349
+ const html = renderConsent({
350
+ params: PARAMS,
351
+ csrfToken: CSRF,
352
+ clientId: "c",
353
+ clientName: "App",
354
+ scopes: ["vault:read"],
355
+ // sameHub omitted → undefined → no badge
356
+ });
357
+ expect(html).not.toContain('class="trust-marker');
358
+ expect(html).not.toContain('class="badge badge-trust-same-hub"');
359
+ expect(html).not.toContain('class="badge badge-trust-external"');
360
+ });
309
361
  });
310
362
 
311
363
  describe("renderError", () => {
@@ -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
 
@@ -5,12 +5,14 @@ import { join } from "node:path";
5
5
  import { hubDbPath, openHubDb } from "../hub-db.ts";
6
6
  import {
7
7
  SESSION_COOKIE_NAME,
8
+ SESSION_MAX_LIFETIME_MS,
8
9
  buildSessionClearCookie,
9
10
  buildSessionCookie,
10
11
  createSession,
11
12
  deleteSession,
12
13
  findSession,
13
14
  parseSessionCookie,
15
+ touchSession,
14
16
  } from "../sessions.ts";
15
17
  import { createUser } from "../users.ts";
16
18
 
@@ -67,6 +69,84 @@ describe("createSession + findSession", () => {
67
69
  });
68
70
  });
69
71
 
72
+ describe("touchSession (sliding renewal)", () => {
73
+ const HOUR = 3600 * 1000;
74
+ const DAY = 24 * HOUR;
75
+
76
+ test("slides expires_at forward to now + TTL", async () => {
77
+ const { db, userId, cleanup } = await makeDb();
78
+ try {
79
+ const t0 = new Date("2026-01-01T00:00:00Z");
80
+ const s = createSession(db, { userId, now: () => t0 });
81
+ // Original expiry: t0 + 24h.
82
+ expect(new Date(s.expiresAt).getTime()).toBe(t0.getTime() + DAY);
83
+ // Touch 1h later → expiry becomes (t0 + 1h) + 24h.
84
+ const t1 = new Date(t0.getTime() + HOUR);
85
+ touchSession(db, s.id, () => t1);
86
+ const found = findSession(db, s.id, () => t1);
87
+ expect(new Date(found?.expiresAt ?? 0).getTime()).toBe(t1.getTime() + DAY);
88
+ } finally {
89
+ cleanup();
90
+ }
91
+ });
92
+
93
+ test("a touched session outlives the ORIGINAL 24h expiry", async () => {
94
+ const { db, userId, cleanup } = await makeDb();
95
+ try {
96
+ const t0 = new Date("2026-01-01T00:00:00Z");
97
+ const s = createSession(db, { userId, now: () => t0 });
98
+ // Activity at +12h slides expiry to +36h.
99
+ touchSession(db, s.id, () => new Date(t0.getTime() + 12 * HOUR));
100
+ // At +30h — PAST the original +24h — the session is still alive.
101
+ const at30h = new Date(t0.getTime() + 30 * HOUR);
102
+ expect(findSession(db, s.id, () => at30h)?.id).toBe(s.id);
103
+ } finally {
104
+ cleanup();
105
+ }
106
+ });
107
+
108
+ test("an UNtouched session still expires at the original 24h", async () => {
109
+ const { db, userId, cleanup } = await makeDb();
110
+ try {
111
+ const t0 = new Date("2026-01-01T00:00:00Z");
112
+ const s = createSession(db, { userId, now: () => t0 });
113
+ // No touch — at +25h it's gone (today's absolute-TTL behavior preserved
114
+ // for idle / closed tabs that stop re-minting).
115
+ const at25h = new Date(t0.getTime() + 25 * HOUR);
116
+ expect(findSession(db, s.id, () => at25h)).toBeNull();
117
+ } finally {
118
+ cleanup();
119
+ }
120
+ });
121
+
122
+ test("caps at created_at + SESSION_MAX_LIFETIME_MS (sliding can't run forever)", async () => {
123
+ const { db, userId, cleanup } = await makeDb();
124
+ try {
125
+ const t0 = new Date("2026-01-01T00:00:00Z");
126
+ const s = createSession(db, { userId, now: () => t0 });
127
+ const ceiling = t0.getTime() + SESSION_MAX_LIFETIME_MS;
128
+ // A touch near the ceiling would slide to now + 24h, but the cap pins it.
129
+ const nearCeiling = new Date(ceiling - HOUR); // raw slide would be ceiling + 23h
130
+ touchSession(db, s.id, () => nearCeiling);
131
+ const found = findSession(db, s.id, () => nearCeiling);
132
+ expect(new Date(found?.expiresAt ?? 0).getTime()).toBe(ceiling);
133
+ // Past the ceiling the session is dead even though it was just "active".
134
+ expect(findSession(db, s.id, () => new Date(ceiling + 1000))).toBeNull();
135
+ } finally {
136
+ cleanup();
137
+ }
138
+ });
139
+
140
+ test("no-op on an unknown session id (does not throw)", async () => {
141
+ const { db, cleanup } = await makeDb();
142
+ try {
143
+ expect(() => touchSession(db, "no-such-session")).not.toThrow();
144
+ } finally {
145
+ cleanup();
146
+ }
147
+ });
148
+ });
149
+
70
150
  describe("deleteSession", () => {
71
151
  test("removes the session row", async () => {
72
152
  const { db, userId, cleanup } = await makeDb();