feat(app): shared fetch wrapper with auth header and 401 refresh-retry - #550
Merged
Merged
Conversation
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
6 tasks
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
Closes #518
Every authenticated dashboard API call now goes through one shared
fetchJsonwrapper (app/lib/api/fetch-json.ts) that attachesAuthorization: Bearer <accessToken>from a module-level token store, refreshes the token once and retries on 401, and redirects to/auth/loginwhen the refresh fails. The single most important design decision: the access token is written into a plain module store byAuthProviderat boot (mirroring the SDK's request-interceptor approach inxstreamroll-sdk/src/client.ts), so the plainlib/api/*functions don't need React context threading.Why
The dashboard sent every request without credentials the API accepts:
streams.tsandadmin-stats.tssentcredentials: "include"but noAuthorizationheader, andtags.tssent anX-User-Idheader the API never reads (api/src/common/guards/jwt-extractor.service.tsreads onlyAuthorization: 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
app/lib/api/token-store.tssetAccessToken/getAccessToken/clearAccessToken) populated byAuthProviderat boot — the SDK-interceptor pattern, so plain functions can read the token without context.app/lib/api/fetch-json.tsfetchJson<T>(url, init, ErrorClass): injectsAuthorizationwhen a token exists, on 401 refreshes once via the app's ownPOST /api/auth/refreshroute and retries once, and on refresh failure clears the token and redirects to/auth/login. Non-2xx throws the caller's error class (keeps existinginstanceofchecks working). Has matchingfetch-json.test.ts.app/lib/api/streams.tslistStreams/getStreamnow delegate tofetchJson;StreamsApiError extends ApiRequestError.app/lib/api/tags.tslistTags/attachTagToStream/detachTagFromStreamdelegate tofetchJson; the ignoredX-User-Idheader and itsuserIdinit param are removed;TagsApiError extends ApiRequestError.app/lib/api/admin-stats.tsfetchAdminStatsdelegates tofetchJson;AdminStatsError extends ApiRequestError.app/components/auth-provider.tsxapp/hooks/useStreams.tsuseStreamTags' inline fetch replaced withfetchJsonso the tags endpoint gets the same auth wiring.app/src/app/dashboard/streams/stream-tag-editor.tsxactingUserIdprop removed (it was a placeholder for JWT auth that the API never consumed).app/src/app/dashboard/streams/page.tsxactingUserId;demoUserIdconstant removed.app/components/streams/stream-tag-editor.test.tsxactingUserIdprops 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.ts—useStreamTagsinline fetch replaced with the shared wrapper so tag-chip loading is authenticated too.app/src/app/dashboard/streams/page.tsx— removed the now-deaddemoUserId.Acceptance criteria coverage
streams.ts,tags.ts,admin-stats.tssendsAuthorization: Bearer <accessToken>via one shared helper (fetch-json.test.ts— header-injection tests; all three modules delegate tofetchJson)X-User-Idheader andactingUserIdprop removed (tags.ts,stream-tag-editor.tsx,page.tsx— no remaining references)fetch-json.test.ts— refresh-and-retry test asserts exactly 3 fetches: original, refresh, retry)fetch-json.test.ts— assertswindow.location.assign("/auth/login")and cleared token)fetch-json.test.ts)useStreams/auth-providercall 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
tokensfield); it is populated byAuthProviderand would need re-population if an alternate auth entry point (e.g. login page) is added later.