fix(tokens): Workspace Tokens tab 500 on 'global' sentinel (no node selected) #1415
Reference in New Issue
Block a user
Delete Branch "fix/workspace-tokens-global-sentinel-500"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Settings → Workspace Tokens returned
GET /workspaces/global/tokens → 500 {"error":"failed to list tokens"}whenever opened with no canvas node selected. Token CREATE in that view broke the same way.Root cause:
SettingsPanelpasses the literal sentinel"global"as the workspace id when no node is selected. The backend queries theuuidworkspace_idcolumn with it → Postgresinvalid input syntax for type uuid: "global"→ opaque 500.SecretsTabalready handles the sentinel (api/secrets.tsreroutes"global"→/settings/secrets);TokensTabdid not — that asymmetry was the bug.Pre-existing since 2026-04-13 — NOT a regression.
Workaround (until merged): select a workspace node before opening the tab, or use the Org API Keys tab.
Changes
Frontend (the user-visible fix) —
canvas/src/components/settings/TokensTab.tsxTokensTabis now sentinel-aware exactly likeSecretsTab. WhenworkspaceId === 'global'it no longer calls/workspaces/global/tokens— it renders a clean state ("Select a workspace node first") that points the user at the Org API Keys tab (the existing org-wide surface). No 500, no scary error UI.TokensTab's local error banner (verified in code — there is no separate error widget tied to this call). It resolves with this guard.Backend (defense-in-depth, same PR) —
workspace-server/internal/handlers/tokens.goList/Create/Revokevalidatec.Param("id")as a UUID up front and return400 {"error":"invalid workspace id"}instead of leaking a DB type error as a 500. Mirrors the existinguuid.Parseguard inhandlers/activity.go.log.Printfon theListquery-error branch — it was the only token handler silently swallowing the DB error, which is why this incident had zero log trail.Product note for CTO
There is no
/workspaces/global/tokensendpoint — workspace tokens are inherently per-workspace; the org-wide equivalent is the separate Org API Keys tab (OrgTokensTab). So unlikeSecretsTab(which reroutes to a real global-secrets endpoint), the lowest-risk safe behavior here is a disabled state + pointer to Org API Keys rather than a reroute. Flag if a different UX is wanted — this was the lowest-risk choice, not a hard product decision.Test plan
go build ./...+go vet ./internal/handlers/— cleango test ./internal/handlers/— full suite pass (incl. new non-UUID 400 table test asserting List/Create/Revoke short-circuit before any DB call)tsc --noEmit— zero errors in production (non-test) code; changed component compiles cleanvitest run src/components/settings/__tests__/— 183/183 pass, incl. new sentinel tests (no API call + Org-pointer rendered + no error banner)🤖 Generated with Claude Code
Settings → Workspace Tokens 500'd whenever opened with no canvas node selected. SettingsPanel passes the literal sentinel "global" as the workspace id; the backend queries the uuid `workspace_id` column with it → Postgres `invalid input syntax for type uuid: "global"` → opaque 500 ("failed to list tokens"). Token create in that view broke the same way. SecretsTab already handles the sentinel (api/secrets.ts reroutes "global" → /settings/secrets); TokensTab did not — that asymmetry was the bug. Pre-existing since 2026-04-13, NOT a regression. Frontend (user-visible fix): TokensTab is now sentinel-aware like SecretsTab. When workspaceId === "global" (no node selected) it no longer calls /workspaces/global/tokens — it renders a clean state pointing the user to the Org API Keys tab (the existing org-wide surface). No 500, no scary error banner. The red account "Error" in this view was just this 500 surfacing through TokensTab's local error banner; it resolves with this guard (verified in code — no separate widget). Backend (defense-in-depth, same PR): List/Create/Revoke validate c.Param("id") as a UUID up front and return 400 {"error":"invalid workspace id"} instead of leaking a DB type error as a 500. Added the missing log.Printf on the List query-error branch — it was the only token handler silently swallowing the DB error, which is why this incident had zero log trail. Mirrors the uuid.Parse guard already in handlers/activity.go. Workaround (pre-merge): select a workspace node before opening the tab, or use the Org API Keys tab. Product note for CTO: there is no /workspaces/global/tokens endpoint (workspace tokens are inherently per-workspace; the org-wide equivalent is the separate Org API Keys tab), so — unlike SecretsTab which reroutes to a real global-secrets endpoint — the lowest-risk safe behavior was a disabled state + pointer to Org API Keys rather than a reroute. Flag if a different UX is wanted. Tests: added TokensTab sentinel tests (no API call + Org-pointer) and a backend table test asserting List/Create/Revoke 400 on non-UUID id without hitting the DB. Updated existing token handler tests to use valid UUIDs (they used "ws-1" etc.). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Backend / Security lens — five-axis review
Reviewed commit
4fd66122on PR #1415 (basestaging). Author ishongming; I am a non-author reviewer (workspace-server / handlers owner).1. Correctness. Root cause confirmed by reading the diff:
workspace_idis auuidcolumn andc.Param("id")flowed unvalidated intoQueryContext/ExecContext, so the"global"sentinel producedinvalid input syntax for type uuid→ opaque 500 (and the same for CREATE).validWorkspaceID(uuid.Parse) is applied at the top ofList,Create, andRevokebefore any DB access. 400 body{"error":"invalid workspace id"}matches the file's existinggin.H{"error":...}shape. Complete across all three user-facing routes.2. Security. Net positive, no regression. Pre-filters malformed input before the DB driver, eliminating the type-error info leak and shrinking the input surface. Does NOT touch the
WorkspaceAuthmiddleware gate — authz semantics unchanged; this is a pure input-shape guard.uuid.Parseis a fixed-grammar parser (no regex/ReDoS). The addedlog.Printfinterpolates a UUID-shaped id +%vof the DB error — no token plaintext/hash logged, consistent with the file's existing logging discipline (tokens:-prefixed lines).3. Observability. The missing
log.Printfon theListquery-error branch is added — this was the precise reason the incident had zero log trail (only token handler that silently swallowed the DB error). Format matches sibling lines inCreate/Revoke.4. Tests.
TestTokenHandler_RejectsNonUUIDWorkspaceIDis a proper table test over List/Create/Revoke asserting 400 + error body ANDExpectationsWereMet()with zero expectations registered — proving the guard short-circuits before any SQL (the meaningful invariant). Existing sqlmock tests correctly migrated"ws-1"→fixed valid UUID constants;TestTokenHandler_RevokeWrongWorkspacecorrectly repointed to a valid-but-different UUID so it still exercises the ownership-404 branch instead of collapsing into the new 400. No test weakened to pass. Verified intent against the prod handler routes (:idis the workspace param,:tokenIdthe token).5. Maintainability / blast radius. Surgical: one helper + three 4-line guards + one log line; mirrors the existing
uuid.Parseprecedent inhandlers/activity.go(cited in the doc comment). Zero contract change for valid-UUID callers (200/201/200 paths byte-identical). Correctly identified as pre-existing since 2026-04-13, not a regression.Verdict: APPROVE (backend/security axes). No blocking findings. Optional non-blocking nit (do not gate): the three identical guard blocks could be a one-line middleware on the
wsAuthgroup, but inline matches surrounding handler style and is clearer for a hotfix.Frontend / UX lens — five-axis review
Reviewed commit
4fd66122on PR #1415 (basestaging). Author ishongming; I am a non-author reviewer (canvas / UX owner).1. Correctness.
SettingsPanelrenders<TokensTab workspaceId={workspaceId} />and passes the literal"global"sentinel when no canvas node is selected — confirmed againstSettingsPanel.tsxand theapi/secrets.ts:9precedent. The fix early-returns a dedicated state whenworkspaceId === GLOBAL_WORKSPACE_IDBEFORE theuseEffect/fetchTokensis mounted (the fetch lives in the extractedWorkspaceTokensTab, only rendered for real ids), so/workspaces/global/tokensis never called andhandleCreateis unreachable in that state. This correctly kills both the list-500 and the create-500. Hooks ordering is safe — the guard is a top-level early return before any hook in the component that owns them (hooks moved intoWorkspaceTokensTab), no conditional-hook violation.2. UX. Sound. Instead of a red error banner, the empty state reuses the tab's existing visual language (same heading +
text-ink-midcopy +py-6centered block as the existing "No active tokens" state), and explicitly points the user to the Org API Keys tab — which is the actually-correct surface for org-wide keys (there is no global workspace-tokens concept server-side). No dead/disabled "+ New Token" button is shown in this state (verified by the new test asserting "New Token" is absent), avoiding a button that would 500 on click. The red account "Error" the user saw was this same 500 surfacing through the local error banner; with the fetch gone it no longer fires — correctly explained in the PR body, no separate widget involved (matches my read of the canvas).3. Accessibility. The new state is plain semantic text (
<h3>+<p>), no interactive traps, consistent contrast tokens (text-ink,text-ink-mid,text-accent) already used elsewhere in the panel. No focusable element that does nothing. Acceptable; no a11y regression.4. Tests. New
TokensTab — global sentineldescribe block asserts (a)api.get/api.postNOT called, (b) the "Select a workspace node" + "Org API Keys" copy renders, (c) no.text-baderror banner, (d) no "New Token" button. These pin exactly the user-visible acceptance criteria. Existing 12 TokensTab cases for the real-workspace path are untouched and still pass (verified the real-id path still routes throughWorkspaceTokensTabunchanged) — the 200 list/create behavior for a real UUID is byte-identical.5. Maintainability. Minimal: an early-return guard + extraction of the existing body into
WorkspaceTokensTabwith zero logic change to that body. Mirrors theapi/secrets.tssentinel precedent and is documented with a comment pointing at it. Product-decision ambiguity (disabled-state vs reroute) is explicitly flagged for CTO in the PR with the lowest-risk option chosen — correct call given there is no/workspaces/global/tokensendpoint to reroute to.Verdict: APPROVE (frontend/UX axes). No blocking findings.
[core-qa-agent] APPROVED — Go handlers tests 14/14 pass, TokensTab Canvas tests 14/14 pass. Fix: TokensTab guards against "global" sentinel (no canvas node selected) returning a graceful empty-state instead of calling /workspaces/global/tokens (which 500s). Regression tests added in both tokens_test.go and TokensTab.test.tsx. e2e: N/A — platform not running locally (see CI).
[core-security-agent] APPROVED — security-positive bug fix. TokensTab guards workspaceId === "global" literal (safe string, not user input) and renders guidance UI instead of calling /workspaces/global/tokens (which 500s on Postgres uuid check). Prevents error disclosure + improves UX. No exec, injection, or auth concerns. OWASP 0/1
Review: LGTM ✓
Clean fix. Adding
validWorkspaceIDguard withuuid.Parsein List/Create/Revoke handlers prevents theinvalid input syntax for type uuidPostgres error from leaking as an opaque 500. The fix is defensive and consistent with existing patterns (mirrors the same guard inactivity.go).Key observations:
validWorkspaceIDis a simple pure function — easy to unit testRevokeWrongWorkspaceto use valid UUID so it exercises the ownership branch (not the validation guard)ws-1/ws-2placeholdersListquery failures — good observability improvementNo code changes requested.
Review: APPROVED
Bug fix — APPROVED ✅
Root cause is correctly identified:
SettingsPanelpasses the literal"global"sentinel as workspace id when no node is selected. Theworkspace_idcolumn is UUID type — passing a non-UUID raisesinvalid input syntax for type uuidwhich leaked as an opaque 500.Implementation — APPROVED ✅
validWorkspaceID()helper mirrors the same guard pattern already used inactivity.go— consistent with codebase style.List,Create, andRevokehandlers before any DB call — correct short-circuit.Listquery failure.TokensTab.tsxchange is appropriate — sentinel handling belongs client-side.Tests — APPROVED ✅
TestTokenHandler_RejectsNonUUIDWorkspaceIDcovers all three handlers against"global"sentinel, asserts 400, and verifies no DB call was made — themock.ExpectationsWereMet()check is a good proof of short-circuit.CI — all green ✅
Canvas (Next.js) build now passes on this PR — confirming the
npm cifix in #1411 resolves the earlier build failures. CI/all-required SUCCESS.