@openparachute/hub 0.7.4-rc.2 → 0.7.4-rc.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.7.4-rc.2",
3
+ "version": "0.7.4-rc.4",
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": {
@@ -151,7 +151,10 @@ describe("expandRedirectUrisForHubOrigins (surface#118 cross-hub-origin DCR expa
151
151
  const hubOrigins = [LOOPBACK, "http://localhost:1939", PUBLIC];
152
152
 
153
153
  test("expands a loopback-rooted URI onto every other hub origin", () => {
154
- const out = expandRedirectUrisForHubOrigins([`${LOOPBACK}/surface/notes/oauth/callback`], hubOrigins);
154
+ const out = expandRedirectUrisForHubOrigins(
155
+ [`${LOOPBACK}/surface/notes/oauth/callback`],
156
+ hubOrigins,
157
+ );
155
158
  // Original is preserved + the public + localhost variants are added.
156
159
  expect(out).toContain(`${LOOPBACK}/surface/notes/oauth/callback`);
157
160
  expect(out).toContain(`${PUBLIC}/surface/notes/oauth/callback`);
@@ -188,10 +191,7 @@ describe("expandRedirectUrisForHubOrigins (surface#118 cross-hub-origin DCR expa
188
191
  });
189
192
 
190
193
  test("single known hub origin → no expansion (submitted set returned as-is)", () => {
191
- const out = expandRedirectUrisForHubOrigins(
192
- [`${LOOPBACK}/surface/notes/`],
193
- [LOOPBACK],
194
- );
194
+ const out = expandRedirectUrisForHubOrigins([`${LOOPBACK}/surface/notes/`], [LOOPBACK]);
195
195
  expect(out).toEqual([`${LOOPBACK}/surface/notes/`]);
196
196
  });
197
197
 
@@ -222,9 +222,9 @@ describe("expandRedirectUrisForHubOrigins (surface#118 cross-hub-origin DCR expa
222
222
  const r = registerClient(db, { redirectUris: expanded });
223
223
  // The public-origin variant now matches exactly at authorize time — the
224
224
  // off-localhost sign-in that surface#118 broke.
225
- expect(
226
- requireRegisteredRedirectUri(r.client, `${PUBLIC}/surface/notes/oauth/callback`),
227
- ).toBe(`${PUBLIC}/surface/notes/oauth/callback`);
225
+ expect(requireRegisteredRedirectUri(r.client, `${PUBLIC}/surface/notes/oauth/callback`)).toBe(
226
+ `${PUBLIC}/surface/notes/oauth/callback`,
227
+ );
228
228
  // A truly-unregistered URI is still rejected — strict match unchanged.
229
229
  expect(() =>
230
230
  requireRegisteredRedirectUri(r.client, "https://evil.example/surface/notes/oauth/callback"),
@@ -352,4 +352,24 @@ describe("isValidRedirectUri", () => {
352
352
  expect(isValidRedirectUri("/relative")).toBe(false);
353
353
  expect(isValidRedirectUri("not a url")).toBe(false);
354
354
  });
355
+ // hub#663: spec-forbidden shapes that the protocol allowlist alone passed.
356
+ test("rejects userinfo-bearing redirect URIs (hub#663)", () => {
357
+ expect(isValidRedirectUri("https://x@evil.com/cb")).toBe(false);
358
+ expect(isValidRedirectUri("https://user:pass@evil.com/cb")).toBe(false);
359
+ expect(isValidRedirectUri("http://attacker@127.0.0.1:3000/cb")).toBe(false);
360
+ });
361
+ test("rejects control chars in the raw input (hub#663)", () => {
362
+ // Control chars must be caught on the RAW string — URL parsing would
363
+ // otherwise strip a trailing \r\n and the smuggled value would pass.
364
+ expect(isValidRedirectUri("https://example.com/cb\r\nSet-Cookie: x")).toBe(false);
365
+ expect(isValidRedirectUri("https://example.com/\x00cb")).toBe(false);
366
+ expect(isValidRedirectUri("https://example.com/cb\x7f")).toBe(false);
367
+ });
368
+ test("still accepts clean http(s) with ports, paths, and queries (regression guard)", () => {
369
+ // Legitimate clients (hub modules, self-built surfaces, Notes, Claude DCR)
370
+ // all register clean URIs — these must keep passing.
371
+ expect(isValidRedirectUri("https://claude.ai/api/mcp/auth_callback")).toBe(true);
372
+ expect(isValidRedirectUri("http://localhost:1939/admin/oauth/callback")).toBe(true);
373
+ expect(isValidRedirectUri("https://my-surface.github.io/cb?x=1")).toBe(true);
374
+ });
355
375
  });
@@ -3,10 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import {
7
- _resetBootstrapTokenForTests,
8
- generateBootstrapToken,
9
- } from "../bootstrap-token.ts";
6
+ import { _resetBootstrapTokenForTests, generateBootstrapToken } from "../bootstrap-token.ts";
10
7
  import { buildCsrfCookie, generateCsrfToken } from "../csrf.ts";
11
8
  import { HUB_SVC, hubPortPath } from "../hub-control.ts";
12
9
  import { createDbHolder } from "../hub-db-liveness.ts";
@@ -3715,7 +3712,9 @@ describe("layerOf — classify trust layer from proxy headers + peer (item E / #
3715
3712
  // flipped an empty XFF back to loopback would re-open the Caddy-direct leak.
3716
3713
  test("loopback peer + empty X-Forwarded-For → public (errs safe, not loopback) [#704]", () => {
3717
3714
  expect(layerOf(req("/", { headers: { "X-Forwarded-For": "" } }), "127.0.0.1")).toBe("public");
3718
- expect(layerOf(req("/", { headers: { "X-Forwarded-For": " " } }), "127.0.0.1")).toBe("public");
3715
+ expect(layerOf(req("/", { headers: { "X-Forwarded-For": " " } }), "127.0.0.1")).toBe(
3716
+ "public",
3717
+ );
3719
3718
  });
3720
3719
 
3721
3720
  // The genuine on-box caller (CLI, health probe, init bootstrap-token loopback
@@ -6090,3 +6089,126 @@ describe("GET /admin/setup bootstrap-token probe — loopback-gated (hub#576 + C
6090
6089
  }
6091
6090
  });
6092
6091
  });
6092
+
6093
+ // hub#643 (Tier-1): non-script security headers on proxied module/surface
6094
+ // text/html pages. The vault content proxy and the generic services-mount
6095
+ // proxy both flow through `decorateWithChrome`, so the headers land on both.
6096
+ // DELIBERATELY no `script-src` — a strict script-src would white-screen
6097
+ // self-built GitHub-hosted surfaces + inline-script module pages (that's the
6098
+ // deferred Tier-2). Header-only: non-HTML proxied responses are NOT decorated.
6099
+ describe("hubFetch proxied-page security headers (hub#643 Tier-1)", () => {
6100
+ const TIER1_CSP = "frame-ancestors 'self'; object-src 'none'; base-uri 'self'";
6101
+
6102
+ // Live upstream that echoes a fixed content-type + body so the test can
6103
+ // exercise both the text/html (decorated) and JSON (untouched) branches.
6104
+ function startUpstream(contentType: string, body: string): { port: number; stop: () => void } {
6105
+ const server = Bun.serve({
6106
+ port: 0,
6107
+ hostname: "127.0.0.1",
6108
+ fetch: () => new Response(body, { status: 200, headers: { "content-type": contentType } }),
6109
+ });
6110
+ return { port: server.port as number, stop: () => server.stop(true) };
6111
+ }
6112
+
6113
+ test("decorates a proxied text/html generic-mount page with nosniff + the Tier-1 CSP", async () => {
6114
+ const h = makeHarness();
6115
+ const upstream = startUpstream(
6116
+ "text/html; charset=utf-8",
6117
+ "<html><body><h1>my surface</h1></body></html>",
6118
+ );
6119
+ try {
6120
+ writeManifest(
6121
+ {
6122
+ services: [
6123
+ {
6124
+ name: "parachute-surface",
6125
+ port: upstream.port,
6126
+ paths: ["/surface"],
6127
+ health: "/surface/health",
6128
+ version: "0.2.0",
6129
+ },
6130
+ ],
6131
+ },
6132
+ h.manifestPath,
6133
+ );
6134
+ const res = await hubFetch(h.dir, { manifestPath: h.manifestPath })(req("/surface/foo"));
6135
+ expect(res.status).toBe(200);
6136
+ expect(res.headers.get("content-type")).toContain("text/html");
6137
+ expect(res.headers.get("x-content-type-options")).toBe("nosniff");
6138
+ const csp = res.headers.get("content-security-policy");
6139
+ expect(csp).toBe(TIER1_CSP);
6140
+ // The critical Tier-1/Tier-2 boundary: NO script-src — self-built
6141
+ // GitHub-hosted surfaces + inline-script module pages must stay
6142
+ // unrestricted. A strict script-src is the deferred Tier-2.
6143
+ expect(csp).not.toContain("script-src");
6144
+ } finally {
6145
+ upstream.stop();
6146
+ h.cleanup();
6147
+ }
6148
+ });
6149
+
6150
+ test("decorates a proxied text/html per-vault page (the Notes-PWA path) with the same headers", async () => {
6151
+ const h = makeHarness();
6152
+ const upstream = startUpstream("text/html; charset=utf-8", "<html><body>notes</body></html>");
6153
+ try {
6154
+ writeManifest(
6155
+ {
6156
+ services: [
6157
+ {
6158
+ name: "parachute-vault",
6159
+ port: upstream.port,
6160
+ paths: ["/vault/default"],
6161
+ health: "/vault/default/health",
6162
+ version: "0.4.0",
6163
+ },
6164
+ ],
6165
+ },
6166
+ h.manifestPath,
6167
+ );
6168
+ const res = await hubFetch(h.dir, { manifestPath: h.manifestPath })(
6169
+ req("/vault/default/some-page"),
6170
+ );
6171
+ expect(res.status).toBe(200);
6172
+ expect(res.headers.get("x-content-type-options")).toBe("nosniff");
6173
+ expect(res.headers.get("content-security-policy")).toBe(TIER1_CSP);
6174
+ expect(res.headers.get("content-security-policy")).not.toContain("script-src");
6175
+ } finally {
6176
+ upstream.stop();
6177
+ h.cleanup();
6178
+ }
6179
+ });
6180
+
6181
+ test("leaves a proxied NON-HTML response (JSON) undecorated", async () => {
6182
+ const h = makeHarness();
6183
+ const upstream = startUpstream("application/json", JSON.stringify({ ok: true }));
6184
+ try {
6185
+ writeManifest(
6186
+ {
6187
+ services: [
6188
+ {
6189
+ name: "parachute-surface",
6190
+ port: upstream.port,
6191
+ paths: ["/surface"],
6192
+ health: "/surface/health",
6193
+ version: "0.2.0",
6194
+ },
6195
+ ],
6196
+ },
6197
+ h.manifestPath,
6198
+ );
6199
+ const res = await hubFetch(h.dir, { manifestPath: h.manifestPath })(req("/surface/api/data"));
6200
+ expect(res.status).toBe(200);
6201
+ expect(res.headers.get("content-type")).toContain("application/json");
6202
+ // No HTML CSP on a JSON API response (proves the header is gated on
6203
+ // content-type, so a `.js` asset proxied through the same path is also
6204
+ // left alone).
6205
+ expect(res.headers.get("content-security-policy")).toBeNull();
6206
+ expect(res.headers.get("x-content-type-options")).toBeNull();
6207
+ const body = (await res.json()) as { ok: boolean };
6208
+ expect(body.ok).toBe(true);
6209
+ } finally {
6210
+ upstream.stop();
6211
+ h.cleanup();
6212
+ }
6213
+ });
6214
+ });
@@ -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)));
@@ -990,6 +990,123 @@ describe("handleSetupGet", () => {
990
990
  db.close();
991
991
  }
992
992
  });
993
+
994
+ // hub#618: gate the JSON `?op=` op-snapshot once setup is complete.
995
+ // Mid-setup it stays OPEN (the unauth CLI wizard + brand-new-operator
996
+ // browser both poll it before any session exists); post-complete it
997
+ // requires a session or loopback (it's a post-setup admin surface, and
998
+ // `/admin/setup` is always lockout-exempt so it's otherwise unauth-reachable).
999
+
1000
+ test("mid-setup unauth ?op= still returns the op snapshot (hub#618 regression guard)", async () => {
1001
+ const db = openHubDb(hubDbPath(h.dir));
1002
+ try {
1003
+ // No admin yet → setup INCOMPLETE → the surface stays open.
1004
+ const reg = getDefaultOperationsRegistry();
1005
+ const op = reg.create("install", "vault");
1006
+ reg.update(op.id, { status: "running" }, "running bun add -g @openparachute/vault@latest");
1007
+ const res = handleSetupGet(
1008
+ req(`/admin/setup?op=${op.id}`, { headers: { accept: "application/json" } }),
1009
+ {
1010
+ db,
1011
+ manifestPath: h.manifestPath,
1012
+ configDir: h.dir,
1013
+ readExposeStateFn: h.readExposeStateFn,
1014
+ issuer: "https://hub.example",
1015
+ registry: reg,
1016
+ // No loopback flag, no session — the unauth first-boot poll.
1017
+ },
1018
+ );
1019
+ expect(res.status).toBe(200);
1020
+ const body = (await res.json()) as {
1021
+ hasAdmin: boolean;
1022
+ operation?: { id: string; status: string; log: readonly string[] };
1023
+ };
1024
+ expect(body.hasAdmin).toBe(false);
1025
+ expect(body.operation).toBeDefined();
1026
+ expect(body.operation?.id).toBe(op.id);
1027
+ expect(body.operation?.status).toBe("running");
1028
+ } finally {
1029
+ db.close();
1030
+ }
1031
+ });
1032
+
1033
+ test("post-complete unauth ?op= omits the op snapshot; session OR loopback restores it (hub#618)", async () => {
1034
+ const db = openHubDb(hubDbPath(h.dir));
1035
+ try {
1036
+ // Drive state to COMPLETE: admin + vault + expose mode.
1037
+ const user = await createUser(db, "owner", "pw");
1038
+ writeManifest(
1039
+ {
1040
+ services: [
1041
+ {
1042
+ name: "parachute-vault",
1043
+ version: "0.1.0",
1044
+ port: 1940,
1045
+ paths: ["/vault/default"],
1046
+ health: "/health",
1047
+ },
1048
+ ],
1049
+ },
1050
+ h.manifestPath,
1051
+ );
1052
+ setSetting(db, "setup_expose_mode", "localhost");
1053
+ const reg = getDefaultOperationsRegistry();
1054
+ const op = reg.create("install", "vault");
1055
+ reg.update(op.id, { status: "running" }, "still running");
1056
+
1057
+ const deps = {
1058
+ db,
1059
+ manifestPath: h.manifestPath,
1060
+ configDir: h.dir,
1061
+ readExposeStateFn: h.readExposeStateFn,
1062
+ issuer: "https://hub.example",
1063
+ registry: reg,
1064
+ };
1065
+
1066
+ // (a) Unauth, non-loopback → operation omitted.
1067
+ const unauth = handleSetupGet(
1068
+ req(`/admin/setup?op=${op.id}`, { headers: { accept: "application/json" } }),
1069
+ deps,
1070
+ );
1071
+ expect(unauth.status).toBe(200);
1072
+ const unauthBody = (await unauth.json()) as {
1073
+ hasAdmin: boolean;
1074
+ hasVault: boolean;
1075
+ hasExposeMode: boolean;
1076
+ operation?: unknown;
1077
+ };
1078
+ // Confirm setup actually derived as complete (else the gate is vacuous).
1079
+ expect(unauthBody.hasAdmin).toBe(true);
1080
+ expect(unauthBody.hasVault).toBe(true);
1081
+ expect(unauthBody.hasExposeMode).toBe(true);
1082
+ expect(unauthBody.operation).toBeUndefined();
1083
+
1084
+ // (b) Valid session → operation restored.
1085
+ const { createSession } = await import("../sessions.ts");
1086
+ const session = createSession(db, { userId: user.id });
1087
+ const authed = handleSetupGet(
1088
+ req(`/admin/setup?op=${op.id}`, {
1089
+ headers: {
1090
+ accept: "application/json",
1091
+ cookie: `${SESSION_COOKIE_NAME}=${session.id}`,
1092
+ },
1093
+ }),
1094
+ deps,
1095
+ );
1096
+ const authedBody = (await authed.json()) as { operation?: { id: string } };
1097
+ expect(authedBody.operation?.id).toBe(op.id);
1098
+
1099
+ // (c) Loopback (no session) → operation restored.
1100
+ const loopback = handleSetupGet(
1101
+ req(`/admin/setup?op=${op.id}`, { headers: { accept: "application/json" } }),
1102
+ { ...deps, requestIsLoopback: true },
1103
+ );
1104
+ const loopbackBody = (await loopback.json()) as { operation?: { id: string } };
1105
+ expect(loopbackBody.operation?.id).toBe(op.id);
1106
+ } finally {
1107
+ db.close();
1108
+ }
1109
+ });
993
1110
  });
994
1111
 
995
1112
  // --- POST /admin/setup/account -------------------------------------------
package/src/clients.ts CHANGED
@@ -323,9 +323,23 @@ function timingSafeEqualHex(a: string, b: string): boolean {
323
323
  * URIs). Doesn't try to match a registered URI; that's `requireRegisteredRedirectUri`.
324
324
  */
325
325
  export function isValidRedirectUri(uri: string): boolean {
326
+ // hub#663: reject control chars (C0 0x00-0x1f + DEL 0x7f) in the RAW input
327
+ // BEFORE URL parsing normalizes/strips them. A `\r`/`\n`/NUL smuggled into a
328
+ // redirect_uri is a header/log-injection vector even though our exact-match +
329
+ // verbatim foreign-storage neutralize it downstream — spec-forbidden hygiene.
330
+ // (Charcode scan rather than a control-char regex literal, which biome's
331
+ // noControlCharactersInRegex rightly flags as an easy footgun.)
332
+ for (let i = 0; i < uri.length; i++) {
333
+ const c = uri.charCodeAt(i);
334
+ if (c <= 0x1f || c === 0x7f) return false;
335
+ }
326
336
  try {
327
337
  const u = new URL(uri);
328
338
  if (u.protocol === "javascript:" || u.protocol === "data:") return false;
339
+ // hub#663: reject userinfo (`https://x@evil.com/cb`). A redirect target
340
+ // carrying credentials is spec-forbidden and an open-redirect / phishing
341
+ // shape; the protocol allowlist alone let it through.
342
+ if (u.username !== "" || u.password !== "") return false;
329
343
  return u.protocol === "http:" || u.protocol === "https:";
330
344
  } catch {
331
345
  return false;
package/src/hub-server.ts CHANGED
@@ -3824,13 +3824,57 @@ async function decorateWithChrome(
3824
3824
  if (setCookie && out !== res) {
3825
3825
  const headers = new Headers(out.headers);
3826
3826
  headers.append("set-cookie", setCookie);
3827
- return new Response(out.body, {
3828
- status: out.status,
3829
- statusText: out.statusText,
3830
- headers,
3831
- });
3827
+ return withProxySecurityHeaders(
3828
+ new Response(out.body, {
3829
+ status: out.status,
3830
+ statusText: out.statusText,
3831
+ headers,
3832
+ }),
3833
+ );
3832
3834
  }
3833
- return out;
3835
+ // hub#643: every exit runs through the security-header step, which self-
3836
+ // gates on content-type — so a non-HTML pass-through (`out === res`, e.g. a
3837
+ // 502 proxy error or a JSON/asset body) is returned unchanged, preserving
3838
+ // the pre-existing behavior for those responses.
3839
+ return withProxySecurityHeaders(out);
3840
+ }
3841
+
3842
+ /**
3843
+ * hub#643 (Tier-1): stamp non-script security headers on proxied `text/html`
3844
+ * pages — the per-vault `/vault/<name>/*` proxy and the generic
3845
+ * services-mount `/<mount>/*` proxy both flow through `decorateWithChrome`,
3846
+ * so this is the single chokepoint that covers a module / surface page.
3847
+ *
3848
+ * - `X-Content-Type-Options: nosniff` — stops content-type sniffing.
3849
+ * - `Content-Security-Policy: frame-ancestors 'self'; object-src 'none';
3850
+ * base-uri 'self'` — clickjacking (external framing) + plugin + base-tag
3851
+ * hardening.
3852
+ *
3853
+ * Deliberately NO `script-src`: a strict script-src would white-screen
3854
+ * self-built GitHub-hosted surfaces (the primary surface story) and
3855
+ * inline-script module pages. The opt-in strict script-src CSP is Tier-2,
3856
+ * explicitly deferred (hub#643 stays open).
3857
+ *
3858
+ * Header-only: we never buffer the body. Only `text/html` responses are
3859
+ * decorated, so JSON / `.js` / CSS / image assets proxied through the same
3860
+ * path are left untouched. Existing headers are preserved (a fresh Headers
3861
+ * copy is mutated); we set (not append) so a re-decorated response can't
3862
+ * accumulate duplicates.
3863
+ */
3864
+ function withProxySecurityHeaders(res: Response): Response {
3865
+ const contentType = res.headers.get("content-type") ?? "";
3866
+ if (!contentType.toLowerCase().includes("text/html")) return res;
3867
+ const headers = new Headers(res.headers);
3868
+ headers.set("x-content-type-options", "nosniff");
3869
+ headers.set(
3870
+ "content-security-policy",
3871
+ "frame-ancestors 'self'; object-src 'none'; base-uri 'self'",
3872
+ );
3873
+ return new Response(res.body, {
3874
+ status: res.status,
3875
+ statusText: res.statusText,
3876
+ headers,
3877
+ });
3834
3878
  }
3835
3879
 
3836
3880
  if (import.meta.main) {
@@ -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
@@ -1592,14 +1592,33 @@ export function handleSetupGet(req: Request, deps: SetupWizardDeps): Response {
1592
1592
  // poll on the auth the wizard already carries.
1593
1593
  const opId = url.searchParams.get("op");
1594
1594
  if (opId) {
1595
- const op = deps.registry?.get(opId);
1596
- if (op) {
1597
- envelope.operation = {
1598
- id: op.id,
1599
- status: op.status,
1600
- log: op.log,
1601
- ...(op.error !== undefined ? { error: op.error } : {}),
1602
- };
1595
+ // hub#618: post-setup this JSON `?op=` surface is unauth-reachable —
1596
+ // `/admin/setup` is always lockout-exempt (the dispatcher's
1597
+ // `shouldGateForSetup` lets it through so a stale bookmark resolves), and
1598
+ // the snapshot is read BEFORE any session check. The leak is small (an
1599
+ // in-memory op's status + install-progress log lines, behind an
1600
+ // unguessable UUID), but it's still a post-setup admin surface, so gate
1601
+ // it once setup is COMPLETE. During setup (no admin yet) the surface
1602
+ // stays OPEN: the unauth CLI wizard (`parachute init`) AND the brand-new-
1603
+ // operator browser both poll this `?op=` snapshot mid-setup before any
1604
+ // session exists — gating then would break first-boot vault
1605
+ // provisioning. Loopback always passes (same on-box trust as the
1606
+ // `bootstrapToken` branch below); a valid session also passes.
1607
+ const setupComplete = state.hasAdmin && state.hasVault && state.hasExposeMode;
1608
+ const opSnapshotAllowed =
1609
+ !setupComplete ||
1610
+ deps.requestIsLoopback === true ||
1611
+ findActiveSession(deps.db, req) !== null;
1612
+ if (opSnapshotAllowed) {
1613
+ const op = deps.registry?.get(opId);
1614
+ if (op) {
1615
+ envelope.operation = {
1616
+ id: op.id,
1617
+ status: op.status,
1618
+ log: op.log,
1619
+ ...(op.error !== undefined ? { error: op.error } : {}),
1620
+ };
1621
+ }
1603
1622
  }
1604
1623
  }
1605
1624
  // hub#576: hand the actual token to a LOOPBACK caller only. The on-box