refactor(errors): centralize 403 FORBIDDEN translation in wrapResult - #247
Conversation
doistbot
left a comment
There was a problem hiding this comment.
This PR successfully centralizes 403 Forbidden error handling within the API proxy, establishing a solid pattern for unified error translation. The refactoring simplifies command-level logic and sets a great foundation for standardizing other API responses. There are a few areas that need further refinement, specifically extending the translation coverage to batch API methods, adapting the error hints to remain accurate for read-only calls, extracting shared logic in the error predicates and test mocks, and strengthening the test assertions to ensure exact messages and object instances are validated.
fd87b43 to
85bb4a1
Compare
95817ad to
e8d108d
Compare
85bb4a1 to
c4f44ae
Compare
| * Precedence: callers must test `isInsufficientScope` first so OAuth-scope | ||
| * 403s keep their dedicated `INSUFFICIENT_SCOPE` code and hints; `isForbidden` | ||
| * is the catch-all fallback for plain workspace-permission 403s. |
There was a problem hiding this comment.
What if instead of relying on a comment here we redesign the error types system to avoid this? here's what I suggest:
- create a
getErrorType(error: unknown)function - the return type is a restricted string union type with the possible answers
- then
isForbiddenandisInsufficientScopecan be implemented like this:function isForbidden(error) { return getErrorType(error) === 'forbidden' } function isInsufficientScope(error) { return getErrorType(error) === 'insufficient-scope' }
- Maybe we do not even need these functions, and we just rely on exporting
getErrorTypeas the main way to check if an error is of a certain type getErrorTypewould have embedded in it the workflow of first checking if it's insufficient scope before returning if it's forbidden.
There was a problem hiding this comment.
The fragility you're flagging is real. The precedence rule lives in a doc comment that nothing enforces, so a future caller could test isForbidden first and get the wrong code without anything noticing. A getErrorType discriminator that bakes the precedence into the implementation would catch that statically.
I'd rather do that as a follow-up PR. This one is scoped to the 403 centralization in wrapResult, and the redesign touches the public shape of errors.ts (whether isForbidden/isInsufficientScope stay as wrappers or get deprecated). It would also be the right place to extend the type union for 404/429/5xx, which is on the follow-up list. Doing all the API status helpers in one pass beats sprinkling the change across two PRs.
Does that work, or would you rather block this on the redesign?
There was a problem hiding this comment.
That's ok, addressing it in a follow-up refactoring PR.
Lift the local isForbidden helper from the channel delete command up into wrapResult so every SDK call gets uniform 403 → CliError(FORBIDDEN) translation, not just channel deletion. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Extract hasTwistStatusCode() shared helper in errors.ts; both isForbidden and isInsufficientScope now delegate the status check to it instead of duplicating the shape narrowing - Rewrite FORBIDDEN hints to be call-agnostic; drops the "workspace admins can perform it" line that was misleading on read-only 403s - Switch api.test.ts error construction to new TwistRequestError() using the existing mock, removing the Object.assign workaround - Expand 403 assertions to cover message and hints, not just code - Tighten pass-through test to toBe(originalError) so a wrapped CliError with the same message would not accidentally pass Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
c4f44ae to
f4d2106
Compare
Promote `hasTwistStatusCode` to a type predicate so the cast in the status check falls away, and TS narrows `error` after the call. Drops both `as` casts in `isInsufficientScope` and replaces the unchecked `responseData` access with a runtime shape validation. Addresses gnapse review feedback on PR #247. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…12) * refactor(errors): centralize 403 FORBIDDEN translation in wrapResult 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> * refactor(errors): address PR review — exclusive isForbidden + test coverage - 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> * test(errors): tighten isForbidden test name per review nit Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
🎉 This PR is included in version 2.45.0 🎉 The release is available on: Your semantic-release bot 📦🚀 |
Summary
isForbiddenhelper 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 sharedhasTwistStatusCode(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.tsexercises the real proxy +wrapResultflow by mocking@doist/twist-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
Twist refused this action: 403 Forbidden.with hintsYou may not have permission for this actionandContact your workspace admin, or re-authenticate with \tw auth login` if your token looks wrong`. #246's 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. Batch responses (assertBatchData/getOptionalBatchData) also bypasswrapResultand would need a separate 403 check; tracked for the same follow-up.Test plan
npm run type-checknpm run lintnpm test(710 passed)tw channel delete id:837190 --yesagainst the parked admin-only channel surfaces the new friendly FORBIDDEN message (verified aftertw channel unarchive, then re-archived).