Skip to content

refactor(errors): centralize 403 FORBIDDEN translation in wrapResult - #247

Merged
lmjabreu merged 3 commits into
mainfrom
lmjabreu/modest-albattani-c8955c
May 28, 2026
Merged

refactor(errors): centralize 403 FORBIDDEN translation in wrapResult#247
lmjabreu merged 3 commits into
mainfrom
lmjabreu/modest-albattani-c8955c

Conversation

@lmjabreu

@lmjabreu lmjabreu commented May 22, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Lift the local isForbidden helper from src/commands/channel/delete.ts into the central wrapResult handler in src/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.
  • New isForbidden(error) predicate in src/lib/errors.ts sits next to isInsufficientScope. Both delegate to a shared hasTwistStatusCode(error, status) type predicate so the shape narrowing lives in one place, with no as casts. JSDoc documents the precedence rule: callers must test isInsufficientScope first so OAuth-scope 403s keep their dedicated INSUFFICIENT_SCOPE code and hints; isForbidden is the catch-all.
  • FORBIDDEN end-to-end translation test in src/lib/api.test.ts exercises the real proxy + wrapResult flow 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 hints You may not have permission for this action and Contact 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 bypass wrapResult and would need a separate 403 check; tracked for the same follow-up.

Test plan

  • npm run type-check
  • npm run lint
  • npm test (710 passed)
  • Manual smoke: tw channel delete id:837190 --yes against the parked admin-only channel surfaces the new friendly FORBIDDEN message (verified after tw channel unarchive, then re-archived).

@doistbot doistbot left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Share FeedbackReview Logs

Comment thread src/lib/api.ts
Comment thread src/lib/api.test.ts Outdated
Comment thread src/lib/errors.ts
Comment thread src/lib/api.ts Outdated
Comment thread src/lib/api.test.ts
Comment thread src/lib/api.test.ts Outdated
@lmjabreu
lmjabreu force-pushed the lmjabreu/modest-albattani-c8955c branch 2 times, most recently from fd87b43 to 85bb4a1 Compare May 27, 2026 13:32
@lmjabreu
lmjabreu requested a review from gnapse May 27, 2026 13:33
@lmjabreu
lmjabreu force-pushed the lmjabreu/determined-poincare-43dd3d branch from 95817ad to e8d108d Compare May 27, 2026 15:01
Base automatically changed from lmjabreu/determined-poincare-43dd3d to main May 27, 2026 15:01
@lmjabreu
lmjabreu force-pushed the lmjabreu/modest-albattani-c8955c branch from 85bb4a1 to c4f44ae Compare May 27, 2026 15:02
Comment thread src/lib/errors.ts Outdated
Comment thread src/lib/errors.ts Outdated
Comment thread src/lib/errors.ts
Comment on lines +95 to +97
* 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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 isForbidden and isInsufficientScope can 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 getErrorType as the main way to check if an error is of a certain type
  • getErrorType would have embedded in it the workflow of first checking if it's insufficient scope before returning if it's forbidden.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's ok, addressing it in a follow-up refactoring PR.

lmjabreu and others added 2 commits May 28, 2026 00:26
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>
@lmjabreu
lmjabreu force-pushed the lmjabreu/modest-albattani-c8955c branch from c4f44ae to f4d2106 Compare May 27, 2026 23:27
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>
@lmjabreu
lmjabreu merged commit 3c156cf into main May 28, 2026
6 checks passed
@lmjabreu
lmjabreu deleted the lmjabreu/modest-albattani-c8955c branch May 28, 2026 08:28
scottlovegrove added a commit to Doist/comms-cli that referenced this pull request May 29, 2026
…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>
@doist-release-bot

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 2.45.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants