diff --git a/docs/systems/uploads.md b/docs/systems/uploads.md index e0f7e244..750126cd 100644 --- a/docs/systems/uploads.md +++ b/docs/systems/uploads.md @@ -79,6 +79,7 @@ Stats: `getStorageStats()` exposes `staleTusSessions` + `staleTusSize` for the a ### Security - JWT verified on every tus request (PRE_CREATE, PRE_PATCH, finalize, HEAD, DELETE). +- **Federated uploads use a per-origin JWT.** When the target space is hosted on a remote instance, the client must send that instance's scoped token (resolved via `getTokenForOrigin(origin)` in `crossStoreResolvers.ts`), not the home-instance token — otherwise the remote rejects the request as it can't verify the home signature or resolve the userId. - PRE_PATCH ownership check: `metadata.userId === req.user.id`. Required to prevent in-flight upload hijack between session creation and finalize. - Size validated against `instance_settings.maxUploadSizeBytes` at PRE_CREATE; tus's own `maxSize` is set as defense-in-depth. - Original filename round-trips through tus metadata (base64-encoded per spec); the on-disk filename uses snowflake + sanitized extension only. @@ -466,7 +467,7 @@ Three stores with strict separation of concerns: | Store | Source file | Ownership | |-------|-------------|-----------| -| `transferStore` | `packages/web/src/stores/transferStore.ts` | Every byte transfer (uploads + downloads). Source of truth for the global tray. Persists transfer metadata via `transferStore@v1`. | +| `transferStore` | `packages/web/src/stores/transferStore.ts` | Every byte transfer (uploads + downloads). Source of truth for the global tray. Persists transfer metadata via `transferStore@v1`. Tus uploads route the bearer token through `getTokenForOrigin(origin)` so federated uploads use the per-instance JWT, not the home-instance one. | | `composerStore` | `packages/web/src/stores/composerStore.ts` | Per-channel staged transfer IDs + draft text + replyTo. Replaces `MessageInput` component-local state. Persists via `composerStore@v1`. | | `pendingMessageStore` | `packages/web/src/stores/pendingMessageStore.ts` | Composed-but-not-yet-sent attachment-bearing bubbles, keyed by `clientId`. Persists via `pendingMessageStore@v1`. Text-only messages are out of scope -- they keep the existing `chatStore.sendMessage` `temp_*` optimistic path. | diff --git a/packages/web/src/stores/instanceStore.ts b/packages/web/src/stores/instanceStore.ts index ae2c3e8d..78264fcd 100644 --- a/packages/web/src/stores/instanceStore.ts +++ b/packages/web/src/stores/instanceStore.ts @@ -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 diff --git a/packages/web/src/stores/spaceStore.ts b/packages/web/src/stores/spaceStore.ts index 6d953950..580e959f 100644 --- a/packages/web/src/stores/spaceStore.ts +++ b/packages/web/src/stores/spaceStore.ts @@ -976,6 +976,8 @@ export { setOriginFromHostnameResolver, setUserIdForOriginResolver, setMyUserIdForOrigin, + setTokenForOriginResolver, + getTokenForOrigin, } from '../utils/crossStoreResolvers'; /** diff --git a/packages/web/src/stores/transferStore.ts b/packages/web/src/stores/transferStore.ts index 9e75b028..fae1ef36 100644 --- a/packages/web/src/stores/transferStore.ts +++ b/packages/web/src/stores/transferStore.ts @@ -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()( }, 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()( // 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()( 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; } diff --git a/packages/web/src/stores/transferStore.upload.test.ts b/packages/web/src/stores/transferStore.upload.test.ts index c1d68d07..e2f1e2d1 100644 --- a/packages/web/src/stores/transferStore.upload.test.ts +++ b/packages/web/src/stores/transferStore.upload.test.ts @@ -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 () => { diff --git a/packages/web/src/utils/crossStoreResolvers.ts b/packages/web/src/utils/crossStoreResolvers.ts index 8b385b26..3cdbb3ab 100644 --- a/packages/web/src/utils/crossStoreResolvers.ts +++ b/packages/web/src/utils/crossStoreResolvers.ts @@ -97,3 +97,32 @@ export function getCachedUserIdForOrigin(origin: string): string | undefined { export function clearMyUserIdCache(): void { _myUserIdByOrigin.clear(); } + +// ─── Token resolution (federation) ──────────────────────────────────────────── +// Registered by instanceStore on import; maps an origin to the local user's +// JWT for that instance. Used by transferStore (tus uploads) and any other +// path that constructs raw HTTP requests to a federated instance and needs to +// pass an Authorization header. + +let _getTokenForOrigin: ((origin: string) => string | null) | null = null; + +export function setTokenForOriginResolver( + resolver: (origin: string) => string | null, +): void { + _getTokenForOrigin = resolver; +} + +/** + * Returns the JWT to use when calling APIs on the given origin. + * - Empty origin → home-instance token from authStore. + * - Connected remote → that instance's scoped token. + * - Unknown / not-yet-connected → null. + * + * Note: this module imports nothing from `./stores/*` to avoid TDZ cycles. + * The home-instance fallback is supplied by the caller via the resolver itself + * (instanceStore registers a resolver that knows how to read authStore for `''`). + */ +export function getTokenForOrigin(origin: string): string | null { + if (!_getTokenForOrigin) return null; + return _getTokenForOrigin(origin); +}