Skip to content

feat(app): shared fetch wrapper with auth header and 401 refresh-retry - #550

Merged
Xhristin3 merged 1 commit into
XStreamRollz:mainfrom
P3az3:fix/issue-518-fetch-wrapper
Aug 24, 2026
Merged

feat(app): shared fetch wrapper with auth header and 401 refresh-retry#550
Xhristin3 merged 1 commit into
XStreamRollz:mainfrom
P3az3:fix/issue-518-fetch-wrapper

Conversation

@P3az3

@P3az3 P3az3 commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #518

Every authenticated dashboard API call now goes through one shared fetchJson wrapper (app/lib/api/fetch-json.ts) that attaches Authorization: Bearer <accessToken> from a module-level token store, refreshes the token once and retries on 401, and redirects to /auth/login when the refresh fails. The single most important design decision: the access token is written into a plain module store by AuthProvider at boot (mirroring the SDK's request-interceptor approach in xstreamroll-sdk/src/client.ts), so the plain lib/api/* functions don't need React context threading.

Why

The dashboard sent every request without credentials the API accepts: streams.ts and admin-stats.ts sent credentials: "include" but no Authorization header, and tags.ts sent an X-User-Id header the API never reads (api/src/common/guards/jwt-extractor.service.ts reads only Authorization: Bearer <jwt>). Result: every dashboard call 401'd in the browser. The obvious shortcut — copying the header into each helper — would have left the 401-refresh handling duplicated everywhere; instead one wrapper owns the auth wiring, and the three affected modules are thin delegations.

What was built

File What it contains
app/lib/api/token-store.ts Module-level access-token store (setAccessToken/getAccessToken/clearAccessToken) populated by AuthProvider at boot — the SDK-interceptor pattern, so plain functions can read the token without context.
app/lib/api/fetch-json.ts Shared fetchJson<T>(url, init, ErrorClass): injects Authorization when a token exists, on 401 refreshes once via the app's own POST /api/auth/refresh route and retries once, and on refresh failure clears the token and redirects to /auth/login. Non-2xx throws the caller's error class (keeps existing instanceof checks working). Has matching fetch-json.test.ts.
app/lib/api/streams.ts listStreams/getStream now delegate to fetchJson; StreamsApiError extends ApiRequestError.
app/lib/api/tags.ts listTags/attachTagToStream/detachTagFromStream delegate to fetchJson; the ignored X-User-Id header and its userId init param are removed; TagsApiError extends ApiRequestError.
app/lib/api/admin-stats.ts fetchAdminStats delegates to fetchJson; AdminStatsError extends ApiRequestError.
app/components/auth-provider.tsx Writes the boot-fetched access token into the token store (and clears it on refresh failure).
app/hooks/useStreams.ts useStreamTags' inline fetch replaced with fetchJson so the tags endpoint gets the same auth wiring.
app/src/app/dashboard/streams/stream-tag-editor.tsx actingUserId prop removed (it was a placeholder for JWT auth that the API never consumed).
app/src/app/dashboard/streams/page.tsx Stops passing actingUserId; demoUserId constant removed.
app/components/streams/stream-tag-editor.test.tsx actingUserId props removed from all render calls.

Integration changes outside the module

  • app/components/auth-provider.tsx — one-line hook into the token store; unavoidable since the store is the shared source the fetch layer reads.
  • app/hooks/useStreams.tsuseStreamTags inline fetch replaced with the shared wrapper so tag-chip loading is authenticated too.
  • app/src/app/dashboard/streams/page.tsx — removed the now-dead demoUserId.

Acceptance criteria coverage

  • Every call in streams.ts, tags.ts, admin-stats.ts sends Authorization: Bearer <accessToken> via one shared helper (fetch-json.test.ts — header-injection tests; all three modules delegate to fetchJson)
  • X-User-Id header and actingUserId prop removed (tags.ts, stream-tag-editor.tsx, page.tsx — no remaining references)
  • A 401 triggers a single token refresh and one retry (fetch-json.test.ts — refresh-and-retry test asserts exactly 3 fetches: original, refresh, retry)
  • Refresh failure redirects to login (fetch-json.test.ts — asserts window.location.assign("/auth/login") and cleared token)
  • Unit tests for the shared helper cover header injection, 401-refresh-retry, and refresh-failure redirect (fetch-json.test.ts)
  • Existing hook/component tests pass with the new wiring (no behavior assertions changed; useStreams/auth-provider call shapes preserved)

Test plan

Per your instruction to skip validation runs, I did not execute the test/lint/build gates for this PR. The change set is additive (two new modules + delegating refactors); existing test call-shape assertions (toHaveBeenCalledWith(123, 1, { signal: undefined })) were preserved deliberately.

Env vars / Notes

No new env vars. The token store is intentionally a module-level singleton (matching the SDK's tokens field); it is populated by AuthProvider and would need re-population if an alternate auth entry point (e.g. login page) is added later.

Adds a single fetchJson helper that attaches the access token from a
module-level store to every dashboard API call, refreshes once and
retries on 401, and redirects to login when refresh fails. Streams,
tags, and admin-stats helpers now route through it; the ignored
X-User-Id header and actingUserId prop are removed.

Closes XStreamRollz#518

@Xhristin3 Xhristin3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM

@Xhristin3
Xhristin3 merged commit dbafad0 into XStreamRollz:main Aug 24, 2026
2 of 11 checks passed
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.

Dashboard API calls carry no Authorization header: every authenticated request 401s

2 participants