Skip to content

feat(cli): actionable errors for missing oauth grants - #751

Draft
maoberlehner wants to merge 15 commits into
feat/DX-490-cli-oauth-loginfrom
feat/DX-486-cli-grant-error-messages
Draft

feat(cli): actionable errors for missing oauth grants#751
maoberlehner wants to merge 15 commits into
feat/DX-490-cli-oauth-loginfrom
feat/DX-486-cli-grant-error-messages

Conversation

@maoberlehner

Copy link
Copy Markdown
Contributor

Stacked on #703 (feat/DX-490-cli-oauth-login) — review that one first.

OAuth grants are scoped, so a logged-in user can be allowed to do some things and not others. Today the server's refusals surface raw: Insufficient scope: stories:write is required, or a bare 403 classified as a generic error. This turns the five server signatures into one-line diagnosis-plus-remedy messages, and fixes the three bulk/summary behaviors that live testing exposed along the way.

Approach

A pure matcher (utils/error/credential-hint.ts) maps (status, serverError, credentialContext) to a message, branching on OAuth vs PAT. program.ts sets the context once per run in the preAction hook; APIError's constructor applies the rewrite, so all ~100 handleAPIError call sites benefit without being touched. No command→scope map, no persisted scope list — the server stays the source of truth and the CLI only translates.

Context is pushed rather than imported to break the utils/error → session → oauth/store → utils cycle.

Also: 403 no longer classifies as errorId: "generic".

Beyond the message catalog

Three things live testing forced into scope:

  • Bulk runs abort on a credential failure instead of repeating the same message per story — and genuinely stop issuing requests, not just stop printing. (FailureReasonGroup collapsing is assets-only; stories push reported per story, so 10 stories meant 10 identical errors.)
  • assets push works again for OAuth users. Library discovery hits shared_asset_folders, which is unannotated and therefore default-denied for every scoped credential — no consent can grant it. Implicit discovery (--target auto, with-referenced) now degrades with a warning; explicit --target shared|all and --library still fail loudly. See the backend note below.
  • Push summaries stopped lying. "N pushed" counted stories that were only created-then-skipped; skipped now has its own label.

Behavior change worth flagging

stories push now exits 1 when any story failed, where it always exited 0 before. This fires on pre-existing failure modes too (one bad reference, one 500), so a pipeline that tolerated partial pushes flips red. It matches assets push and is documented in stories/push/README.md, but it's the one change here that can break someone's CI.

Backend follow-up

shared_asset_folders_controller and shared_internal_tags_controller lack require_token_scopes, so enforce_pat_default_deny rejects every scoped credential regardless of granted scopes. credential.rb:23 means an OAuth grant can never receive the user_permission bypass, so this is not fixable by consent. The degradation above is a workaround; a storyrails ticket annotating those two controllers is the real fix. Verified live with all 34 scopes granted.

Verification

lint, test:types, 1378 tests, and build all clean with the Nx cache skipped. Live-verified against an OAuth grant before implementation; a manual QA pass against the finished code is still outstanding.

Wires matchCredentialError/getCredentialContext into APIError's constructor
so every handleAPIError call site benefits without being touched. Also fixes
403 falling through getErrorId() to "generic" (rendered as "Error fetching
data from the API") by adding explicit forbidden/insufficient_scope error
ids. The rewrite runs as the final constructor step, after the existing
server-message promotion, and only replaces the last messageStack entry so
the API_ACTIONS[action] context line stays visible.
…mmand

getUser's local 401 message duplicated the centralized credential rewrite
and, worse, suppressed it for OAuth sessions by always passing a
customMessage. Gate the local message on an unknown credential context
(the login --token validation path, before any session exists) so an
OAuth session's 401 goes through the centralized "OAuth login is no
longer valid" message instead of the masked-token text. Also collapse
the message to a single line; the old template literal leaked its
source indentation into the terminal output.
Without stories:write, `stories push` printed the same missing-permission
message once per story, ballooning to ~1500 lines at 500 stories. A missing
permission is a property of the credential, not any individual story, so
every remaining item would fail identically. FailureCollector now tracks
whether a recorded failure is credential-level (APIError.fatal) via
hasFatal, and each of the four push loop bodies checks it to short-circuit
further recording once set.
…d exit nonzero

The prior fix only suppressed repeated reporting; readLocalStoriesStream,
mapReferencesStream, and writeStoryStream kept running and writeStoryStream
kept issuing PUT requests for every remaining story after the first
credential-level failure. Add a shouldStop hook to all three stream stages
plus a break in the pass-1 level loop, all keyed off FailureCollector.hasFatal,
so no-longer-useful work is skipped instead of merely going unreported.

Also: stories push never set process.exitCode, so a run that failed every
story exited 0. Set it in the finally block (mirroring assets/push), guarded
so it never overrides an exit code handleError already set. Move the
mapReferencesStream onStoryError log call above its fatal guard so the audit
trail comment stays true, and note why scanLocalStoryIndex's onError needs
no guard (its errors are never fatal APIErrors).
The push summary headline counted attempted creations as "pushed" and
folded skipped creations into "succeeded", so a run that wrote nothing
(e.g. every story already existed and every update failed) still
printed a fully green summary. The headline now counts
updateResults.succeeded only, since a story's content is only written
during the update phase; creation only reserves a placeholder id, so
summing creation and update successes would double count every
newly-created story. Skipped creations get their own label in the
per-phase row instead of being folded into "succeeded".
…is forbidden

shared_asset_folders carries no scope annotations, so an OAuth credential is
403'd regardless of consent. `push --target auto` and `pull --target
with-referenced` now degrade to the space scope with a warning instead of
failing outright; explicit `--target shared`/`--target all`/`--library`
requests still fail with the actionable message.
resolveOAuthClient() threw one message advising --oauth and a PAT login
regardless of caller. logout and the preAction refresh path surfaced that
login advice while removing credentials or silently refreshing a session.
Reduce the thrown error to the cause only; each caller now appends its own
consequence and remedy.
The try/catch blocks added for the login and refresh remedies had zero
coverage: login-flow.test.ts mocks ./client wholesale, and refresh.test.ts
always sets the env-var override, so neither catch path ever ran. Add one
test per caller that reaches the real placeholder-client guard and asserts
on each caller's distinctive remedy text.
Also documents three stories push behavior changes from the preceding
tasks: halting on the first credential-level failure, exiting 1 on any
story failure, and the summary headline counting only stories whose
content was actually written.
…iscovery degrade

- credential-hint.ts: give PATs their own space-not-allowed message instead of
  reusing OAuth wording (consent screen, empty authorized-spaces list); omit
  the authorized-spaces parenthetical entirely when no space ids are known,
  for both credential kinds.
- APIError now exposes the raw pre-rewrite serverError string so
  listLibrariesOrDegrade/buildLibraryRootResolver can degrade only on the
  unsupported-token-type signature instead of any 403.
- Add missing coverage: customMessage suppression invariant, pat 401 context,
  and a different-403-still-fails regression for the library discovery
  degrade paths.
- login-flow.ts/refresh.ts: type `client` explicitly and drop `as Error`
  casts in favor of instanceof narrowing.
- stories/push: fix a stale "422" comment (helper returns 500), reset
  process.exitCode in afterAll so it can't leak into other suites, and
  document the exit-1-on-any-failure behavior in the push command README.
Manual QA against a real OAuth grant surfaced five issues:

- Shared-folder discovery reported "Failed to pull library folders" on the
  push path. The lookup serves both pull and push, so the action is now
  verb-neutral: "Failed to list library folders".
- A fatal credential failure stops a phase mid-run, but the summary still
  printed the full denominator ("0/10 succeeded, 1 failed"), leaving nine
  stories silently unaccounted for. Phase lines now name them explicitly.
- `stories push` exited 0 when it bailed on a precondition (no local
  components, schema issues), so CI read an aborted push as a clean run.
  Both now go through `handleError`, matching the sibling --space check.
- `login --oauth` opened a browser before discovering the callback port was
  taken, sending the user through a consent screen whose redirect it could
  never receive. `waitForCallback` becomes `startCallbackServer`, which
  resolves only once bound, so a conflict fails before the tab opens.
- test/GUIDE.md claimed a port conflict hangs for the full callback timeout,
  which is no longer true, and lacked the headless consent procedure that
  makes the whole OAuth flow testable without a browser.

Fixes DX-486
@pkg-pr-new

pkg-pr-new Bot commented Aug 13, 2026

Copy link
Copy Markdown

Open in StackBlitz

@storyblok/angular

npm i https://pkg.pr.new/storyblok/monoblok/@storyblok/angular@751

@storyblok/astro

npm i https://pkg.pr.new/storyblok/monoblok/@storyblok/astro@751

@storyblok/api-client

npm i https://pkg.pr.new/storyblok/monoblok/@storyblok/api-client@751

storyblok

npm i https://pkg.pr.new/storyblok/monoblok/storyblok@751

@storyblok/experiments

npm i https://pkg.pr.new/storyblok/monoblok/@storyblok/experiments@751

@storyblok/js

npm i https://pkg.pr.new/storyblok/monoblok/@storyblok/js@751

storyblok-js-client

npm i https://pkg.pr.new/storyblok/monoblok/storyblok-js-client@751

@storyblok/lint-config

npm i https://pkg.pr.new/storyblok/monoblok/@storyblok/lint-config@751

@storyblok/live-preview

npm i https://pkg.pr.new/storyblok/monoblok/@storyblok/live-preview@751

@storyblok/management-api-client

npm i https://pkg.pr.new/storyblok/monoblok/@storyblok/management-api-client@751

@storyblok/migrations

npm i https://pkg.pr.new/storyblok/monoblok/@storyblok/migrations@751

@storyblok/nuxt

npm i https://pkg.pr.new/storyblok/monoblok/@storyblok/nuxt@751

@storyblok/react

npm i https://pkg.pr.new/storyblok/monoblok/@storyblok/react@751

@storyblok/region-helper

npm i https://pkg.pr.new/storyblok/monoblok/@storyblok/region-helper@751

@storyblok/richtext

npm i https://pkg.pr.new/storyblok/monoblok/@storyblok/richtext@751

@storyblok/schema

npm i https://pkg.pr.new/storyblok/monoblok/@storyblok/schema@751

@storyblok/svelte

npm i https://pkg.pr.new/storyblok/monoblok/@storyblok/svelte@751

@storyblok/vue

npm i https://pkg.pr.new/storyblok/monoblok/@storyblok/vue@751

commit: 21d4284

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant