@openparachute/hub 0.7.4-rc.2 → 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
|
@@ -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
|
-
|
|
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)));
|
package/src/oauth-handlers.ts
CHANGED
|
@@ -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(
|
|
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
|
|
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:
|
|
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
|