diff --git a/docs/systems/auth.md b/docs/systems/auth.md index af53070c..0015f7f7 100644 --- a/docs/systems/auth.md +++ b/docs/systems/auth.md @@ -561,6 +561,8 @@ Called by `useAuth()` hook when token exists but user object is null: 3. `initSession(token, finalUser)` -- activates Zustand state, triggers redirect 4. Navigate to redirect param or `/channels/@me` + **Auth-token source of truth.** During the step-2 avatar upload, the JWT lives in `localStorage` only -- `authStore.token` (Zustand) is still null because step 3 hasn't fired. Both the home `api` client (`api/client.ts`) and the home-origin branch of `setTokenForOriginResolver` in `instanceStore.ts` therefore read the home JWT from `localStorage.getItem('backspace_token')`, never from `authStore.token`. This keeps `transferStore.startUpload` (and any other path that resolves a home-origin bearer) authenticated during the registration window. The two stores are written together everywhere else (`initSession`/`logout`), so the divergence only matters between steps 1 and 3 here. + --- ## 8. Federation-Aware Identity Utilities diff --git a/packages/web/src/stores/instanceStore.tokenResolver.test.ts b/packages/web/src/stores/instanceStore.tokenResolver.test.ts new file mode 100644 index 00000000..a56daafb --- /dev/null +++ b/packages/web/src/stores/instanceStore.tokenResolver.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// Same shims as instanceStore.failover.test.ts so importing instanceStore +// doesn't pull in real WS / audio / federation machinery. +vi.mock('../utils/dmOriginFailover', () => ({ + failoverDmOriginsFromDisconnected: vi.fn(), +})); +vi.mock('../hooks/useWebSocket', () => ({ + connectInstance: vi.fn(), + disconnectInstance: vi.fn(), + disconnectAllRemote: vi.fn(), +})); +vi.mock('../utils/federationOps', () => ({ clearPasswordSyncTimers: vi.fn() })); +vi.mock('../audio/AudioManager', () => ({ + AudioManager: { getInstance: vi.fn().mockReturnValue({ setOutputDevice: vi.fn(), setVolume: vi.fn() }) }, +})); +// authStore.token is forced to null for every test in this file — that's the +// invariant we're verifying the resolver tolerates (registration window). +vi.mock('./authStore', () => ({ + useAuthStore: Object.assign( + (selector: (s: unknown) => unknown) => selector({ user: null, token: null }), + { getState: () => ({ user: null, token: null }), setState: vi.fn(), subscribe: vi.fn() } + ), +})); + +// Importing instanceStore registers the production resolver. +import { useInstanceStore } from './instanceStore'; +import { getTokenForOrigin } from '../utils/crossStoreResolvers'; + +beforeEach(() => { + localStorage.clear(); + useInstanceStore.setState({ instances: [], registry: new Map(), registryUpdatedAt: 0 }); +}); + +describe('home-origin token resolver', () => { + // Regression: during the RegisterPage step-2 avatar upload window, the JWT + // is in localStorage but authStore.token is still null (initSession is + // intentionally deferred so AuthRedirect doesn't yank the user off /register + // mid-upload). The home `api` client reads localStorage directly, so the + // token resolver must too — otherwise transferStore.startUpload throws + // "not authenticated" and the avatar upload silently fails. + it('returns localStorage token for empty origin when authStore.token is null', () => { + localStorage.setItem('backspace_token', 'register-window-jwt'); + expect(getTokenForOrigin('')).toBe('register-window-jwt'); + }); + + it('returns null for empty origin when localStorage has no token', () => { + expect(getTokenForOrigin('')).toBeNull(); + }); + + it('returns the per-instance token for a known remote origin', () => { + useInstanceStore.setState({ + instances: [{ + origin: 'https://remote.example.com', + label: 'remote', + token: 'remote-jwt', + username: 'u', + status: 'connected', + user: { id: 'u', username: 'u' } as never, + api: {} as never, + }], + }); + expect(getTokenForOrigin('https://remote.example.com')).toBe('remote-jwt'); + }); + + it('returns null for an unknown remote origin', () => { + expect(getTokenForOrigin('https://unknown.example.com')).toBeNull(); + }); +}); diff --git a/packages/web/src/stores/instanceStore.ts b/packages/web/src/stores/instanceStore.ts index 5ecd3a19..f9523872 100644 --- a/packages/web/src/stores/instanceStore.ts +++ b/packages/web/src/stores/instanceStore.ts @@ -1160,11 +1160,19 @@ setUserIdForOriginResolver((origin: string): string | undefined => { // ─── Token resolution (federation) ──────────────────────────────────────────── // Maps an origin to the JWT for that instance. Used by transferStore for tus // uploads and any other path that constructs raw HTTP requests to a federated -// instance and needs to pass an Authorization header. Empty origin falls back -// to the home-instance token from authStore. +// instance and needs to pass an Authorization header. +// +// Empty origin reads from localStorage, mirroring the home `api` client +// (api/client.ts). authStore.token is the React-state mirror of the same value +// and is written together with localStorage by initSession/logout — but the +// register page intentionally writes localStorage *before* initSession (so +// AuthRedirect doesn't yank the user off /register while the avatar is still +// uploading). Reading authStore.token here would return null in that window +// and the upload would silently fail. Aligning with the api client closes the +// gap and gives one source of truth for the home JWT. setTokenForOriginResolver((origin: string): string | null => { - if (!origin) return useAuthStore.getState().token; + if (!origin) return localStorage.getItem('backspace_token'); const instance = useInstanceStore.getState().instances.find(i => i.origin === origin); return instance?.token ?? null; });