fix(register): read home JWT from localStorage so step-2 avatar uploads
RegisterPage step 2 writes the new token to localStorage but defers
initSession (and thus the authStore.token write) until after the avatar
upload, to keep AuthRedirect from yanking the user off /register
mid-upload. The home-origin branch of setTokenForOriginResolver was
reading authStore.token, so getTokenForOrigin('') returned null during
that window — transferStore.startUpload threw "not authenticated" and
the surrounding catch-and-ignore silently dropped the avatar. Align the
resolver with the home api client and read localStorage so both paths
share one source of truth for the home JWT.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user