refactor(errors): centralize 403 FORBIDDEN translation in wrapResult#12
Merged
Conversation
Lifts the local 403 try/catch out of channel/delete.ts into wrapResult in src/lib/api.ts, so every SDK call gets uniform FORBIDDEN translation. Adds isForbidden predicate next to isInsufficientScope; both delegate to a shared hasCommsStatusCode helper. Callers must test isInsufficientScope first so OAuth-scope 403s keep their dedicated INSUFFICIENT_SCOPE code; isForbidden is the catch-all. Ports Doist/twist-cli#247. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
doistbot
reviewed
May 28, 2026
Member
doistbot
left a comment
There was a problem hiding this comment.
Thanks scottlovegrove for your contribution 🤗. The refactoring cleanly centralizes 403 FORBIDDEN handling and safely removes manual type casts by extracting the hasCommsStatusCode helper.
Few things worth tightening:
- Top-level callable properties like
client.batch(...)are not currently wrapped by the root proxy, meaning batch calls will still bypass the translation layer and leak rawCommsRequestErrors. - The new tests mock
isMutatingMethodtofalse, which bypasses the permission-checked branch fordeleteChannel; consider adjusting the mock for at least one case or keeping a command-level regression test to ensure the actual mutating path remains covered.
I also included a few optional follow-up notes in the details below.
Optional follow-up notes (2)
- [P3] src/lib/errors.ts:99:
isForbidden()overlaps withisInsufficientScope(), so callers have to remember the ordering rule from the docstring to avoid downgrading scope errors toFORBIDDEN. That’s a brittle shared API: the next call site that checks onlyisForbidden()will get the wrong CLI error. Consider makingisForbidden()exclusive (403 && !isInsufficientScope(error)) or replacing the two booleans with a single classifier. - [P3] src/lib/api.test.ts:98:
vi.resetModules()and the repeated dynamicawait import('./api.js')inside each test are unnecessary.createWrappedCommsClientis a pure factory that returns a fresh proxy instance every time it is called. You can safely addcreateWrappedCommsClientto the existing top-level dynamic import (line 48) and simplify the tests by calling it directly.
…verage - Make isForbidden exclusive with isInsufficientScope so the two predicates can be checked in any order without downgrading a scope error. - Update isMutatingMethod mock to recognize channels.deleteChannel so the 403-translation tests actually exercise the permission-checked branch that real delete calls use. - Drop vi.resetModules()/per-test dynamic imports — createWrappedCommsClient is a pure factory, so the existing top-level dynamic import suffices. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Contributor
|
🎉 This PR is included in version 1.4.0 🎉 The release is available on: Your semantic-release bot 📦🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Ports Doist/twist-cli#247 to comms-cli.
isForbiddencheck fromsrc/commands/channel/delete.tsinto the centralwrapResulthandler insrc/lib/api.ts. Every SDK call now gets uniform 403 →CliError('FORBIDDEN')translation, not just channel deletion. Future commands (kick user, group delete, etc.) inherit the friendly message for free.isForbidden(error)predicate insrc/lib/errors.tssits next toisInsufficientScope. Both delegate to a sharedhasCommsStatusCode(error, status)type predicate so the shape narrowing lives in one place, with noascasts. JSDoc documents the precedence rule: callers must testisInsufficientScopefirst so OAuth-scope 403s keep their dedicatedINSUFFICIENT_SCOPEcode and hints;isForbiddenis the catch-all.src/lib/api.test.tsexercise the real proxy +wrapResultflow by mocking@doist/comms-sdk. Covers FORBIDDEN translation, INSUFFICIENT_SCOPE precedence, and non-403 pass-through (asserted by object identity, not just message).Hint copy is call-agnostic. The central message reads
Comms refused this action: 403 Forbidden.with hintsYou may not have permission for this actionandContact your workspace admin, or re-authenticate with `tdc auth login` if your token looks wrong. The previous local message included the channel name; uniform coverage is the deliberate tradeoff. If per-command flavour matters later, we can plumb the spinner config's command name into the central message.Follow-ups (not this PR): the same central-translation pattern should eventually cover 404 (
NOT_FOUND-style with resource hint), 429 (rate-limit with retry-after), and 5xx (transient retry with backoff). Each warrants its own PR with its own behaviour decision.Test plan
🤖 Generated with Claude Code