@openparachute/hub 0.7.3 → 0.7.4-rc.10

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 (39) hide show
  1. package/package.json +4 -11
  2. package/src/__tests__/admin-vaults.test.ts +164 -1
  3. package/src/__tests__/api-account-2fa.test.ts +381 -0
  4. package/src/__tests__/api-hub-upgrade.test.ts +59 -3
  5. package/src/__tests__/cli.test.ts +22 -0
  6. package/src/__tests__/clients.test.ts +28 -8
  7. package/src/__tests__/cloudflare-connector-service.test.ts +3 -1
  8. package/src/__tests__/hub-server.test.ts +127 -5
  9. package/src/__tests__/init.test.ts +507 -1
  10. package/src/__tests__/managed-unit.test.ts +62 -0
  11. package/src/__tests__/oauth-handlers.test.ts +488 -0
  12. package/src/__tests__/oauth-ui.test.ts +55 -1
  13. package/src/__tests__/scope-explanations.test.ts +19 -0
  14. package/src/__tests__/setup-wizard.test.ts +124 -7
  15. package/src/__tests__/status-supervisor.test.ts +152 -3
  16. package/src/__tests__/supervisor.test.ts +25 -0
  17. package/src/__tests__/vault-names.test.ts +32 -3
  18. package/src/__tests__/well-known.test.ts +37 -2
  19. package/src/admin-vaults.ts +7 -12
  20. package/src/api-account-2fa.ts +373 -0
  21. package/src/api-hub-upgrade.ts +38 -3
  22. package/src/api-me.ts +11 -2
  23. package/src/cli.ts +49 -5
  24. package/src/clients.ts +14 -0
  25. package/src/commands/init.ts +224 -37
  26. package/src/commands/status.ts +108 -5
  27. package/src/help.ts +17 -0
  28. package/src/hub-server.ts +72 -7
  29. package/src/managed-unit.ts +30 -1
  30. package/src/oauth-handlers.ts +98 -6
  31. package/src/oauth-ui.ts +123 -0
  32. package/src/scope-explanations.ts +2 -1
  33. package/src/setup-wizard.ts +40 -21
  34. package/src/supervisor.ts +46 -2
  35. package/src/vault-names.ts +15 -4
  36. package/src/well-known.ts +10 -1
  37. package/web/ui/dist/assets/{index--728BX3j.css → index-BcC4U5gM.css} +1 -1
  38. package/web/ui/dist/assets/{index-DZzX_Enf.js → index-DygKux-C.js} +13 -13
  39. package/web/ui/dist/index.html +2 -2
@@ -2914,6 +2914,75 @@ describe("handleToken — full OAuth dance", () => {
2914
2914
  notes: { url: `${ISSUER}/notes`, version: "0.3.0" },
2915
2915
  });
2916
2916
  });
2917
+
2918
+ // closes #478 — an empty-paths vault row ("installed but no servable
2919
+ // instance"; vault's self-register emits `paths: []` at zero vaults) must
2920
+ // NOT synthesize a phantom `vault` / `vault:default` entry pointing at
2921
+ // root in the /oauth/token services catalog. Pre-fix the `["/"]` fallback
2922
+ // resolved `vaultInstanceNameFor(name, "/")` → "default" and advertised
2923
+ // `${ISSUER}/` as the vault. Mirrors the skip in well-known.ts /
2924
+ // admin-vaults.ts / vault-names.ts.
2925
+ test("empty-paths vault row produces NO catalog entry — no phantom default (#478)", () => {
2926
+ const emptyPathsManifest: ServicesManifest = {
2927
+ services: [
2928
+ {
2929
+ name: "parachute-vault",
2930
+ port: 1940,
2931
+ paths: [],
2932
+ health: "/vault/default/health",
2933
+ version: "0.7.0",
2934
+ },
2935
+ ],
2936
+ };
2937
+ // Broad scope: would have leaked `vault` + `vault:default` at `/`.
2938
+ expect(buildServicesCatalog(emptyPathsManifest, ISSUER, ["vault:read"])).toEqual({});
2939
+ // Per-vault-narrowed scope for the phantom name: also nothing.
2940
+ expect(buildServicesCatalog(emptyPathsManifest, ISSUER, ["vault:default:read"])).toEqual({});
2941
+ });
2942
+
2943
+ test("positive control: a vault row WITH a path is still cataloged (#478)", () => {
2944
+ const realManifest: ServicesManifest = {
2945
+ services: [
2946
+ {
2947
+ name: "parachute-vault",
2948
+ port: 1940,
2949
+ paths: ["/vault/default"],
2950
+ health: "/vault/default/health",
2951
+ version: "0.7.0",
2952
+ },
2953
+ ],
2954
+ };
2955
+ expect(buildServicesCatalog(realManifest, ISSUER, ["vault:read"])).toEqual({
2956
+ vault: { url: `${ISSUER}/vault/default`, version: "0.7.0" },
2957
+ });
2958
+ });
2959
+
2960
+ test("empty-paths vault row alongside a real vault: only the real one is cataloged (#478)", () => {
2961
+ // A transitional manifest could carry both a path-less bare row and a
2962
+ // real instance row. The empty-paths row must contribute nothing; the
2963
+ // real vault is unaffected.
2964
+ const mixedManifest: ServicesManifest = {
2965
+ services: [
2966
+ {
2967
+ name: "parachute-vault",
2968
+ port: 1940,
2969
+ paths: [],
2970
+ health: "/vault/default/health",
2971
+ version: "0.7.0",
2972
+ },
2973
+ {
2974
+ name: "parachute-vault-work",
2975
+ port: 1941,
2976
+ paths: ["/vault/work"],
2977
+ health: "/vault/work/health",
2978
+ version: "0.7.0",
2979
+ },
2980
+ ],
2981
+ };
2982
+ expect(buildServicesCatalog(mixedManifest, ISSUER, ["vault:read"])).toEqual({
2983
+ vault: { url: `${ISSUER}/vault/work`, version: "0.7.0" },
2984
+ });
2985
+ });
2917
2986
  });
2918
2987
  });
2919
2988
 
@@ -9899,3 +9968,422 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
9899
9968
  }
9900
9969
  });
9901
9970
  });
9971
+
9972
+ // ───────────────────────────────────────────────────────────────────────────
9973
+ // hub#689 — owner-on-own-vault VERB SELECTOR. The consent screen offers an
9974
+ // owner of the picked vault a read/write/admin selector (pre-selected to
9975
+ // admin) when the client requested an UNNAMED `vault:read`/`vault:write`. On
9976
+ // submit, the owner's selection widens the unnamed verb to the chosen level
9977
+ // on the picked vault — BEFORE `capScopesToUserAuthority`, which remains the
9978
+ // backstop. The selector value is an UNTRUSTED hint: the handler re-derives
9979
+ // ownership of the picked vault server-side, and the cap drops any verb the
9980
+ // user doesn't actually hold.
9981
+ // ───────────────────────────────────────────────────────────────────────────
9982
+ describe("hub#689 — owner-on-own-vault verb selector + widening", () => {
9983
+ const TTL_S = Math.floor(SESSION_TTL_MS / 1000);
9984
+ const SEL_MANIFEST: ServicesManifest = {
9985
+ services: [
9986
+ {
9987
+ name: "parachute-vault",
9988
+ port: 1940,
9989
+ paths: ["/vault/work", "/vault/other"],
9990
+ health: "/health",
9991
+ version: "0.7.0",
9992
+ },
9993
+ ],
9994
+ };
9995
+ const selDeps = {
9996
+ issuer: ISSUER,
9997
+ loadServicesManifest: () => SEL_MANIFEST,
9998
+ hubBoundOrigins: () => [ISSUER],
9999
+ };
10000
+
10001
+ async function submitConsent(
10002
+ db: Awaited<ReturnType<typeof makeDb>>["db"],
10003
+ sessionId: string,
10004
+ clientId: string,
10005
+ scope: string,
10006
+ challenge: string,
10007
+ extra: Record<string, string> = {},
10008
+ ): Promise<Response> {
10009
+ const form = new URLSearchParams({
10010
+ __action: "consent",
10011
+ __csrf: TEST_CSRF,
10012
+ approve: "yes",
10013
+ client_id: clientId,
10014
+ redirect_uri: "https://app.example/cb",
10015
+ response_type: "code",
10016
+ scope,
10017
+ code_challenge: challenge,
10018
+ code_challenge_method: "S256",
10019
+ ...extra,
10020
+ });
10021
+ return handleAuthorizePost(
10022
+ db,
10023
+ new Request(`${ISSUER}/oauth/authorize`, {
10024
+ method: "POST",
10025
+ body: form,
10026
+ headers: {
10027
+ "content-type": "application/x-www-form-urlencoded",
10028
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(sessionId, TTL_S)}`,
10029
+ },
10030
+ }),
10031
+ selDeps,
10032
+ );
10033
+ }
10034
+
10035
+ async function redeemScope(
10036
+ db: Awaited<ReturnType<typeof makeDb>>["db"],
10037
+ code: string,
10038
+ clientId: string,
10039
+ verifier: string,
10040
+ ): Promise<string> {
10041
+ const tokenRes = await handleToken(
10042
+ db,
10043
+ new Request(`${ISSUER}/oauth/token`, {
10044
+ method: "POST",
10045
+ body: new URLSearchParams({
10046
+ grant_type: "authorization_code",
10047
+ code,
10048
+ client_id: clientId,
10049
+ redirect_uri: "https://app.example/cb",
10050
+ code_verifier: verifier,
10051
+ }),
10052
+ headers: { "content-type": "application/x-www-form-urlencoded" },
10053
+ }),
10054
+ selDeps,
10055
+ );
10056
+ expect(tokenRes.status).toBe(200);
10057
+ const body = (await tokenRes.json()) as { scope: string };
10058
+ return body.scope;
10059
+ }
10060
+
10061
+ // GET render: owner of the picked vault sees the selector. A non-admin
10062
+ // assigned to exactly one vault gets the locked picker → the selector is
10063
+ // offered (they hold admin on their assigned vault).
10064
+ test("selector RENDERED for an owner (assigned user) of the picked vault", async () => {
10065
+ const { db, cleanup } = await makeDb();
10066
+ try {
10067
+ await createUser(db, "owner", "pw"); // consumes the admin slot
10068
+ const friend = await createUser(db, "friend", "pw", { allowMulti: true });
10069
+ setUserVaults(db, friend.id, ["work"]); // role=write → holds admin on "work"
10070
+ const session = createSession(db, { userId: friend.id });
10071
+ const reg = registerClient(db, {
10072
+ redirectUris: ["https://app.example/cb"],
10073
+ status: "approved",
10074
+ });
10075
+ const { challenge } = makePkce();
10076
+ const res = handleAuthorizeGet(
10077
+ db,
10078
+ new Request(
10079
+ authorizeUrl({
10080
+ client_id: reg.client.clientId,
10081
+ redirect_uri: "https://app.example/cb",
10082
+ response_type: "code",
10083
+ code_challenge: challenge,
10084
+ code_challenge_method: "S256",
10085
+ scope: "vault:read",
10086
+ }),
10087
+ { headers: { cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, TTL_S)}` } },
10088
+ ),
10089
+ selDeps,
10090
+ );
10091
+ expect(res.status).toBe(200);
10092
+ const html = await res.text();
10093
+ expect(html).toContain("Access level");
10094
+ expect(html).toContain('name="verb_select"');
10095
+ // Admin pre-selected, still visibly flagged.
10096
+ expect(html).toMatch(/name="verb_select" value="admin"[^>]*checked/);
10097
+ expect(html).toContain("badge-admin");
10098
+ } finally {
10099
+ cleanup();
10100
+ }
10101
+ });
10102
+
10103
+ // GET render: a read-only-assigned user (role=read → holds read, NOT admin)
10104
+ // does NOT see the selector — offering admin pre-selected would promise an
10105
+ // upgrade the cap silently demotes. They hold the vault but not admin on it.
10106
+ test("selector NOT rendered for a read-only-assigned user (holds read, not admin)", async () => {
10107
+ const { db, cleanup } = await makeDb();
10108
+ try {
10109
+ await createUser(db, "owner", "pw");
10110
+ const reader = await createUser(db, "reader", "pw", { allowMulti: true });
10111
+ // role=read directly (setUserVaults hardcodes write) → holds read only.
10112
+ db.prepare(
10113
+ "INSERT INTO user_vaults (user_id, vault_name, role, created_at) VALUES (?, ?, 'read', ?)",
10114
+ ).run(reader.id, "work", new Date().toISOString());
10115
+ const session = createSession(db, { userId: reader.id });
10116
+ const reg = registerClient(db, {
10117
+ redirectUris: ["https://app.example/cb"],
10118
+ status: "approved",
10119
+ });
10120
+ const { challenge } = makePkce();
10121
+ const res = handleAuthorizeGet(
10122
+ db,
10123
+ new Request(
10124
+ authorizeUrl({
10125
+ client_id: reg.client.clientId,
10126
+ redirect_uri: "https://app.example/cb",
10127
+ response_type: "code",
10128
+ code_challenge: challenge,
10129
+ code_challenge_method: "S256",
10130
+ scope: "vault:read",
10131
+ }),
10132
+ { headers: { cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, TTL_S)}` } },
10133
+ ),
10134
+ selDeps,
10135
+ );
10136
+ expect(res.status).toBe(200);
10137
+ const html = await res.text();
10138
+ expect(html).not.toContain("Access level");
10139
+ expect(html).not.toContain('name="verb_select"');
10140
+ } finally {
10141
+ cleanup();
10142
+ }
10143
+ });
10144
+
10145
+ // GET render: a non-owner (non-admin with ZERO assigned vaults) does NOT
10146
+ // see the selector — they can't authorize a vault scope at all.
10147
+ test("selector NOT rendered for a non-owner (zero-vault non-admin)", async () => {
10148
+ const { db, cleanup } = await makeDb();
10149
+ try {
10150
+ await createUser(db, "owner", "pw");
10151
+ const stranger = await createUser(db, "stranger", "pw", { allowMulti: true });
10152
+ // No setUserVaults → zero assignments → not an owner of anything.
10153
+ const session = createSession(db, { userId: stranger.id });
10154
+ const reg = registerClient(db, {
10155
+ redirectUris: ["https://app.example/cb"],
10156
+ status: "approved",
10157
+ });
10158
+ const { challenge } = makePkce();
10159
+ const res = handleAuthorizeGet(
10160
+ db,
10161
+ new Request(
10162
+ authorizeUrl({
10163
+ client_id: reg.client.clientId,
10164
+ redirect_uri: "https://app.example/cb",
10165
+ response_type: "code",
10166
+ code_challenge: challenge,
10167
+ code_challenge_method: "S256",
10168
+ scope: "vault:read",
10169
+ }),
10170
+ { headers: { cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, TTL_S)}` } },
10171
+ ),
10172
+ selDeps,
10173
+ );
10174
+ expect(res.status).toBe(200);
10175
+ const html = await res.text();
10176
+ expect(html).not.toContain("Access level");
10177
+ expect(html).not.toContain('name="verb_select"');
10178
+ } finally {
10179
+ cleanup();
10180
+ }
10181
+ });
10182
+
10183
+ // Submit: owner (first admin) + client requested unnamed vault:read + selects
10184
+ // admin → minted vault:<picked>:admin. THE core bug fix.
10185
+ test("owner selects admin on an unnamed vault:read → minted vault:work:admin", async () => {
10186
+ const { db, cleanup } = await makeDb();
10187
+ try {
10188
+ const owner = await createUser(db, "owner", "pw"); // first admin
10189
+ const session = createSession(db, { userId: owner.id });
10190
+ const reg = registerClient(db, {
10191
+ redirectUris: ["https://app.example/cb"],
10192
+ status: "approved",
10193
+ });
10194
+ const { verifier, challenge } = makePkce();
10195
+ const res = await submitConsent(
10196
+ db,
10197
+ session.id,
10198
+ reg.client.clientId,
10199
+ "vault:read",
10200
+ challenge,
10201
+ {
10202
+ vault_pick: "work",
10203
+ verb_select: "admin",
10204
+ },
10205
+ );
10206
+ expect(res.status).toBe(302);
10207
+ const code = new URL(res.headers.get("location") ?? "").searchParams.get("code");
10208
+ expect(code).toBeTruthy();
10209
+ const scope = await redeemScope(db, code ?? "", reg.client.clientId, verifier);
10210
+ expect(scope).toBe("vault:work:admin");
10211
+ } finally {
10212
+ cleanup();
10213
+ }
10214
+ });
10215
+
10216
+ // Submit: owner selects write → vault:<picked>:write.
10217
+ test("owner selects write on an unnamed vault:read → minted vault:work:write", async () => {
10218
+ const { db, cleanup } = await makeDb();
10219
+ try {
10220
+ const owner = await createUser(db, "owner", "pw");
10221
+ const session = createSession(db, { userId: owner.id });
10222
+ const reg = registerClient(db, {
10223
+ redirectUris: ["https://app.example/cb"],
10224
+ status: "approved",
10225
+ });
10226
+ const { verifier, challenge } = makePkce();
10227
+ const res = await submitConsent(
10228
+ db,
10229
+ session.id,
10230
+ reg.client.clientId,
10231
+ "vault:read",
10232
+ challenge,
10233
+ {
10234
+ vault_pick: "work",
10235
+ verb_select: "write",
10236
+ },
10237
+ );
10238
+ expect(res.status).toBe(302);
10239
+ const code = new URL(res.headers.get("location") ?? "").searchParams.get("code");
10240
+ const scope = await redeemScope(db, code ?? "", reg.client.clientId, verifier);
10241
+ expect(scope).toBe("vault:work:write");
10242
+ } finally {
10243
+ cleanup();
10244
+ }
10245
+ });
10246
+
10247
+ // Submit: owner DOWNGRADES — selects read on an unnamed vault:write → read.
10248
+ test("owner selects read on an unnamed vault:write → minted vault:work:read (downgrade)", async () => {
10249
+ const { db, cleanup } = await makeDb();
10250
+ try {
10251
+ const owner = await createUser(db, "owner", "pw");
10252
+ const session = createSession(db, { userId: owner.id });
10253
+ const reg = registerClient(db, {
10254
+ redirectUris: ["https://app.example/cb"],
10255
+ status: "approved",
10256
+ });
10257
+ const { verifier, challenge } = makePkce();
10258
+ const res = await submitConsent(
10259
+ db,
10260
+ session.id,
10261
+ reg.client.clientId,
10262
+ "vault:write",
10263
+ challenge,
10264
+ {
10265
+ vault_pick: "work",
10266
+ verb_select: "read",
10267
+ },
10268
+ );
10269
+ expect(res.status).toBe(302);
10270
+ const code = new URL(res.headers.get("location") ?? "").searchParams.get("code");
10271
+ const scope = await redeemScope(db, code ?? "", reg.client.clientId, verifier);
10272
+ expect(scope).toBe("vault:work:read");
10273
+ } finally {
10274
+ cleanup();
10275
+ }
10276
+ });
10277
+
10278
+ // SECURITY: a non-owner who holds only READ on the picked vault forges
10279
+ // verb_select=admin → the server re-derives ownership (no admin held) and
10280
+ // refuses to widen; the cap is the backstop. Minted scope is capped to
10281
+ // their actual authority (read), NOT elevated to admin.
10282
+ test("SECURITY: read-only-assigned non-owner forges verb_select=admin → minted vault:work:read, NOT admin", async () => {
10283
+ const { db, cleanup } = await makeDb();
10284
+ try {
10285
+ await createUser(db, "owner", "pw"); // first admin = owner
10286
+ const reader = await createUser(db, "reader", "pw", { allowMulti: true });
10287
+ // Assign "work" with role=read directly → holds read only (NOT admin).
10288
+ // setUserVaults hardcodes role=write, so insert the read row by hand to
10289
+ // construct the read-only-authority case the cap must defend.
10290
+ db.prepare(
10291
+ "INSERT INTO user_vaults (user_id, vault_name, role, created_at) VALUES (?, ?, 'read', ?)",
10292
+ ).run(reader.id, "work", new Date().toISOString());
10293
+ const session = createSession(db, { userId: reader.id });
10294
+ const reg = registerClient(db, {
10295
+ redirectUris: ["https://app.example/cb"],
10296
+ status: "approved",
10297
+ });
10298
+ const { verifier, challenge } = makePkce();
10299
+ const res = await submitConsent(
10300
+ db,
10301
+ session.id,
10302
+ reg.client.clientId,
10303
+ "vault:read",
10304
+ challenge,
10305
+ {
10306
+ vault_pick: "work",
10307
+ verb_select: "admin", // FORGED — reader holds read only
10308
+ },
10309
+ );
10310
+ // Read survives (held); admin never rides along.
10311
+ expect(res.status).toBe(302);
10312
+ const code = new URL(res.headers.get("location") ?? "").searchParams.get("code");
10313
+ expect(code).toBeTruthy();
10314
+ const scope = await redeemScope(db, code ?? "", reg.client.clientId, verifier);
10315
+ expect(scope).toBe("vault:work:read");
10316
+ expect(scope).not.toContain("admin");
10317
+ // And the recorded grant carries no admin verb either.
10318
+ const grant = findGrant(db, reader.id, reg.client.clientId);
10319
+ expect(grant?.scopes ?? []).not.toContain("vault:work:admin");
10320
+ } finally {
10321
+ cleanup();
10322
+ }
10323
+ });
10324
+
10325
+ // SECURITY: a non-admin assigned to "work" picks/forges admin on "other"
10326
+ // (a vault outside their assignment) — the assignment-mismatch gate refuses
10327
+ // before widening ever runs. No token minted.
10328
+ test("SECURITY: forged verb_select=admin against an UNASSIGNED vault → 400 (mismatch gate, no mint)", async () => {
10329
+ const { db, cleanup } = await makeDb();
10330
+ try {
10331
+ await createUser(db, "owner", "pw");
10332
+ const friend = await createUser(db, "friend", "pw", { allowMulti: true });
10333
+ setUserVaults(db, friend.id, ["work"]); // assigned "work" only
10334
+ const session = createSession(db, { userId: friend.id });
10335
+ const reg = registerClient(db, {
10336
+ redirectUris: ["https://app.example/cb"],
10337
+ status: "approved",
10338
+ });
10339
+ const { challenge } = makePkce();
10340
+ const res = await submitConsent(
10341
+ db,
10342
+ session.id,
10343
+ reg.client.clientId,
10344
+ "vault:read",
10345
+ challenge,
10346
+ {
10347
+ vault_pick: "other", // NOT in friend's assignment
10348
+ verb_select: "admin",
10349
+ },
10350
+ );
10351
+ expect(res.status).toBe(400);
10352
+ expect(findGrant(db, friend.id, reg.client.clientId)).toBeNull();
10353
+ } finally {
10354
+ cleanup();
10355
+ }
10356
+ });
10357
+
10358
+ // Owner without a verb_select field (older form / JS-off) → unchanged
10359
+ // behavior: the unnamed verb narrows as-requested (vault:read → work:read).
10360
+ test("owner with NO verb_select → unchanged narrowing (vault:read → vault:work:read)", async () => {
10361
+ const { db, cleanup } = await makeDb();
10362
+ try {
10363
+ const owner = await createUser(db, "owner", "pw");
10364
+ const session = createSession(db, { userId: owner.id });
10365
+ const reg = registerClient(db, {
10366
+ redirectUris: ["https://app.example/cb"],
10367
+ status: "approved",
10368
+ });
10369
+ const { verifier, challenge } = makePkce();
10370
+ const res = await submitConsent(
10371
+ db,
10372
+ session.id,
10373
+ reg.client.clientId,
10374
+ "vault:read",
10375
+ challenge,
10376
+ {
10377
+ vault_pick: "work",
10378
+ // no verb_select
10379
+ },
10380
+ );
10381
+ expect(res.status).toBe(302);
10382
+ const code = new URL(res.headers.get("location") ?? "").searchParams.get("code");
10383
+ const scope = await redeemScope(db, code ?? "", reg.client.clientId, verifier);
10384
+ expect(scope).toBe("vault:work:read");
10385
+ } finally {
10386
+ cleanup();
10387
+ }
10388
+ });
10389
+ });
@@ -116,7 +116,8 @@ describe("renderConsent", () => {
116
116
  expect(html).toContain("vault:admin");
117
117
  // Scope explanations from the registry
118
118
  expect(html).toContain("Read your notes");
119
- expect(html).toContain("Full vault access");
119
+ // hub#689 Leg 1: the admin label now enumerates the concrete grants.
120
+ expect(html).toContain("Read and write everything, plus admin");
120
121
  });
121
122
 
122
123
  test("highlights admin scopes with a danger color and badge", () => {
@@ -252,6 +253,59 @@ describe("renderConsent", () => {
252
253
  expect(html).not.toContain("You have no assigned vaults");
253
254
  expect(html).not.toContain('value="yes" class="btn btn-primary" disabled');
254
255
  });
256
+
257
+ // hub#689 — owner-on-own-vault verb selector rendering.
258
+ test("renders the owner verb selector (read/write/admin), pre-selected to admin", () => {
259
+ const html = renderConsent({
260
+ params: { ...PARAMS, scope: "vault:read" },
261
+ csrfToken: CSRF,
262
+ clientId: "c",
263
+ clientName: "App",
264
+ scopes: ["vault:read"],
265
+ vaultPicker: { unnamedVerbs: ["read"], availableVaults: ["work"], lockedVault: "work" },
266
+ ownerVerbSelector: { requestedVerbs: ["read"] },
267
+ });
268
+ expect(html).toContain("Access level");
269
+ expect(html).toContain('name="verb_select" value="read"');
270
+ expect(html).toContain('name="verb_select" value="write"');
271
+ expect(html).toContain('name="verb_select" value="admin"');
272
+ // Admin is the pre-selected (checked) option.
273
+ expect(html).toMatch(/name="verb_select" value="admin"[^>]*checked/);
274
+ // read/write are NOT pre-checked.
275
+ expect(html).not.toMatch(/name="verb_select" value="read"[^>]*checked/);
276
+ expect(html).not.toMatch(/name="verb_select" value="write"[^>]*checked/);
277
+ });
278
+
279
+ test("owner verb selector keeps the admin option visibly flagged (admin badge + red border)", () => {
280
+ const html = renderConsent({
281
+ params: { ...PARAMS, scope: "vault:read" },
282
+ csrfToken: CSRF,
283
+ clientId: "c",
284
+ clientName: "App",
285
+ scopes: ["vault:read"],
286
+ vaultPicker: { unnamedVerbs: ["read"], availableVaults: ["work"], lockedVault: "work" },
287
+ ownerVerbSelector: { requestedVerbs: ["read"] },
288
+ });
289
+ // The .scope-admin red-border class + the admin badge ride on the admin
290
+ // radio option so a pre-selected admin grant stays transparent.
291
+ expect(html).toContain("verb-option-admin");
292
+ expect(html).toContain("scope-admin");
293
+ expect(html).toContain("badge-admin");
294
+ });
295
+
296
+ test("does NOT render the verb selector when ownerVerbSelector is absent (non-owner)", () => {
297
+ const html = renderConsent({
298
+ params: { ...PARAMS, scope: "vault:read" },
299
+ csrfToken: CSRF,
300
+ clientId: "c",
301
+ clientName: "App",
302
+ scopes: ["vault:read"],
303
+ vaultPicker: { unnamedVerbs: ["read"], availableVaults: ["work"], lockedVault: "work" },
304
+ // ownerVerbSelector omitted → no selector
305
+ });
306
+ expect(html).not.toContain("Access level");
307
+ expect(html).not.toContain('name="verb_select"');
308
+ });
255
309
  });
256
310
 
257
311
  describe("renderError", () => {
@@ -29,6 +29,25 @@ describe("SCOPE_EXPLANATIONS", () => {
29
29
  }
30
30
  });
31
31
 
32
+ // hub#689 Leg 1: the vault:admin consent copy must enumerate what
33
+ // admin actually grants (config/settings, triggers/automation, GitHub
34
+ // backup, token minting) on top of read/write — so the consent screen
35
+ // is honest about the admin blast radius, not a vague "configuration
36
+ // changes" hand-wave.
37
+ test("vault:admin label enumerates the concrete admin grants (hub#689 Leg 1)", () => {
38
+ const label = SCOPE_EXPLANATIONS["vault:admin"]?.label ?? "";
39
+ const lower = label.toLowerCase();
40
+ expect(SCOPE_EXPLANATIONS["vault:admin"]?.level).toBe("admin");
41
+ // Read + write are still part of what admin grants.
42
+ expect(lower).toContain("read");
43
+ expect(lower).toContain("write");
44
+ // The four enumerated admin powers.
45
+ expect(lower).toContain("config");
46
+ expect(lower).toContain("trigger");
47
+ expect(lower).toContain("github");
48
+ expect(lower).toContain("token");
49
+ });
50
+
32
51
  test("FIRST_PARTY_SCOPES is sorted and matches the keys of SCOPE_EXPLANATIONS", () => {
33
52
  expect(FIRST_PARTY_SCOPES).toEqual([...FIRST_PARTY_SCOPES].sort());
34
53
  expect(new Set(FIRST_PARTY_SCOPES)).toEqual(new Set(Object.keys(SCOPE_EXPLANATIONS)));