Files
pad/internal/server/testdata/token_scope_levels.json
T
xarmian b901ec629c feat(web): choose an API key's access when creating it, and see every key's access (TASK-3866) (#2216)
* feat(web): choose an API key's access when creating it, and see every key's access (TASK-3866)

Console settings now offers Read and write (["*"], the default, the same
value the store writes when scopes are omitted) or Read only (["read"])
when making a key, and the token list says what each key can do. The
label mirrors tokenScopeAllows, so legacy ""/[] keys read as
unrestricted and null, unparseable or all-unknown values read as No
access, never as full access. One fixture,
internal/server/testdata/token_scope_levels.json, is checked by both the
Go test and the vitest test, so the label cannot drift from the server.

Both create doors (POST /auth/tokens and POST /workspaces/{ws}/tokens)
now refuse scopes the server would not honour, with 400 validation_error
naming the value: non-arrays, the string "null", the legacy [] form,
and unknown values. They used to be stored verbatim, minting dead keys.
Omitted still means ["*"]. Documented in docs/api.md.

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ

* test(e2e): mint through the admin session; the suite PAT is refused session_required (TASK-3866)

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ

* test: fixture rows where JS trim and Go TrimSpace disagree, and read after write; scopes is always a string (TASK-3866)

Mutants T2, T6 and T8 survived the first set: NBSP is trimmed by both
runtimes, no row put read after write, and the null branch was
unreachable because the API's scopes is a Go string.

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ

* fix(server): an explicit "scopes": null is refused, not read as omitted (TASK-3866)

Codex r1 (High): decoded into APITokenCreate, a JSON null scopes field
arrived as "" and minted a full-access key. Both create doors now keep
the field raw (apiTokenCreateRequest), so a null, an array instead of a
string, or a number is refused 400 validation_error naming the value.
Omitted or "" still keeps the ["*"] default.

describeTokenScopes treats any non-string as No access instead of
throwing: the existing settings tests build tokens without scopes, and
the string-only signature from the previous commit crashed them.

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ

* fix(server): only an omitted scopes field gets the default; "" is refused (TASK-3866)

Codex r2 M1: an explicit "scopes": "" minted a full-access key. "" is
the stored legacy unrestricted form, refused for the reason [] is. No
caller sends it: APITokenCreate.Scopes has carried omitempty since the
model was added, and the web sends no field or a non-empty one.

The raw field now decodes into a *string, so a null is nil however it
is spaced. Codex r2 H1 (a spaced null bypassing the check) did not
reproduce: encoding/json hands RawMessage the bare literal.

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ
2026-10-17 06:13:14 -04:00

38 lines
2.3 KiB
JSON

{
"_comment": "TASK-3866: what each stored api_tokens.scopes value lets a key do. Read by TestTokenScopeLevelsFixture (Go, against tokenScopeAllows) and by web/src/lib/settings/tokenScopes.test.ts (against describeTokenScopes), so the web's label cannot drift from the server's check. level: write = reads and writes, read = reads only, none = every request refused. legacy marks the unrestricted pre-enforcement forms. unknown lists the values the server ignores.",
"rows": [
{ "scopes": "[\"*\"]", "level": "write" },
{ "scopes": "[ \"*\" ]", "level": "write" },
{ "scopes": "\u00a0[\"*\"]", "level": "write" },
{ "scopes": "\u0085[\"*\"]", "level": "write" },
{ "scopes": "\ufeff[\"*\"]", "level": "none" },
{ "scopes": "[\"write\"]", "level": "write" },
{ "scopes": "[\"pad:write\"]", "level": "write" },
{ "scopes": "[\"pad:admin\"]", "level": "write" },
{ "scopes": "[\"read\",\"write\"]", "level": "write" },
{ "scopes": "[\"write\",\"read\"]", "level": "write" },
{ "scopes": "[\"*\",\"pad:read\"]", "level": "write" },
{ "scopes": "[\"read\"]", "level": "read" },
{ "scopes": "[\"pad:read\"]", "level": "read" },
{ "scopes": "[\"read\",\"pad:read\"]", "level": "read" },
{ "scopes": "", "level": "write", "legacy": true },
{ "scopes": "[]", "level": "write", "legacy": true },
{ "scopes": "[ ]", "level": "write", "legacy": true },
{ "scopes": "[\n]", "level": "write", "legacy": true },
{ "scopes": "null", "level": "none" },
{ "scopes": " ", "level": "none" },
{ "scopes": "not-json", "level": "none" },
{ "scopes": "{}", "level": "none" },
{ "scopes": "\"read\"", "level": "none" },
{ "scopes": "[\"read\",1]", "level": "none" },
{ "scopes": "\u00a0[\"read\"]", "level": "none" },
{ "scopes": "[\"docs\"]", "level": "none", "unknown": ["docs"] },
{ "scopes": "[\"read-only\"]", "level": "none", "unknown": ["read-only"] },
{ "scopes": "[\"Read\"]", "level": "none", "unknown": ["Read"] },
{ "scopes": "[null]", "level": "none", "unknown": [""] },
{ "scopes": "[\"docs\",\"read\"]", "level": "read", "unknown": ["docs"] },
{ "scopes": "[\"read\",null]", "level": "read", "unknown": [""] },
{ "scopes": "[\"docs\",\"*\"]", "level": "write", "unknown": ["docs"] }
]
}