fix: fail fast and explain 401s on group writes - #45
Conversation
Group writes need the `workspaces:write` scope, which only ships with `tdc auth login --full-access`. A default login previously round-tripped to the API and surfaced a bare `Request failed with status 401`. Add `ensureScopeAllowed`, a per-method scope table checked before the request fires, so an under-scoped grant fails immediately with the command that fixes it. The guard fails open when the granted scope is unknown (`COMMS_API_TOKEN`, manually-saved tokens) — those may be session tokens, which bypass scope enforcement server-side. Also map Comms 401s onto an actionable `INVALID_TOKEN` error rather than letting the raw SDK message through, and document in the README and skill content that group writes require `--full-access`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
05947d9 to
0949787
Compare
doistbot
left a comment
There was a problem hiding this comment.
This PR adds a client-side scope guard (workspaces:write) for group writes, translates 401 responses into structured INVALID_TOKEN errors with re-auth guidance, and updates documentation across the README, skill content, and groups command.
I also included a few optional follow-up notes in the details below.
Optional follow-up notes (5)
src/lib/errors.test.ts:116:
isInvalidTokenhas no dedicated unit tests inerrors.test.ts. The existing predicatesisInsufficientScopeandisForbiddenboth have isolation tests covering true/false cases (correct status, wrong status, plain errors, non-objects). The new predicate is only exercised indirectly through thewrapResultintegration test inapi.test.ts. Adding parallel tests here would maintain the established convention and catch regressions if the status-code check changes.src/lib/permissions.ts:81: Reuse the scope-token parser in
auth-provider.ts(export or movesplitScopeString) rather than adding a second parser here. The existing parser also normalizes comma delimiters; this version treatsworkspaces:write,comms:content:writeas one token and rejects the grant, so the two scope-handling paths can drift.src/lib/api.ts:162:
ensureWriteAllowed()andensureScopeAllowed(fullPath)each independently callgetAuthMetadata(), which reads the config file from disk viagetConfig()with no caching. Since both run sequentially before the same request, the second read is redundant — for config-file auth (notCOMMS_API_TOKEN), every mutating call now does 2 disk reads where it previously did 1. Fetch the metadata once and pass it to both checks (e.g. giveensureScopeAllowedan optional pre-fetchedAuthMetadataparameter) to avoid the duplicate I/O on this per-mutating-call path.src/lib/api.ts:162: Group writes now call
getAuthMetadata()twice in sequence: once throughensureWriteAllowed()and again throughensureScopeAllowed(). For stored credentials, each invocation loads the config viagetConfig(). Combine these checks behind one metadata lookup (or pass the first lookup's metadata into the scope check) to avoid duplicate config I/O on every group mutation.src/lib/api.test.ts:246: The test title promises "re-auth guidance," and the sibling FORBIDDEN and INSUFFICIENT_SCOPE tests in this same describe block all assert on
hints. This one only checkscodeandmessage, so removing or garbling the hints (including the known-issue tracking link) would pass silently. Add at least the stable first hint to thetoMatchObject, e.g.hints: expect.arrayContaining(['Re-authenticate with \tdc auth login`, then check `tdc auth status`'])` — or assert the full array to match the established pattern.
- Regenerate `skills/comms-cli/SKILL.md`; `check:skill-sync` compares it byte-for-byte against the built content and was failing. - Gate the scope hint on 401s to methods that declare a required scope. Every call routes through `wrapResult`, reads included, so an expired token on a read was being blamed on group/workspace scopes. - Add `ensureMutationAllowed`, so the write and scope checks share one `getAuthMetadata()` call. `getConfig()` is uncached, so the two guards were doing two disk reads per mutating call. - Extract `splitScopeString` into `scopes.ts` and reuse it, rather than hand-rolling a second parser that missed comma-delimited grants. It lives in its own module because `permissions` importing `auth-provider` would close an `auth-provider` -> `api` -> `permissions` cycle. - Cover `isInvalidToken` in `errors.test.ts` alongside the sibling predicates, and assert the full `hints` array on the 401 tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks — all five follow-up notes addressed in 488dd37. The two inline threads have their own replies; these are the ones from the summary block, which have no inline anchor to reply to:
I could not import
|
An under-scoped grant is a 403 `Insufficient scope`, which has its own branch above — a 401 only ever means the token is bad or expired, so naming a required scope there is wrong rather than merely imprecise. The pre-flight guard already catches an under-scoped grant locally before any request is made. Removes the `methodPath` plumbing threaded through `wrapResult`, which existed only to gate that hint. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## [2.0.1](v2.0.0...v2.0.1) (2026-07-22) ### Bug Fixes * fail fast and explain 401s on group writes ([#45](#45)) ([670722e](670722e))
|
🎉 This PR is included in version 2.0.1 🎉 The release is available on: Your semantic-release bot 📦🚀 |
Reported in [CLI] Can't remove/add users to groups —
tdc groups add-user/remove-userfail with a bareRequest failed with status 401. Tracked as Comms Issues.Every group write route (
add,update,remove,add_users,remove_users) requires theworkspaces:writescope, which the CLI only requests under--full-access. On a default login we round-tripped to the API and surfaced whatever the server said, with no hint that the grant was the problem.Changes
src/lib/permissions.ts—API_METHOD_SCOPEStable +ensureScopeAllowed(). An under-scoped grant now fails immediately, naming the scope and the command that fixes it, instead of after a request. Fails open when the granted scope is unknown (COMMS_API_TOKEN, manually-saved tokens) — those may be session tokens, which bypass scope enforcement server-side, so blocking them would break working setups.src/lib/api.ts— guard wired into the client proxy alongsideensureWriteAllowed; new 401 →INVALID_TOKENbranch inwrapResult, so a rejected token gets re-auth guidance rather than the raw SDK message.src/lib/errors.ts—isInvalidToken()predicate, alongside the existingisInsufficientScope/isForbidden.groupscommand description — group writes need--full-access; group reads work on a default login.Channel writes are deliberately absent from the scope table: they already surface a clean 403 from Comms, which
wrapResultturns into the same guidance.Before / after
An under-scoped grant never reaches this path: Comms returns a 403
Insufficient scope(handled by the existing branch), and the pre-flight guard catches it locally before any request goes out.Note for reviewers
While tracing this I found a separate server-side issue in the group write path and have written it up for the Comms backend team — it's independent of this PR, which stands on its own as the client-side scope handling.
Testing
npm run type-check,npm test(847 pass),npm run lint:check,npm run buildall clean. New coverage inpermissions.test.ts(blocks/allows, whole-scope matching not substring, fails open on unknown scope, all five group methods) andapi.test.ts(guard runs before the request fires; 401 translation).The 401 mapping is verified against the live API. The scope-guard path is unit-tested only — verifying it end-to-end requires a default-scope login, which I didn't want to force.
🤖 Generated with Claude Code