@openparachute/hub 0.7.4-rc.1 → 0.7.4-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.7.4-rc.1",
3
+ "version": "0.7.4-rc.3",
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": {
@@ -9899,3 +9899,422 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
9899
9899
  }
9900
9900
  });
9901
9901
  });
9902
+
9903
+ // ───────────────────────────────────────────────────────────────────────────
9904
+ // hub#689 — owner-on-own-vault VERB SELECTOR. The consent screen offers an
9905
+ // owner of the picked vault a read/write/admin selector (pre-selected to
9906
+ // admin) when the client requested an UNNAMED `vault:read`/`vault:write`. On
9907
+ // submit, the owner's selection widens the unnamed verb to the chosen level
9908
+ // on the picked vault — BEFORE `capScopesToUserAuthority`, which remains the
9909
+ // backstop. The selector value is an UNTRUSTED hint: the handler re-derives
9910
+ // ownership of the picked vault server-side, and the cap drops any verb the
9911
+ // user doesn't actually hold.
9912
+ // ───────────────────────────────────────────────────────────────────────────
9913
+ describe("hub#689 — owner-on-own-vault verb selector + widening", () => {
9914
+ const TTL_S = Math.floor(SESSION_TTL_MS / 1000);
9915
+ const SEL_MANIFEST: ServicesManifest = {
9916
+ services: [
9917
+ {
9918
+ name: "parachute-vault",
9919
+ port: 1940,
9920
+ paths: ["/vault/work", "/vault/other"],
9921
+ health: "/health",
9922
+ version: "0.7.0",
9923
+ },
9924
+ ],
9925
+ };
9926
+ const selDeps = {
9927
+ issuer: ISSUER,
9928
+ loadServicesManifest: () => SEL_MANIFEST,
9929
+ hubBoundOrigins: () => [ISSUER],
9930
+ };
9931
+
9932
+ async function submitConsent(
9933
+ db: Awaited<ReturnType<typeof makeDb>>["db"],
9934
+ sessionId: string,
9935
+ clientId: string,
9936
+ scope: string,
9937
+ challenge: string,
9938
+ extra: Record<string, string> = {},
9939
+ ): Promise<Response> {
9940
+ const form = new URLSearchParams({
9941
+ __action: "consent",
9942
+ __csrf: TEST_CSRF,
9943
+ approve: "yes",
9944
+ client_id: clientId,
9945
+ redirect_uri: "https://app.example/cb",
9946
+ response_type: "code",
9947
+ scope,
9948
+ code_challenge: challenge,
9949
+ code_challenge_method: "S256",
9950
+ ...extra,
9951
+ });
9952
+ return handleAuthorizePost(
9953
+ db,
9954
+ new Request(`${ISSUER}/oauth/authorize`, {
9955
+ method: "POST",
9956
+ body: form,
9957
+ headers: {
9958
+ "content-type": "application/x-www-form-urlencoded",
9959
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(sessionId, TTL_S)}`,
9960
+ },
9961
+ }),
9962
+ selDeps,
9963
+ );
9964
+ }
9965
+
9966
+ async function redeemScope(
9967
+ db: Awaited<ReturnType<typeof makeDb>>["db"],
9968
+ code: string,
9969
+ clientId: string,
9970
+ verifier: string,
9971
+ ): Promise<string> {
9972
+ const tokenRes = await handleToken(
9973
+ db,
9974
+ new Request(`${ISSUER}/oauth/token`, {
9975
+ method: "POST",
9976
+ body: new URLSearchParams({
9977
+ grant_type: "authorization_code",
9978
+ code,
9979
+ client_id: clientId,
9980
+ redirect_uri: "https://app.example/cb",
9981
+ code_verifier: verifier,
9982
+ }),
9983
+ headers: { "content-type": "application/x-www-form-urlencoded" },
9984
+ }),
9985
+ selDeps,
9986
+ );
9987
+ expect(tokenRes.status).toBe(200);
9988
+ const body = (await tokenRes.json()) as { scope: string };
9989
+ return body.scope;
9990
+ }
9991
+
9992
+ // GET render: owner of the picked vault sees the selector. A non-admin
9993
+ // assigned to exactly one vault gets the locked picker → the selector is
9994
+ // offered (they hold admin on their assigned vault).
9995
+ test("selector RENDERED for an owner (assigned user) of the picked vault", async () => {
9996
+ const { db, cleanup } = await makeDb();
9997
+ try {
9998
+ await createUser(db, "owner", "pw"); // consumes the admin slot
9999
+ const friend = await createUser(db, "friend", "pw", { allowMulti: true });
10000
+ setUserVaults(db, friend.id, ["work"]); // role=write → holds admin on "work"
10001
+ const session = createSession(db, { userId: friend.id });
10002
+ const reg = registerClient(db, {
10003
+ redirectUris: ["https://app.example/cb"],
10004
+ status: "approved",
10005
+ });
10006
+ const { challenge } = makePkce();
10007
+ const res = handleAuthorizeGet(
10008
+ db,
10009
+ new Request(
10010
+ authorizeUrl({
10011
+ client_id: reg.client.clientId,
10012
+ redirect_uri: "https://app.example/cb",
10013
+ response_type: "code",
10014
+ code_challenge: challenge,
10015
+ code_challenge_method: "S256",
10016
+ scope: "vault:read",
10017
+ }),
10018
+ { headers: { cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, TTL_S)}` } },
10019
+ ),
10020
+ selDeps,
10021
+ );
10022
+ expect(res.status).toBe(200);
10023
+ const html = await res.text();
10024
+ expect(html).toContain("Access level");
10025
+ expect(html).toContain('name="verb_select"');
10026
+ // Admin pre-selected, still visibly flagged.
10027
+ expect(html).toMatch(/name="verb_select" value="admin"[^>]*checked/);
10028
+ expect(html).toContain("badge-admin");
10029
+ } finally {
10030
+ cleanup();
10031
+ }
10032
+ });
10033
+
10034
+ // GET render: a read-only-assigned user (role=read → holds read, NOT admin)
10035
+ // does NOT see the selector — offering admin pre-selected would promise an
10036
+ // upgrade the cap silently demotes. They hold the vault but not admin on it.
10037
+ test("selector NOT rendered for a read-only-assigned user (holds read, not admin)", async () => {
10038
+ const { db, cleanup } = await makeDb();
10039
+ try {
10040
+ await createUser(db, "owner", "pw");
10041
+ const reader = await createUser(db, "reader", "pw", { allowMulti: true });
10042
+ // role=read directly (setUserVaults hardcodes write) → holds read only.
10043
+ db.prepare(
10044
+ "INSERT INTO user_vaults (user_id, vault_name, role, created_at) VALUES (?, ?, 'read', ?)",
10045
+ ).run(reader.id, "work", new Date().toISOString());
10046
+ const session = createSession(db, { userId: reader.id });
10047
+ const reg = registerClient(db, {
10048
+ redirectUris: ["https://app.example/cb"],
10049
+ status: "approved",
10050
+ });
10051
+ const { challenge } = makePkce();
10052
+ const res = handleAuthorizeGet(
10053
+ db,
10054
+ new Request(
10055
+ authorizeUrl({
10056
+ client_id: reg.client.clientId,
10057
+ redirect_uri: "https://app.example/cb",
10058
+ response_type: "code",
10059
+ code_challenge: challenge,
10060
+ code_challenge_method: "S256",
10061
+ scope: "vault:read",
10062
+ }),
10063
+ { headers: { cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, TTL_S)}` } },
10064
+ ),
10065
+ selDeps,
10066
+ );
10067
+ expect(res.status).toBe(200);
10068
+ const html = await res.text();
10069
+ expect(html).not.toContain("Access level");
10070
+ expect(html).not.toContain('name="verb_select"');
10071
+ } finally {
10072
+ cleanup();
10073
+ }
10074
+ });
10075
+
10076
+ // GET render: a non-owner (non-admin with ZERO assigned vaults) does NOT
10077
+ // see the selector — they can't authorize a vault scope at all.
10078
+ test("selector NOT rendered for a non-owner (zero-vault non-admin)", async () => {
10079
+ const { db, cleanup } = await makeDb();
10080
+ try {
10081
+ await createUser(db, "owner", "pw");
10082
+ const stranger = await createUser(db, "stranger", "pw", { allowMulti: true });
10083
+ // No setUserVaults → zero assignments → not an owner of anything.
10084
+ const session = createSession(db, { userId: stranger.id });
10085
+ const reg = registerClient(db, {
10086
+ redirectUris: ["https://app.example/cb"],
10087
+ status: "approved",
10088
+ });
10089
+ const { challenge } = makePkce();
10090
+ const res = handleAuthorizeGet(
10091
+ db,
10092
+ new Request(
10093
+ authorizeUrl({
10094
+ client_id: reg.client.clientId,
10095
+ redirect_uri: "https://app.example/cb",
10096
+ response_type: "code",
10097
+ code_challenge: challenge,
10098
+ code_challenge_method: "S256",
10099
+ scope: "vault:read",
10100
+ }),
10101
+ { headers: { cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, TTL_S)}` } },
10102
+ ),
10103
+ selDeps,
10104
+ );
10105
+ expect(res.status).toBe(200);
10106
+ const html = await res.text();
10107
+ expect(html).not.toContain("Access level");
10108
+ expect(html).not.toContain('name="verb_select"');
10109
+ } finally {
10110
+ cleanup();
10111
+ }
10112
+ });
10113
+
10114
+ // Submit: owner (first admin) + client requested unnamed vault:read + selects
10115
+ // admin → minted vault:<picked>:admin. THE core bug fix.
10116
+ test("owner selects admin on an unnamed vault:read → minted vault:work:admin", async () => {
10117
+ const { db, cleanup } = await makeDb();
10118
+ try {
10119
+ const owner = await createUser(db, "owner", "pw"); // first admin
10120
+ const session = createSession(db, { userId: owner.id });
10121
+ const reg = registerClient(db, {
10122
+ redirectUris: ["https://app.example/cb"],
10123
+ status: "approved",
10124
+ });
10125
+ const { verifier, challenge } = makePkce();
10126
+ const res = await submitConsent(
10127
+ db,
10128
+ session.id,
10129
+ reg.client.clientId,
10130
+ "vault:read",
10131
+ challenge,
10132
+ {
10133
+ vault_pick: "work",
10134
+ verb_select: "admin",
10135
+ },
10136
+ );
10137
+ expect(res.status).toBe(302);
10138
+ const code = new URL(res.headers.get("location") ?? "").searchParams.get("code");
10139
+ expect(code).toBeTruthy();
10140
+ const scope = await redeemScope(db, code ?? "", reg.client.clientId, verifier);
10141
+ expect(scope).toBe("vault:work:admin");
10142
+ } finally {
10143
+ cleanup();
10144
+ }
10145
+ });
10146
+
10147
+ // Submit: owner selects write → vault:<picked>:write.
10148
+ test("owner selects write on an unnamed vault:read → minted vault:work:write", async () => {
10149
+ const { db, cleanup } = await makeDb();
10150
+ try {
10151
+ const owner = await createUser(db, "owner", "pw");
10152
+ const session = createSession(db, { userId: owner.id });
10153
+ const reg = registerClient(db, {
10154
+ redirectUris: ["https://app.example/cb"],
10155
+ status: "approved",
10156
+ });
10157
+ const { verifier, challenge } = makePkce();
10158
+ const res = await submitConsent(
10159
+ db,
10160
+ session.id,
10161
+ reg.client.clientId,
10162
+ "vault:read",
10163
+ challenge,
10164
+ {
10165
+ vault_pick: "work",
10166
+ verb_select: "write",
10167
+ },
10168
+ );
10169
+ expect(res.status).toBe(302);
10170
+ const code = new URL(res.headers.get("location") ?? "").searchParams.get("code");
10171
+ const scope = await redeemScope(db, code ?? "", reg.client.clientId, verifier);
10172
+ expect(scope).toBe("vault:work:write");
10173
+ } finally {
10174
+ cleanup();
10175
+ }
10176
+ });
10177
+
10178
+ // Submit: owner DOWNGRADES — selects read on an unnamed vault:write → read.
10179
+ test("owner selects read on an unnamed vault:write → minted vault:work:read (downgrade)", async () => {
10180
+ const { db, cleanup } = await makeDb();
10181
+ try {
10182
+ const owner = await createUser(db, "owner", "pw");
10183
+ const session = createSession(db, { userId: owner.id });
10184
+ const reg = registerClient(db, {
10185
+ redirectUris: ["https://app.example/cb"],
10186
+ status: "approved",
10187
+ });
10188
+ const { verifier, challenge } = makePkce();
10189
+ const res = await submitConsent(
10190
+ db,
10191
+ session.id,
10192
+ reg.client.clientId,
10193
+ "vault:write",
10194
+ challenge,
10195
+ {
10196
+ vault_pick: "work",
10197
+ verb_select: "read",
10198
+ },
10199
+ );
10200
+ expect(res.status).toBe(302);
10201
+ const code = new URL(res.headers.get("location") ?? "").searchParams.get("code");
10202
+ const scope = await redeemScope(db, code ?? "", reg.client.clientId, verifier);
10203
+ expect(scope).toBe("vault:work:read");
10204
+ } finally {
10205
+ cleanup();
10206
+ }
10207
+ });
10208
+
10209
+ // SECURITY: a non-owner who holds only READ on the picked vault forges
10210
+ // verb_select=admin → the server re-derives ownership (no admin held) and
10211
+ // refuses to widen; the cap is the backstop. Minted scope is capped to
10212
+ // their actual authority (read), NOT elevated to admin.
10213
+ test("SECURITY: read-only-assigned non-owner forges verb_select=admin → minted vault:work:read, NOT admin", async () => {
10214
+ const { db, cleanup } = await makeDb();
10215
+ try {
10216
+ await createUser(db, "owner", "pw"); // first admin = owner
10217
+ const reader = await createUser(db, "reader", "pw", { allowMulti: true });
10218
+ // Assign "work" with role=read directly → holds read only (NOT admin).
10219
+ // setUserVaults hardcodes role=write, so insert the read row by hand to
10220
+ // construct the read-only-authority case the cap must defend.
10221
+ db.prepare(
10222
+ "INSERT INTO user_vaults (user_id, vault_name, role, created_at) VALUES (?, ?, 'read', ?)",
10223
+ ).run(reader.id, "work", new Date().toISOString());
10224
+ const session = createSession(db, { userId: reader.id });
10225
+ const reg = registerClient(db, {
10226
+ redirectUris: ["https://app.example/cb"],
10227
+ status: "approved",
10228
+ });
10229
+ const { verifier, challenge } = makePkce();
10230
+ const res = await submitConsent(
10231
+ db,
10232
+ session.id,
10233
+ reg.client.clientId,
10234
+ "vault:read",
10235
+ challenge,
10236
+ {
10237
+ vault_pick: "work",
10238
+ verb_select: "admin", // FORGED — reader holds read only
10239
+ },
10240
+ );
10241
+ // Read survives (held); admin never rides along.
10242
+ expect(res.status).toBe(302);
10243
+ const code = new URL(res.headers.get("location") ?? "").searchParams.get("code");
10244
+ expect(code).toBeTruthy();
10245
+ const scope = await redeemScope(db, code ?? "", reg.client.clientId, verifier);
10246
+ expect(scope).toBe("vault:work:read");
10247
+ expect(scope).not.toContain("admin");
10248
+ // And the recorded grant carries no admin verb either.
10249
+ const grant = findGrant(db, reader.id, reg.client.clientId);
10250
+ expect(grant?.scopes ?? []).not.toContain("vault:work:admin");
10251
+ } finally {
10252
+ cleanup();
10253
+ }
10254
+ });
10255
+
10256
+ // SECURITY: a non-admin assigned to "work" picks/forges admin on "other"
10257
+ // (a vault outside their assignment) — the assignment-mismatch gate refuses
10258
+ // before widening ever runs. No token minted.
10259
+ test("SECURITY: forged verb_select=admin against an UNASSIGNED vault → 400 (mismatch gate, no mint)", async () => {
10260
+ const { db, cleanup } = await makeDb();
10261
+ try {
10262
+ await createUser(db, "owner", "pw");
10263
+ const friend = await createUser(db, "friend", "pw", { allowMulti: true });
10264
+ setUserVaults(db, friend.id, ["work"]); // assigned "work" only
10265
+ const session = createSession(db, { userId: friend.id });
10266
+ const reg = registerClient(db, {
10267
+ redirectUris: ["https://app.example/cb"],
10268
+ status: "approved",
10269
+ });
10270
+ const { challenge } = makePkce();
10271
+ const res = await submitConsent(
10272
+ db,
10273
+ session.id,
10274
+ reg.client.clientId,
10275
+ "vault:read",
10276
+ challenge,
10277
+ {
10278
+ vault_pick: "other", // NOT in friend's assignment
10279
+ verb_select: "admin",
10280
+ },
10281
+ );
10282
+ expect(res.status).toBe(400);
10283
+ expect(findGrant(db, friend.id, reg.client.clientId)).toBeNull();
10284
+ } finally {
10285
+ cleanup();
10286
+ }
10287
+ });
10288
+
10289
+ // Owner without a verb_select field (older form / JS-off) → unchanged
10290
+ // behavior: the unnamed verb narrows as-requested (vault:read → work:read).
10291
+ test("owner with NO verb_select → unchanged narrowing (vault:read → vault:work:read)", async () => {
10292
+ const { db, cleanup } = await makeDb();
10293
+ try {
10294
+ const owner = await createUser(db, "owner", "pw");
10295
+ const session = createSession(db, { userId: owner.id });
10296
+ const reg = registerClient(db, {
10297
+ redirectUris: ["https://app.example/cb"],
10298
+ status: "approved",
10299
+ });
10300
+ const { verifier, challenge } = makePkce();
10301
+ const res = await submitConsent(
10302
+ db,
10303
+ session.id,
10304
+ reg.client.clientId,
10305
+ "vault:read",
10306
+ challenge,
10307
+ {
10308
+ vault_pick: "work",
10309
+ // no verb_select
10310
+ },
10311
+ );
10312
+ expect(res.status).toBe(302);
10313
+ const code = new URL(res.headers.get("location") ?? "").searchParams.get("code");
10314
+ const scope = await redeemScope(db, code ?? "", reg.client.clientId, verifier);
10315
+ expect(scope).toBe("vault:work:read");
10316
+ } finally {
10317
+ cleanup();
10318
+ }
10319
+ });
10320
+ });
@@ -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)));
@@ -80,6 +80,12 @@ interface SupervisorArmOpts {
80
80
  hubHealthy: boolean;
81
81
  moduleStates?: ModuleStatesResult;
82
82
  fetchModuleStatesImpl?: () => Promise<ModuleStatesResult>;
83
+ /**
84
+ * Inject the unauthenticated module-liveness probe (#700). Defaults to "every
85
+ * module is down" so the degraded-read tests don't accidentally hit the
86
+ * network; specific tests override to mark a module live.
87
+ */
88
+ probeModuleHealth?: (port: number, health: string) => Promise<boolean>;
83
89
  }
84
90
 
85
91
  /** Drive `status` through the supervisor arm with fully stubbed seams. */
@@ -96,6 +102,7 @@ function supervisorOpts(configDir: string, path: string, o: SupervisorArmOpts) {
96
102
  fetchModuleStates:
97
103
  o.fetchModuleStatesImpl ??
98
104
  (async () => o.moduleStates ?? { supervisorAvailable: true, modules: [] }),
105
+ probeModuleHealth: o.probeModuleHealth ?? (async () => false),
99
106
  openDb: fakeOpenDb as unknown as (configDir: string) => import("bun:sqlite").Database,
100
107
  },
101
108
  };
@@ -377,7 +384,7 @@ describe("status — Phase 3c supervisor arm: module rows", () => {
377
384
  }
378
385
  });
379
386
 
380
- test("no operator token graceful degrade (manifest rows + actionable hint), no 401 crash", async () => {
387
+ test("no operator token (fresh box, no admin) note targets set-password, NOT rotate-operator (#700)", async () => {
381
388
  const { path, configDir, cleanup } = makeTempPath();
382
389
  try {
383
390
  upsertService(
@@ -392,15 +399,121 @@ describe("status — Phase 3c supervisor arm: module rows", () => {
392
399
  fetchModuleStatesImpl: async () => {
393
400
  throw new NoOperatorTokenError();
394
401
  },
402
+ // No probe-live module here → row stays inactive (exit 0).
403
+ probeModuleHealth: async () => false,
395
404
  }),
396
405
  print: (l) => lines.push(l),
397
406
  });
398
407
  // We could not read run-state, but didn't crash. The module row falls back
399
- // to `inactive` (no supervisor snapshot) — a stopped row is exit 0.
408
+ // to `inactive` (no supervisor snapshot, probe down) — a stopped row is exit 0.
400
409
  expect(code).toBe(0);
401
410
  const out = lines.join("\n");
402
411
  expect(out).toMatch(/parachute-vault/);
403
- expect(out).toMatch(/run `parachute auth rotate-operator`/);
412
+ // #700: a fresh box has no admin, so rotate-operator would itself error.
413
+ // The note must point at set-password and must NOT be the bare
414
+ // rotate-operator guidance.
415
+ expect(out).toMatch(/parachute auth set-password/);
416
+ expect(out).not.toMatch(/run `parachute auth rotate-operator` to mint an operator token/);
417
+ const vaultLine = lines.find((l) => l.includes("parachute-vault"));
418
+ expect(vaultLine).toMatch(/\binactive\b/);
419
+ } finally {
420
+ cleanup();
421
+ }
422
+ });
423
+
424
+ test("no operator token + module answers /health probe → LIVE (active), not inactive (#700)", async () => {
425
+ const { path, configDir, cleanup } = makeTempPath();
426
+ try {
427
+ upsertService(
428
+ { name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.6.2" },
429
+ path,
430
+ );
431
+ const probed: Array<{ port: number; health: string }> = [];
432
+ const lines: string[] = [];
433
+ const code = await status({
434
+ ...supervisorOpts(configDir, path, {
435
+ managerState: { state: "active" },
436
+ hubHealthy: true,
437
+ fetchModuleStatesImpl: async () => {
438
+ throw new NoOperatorTokenError();
439
+ },
440
+ // vault is genuinely up — its /health answers (2xx or 401 → live).
441
+ probeModuleHealth: async (port, health) => {
442
+ probed.push({ port, health });
443
+ return true;
444
+ },
445
+ }),
446
+ print: (l) => lines.push(l),
447
+ });
448
+ expect(code).toBe(0);
449
+ // The probe targeted the module's own port + health path from the manifest.
450
+ expect(probed).toEqual([{ port: 1940, health: "/health" }]);
451
+ const vaultLine = lines.find((l) => l.includes("parachute-vault"));
452
+ expect(vaultLine).toMatch(/\bactive\b/);
453
+ expect(vaultLine).not.toMatch(/\binactive\b/);
454
+ const out = lines.join("\n");
455
+ // The row is labelled as probe-derived so the operator knows it's thin.
456
+ expect(out).toMatch(/live via unauthenticated health probe/);
457
+ // The degraded-read hint still appears (why PID/uptime are absent).
458
+ expect(out).toMatch(/parachute auth set-password/);
459
+ } finally {
460
+ cleanup();
461
+ }
462
+ });
463
+
464
+ test("degraded read + module probe FAILS → row stays inactive (#700)", async () => {
465
+ const { path, configDir, cleanup } = makeTempPath();
466
+ try {
467
+ upsertService(
468
+ { name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.6.2" },
469
+ path,
470
+ );
471
+ const lines: string[] = [];
472
+ const code = await status({
473
+ ...supervisorOpts(configDir, path, {
474
+ managerState: { state: "active" },
475
+ hubHealthy: true,
476
+ fetchModuleStatesImpl: async () => {
477
+ throw new NoOperatorTokenError();
478
+ },
479
+ probeModuleHealth: async () => false,
480
+ }),
481
+ print: (l) => lines.push(l),
482
+ });
483
+ expect(code).toBe(0);
484
+ const vaultLine = lines.find((l) => l.includes("parachute-vault"));
485
+ expect(vaultLine).toMatch(/\binactive\b/);
486
+ const out = lines.join("\n");
487
+ expect(out).not.toMatch(/live via unauthenticated health probe/);
488
+ } finally {
489
+ cleanup();
490
+ }
491
+ });
492
+
493
+ test("a throwing module probe never crashes status — row degrades to inactive (#700)", async () => {
494
+ const { path, configDir, cleanup } = makeTempPath();
495
+ try {
496
+ upsertService(
497
+ { name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.6.2" },
498
+ path,
499
+ );
500
+ const lines: string[] = [];
501
+ const code = await status({
502
+ ...supervisorOpts(configDir, path, {
503
+ managerState: { state: "active" },
504
+ hubHealthy: true,
505
+ fetchModuleStatesImpl: async () => {
506
+ throw new NoOperatorTokenError();
507
+ },
508
+ probeModuleHealth: async () => {
509
+ throw new Error("probe exploded");
510
+ },
511
+ }),
512
+ print: (l) => lines.push(l),
513
+ });
514
+ expect(code).toBe(0);
515
+ const vaultLine = lines.find((l) => l.includes("parachute-vault"));
516
+ expect(vaultLine).toMatch(/\binactive\b/);
404
517
  } finally {
405
518
  cleanup();
406
519
  }
@@ -433,6 +546,42 @@ describe("status — Phase 3c supervisor arm: module rows", () => {
433
546
  }
434
547
  });
435
548
 
549
+ test("expired operator token + module answers /health probe → LIVE (active) (#700)", async () => {
550
+ // Symmetry with the no-token case: the unauthenticated probe fallback fires
551
+ // on ANY degraded read where the hub is up + run-state is missing, so an
552
+ // expired-token box still shows a genuinely-serving module as `active`.
553
+ const { path, configDir, cleanup } = makeTempPath();
554
+ try {
555
+ upsertService(
556
+ { name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.6.2" },
557
+ path,
558
+ );
559
+ const lines: string[] = [];
560
+ const code = await status({
561
+ ...supervisorOpts(configDir, path, {
562
+ managerState: { state: "active" },
563
+ hubHealthy: true,
564
+ fetchModuleStatesImpl: async () => {
565
+ throw new OperatorTokenExpiredError(
566
+ "token expired — run `parachute auth rotate-operator`",
567
+ );
568
+ },
569
+ probeModuleHealth: async () => true,
570
+ }),
571
+ print: (l) => lines.push(l),
572
+ });
573
+ expect(code).toBe(0);
574
+ const vaultLine = lines.find((l) => l.includes("parachute-vault"));
575
+ expect(vaultLine).toMatch(/\bactive\b/);
576
+ const out = lines.join("\n");
577
+ expect(out).toMatch(/live via unauthenticated health probe/);
578
+ // The expired-token degraded-read hint still points at rotate-operator.
579
+ expect(out).toMatch(/rotate-operator/);
580
+ } finally {
581
+ cleanup();
582
+ }
583
+ });
584
+
436
585
  test("API error reading module states → degrade with the message, no crash", async () => {
437
586
  const { path, configDir, cleanup } = makeTempPath();
438
587
  try {
@@ -19,8 +19,8 @@ import {
19
19
  } from "../install-source.ts";
20
20
  import {
21
21
  type DriveModuleOpDeps,
22
- type ModuleStatesResult,
23
22
  type ModuleStateSnapshot,
23
+ type ModuleStatesResult,
24
24
  NoOperatorTokenError,
25
25
  OperatorTokenExpiredError,
26
26
  fetchModuleStates as fetchModuleStatesImpl,
@@ -71,6 +71,17 @@ export interface StatusOpts {
71
71
  probeHubHealth?: (port: number) => Promise<boolean>;
72
72
  /** Read the running supervisor's module states (§6.4 module rows). */
73
73
  fetchModuleStates?: (deps: DriveModuleOpDeps) => Promise<ModuleStatesResult>;
74
+ /**
75
+ * Unauthenticated module-liveness probe (#700). Used ONLY on the degraded
76
+ * path where the supervisor run-state read couldn't run (no/expired/invalid
77
+ * operator token, or any API error) but the hub itself is up: probes a
78
+ * module's own `/health` directly on its loopback port. Treats 2xx AND 401
79
+ * as live (mirrors the "auth-gated health = healthy" rule, #423: a module
80
+ * that answers 401 is authenticated-but-alive, not down). Bounded; never
81
+ * throws. Production reuses the same bounded fetch shape as the hub probe;
82
+ * tests inject so they don't hit the network.
83
+ */
84
+ probeModuleHealth?: (port: number, health: string) => Promise<boolean>;
74
85
  /**
75
86
  * Open the hub DB used to validate/auto-rotate the operator token in
76
87
  * `fetchModuleStates`. Production opens `<configDir>/hub.db`; tests inject a
@@ -162,6 +173,15 @@ interface StatusRow {
162
173
  * Printed on a continuation line like the other notes.
163
174
  */
164
175
  managerNote?: string;
176
+ /**
177
+ * Set on a module row whose STATE was derived from an unauthenticated
178
+ * `/health` probe rather than the supervisor's run-state (#700) — the
179
+ * degraded-read fallback (no/expired operator token, or an API error) where
180
+ * the module is genuinely serving. Tells the operator the row is live-but-
181
+ * thin: no PID/uptime/structured run-state until they sign in. Printed on a
182
+ * continuation line like the other notes.
183
+ */
184
+ probeNote?: string;
165
185
  }
166
186
 
167
187
  /**
@@ -319,6 +339,7 @@ function renderRows(rows: StatusRow[], print: (line: string) => void): void {
319
339
  print(` ! probe: ${row.healthDetail}`);
320
340
  }
321
341
  if (row.managerNote) print(` ! ${row.managerNote}`);
342
+ if (row.probeNote) print(` → ${row.probeNote}`);
322
343
  if (row.driftWarning) print(` ! ${row.driftWarning}`);
323
344
  if (row.staleNote) print(` ! ${row.staleNote}`);
324
345
  if (row.startErrorNote) print(` ! ${row.startErrorNote}`);
@@ -336,12 +357,33 @@ function renderRows(rows: StatusRow[], print: (line: string) => void): void {
336
357
  // in Phase 5b.
337
358
  // ---------------------------------------------------------------------------
338
359
 
360
+ /**
361
+ * Default unauthenticated module-liveness probe (#700). A bounded `fetch` to the
362
+ * module's own `http://127.0.0.1:<port><health>`. Treats 2xx AND 401 as live —
363
+ * an auth-gated `/health` that answers 401 is authenticated-but-alive, not down
364
+ * (the "auth-gated health = healthy" rule, #423). Any other status / network
365
+ * error / timeout → false. 1.5s timeout, mirroring hub-unit's `defaultProbeHealth`.
366
+ */
367
+ async function defaultProbeModuleHealth(port: number, health: string): Promise<boolean> {
368
+ try {
369
+ const res = await fetch(`http://127.0.0.1:${port}${health}`, {
370
+ signal: AbortSignal.timeout(1500),
371
+ // Loopback-only target, but never chase a redirect off-box (defensive).
372
+ redirect: "manual",
373
+ });
374
+ return res.ok || res.status === 401;
375
+ } catch {
376
+ return false;
377
+ }
378
+ }
379
+
339
380
  /** Resolved supervisor-path seams (see `StatusOpts.supervisor`). */
340
381
  interface ResolvedStatusSupervisor {
341
382
  hubUnitDeps: HubUnitDeps;
342
383
  queryHubUnitState: (deps: HubUnitDeps) => HubUnitStateResult;
343
384
  probeHubHealth: (port: number) => Promise<boolean>;
344
385
  fetchModuleStates: (deps: DriveModuleOpDeps) => Promise<ModuleStatesResult>;
386
+ probeModuleHealth: (port: number, health: string) => Promise<boolean>;
345
387
  openDb: (configDir: string) => Database;
346
388
  baseUrl: string | undefined;
347
389
  }
@@ -357,6 +399,7 @@ function resolveStatusSupervisor(opts: StatusOpts["supervisor"]): ResolvedStatus
357
399
  queryHubUnitState: opts?.queryHubUnitState ?? queryHubUnitStateImpl,
358
400
  probeHubHealth: opts?.probeHubHealth ?? hubUnitDeps.probeHealth,
359
401
  fetchModuleStates: opts?.fetchModuleStates ?? fetchModuleStatesImpl,
402
+ probeModuleHealth: opts?.probeModuleHealth ?? defaultProbeModuleHealth,
360
403
  openDb: opts?.openDb ?? ((configDir) => openHubDb(hubDbPath(configDir))),
361
404
  baseUrl: opts?.baseUrl,
362
405
  };
@@ -471,10 +514,17 @@ async function buildSupervisorRows(args: BuildSupervisorRowsArgs): Promise<Statu
471
514
  ...(sup.baseUrl !== undefined ? { baseUrl: sup.baseUrl } : {}),
472
515
  });
473
516
  } catch (err) {
474
- if (err instanceof NoOperatorTokenError || err instanceof OperatorTokenExpiredError) {
475
- // No / expired operator token: we can't read module run-state, but the
476
- // hub is up. Show the manifest-derived rows with an actionable note —
477
- // do NOT 401-crash status (§6.4 graceful degradation).
517
+ if (err instanceof NoOperatorTokenError) {
518
+ // No operator token AND none can be minted yet on a fresh box the
519
+ // first admin doesn't exist, so `rotate-operator` would itself hard-error
520
+ // ("no hub users yet"). Point at `set-password` (create the first admin),
521
+ // the actual unblocking step. We still can't read run-state, but the hub
522
+ // is up — degrade gracefully (§6.4), do NOT 401-crash status (#700).
523
+ moduleReadNote =
524
+ "couldn't read live module state — run `parachute auth set-password` to create the first admin (then `parachute auth rotate-operator`)";
525
+ } else if (err instanceof OperatorTokenExpiredError) {
526
+ // Token exists but is stale: an admin already exists, so re-minting works.
527
+ // Keep the rotate-operator guidance.
478
528
  moduleReadNote =
479
529
  "couldn't read live module state — run `parachute auth rotate-operator` to mint an operator token";
480
530
  } else {
@@ -500,6 +550,26 @@ async function buildSupervisorRows(args: BuildSupervisorRowsArgs): Promise<Statu
500
550
  if (m.short && !stateByShort.has(m.short)) stateByShort.set(m.short, m);
501
551
  }
502
552
 
553
+ // Unauthenticated-liveness fallback (#700). On the degraded path — the hub is
554
+ // up but we couldn't read supervisor run-state (no/expired operator token, or
555
+ // an API error) — probe each module's own `/health` directly so a module that
556
+ // is genuinely serving reads LIVE instead of being mapped null→`inactive`
557
+ // (which falsely told fresh-box operators a working install was broken). Keyed
558
+ // by the unique `entry.name`; probed concurrently, bounded, never throws.
559
+ const probeAlive = new Map<string, boolean>();
560
+ if (hubHealthy && !states) {
561
+ await Promise.all(
562
+ manifest.services.map(async (entry) => {
563
+ try {
564
+ const alive = await sup.probeModuleHealth(entry.port, entry.health);
565
+ if (alive) probeAlive.set(entry.name, true);
566
+ } catch {
567
+ // Probe must never crash status — absent from the map = treated as down.
568
+ }
569
+ }),
570
+ );
571
+ }
572
+
503
573
  const rows: StatusRow[] = manifest.services.map((entry) => {
504
574
  const base = manifestRowBase(entry, installSourceDeps);
505
575
  const snap = base.short ? stateByShort.get(base.short) : undefined;
@@ -526,6 +596,39 @@ async function buildSupervisorRows(args: BuildSupervisorRowsArgs): Promise<Statu
526
596
  };
527
597
  }
528
598
 
599
+ // Degraded read, but the module answered an unauthenticated `/health` probe
600
+ // (#700): show it LIVE instead of null→`inactive`. We can't surface PID/
601
+ // uptime/structured run-state (those need the operator token), so keep the
602
+ // degraded `moduleReadNote` AND add a probe-derived continuation note so the
603
+ // operator understands the row is from a liveness probe, not full supervisor
604
+ // state. `skipped: true` keeps a working install at exit 0.
605
+ if (!snap && probeAlive.get(entry.name)) {
606
+ const row: StatusRow = {
607
+ service: entry.name,
608
+ port: String(entry.port),
609
+ version: entry.version,
610
+ stateLabel: "active",
611
+ pidLabel: "-",
612
+ uptimeLabel: "-",
613
+ healthDetail: "-",
614
+ latencyLabel: "-",
615
+ sourceLabel: base.sourceLabel,
616
+ url: base.url,
617
+ healthy: true,
618
+ skipped: true,
619
+ };
620
+ row.probeNote = "live via unauthenticated health probe — sign in for full supervisor state";
621
+ if (base.driftWarning) row.driftWarning = base.driftWarning;
622
+ if (base.staleNote) row.staleNote = base.staleNote;
623
+ if (base.manifestStartErrorNote) row.startErrorNote = base.manifestStartErrorNote;
624
+ // Surface the degraded-read note ONCE (first module row), same as below.
625
+ if (moduleReadNote) {
626
+ row.managerNote = moduleReadNote;
627
+ moduleReadNote = undefined;
628
+ }
629
+ return row;
630
+ }
631
+
529
632
  const { stateLabel, healthy, skipped } = mapSupervisorStatus(snap?.supervisor_status ?? null);
530
633
  // Prefer the supervisor's structured start-error (live), else the persisted
531
634
  // services.json note — same friendly surface either way (#188).
@@ -1209,9 +1209,31 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
1209
1209
  return issueAuthCodeRedirect(db, parsed, requestedScopes, session.userId, deps);
1210
1210
  }
1211
1211
 
1212
+ // hub#689 — does the user hold ADMIN on every vault they could pick? Admin
1213
+ // (isFirstAdmin) owns the whole hub. A non-admin owns a vault only if their
1214
+ // `user_vaults` role grants admin there (today role=write does; a role=read
1215
+ // assignment would NOT). Re-derived from the DB so the owner-verb-selector
1216
+ // is offered only to a genuine owner — the submit path re-checks the PICKED
1217
+ // vault and the cap is the backstop, but rendering it precisely avoids
1218
+ // promising an admin upgrade the cap would silently demote.
1219
+ const userHoldsAdminOnPickable =
1220
+ userIsAdmin ||
1221
+ (assignedVaults.length > 0 &&
1222
+ assignedVaults.every((v) =>
1223
+ (vaultVerbsForUserVault(db, session.userId, v) ?? []).includes("admin"),
1224
+ ));
1225
+
1212
1226
  return htmlResponse(
1213
1227
  renderConsent(
1214
- consentProps(client, parsed, vaultNames, csrf.token, assignedVaults, userIsAdmin),
1228
+ consentProps(
1229
+ client,
1230
+ parsed,
1231
+ vaultNames,
1232
+ csrf.token,
1233
+ assignedVaults,
1234
+ userIsAdmin,
1235
+ userHoldsAdminOnPickable,
1236
+ ),
1215
1237
  ),
1216
1238
  200,
1217
1239
  extra,
@@ -1270,7 +1292,8 @@ function capScopesToUserAuthority(
1270
1292
  if (name === undefined || verb === undefined || !VAULT_VERBS.has(verb)) return true;
1271
1293
  // Named vault verb requested by a non-owner: admit only if the user holds
1272
1294
  // it. `vaultVerbsForUserVault` returns null for an unassigned vault (drop)
1273
- // or the held verb list (today read/write only never admin).
1295
+ // or the held verb list a `write` role holds [read, write, admin], a
1296
+ // `read` role holds [read] (see `vaultVerbsForRole`).
1274
1297
  const held = vaultVerbsForUserVault(db, userId, name);
1275
1298
  return held !== null && (held as readonly string[]).includes(verb);
1276
1299
  });
@@ -1682,6 +1705,44 @@ async function handleConsentSubmit(
1682
1705
  400,
1683
1706
  );
1684
1707
  }
1708
+ // hub#689 — owner-on-own-vault verb widening. The consent screen offers
1709
+ // owners a read/write/admin selector (pre-selected to admin) for an
1710
+ // unnamed `vault:read`/`vault:write` request, so an owner whose AI client
1711
+ // asked for read-only can grant the level it actually needs in-flow. The
1712
+ // submitted `verb_select` is an UNTRUSTED hint — we re-derive ownership of
1713
+ // the PICKED vault server-side here, and `capScopesToUserAuthority` (inside
1714
+ // issueAuthCodeRedirect) is the backstop that drops any verb the user
1715
+ // doesn't actually hold. This only ever rewrites the unnamed read/write
1716
+ // verb(s) to the selected level on the picked vault; named scopes and every
1717
+ // other scope are untouched. A forged `verb_select=admin` from a user who
1718
+ // doesn't own the picked vault gets capped back to what they hold (or, for
1719
+ // a vault outside a pinned user's assignment, never reaches here — the
1720
+ // mismatch checks above already 400'd it).
1721
+ const selectedVerb = String(form.get("verb_select") ?? "").trim();
1722
+ if (selectedVerb === "read" || selectedVerb === "write" || selectedVerb === "admin") {
1723
+ // Re-derive, server-side, whether THIS user owns (holds admin on) the
1724
+ // PICKED vault. Owner === first admin (holds admin everywhere) OR an
1725
+ // assigned user whose role grants admin on this vault. Never trust the
1726
+ // client-submitted selector to establish authority.
1727
+ const heldOnPicked = vaultVerbsForUserVault(db, session.userId, pickedVault);
1728
+ const ownsPicked = userIsAdmin || (heldOnPicked?.includes("admin") ?? false);
1729
+ if (ownsPicked) {
1730
+ scopes = scopes.map((s) => {
1731
+ const parts = s.split(":");
1732
+ // Only widen the unnamed read/write verbs the selector was offered
1733
+ // for — leave an unnamed `vault:admin`, named scopes, and non-vault
1734
+ // scopes exactly as requested.
1735
+ if (
1736
+ parts.length === 2 &&
1737
+ parts[0] === "vault" &&
1738
+ (parts[1] === "read" || parts[1] === "write")
1739
+ ) {
1740
+ return `vault:${selectedVerb}`;
1741
+ }
1742
+ return s;
1743
+ });
1744
+ }
1745
+ }
1685
1746
  scopes = narrowVaultScopes(scopes, pickedVault);
1686
1747
  }
1687
1748
 
@@ -2746,6 +2807,10 @@ function consentProps(
2746
2807
  csrfToken: string,
2747
2808
  assignedVaults: readonly string[],
2748
2809
  userIsAdmin: boolean,
2810
+ // hub#689 — true when the user holds admin on every vault they could pick
2811
+ // (admin owns the hub; an assigned non-admin only if their role grants admin
2812
+ // on each assigned vault). Gates whether the owner-verb-selector renders.
2813
+ userHoldsAdminOnPickable = userIsAdmin,
2749
2814
  ) {
2750
2815
  const scopes = params.scope.split(" ").filter((s) => s.length > 0);
2751
2816
  const unnamedVerbs = unnamedVaultVerbs(scopes);
@@ -2875,6 +2940,25 @@ function consentProps(
2875
2940
  const only = vaultNames[0];
2876
2941
  if (only) displayVault = only;
2877
2942
  }
2943
+ // hub#689 — owner-on-own-vault verb selector. The client requested an
2944
+ // unnamed `vault:read`/`vault:write` verb, and the consenting user owns
2945
+ // (holds admin on) every vault they could pick — first admin owns the whole
2946
+ // hub; an assigned non-admin holds admin on each of their assigned vaults
2947
+ // (vaultVerbsForRole('write') → [read,write,admin]). Offer the selector so
2948
+ // they can grant the level their client actually needs (or downgrade), with
2949
+ // admin pre-selected. Suppressed when the request can't be authorized (zero-
2950
+ // vault non-admin) or the assignment is stale (no valid vault to own).
2951
+ //
2952
+ // SECURITY: this only DECIDES WHETHER TO RENDER. The actual widening is
2953
+ // re-derived server-side in `handleConsentSubmit` against the *picked* vault
2954
+ // and capped by `capScopesToUserAuthority`. The selector value is a hint.
2955
+ const upgradeableUnnamedVerbs = unnamedVerbs.filter((v) => v === "read" || v === "write");
2956
+ const userOwnsEveryPickableVault =
2957
+ !hasStaleAssignment && userCanAuthorizeRequest && userHoldsAdminOnPickable;
2958
+ const ownerVerbSelector =
2959
+ upgradeableUnnamedVerbs.length > 0 && userOwnsEveryPickableVault
2960
+ ? { requestedVerbs: upgradeableUnnamedVerbs }
2961
+ : undefined;
2878
2962
  return {
2879
2963
  params,
2880
2964
  clientId: client.clientId,
@@ -2883,6 +2967,7 @@ function consentProps(
2883
2967
  csrfToken,
2884
2968
  vaultPicker,
2885
2969
  displayVault,
2970
+ ownerVerbSelector,
2886
2971
  staleAssignedVault,
2887
2972
  // Approve stays enabled for non-vault scopes even when assigned_vault
2888
2973
  // is stale — the user can still consent to e.g. `scribe:transcribe`
package/src/oauth-ui.ts CHANGED
@@ -147,6 +147,31 @@ export interface ConsentViewProps {
147
147
  * the user on an error page. Defaults to authorizable when omitted.
148
148
  */
149
149
  userCanAuthorizeRequest?: boolean;
150
+ /**
151
+ * hub#689 — owner-on-own-vault verb selector. Set when the consenting user
152
+ * OWNS (holds admin on) every vault they could pick AND the client requested
153
+ * an unnamed `vault:read`/`vault:write` verb. Renders a read/write/admin
154
+ * radio group, pre-selected to admin, so the owner can grant the level their
155
+ * AI client actually needs in-flow (the requested-scope shape was the
156
+ * blocker, not the user's authority) — or transparently downgrade.
157
+ *
158
+ * The submitted `verb_select` is an UNTRUSTED hint: the consent-submit
159
+ * handler re-derives, server-side, whether the user actually owns the picked
160
+ * vault before widening, and `capScopesToUserAuthority` remains the backstop
161
+ * that drops any verb the user doesn't hold. The selector only ever WIDENS
162
+ * the unnamed verb(s) on the picked vault; it never touches any other scope.
163
+ */
164
+ ownerVerbSelector?: OwnerVerbSelector;
165
+ }
166
+
167
+ export interface OwnerVerbSelector {
168
+ /**
169
+ * The unnamed read/write verb(s) the client requested. Only `read`/`write`
170
+ * are upgradeable here — an unnamed `vault:admin` request already renders
171
+ * with the admin badge and needs no selector. Used to word the selector
172
+ * help text ("the app asked for write access").
173
+ */
174
+ requestedVerbs: string[];
150
175
  }
151
176
 
152
177
  export interface VaultPicker {
@@ -328,6 +353,7 @@ export function renderConsent(props: ConsentViewProps): string {
328
353
  staleAssignedVault,
329
354
  blockApproveForStaleAssignment,
330
355
  userCanAuthorizeRequest,
356
+ ownerVerbSelector,
331
357
  } = props;
332
358
  // Substitute unnamed `vault:<verb>` rows with the resolved named form so
333
359
  // the operator sees the scope shape that will appear in the token. Raw
@@ -339,6 +365,7 @@ export function renderConsent(props: ConsentViewProps): string {
339
365
  ? `<li class="scope scope-empty">No scopes requested — the app gets a session token only.</li>`
340
366
  : displayedScopes.map(renderScopeRow).join("\n");
341
367
  const pickerSection = vaultPicker ? renderVaultPicker(vaultPicker) : "";
368
+ const verbSelectorSection = ownerVerbSelector ? renderOwnerVerbSelector(ownerVerbSelector) : "";
342
369
  // Approve is disabled when the picker can't yield a valid vault. The
343
370
  // empty-vault branch (no vaults registered) is the original case. A
344
371
  // locked-vault picker (multi-user Phase 1) always has a valid value via
@@ -418,6 +445,7 @@ export function renderConsent(props: ConsentViewProps): string {
418
445
  ${renderCsrfHiddenInput(csrfToken)}
419
446
  ${renderHiddenInputs(params)}
420
447
  ${pickerSection}
448
+ ${verbSelectorSection}
421
449
  <div class="button-row">
422
450
  <button type="submit" name="approve" value="yes" class="btn btn-primary"${approveDisabled}>Approve</button>
423
451
  <button type="submit" name="approve" value="no" class="btn btn-secondary">Deny</button>
@@ -492,6 +520,60 @@ function renderVaultPicker(picker: VaultPicker): string {
492
520
  </section>`;
493
521
  }
494
522
 
523
+ /**
524
+ * hub#689 — owner-on-own-vault verb selector. Rendered only when the
525
+ * consenting user owns (holds admin on) every vault they could pick and the
526
+ * client requested an unnamed `vault:read`/`vault:write` verb. Three radios
527
+ * (read / write / admin), pre-selected to **admin** so the common case (the
528
+ * owner's own AI client that needs full access) is one click — but the owner
529
+ * sees and submits the choice, and can downgrade.
530
+ *
531
+ * The `admin` option keeps the `.scope-admin` red border + admin badge so an
532
+ * admin grant stays visibly flagged even when pre-selected. The submitted
533
+ * `verb_select` is an untrusted hint re-checked server-side (ownership
534
+ * re-derivation in `handleConsentSubmit` + `capScopesToUserAuthority` backstop);
535
+ * this template only renders the choice.
536
+ */
537
+ function renderOwnerVerbSelector(selector: OwnerVerbSelector): string {
538
+ const requested = selector.requestedVerbs.map((v) => `<code>vault:${escapeHtml(v)}</code>`);
539
+ const requestedList =
540
+ requested.length === 1
541
+ ? requested[0]
542
+ : `${requested.slice(0, -1).join(", ")} and ${requested.at(-1)}`;
543
+ const option = (
544
+ verb: "read" | "write" | "admin",
545
+ title: string,
546
+ desc: string,
547
+ checked: boolean,
548
+ ): string => {
549
+ const isAdmin = verb === "admin";
550
+ const cls = `verb-option${isAdmin ? " verb-option-admin scope-admin" : ""}`;
551
+ const badge = isAdmin ? `<span class="badge badge-admin">admin</span>` : "";
552
+ return `
553
+ <label class="${cls}">
554
+ <input type="radio" name="verb_select" value="${verb}"${checked ? " checked" : ""} />
555
+ <span class="verb-option-body">
556
+ <span class="verb-option-head">
557
+ <span class="verb-option-title">${escapeHtml(title)}</span>
558
+ ${badge}
559
+ </span>
560
+ <span class="verb-option-desc">${escapeHtml(desc)}</span>
561
+ </span>
562
+ </label>`;
563
+ };
564
+ return `
565
+ <section class="verb-selector">
566
+ <h2 class="scopes-title">Access level</h2>
567
+ <p class="picker-help">
568
+ This app asked for ${requestedList} access to your vault. Because you own
569
+ this vault, you can grant a different level — admin is selected so your app
570
+ can do everything it might need; lower it if you'd rather not.
571
+ </p>
572
+ <div class="verb-options">${option("read", "Read only", "View notes, tags, attachments, and config.", false)}${option("write", "Read & write", "Create, edit, and delete notes, tags, and attachments.", false)}${option("admin", "Admin", "Full access plus config, triggers/automation, GitHub backup, and minting tokens.", true)}
573
+ </div>
574
+ </section>`;
575
+ }
576
+
495
577
  /**
496
578
  * "App not yet approved" page (#74). Two branches:
497
579
  *
@@ -1282,6 +1364,47 @@ const STYLES = `
1282
1364
  font-size: 0.88rem;
1283
1365
  color: ${PALETTE.fg};
1284
1366
  }
1367
+ /* hub#689 — owner-on-own-vault verb selector. Same card shell as the
1368
+ vault picker; the admin option carries the .scope-admin red border so an
1369
+ admin grant stays visibly flagged even when pre-selected. */
1370
+ .verb-selector {
1371
+ margin: 0 0 1.25rem;
1372
+ padding: 0.75rem 0.85rem;
1373
+ border: 1px solid ${PALETTE.borderLight};
1374
+ border-radius: 6px;
1375
+ background: ${PALETTE.bgSoft};
1376
+ }
1377
+ .verb-selector .scopes-title { margin-bottom: 0.4rem; }
1378
+ .verb-options {
1379
+ display: flex;
1380
+ flex-direction: column;
1381
+ gap: 0.4rem;
1382
+ }
1383
+ .verb-option {
1384
+ display: flex;
1385
+ align-items: flex-start;
1386
+ gap: 0.5rem;
1387
+ padding: 0.5rem 0.65rem;
1388
+ border: 1px solid ${PALETTE.border};
1389
+ border-radius: 6px;
1390
+ background: ${PALETTE.cardBg};
1391
+ cursor: pointer;
1392
+ transition: border-color 0.15s ease, background 0.15s ease;
1393
+ }
1394
+ .verb-option:hover { border-color: ${PALETTE.accent}; }
1395
+ .verb-option input[type=radio] { margin-top: 0.25rem; }
1396
+ .verb-option input[type=radio]:focus { outline: 2px solid ${PALETTE.accent}; outline-offset: 2px; }
1397
+ .verb-option-body { display: flex; flex-direction: column; gap: 0.1rem; }
1398
+ .verb-option-head {
1399
+ display: flex;
1400
+ align-items: center;
1401
+ gap: 0.4rem;
1402
+ flex-wrap: wrap;
1403
+ }
1404
+ .verb-option-title { font-weight: 500; color: ${PALETTE.fg}; font-size: 0.9rem; }
1405
+ .verb-option-desc { font-size: 0.82rem; color: ${PALETTE.fgMuted}; }
1406
+ .verb-option-admin .verb-option-title { color: ${PALETTE.danger}; }
1407
+
1285
1408
  .vault-picker-empty .picker-help { color: ${PALETTE.danger}; }
1286
1409
  .vault-picker-empty .picker-help code { color: ${PALETTE.fg}; }
1287
1410
  .vault-picker-locked .picker-help { color: ${PALETTE.fgMuted}; }
@@ -42,7 +42,8 @@ export const SCOPE_EXPLANATIONS: Record<string, ScopeExplanation> = {
42
42
  level: "write",
43
43
  },
44
44
  "vault:admin": {
45
- label: "Full vault access plus configuration changes (rotate tokens, change settings).",
45
+ label:
46
+ "Read and write everything, plus admin: config & settings, triggers & automation, GitHub backup, and minting access tokens.",
46
47
  level: "admin",
47
48
  },
48
49
  // Optional-module scopes (scribe / agent). These are in FIRST_PARTY_SCOPES