fix(web): tus uploads use per-origin token (federation auth)

This commit is contained in:
Jannis Braun
2026-05-02 20:37:17 +02:00
parent 33cfc66ac4
commit 3a050f475b
6 changed files with 117 additions and 5 deletions
+13
View File
@@ -6,6 +6,7 @@ import {
setApiForOriginResolver,
setUserIdForOriginResolver,
setOriginFromHostnameResolver,
setTokenForOriginResolver,
} from '../utils/crossStoreResolvers';
import { useSpaceStore } from './spaceStore';
import { connectInstance, disconnectInstance as disconnectWs, disconnectAllRemote } from '../hooks/useWebSocket';
@@ -1112,6 +1113,18 @@ setUserIdForOriginResolver((origin: string): string | undefined => {
return instance?.user.id;
});
// ─── 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.
setTokenForOriginResolver((origin: string): string | null => {
if (!origin) return useAuthStore.getState().token;
const instance = useInstanceStore.getState().instances.find(i => i.origin === origin);
return instance?.token ?? null;
});
// ─── Electron: push connected-instance origins to main process ───────────────
// Enables the main process to intercept invite URLs that point to instances
// we're already signed into. Uses the basic subscribe(listener) form (no
+2
View File
@@ -976,6 +976,8 @@ export {
setOriginFromHostnameResolver,
setUserIdForOriginResolver,
setMyUserIdForOrigin,
setTokenForOriginResolver,
getTokenForOrigin,
} from '../utils/crossStoreResolvers';
/**
+5 -4
View File
@@ -3,6 +3,7 @@ import { persist, type PersistStorage, type StorageValue } from 'zustand/middlew
import { Upload, type UploadOptions } from 'tus-js-client';
import type { Attachment } from '@backspace/shared';
import { useAuthStore } from './authStore';
import { getTokenForOrigin } from '../utils/crossStoreResolvers';
export type TransferType = 'upload' | 'download';
export type TransferState =
@@ -229,7 +230,7 @@ export const useTransferStore = create<TransferStore>()(
},
startUpload: async (file, opts) => {
const token = useAuthStore.getState().token;
const token = getTokenForOrigin(opts.origin ?? '');
const user = useAuthStore.getState().user;
if (!token) throw new Error('Cannot start upload — not authenticated');
@@ -317,7 +318,7 @@ export const useTransferStore = create<TransferStore>()(
// No live instance, but server-side .tus state still exists (e.g., this
// transfer failed mid-flight or was paused with a stored URL). Send DELETE
// directly so the partial bytes don't sit on disk until the janitor sweeps.
const token = useAuthStore.getState().token;
const token = getTokenForOrigin(t.origin ?? '');
if (token) {
const fullUrl = t.tusUploadUrl.startsWith('http')
? t.tusUploadUrl
@@ -349,9 +350,9 @@ export const useTransferStore = create<TransferStore>()(
const t = get().get(id);
if (!t || t.type !== 'upload') return;
const token = useAuthStore.getState().token;
const token = getTokenForOrigin(t.origin ?? '');
if (!token) {
get().setError(id, { message: 'Not authenticated', permanent: true });
get().setError(id, { message: 'Not authenticated for this instance', permanent: true });
return;
}
@@ -4,6 +4,14 @@ import 'fake-indexeddb/auto';
// We mock tus-js-client to drive lifecycle synchronously without real network.
const startMock = vi.fn();
const abortMock = vi.fn().mockResolvedValue(undefined);
// Captures every set of UploadOptions handed to `new Upload(...)`. Tests can
// inspect `lastUploadOpts()` to assert on endpoint + Authorization header,
// which is the only way we verify per-origin token routing without hitting
// the network.
const constructedOpts: any[] = [];
function lastUploadOpts(): any {
return constructedOpts[constructedOpts.length - 1];
}
vi.mock('tus-js-client', () => {
class MockUpload {
@@ -11,6 +19,7 @@ vi.mock('tus-js-client', () => {
public url: string | null = null;
constructor(_file: File, opts: any) {
this.opts = opts;
constructedOpts.push(opts);
}
start() {
startMock();
@@ -59,13 +68,27 @@ vi.mock('./authStore', () => ({
}));
import { useTransferStore } from './transferStore';
import { setTokenForOriginResolver } from '../utils/crossStoreResolvers';
import { useAuthStore as authStoreMod } from './authStore';
// Default resolver mirrors prod wiring: empty origin → home authStore token,
// any other origin → null (no federated instance is wired up in tests unless
// the test explicitly registers a different resolver).
function installDefaultTokenResolver(): void {
setTokenForOriginResolver((origin: string): string | null => {
if (origin) return null;
return authStoreMod.getState().token ?? null;
});
}
describe('transferStore.startUpload', () => {
beforeEach(() => {
useTransferStore.setState({ transfers: new Map(), hasInMemoryFile: new Set() });
startMock.mockClear();
abortMock.mockClear();
constructedOpts.length = 0;
if (typeof localStorage !== 'undefined') localStorage.clear();
installDefaultTokenResolver();
});
it('drives a transfer through tus → completed with attachmentId + attachmentFilename', async () => {
@@ -114,6 +137,47 @@ describe('transferStore.startUpload', () => {
useTransferStore.getState().pauseUpload(id);
expect(useTransferStore.getState().get(id)!.state).toBe('paused');
});
it('startUpload uses the per-origin token + endpoint for federated uploads', async () => {
// Register a resolver that returns a federated token for a specific origin
// and the home token (from the mocked authStore) for the empty origin.
setTokenForOriginResolver((origin: string): string | null => {
if (origin === 'https://remote.example.com') return 'federated-token-XYZ';
if (!origin) return authStoreMod.getState().token ?? null;
return null;
});
const file = new File([new Uint8Array(100)], 'a.png', { type: 'image/png' });
const id = await useTransferStore.getState().startUpload(file, {
tray: true,
origin: 'https://remote.example.com',
});
const t = useTransferStore.getState().get(id);
expect(t).toBeDefined();
// The synchronous mock drives the full lifecycle, so we end completed.
expect(t!.state).toBe('completed');
// The transfer carries the remote origin so resume/abort route correctly.
expect(t!.origin).toBe('https://remote.example.com');
// The tus client was constructed with the federated bearer + remote endpoint —
// proves we did NOT fall back to the home authStore token.
const opts = lastUploadOpts();
expect(opts.endpoint).toBe('https://remote.example.com/api/files/');
expect(opts.headers.Authorization).toBe('Bearer federated-token-XYZ');
});
it('startUpload throws when no resolver is registered for the federated origin', async () => {
// Resolver returns null for unknown origin → no fallback to home token.
setTokenForOriginResolver((origin: string): string | null => {
if (!origin) return authStoreMod.getState().token ?? null;
return null;
});
const file = new File([new Uint8Array(10)], 'a.png', { type: 'image/png' });
await expect(
useTransferStore.getState().startUpload(file, {
tray: true,
origin: 'https://unknown.example.com',
}),
).rejects.toThrow(/not authenticated/i);
});
});
describe('transferStore.resumeUpload', () => {
@@ -121,6 +185,7 @@ describe('transferStore.resumeUpload', () => {
useTransferStore.setState({ transfers: new Map(), hasInMemoryFile: new Set() });
startMock.mockClear();
abortMock.mockClear();
installDefaultTokenResolver();
});
it('marks failed when transfer has no available blob to resume from', async () => {
@@ -177,6 +242,7 @@ describe('transferStore.hasInMemoryFile', () => {
startMock.mockClear();
abortMock.mockClear();
if (typeof localStorage !== 'undefined') localStorage.clear();
installDefaultTokenResolver();
});
it('hasInMemoryFile tracks startUpload + remove lifecycle', async () => {