diff --git a/apps/web/src/app/api/uploadthing/core.ts b/apps/web/src/app/api/uploadthing/core.ts index b56b1040..fc1cf2f5 100644 --- a/apps/web/src/app/api/uploadthing/core.ts +++ b/apps/web/src/app/api/uploadthing/core.ts @@ -1,7 +1,22 @@ import { createUploadthing, type FileRouter } from "uploadthing/next"; -import { excalidrawFileIdSchema } from "@drawstuff/collaboration/asset"; +import { + ASSET_CRYPTO_VERSION, + excalidrawFileIdSchema, + MAX_ASSET_CIPHERTEXT_BYTES, + MIN_ASSET_CIPHERTEXT_BYTES, +} from "@drawstuff/collaboration/asset"; +import { + roomAuthGenerationSchema, + roomRoleCanEditScene, +} from "@drawstuff/collaboration/room-auth"; import { FILE_UPLOAD_MAX_BYTES } from "@/config/app-constants"; import { getMaxFileSizeString } from "@/lib/utils"; +import { + commitRoomAssetUpload, + type AssetUploadOutcome, +} from "@/server/collab/assets"; +import { resolveRoomAccess } from "@/server/collab/rooms"; +import { db } from "@/server/db"; import { QUERIES } from "@/server/db/queries"; import { z } from "zod"; import { getServerSession } from "@/lib/auth/server"; @@ -235,6 +250,126 @@ export const uploadRouter = { }; }), + /** + * 共編 room 資產上傳(Plan 17)。身份是 room + 授權世代 + Excalidraw file id。 + * + * 上傳的位元組是**客戶端封裝好的密文**:明文的 data URL 與 MIME type 都在密文裡, + * 由 room key 衍生的 asset key 保護,而 room key 只存在於 URL fragment 與瀏覽器 + * 記憶體。這條路徑因此對 payload 完全不透明——伺服器不解、不驗、也不記錄它。 + * + * 授權檢查做兩次而不是一次,因為兩者擋的是不同的事:middleware 在上傳前擋掉沒有 + * 權限的人(不讓他把位元組送進 storage),webhook 在寫入前再檢一次,擋掉「上傳期間 + * 權限被撤銷或世代被轉動」。第二次檢查在 room lock 的交易內,與每一個 room + * lifecycle mutation 同一套順序(Plan 13)。 + */ + collaborationAssetUploader: f({ + blob: { + maxFileSize: getMaxFileSizeString(MAX_ASSET_CIPHERTEXT_BYTES), + maxFileCount: 1, + }, + }) + .input( + z.object({ + roomId: z.string().min(1).max(64), + /** + * 密文封裝時所用的世代。要求它仍然是當前世代:封裝綁定了世代,存到別的 + * 世代底下只會產生沒有人能解開的一列。 + */ + authGeneration: roomAuthGenerationSchema, + // 一次一個檔案:身份必須顯式隨上傳帶入,檔名不是身份。 + excalidrawFileId: excalidrawFileIdSchema, + cryptoVersion: z.literal(ASSET_CRYPTO_VERSION), + }), + ) + .middleware(async ({ input }) => { + const session = await getServerSession(); + if (!session) throw new Error("Unauthorized"); + const access = await resolveRoomAccess(db, { + roomId: input.roomId, + userId: session.user.id, + now: new Date(), + }); + if (access.status !== "ok") throw new Error("Forbidden"); + // Viewer 可以讀資產,但不能替 room 新增 durable 狀態;relay 對它的即時 + // 變更也是拒絕的,這裡擋的是同一件事的另一道門。 + if (!roomRoleCanEditScene(access.role)) throw new Error("Forbidden"); + if (access.room.authGeneration !== input.authGeneration) { + throw new Error("Stale room generation"); + } + return { + userId: session.user.id, + roomId: access.room.roomId, + authGeneration: input.authGeneration, + excalidrawFileId: input.excalidrawFileId, + cryptoVersion: input.cryptoVersion, + } as const; + }) + .onUploadComplete(async ({ metadata, file }) => { + const context = { + roomId: metadata.roomId, + excalidrawFileId: metadata.excalidrawFileId, + }; + const discard = async (reason: string): Promise => { + const ok = await deleteFileWithRetry(file.key, { ...context, reason }); + if (!ok) await enqueueDeferredCleanup(file.key, reason, context); + }; + + // 大小上下界在寫入前檢查:sealed envelope 有固定 overhead,長度落在區間外的 + // 物件不可能是這個 room 的資產密文。 + if ( + file.size < MIN_ASSET_CIPHERTEXT_BYTES || + file.size > MAX_ASSET_CIPHERTEXT_BYTES + ) { + await discard("asset-size-out-of-range"); + throw new Error("Collaboration asset size is out of range"); + } + + let outcome: AssetUploadOutcome; + try { + outcome = await commitRoomAssetUpload(db, { + roomId: metadata.roomId, + userId: metadata.userId, + authGeneration: metadata.authGeneration, + fileId: metadata.excalidrawFileId, + storage: { + cryptoVersion: metadata.cryptoVersion, + utFileKey: file.key, + url: file.ufsUrl, + byteLength: file.size, + }, + now: new Date(), + }); + } catch (error) { + // 只記錯誤名稱,不記整個 error:Drizzle 的 `DrizzleQueryError` 的 message 內含 + // `params:`,也就是這次 insert 的 `url` 與 storage key——那是取得密文的 + // capability,不能因為一次寫入失敗就被複製進應用日誌。 + console.error("Error recording collaboration asset:", { + ...context, + error: error instanceof Error ? error.name : "unknown", + }); + await discard("db-write-failed"); + throw new Error("Failed to record collaboration asset"); + } + + if (outcome !== "recorded") { + // 這個世代已經有同一個 file id:同一個 id 就是同一份明文,既有那筆一樣有效, + // 剛上傳的物件沒有引用者。被拒絕或超出額度的情況同樣沒有引用者。 + await discard(`asset-${outcome}`); + if (outcome === "rejected") { + throw new Error("Not allowed to upload this collaboration asset"); + } + if (outcome === "budget-exceeded") { + throw new Error("This collaboration room has too many assets"); + } + } + + // 回傳刻意不含 URL:URL 是取得密文的能力,只由 `collaborationAsset.resolve` + // 在授權後發給成員,不透過上傳回應擴散。清理失敗時共用的 + // `deleteFileWithRetry`/`enqueueDeferredCleanup` 仍會記下 storage key——那是 + // 處理孤兒物件唯一可用的線索,且它指向的內容只有密文。 + return { uploadedBy: metadata.userId, fileKey: file.key }; + }), + // 新增:場景縮圖上傳(不做內容去重,最後寫入生效) sceneThumbnailUploader: f({ blob: { diff --git a/apps/web/src/hooks/excalidraw/use-collaboration-room.ts b/apps/web/src/hooks/excalidraw/use-collaboration-room.ts index a3c774a3..bed693fe 100644 --- a/apps/web/src/hooks/excalidraw/use-collaboration-room.ts +++ b/apps/web/src/hooks/excalidraw/use-collaboration-room.ts @@ -29,6 +29,7 @@ import { claimCanvasForRoom, releaseCanvasRoom, } from "@/lib/collab/canvas-room-marker"; +import { uploadCollaborationAsset } from "@/lib/collab/asset-upload"; import type { BaselineOutcome } from "@/lib/collab/collaboration-session"; import { startCollaborationRoomSession, @@ -357,6 +358,16 @@ export function useCollaborationRoom(options: { put: (input) => utilsRef.current.client.collaborationSnapshot.put.mutate(input), }, + // Same shape, and for the same reason: the store needs two plain async + // functions, one to find out where a room's ciphertext lives and one to + // put ciphertext there. Neither can read what it carries. + assetApi: { + resolve: (input, signal) => + utilsRef.current.client.collaborationAsset.resolve.query(input, { + signal, + }), + upload: uploadCollaborationAsset, + }, wrapRemoteApply, canSyncScene: () => canvasBelongsToRoom(joined.roomId), onBaselineResolved: (outcome) => { diff --git a/apps/web/src/lib/collab/asset-store.ts b/apps/web/src/lib/collab/asset-store.ts new file mode 100644 index 00000000..12f1ccfa --- /dev/null +++ b/apps/web/src/lib/collab/asset-store.ts @@ -0,0 +1,753 @@ +import { + createAssetCryptoCodec, + decodeCollaborationAssetPayload, + encodeCollaborationAssetPayload, + EXCALIDRAW_FILE_ID_PATTERN, + MAX_ASSET_CIPHERTEXT_BYTES, + MAX_ASSET_LOOKUP_BATCH, + MAX_ROOM_ASSETS_PER_GENERATION, + type ASSET_CRYPTO_VERSION, + type CollaborationAssetRecord, +} from "@drawstuff/collaboration/asset"; +import type { RoomId } from "@drawstuff/collaboration/protocol"; +import type { RoomKey } from "@drawstuff/collaboration/realtime-crypto"; +import type { + BinaryFileData, + DataURL, + FileId, +} from "@drawstuff/excalidraw-adapter/types"; + +/** + * Client half of encrypted asset transfer: the only place an asset is sealed or + * opened. + * + * The split mirrors the snapshot store's. Authorization comes from the backend + * (the room API decides who may discover an asset URL and who may upload one); + * confidentiality comes from the URL fragment (the room key, which never leaves + * the browser). So this module needs both, and everything below it handles + * ciphertext only — there is no code path that could upload a readable image, + * because `publish` seals before it calls the API and `request` opens after. + * + * ## What is bounded, and where + * + * An asset is three orders of magnitude larger than a scene delta, so every step + * has a ceiling rather than a best effort: + * + * - **Requests.** Lookups are batched (`MAX_ASSET_LOOKUP_BATCH`), never one per + * element: a scene with 40 copies of one image asks about one file id, and a + * scene with 40 images asks once. + * - **In flight.** Downloads and uploads run at a fixed concurrency, so a late + * joiner with a full room of images holds a few ciphertexts in memory instead of + * all of them. + * - **Bodies.** A response is read through a bounded reader against the length the + * record declares, so a storage endpoint that streams forever is cut off rather + * than buffered. + * - **Bookkeeping.** Every id set is capped at the room's own asset budget with + * FIFO eviction. Evicting a resolved id costs one redundant lookup; not capping + * it would let a long session grow without limit. + * - **Retries.** Bounded and only for the failures a retry can fix. + * + * There is deliberately no decrypted-bytes cache and no object URL. The engine's + * file store *is* the cache: an opened asset is handed to `addFiles` and this + * module keeps only its id. So teardown has nothing to release beyond in-flight + * requests and one timer. + * + * ## Why "missing" is not an error + * + * A peer broadcasts an image element the instant it is added and its upload lands + * a beat later, so the first lookup for a fresh image legitimately finds nothing. + * That is retried with backoff. A payload that fails to open or decode is the + * opposite case — retrying cannot change it — so it is abandoned, and the scene + * keeps syncing without the image rather than stalling on it. + */ + +/** The backend surface this store needs; the tRPC client and the uploader satisfy it. */ +export type AssetApi = { + /** + * `signal` is part of the contract rather than an option: leaving a room while a + * lookup is in flight has to end the lookup, or the store's teardown would only + * take effect whenever the network happened to answer. + */ + resolve( + input: { roomId: string; fileIds: string[] }, + signal: AbortSignal, + ): Promise<{ + authGeneration: number; + assets: CollaborationAssetRecord[]; + missing: string[]; + }>; + /** Resolves when the ciphertext is stored and recorded; throws otherwise. */ + upload(input: { + roomId: string; + /** Generation the ciphertext was sealed for; the server refuses a mismatch. */ + authGeneration: number; + excalidrawFileId: string; + cryptoVersion: typeof ASSET_CRYPTO_VERSION; + ciphertext: Uint8Array; + signal: AbortSignal; + }): Promise; +}; + +export type CollaborationAssetStore = { + /** + * Seals and uploads every file the room does not have yet. Idempotent: a file + * already published, in flight, or known to be in the room is skipped, so the + * caller may hand over the whole current file set on every scene flush. + */ + publish(files: readonly BinaryFileData[]): Promise; + /** + * Fetches and opens the assets for ids the canvas is missing, handing the + * results to `onAssetsResolved`. Concurrent calls for one id share a single + * download. + */ + request(fileIds: readonly string[]): Promise; + /** Aborts in-flight transfers, cancels the retry timer, and drops all state. */ + destroy(): void; +}; + +/** + * Scheduled retries per download, counting the first attempt. + * + * Only the timer chain is bounded, not the id: an asset that is merely *not + * uploaded yet* is never given up on permanently, because the peer that has it may + * simply be slow. What stops it from becoming a request loop is the deadline — + * after the chain ends, a further attempt happens only when new traffic asks for + * the id again, and never sooner than `MAX_RETRY_DELAY_MS` after the last one. + */ +const MAX_SCHEDULED_DOWNLOAD_ATTEMPTS = 4; +/** Attempts per upload, counting the first. */ +const MAX_PUBLISH_ATTEMPTS = 3; +const RETRY_BASE_DELAY_MS = 1_000; +const RETRY_BACKOFF_FACTOR = 2; +const RETRY_JITTER_MS = 250; +/** Ceiling on the backoff, and the floor on how often one id may be re-requested. */ +const MAX_RETRY_DELAY_MS = 30_000; + +/** + * Simultaneous transfers **for the whole store**, uploads and downloads together. + * Four is the same order as a browser's per-host connection budget, and it caps + * peak memory at four ciphertexts plus their plaintexts rather than a whole + * room's worth. Per-call limiting would not do that: two overlapping scene + * messages would each open their own budget. + */ +const MAX_CONCURRENT_TRANSFERS = 4; + +/** Every id set and id map is capped at the room's own budget. */ +const MAX_TRACKED_IDS = MAX_ROOM_ASSETS_PER_GENERATION; + +/** + * Insertion-ordered map with FIFO eviction; the oldest entry is always first. + * + * `onEvict` exists because a bounded map is only safe if everything derived from + * it is dropped with it: an id whose retry state was evicted while something else + * still listed it would look like an id with no deadline, which reads as "due + * now". + */ +const createBoundedIdMap = ( + limit: number, + onEvict?: (id: string) => void, +) => { + const entries = new Map(); + return { + get: (id: string): T | undefined => entries.get(id), + has: (id: string): boolean => entries.has(id), + set(id: string, value: T): void { + if (!entries.has(id)) { + while (entries.size >= limit) { + const oldest = entries.keys().next(); + if (oldest.done) break; + entries.delete(oldest.value); + onEvict?.(oldest.value); + } + } + entries.set(id, value); + }, + delete(id: string): void { + entries.delete(id); + }, + clear(): void { + entries.clear(); + }, + get size(): number { + return entries.size; + }, + }; +}; + +type BoundedIdSet = { + has(id: string): boolean; + add(id: string): void; + delete(id: string): void; + readonly size: number; +}; + +const createBoundedIdSet = (limit: number): BoundedIdSet => { + const ids = createBoundedIdMap(limit); + return { + has: (id) => ids.has(id), + add: (id) => { + ids.set(id, true); + }, + delete: (id) => { + ids.delete(id); + }, + get size() { + return ids.size; + }, + }; +}; + +/** + * Store-wide transfer budget. + * + * A slot is either held by a running transfer or handed directly to the next + * waiter, so the count can neither drift nor be exceeded by callers that overlap. + */ +const createTransferGate = (limit: number) => { + let active = 0; + const waiting: (() => void)[] = []; + return { + async run(task: () => Promise): Promise { + if (active < limit) active += 1; + else await new Promise((resolve) => waiting.push(resolve)); + try { + return await task(); + } finally { + const next = waiting.shift(); + if (next) next(); + else active -= 1; + } + }, + }; +}; + +const defaultScheduleTimeout = ( + run: () => void, + delayMs: number, +): (() => void) => { + const timerId = setTimeout(run, delayMs); + return () => clearTimeout(timerId); +}; + +const retryDelayMs = (attempts: number): number => + Math.min( + RETRY_BASE_DELAY_MS * RETRY_BACKOFF_FACTOR ** (attempts - 1), + MAX_RETRY_DELAY_MS, + ) + Math.floor(Math.random() * RETRY_JITTER_MS); + +/** + * Reads a response body without ever holding more than `maxBytes`. + * + * `arrayBuffer()` would decide the size after materializing it, which is the one + * thing a bound has to prevent — the record's declared length is what this trusts, + * and a body that exceeds it is cancelled mid-stream. + */ +const readBoundedBody = async ( + response: Response, + maxBytes: number, +): Promise => { + const body = response.body; + if (!body) { + // No streaming body (a non-streaming fetch implementation): the declared + // length is still enforced, just after the fact. + const buffer = new Uint8Array(await response.arrayBuffer()); + return buffer.byteLength <= maxBytes ? buffer : null; + } + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maxBytes) { + await reader.cancel(); + return null; + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +}; + +/** Per-id outcome of one transfer attempt. */ +type TransferOutcome = "resolved" | "retry" | "abandon"; + +export async function createCollaborationAssetStore(options: { + api: AssetApi; + roomId: RoomId; + /** End-to-end room key from the URL fragment; never from the backend. */ + roomKey: RoomKey; + /** Authorization generation the session joined under. */ + authGeneration: number; + /** Called with every batch of opened assets, for injection into the canvas. */ + onAssetsResolved: (files: readonly BinaryFileData[]) => void; + /** + * Asks the canvas to offer its files again after a failed upload. + * + * Inverted rather than retried from here on purpose: a retry has to use the + * *current* scene, or it would re-upload an image the user has since deleted — + * and holding the bytes for a retry would pin megabytes the engine already owns. + */ + onPublishRetryDue?: () => void; + /** Injected by tests so retry backoff does not depend on wall time. */ + scheduleTimeout?: (run: () => void, delayMs: number) => () => void; + now?: () => number; + /** Injected by tests; production uses the global. */ + fetchImpl?: typeof fetch; +}): Promise { + const { + api, + roomId, + authGeneration, + onAssetsResolved, + onPublishRetryDue, + scheduleTimeout = defaultScheduleTimeout, + now = Date.now, + fetchImpl = (input: RequestInfo | URL, init?: RequestInit) => + fetch(input, init), + } = options; + + // Derived once per session: the key is bound to (room, generation, purpose), + // and it is non-extractable, so it cannot end up in a log or an error payload. + const codec = await createAssetCryptoCodec({ + roomKey: options.roomKey, + roomId, + authGeneration, + }); + + const controller = new AbortController(); + let destroyed = false; + + /** Ids already handed to the canvas; never downloaded twice. */ + const resolved = createBoundedIdSet(MAX_TRACKED_IDS); + /** Ids no retry can help: unopenable, undecodable, or out of attempts. */ + const abandoned = createBoundedIdSet(MAX_TRACKED_IDS); + /** Ids this client has uploaded or seen in the room. */ + const available = createBoundedIdSet(MAX_TRACKED_IDS); + /** + * One shared download per id, so concurrent requests do not duplicate work. + * Bounded by the transfer gate rather than by a cap: an entry exists only while + * a transfer is claimed, so this cannot outgrow what is in flight. + */ + const downloading = new Map>(); + const uploading = new Map>(); + let cancelRetry: (() => void) | undefined; + /** + * Ids awaiting a scheduled retry. Never outlives `retrying`: an entry evicted + * there is dropped here too, or it would sit in the queue with no deadline — + * which the timer would read as "due now" and re-request in a tight loop. + */ + let retryQueue = new Set(); + /** Backoff state for ids that failed in a way a later attempt could fix. */ + const retrying = createBoundedIdMap<{ attempts: number; notBefore: number }>( + MAX_TRACKED_IDS, + (evicted) => retryQueue.delete(evicted), + ); + const uploadAttempts = createBoundedIdMap(MAX_TRACKED_IDS); + const transfers = createTransferGate(MAX_CONCURRENT_TRANSFERS); + + let cancelPublishRetry: (() => void) | undefined; + + const forget = (fileId: string): void => { + retrying.delete(fileId); + retryQueue.delete(fileId); + }; + + /** + * Records a retryable failure and, while the scheduled chain lasts, queues the + * id for another attempt. + * + * Past the chain the deadline stays and nothing is queued — the id is then only + * re-requested if new traffic references it, which is what keeps a genuinely + * absent asset from being either given up on or polled for. + */ + const deferRetry = (fileId: string): void => { + if (destroyed) return; + const attempts = (retrying.get(fileId)?.attempts ?? 0) + 1; + retrying.set(fileId, { + attempts, + notBefore: now() + retryDelayMs(attempts), + }); + if (attempts < MAX_SCHEDULED_DOWNLOAD_ATTEMPTS) retryQueue.add(fileId); + }; + + /** + * Arms the timer for whatever the last request deferred. + * + * Called once a request has released its claims, and that ordering is the whole + * point: a timer armed mid-request could fire while the request that scheduled + * it still holds the id, and the retry would be deduplicated against it — losing + * the chain and leaving the asset waiting for unrelated traffic. + * + * One timer for the whole queue rather than one per id: a room that gains ten + * images at once must produce one retry round, not ten. It fires at the earliest + * deadline and takes only the ids that are actually due — jitter means the rest + * of the queue is a few hundred milliseconds behind, and draining them here + * would hand them to `request`, which filters them out and would then have + * nothing left to re-arm from. + */ + const armRetryTimer = (): void => { + if (destroyed || cancelRetry) return; + let earliest = Number.POSITIVE_INFINITY; + for (const fileId of [...retryQueue]) { + const state = retrying.get(fileId); + // No state means the entry was evicted: it is not owed a retry, and + // treating it as due would make this a zero-delay loop. + if (!state) { + retryQueue.delete(fileId); + continue; + } + if (state.notBefore < earliest) earliest = state.notBefore; + } + if (retryQueue.size === 0) return; + const delay = Math.max(0, earliest - now()); + cancelRetry = scheduleTimeout(() => { + cancelRetry = undefined; + if (destroyed) return; + const at = now(); + const due: string[] = []; + for (const fileId of [...retryQueue]) { + const state = retrying.get(fileId); + if (!state) { + retryQueue.delete(fileId); + continue; + } + if (state.notBefore > at) continue; + due.push(fileId); + retryQueue.delete(fileId); + } + // Whatever stayed queued gets its own timer as soon as this request lets go. + void request(due); + if (due.length === 0) armRetryTimer(); + }, delay); + }; + + const openRecord = async ( + record: CollaborationAssetRecord, + ): Promise<{ outcome: TransferOutcome; file?: BinaryFileData }> => { + // A record sealed under an envelope version this client does not implement is + // not a transient failure: nothing here can ever open it. + if (record.cryptoVersion !== codec.cryptoVersion) { + return { outcome: "abandon" }; + } + const limit = Math.min(record.byteLength, MAX_ASSET_CIPHERTEXT_BYTES); + + let ciphertext: Uint8Array | null; + try { + const response = await fetchImpl(record.url, { + signal: controller.signal, + }); + if (!response.ok) return { outcome: "retry" }; + ciphertext = await readBoundedBody(response, limit); + } catch { + // Abort included: the caller is gone, and `destroyed` stops the retry. + return { outcome: "retry" }; + } + // A body that disagrees with its record is not this asset, whichever is + // wrong; a retry would fetch the same bytes. + if (!ciphertext || ciphertext.byteLength !== record.byteLength) { + return { outcome: "abandon" }; + } + + const opened = await codec.open({ + excalidrawFileId: record.excalidrawFileId, + ciphertext, + }); + if (!opened.ok) return { outcome: "abandon" }; + + const decoded = decodeCollaborationAssetPayload(opened.plaintext, { + roomId, + excalidrawFileId: record.excalidrawFileId, + }); + if (!decoded.ok) return { outcome: "abandon" }; + + return { + outcome: "resolved", + file: { + id: decoded.payload.excalidrawFileId as FileId, + dataURL: decoded.payload.dataUrl as DataURL, + mimeType: decoded.payload.mimeType, + // The room is the origin of these bytes for this client, so the + // timestamps describe *this* retrieval. Copying a sender's clock would + // put another machine's time into local file bookkeeping. + created: Date.now(), + lastRetrieved: Date.now(), + }, + }; + }; + + /** + * Fetches a batch of ids, and only ids nothing else is already fetching. + * + * Ids skipped because a download is in flight are not dropped: the caller waits + * for that download and asks again for whatever it did not deliver. Without that + * step a request arriving mid-download would vanish — and since the download in + * progress has already recorded its own retry, nothing would ever come back to + * it. + */ + async function request(fileIds: readonly string[]): Promise { + if (destroyed) return; + const at = now(); + const unique = [...new Set(fileIds)].sort(); + const needed: string[] = []; + for (const fileId of unique) { + if (resolved.has(fileId) || abandoned.has(fileId)) continue; + // Validated per id, not per batch. These ids come off remote elements, and + // the lookup API rejects a whole batch containing one malformed id — so a + // single bad `fileId` on somebody's element would keep every other image in + // the same message from ever loading. + if (!EXCALIDRAW_FILE_ID_PATTERN.test(fileId)) { + abandoned.add(fileId); + continue; + } + needed.push(fileId); + } + const joinable = needed.filter((fileId) => downloading.has(fileId)); + const wanted: string[] = []; + for (const fileId of needed) { + if (downloading.has(fileId)) continue; + const state = retrying.get(fileId); + // Rate limit per id: traffic that keeps naming an asset the room does not + // have must not turn into a lookup per message. An id that is merely early + // stays queued so its own deadline still gets a timer. + if (state && state.notBefore > at) { + if (state.attempts < MAX_SCHEDULED_DOWNLOAD_ATTEMPTS) { + retryQueue.add(fileId); + } + continue; + } + wanted.push(fileId); + } + + try { + if (wanted.length > 0) await fetchBatch(wanted); + if (joinable.length === 0 || destroyed) return; + + await Promise.all( + joinable + .map((fileId) => downloading.get(fileId)) + .filter((claim): claim is Promise => claim !== undefined), + ); + if (destroyed) return; + const unresolved = joinable.filter( + (fileId) => + !resolved.has(fileId) && + !abandoned.has(fileId) && + !downloading.has(fileId), + ); + // Terminates: every id is now resolved, abandoned, rate limited, or claimed + // by a newer download, and each of those cases filters it out above. + if (unresolved.length > 0) await request(unresolved); + } finally { + // Armed here rather than inside `fetchBatch` so every path re-arms — a + // request that turned out to have nothing to fetch may still have queued an + // id whose deadline has not arrived. + armRetryTimer(); + } + } + + async function fetchBatch(wanted: readonly string[]): Promise { + // Claimed before the first await so a second `request` in the same tick joins + // this download instead of starting another. + let settle = (): void => undefined; + const claim = new Promise((resolve) => { + settle = resolve; + }); + for (const fileId of wanted) downloading.set(fileId, claim); + + try { + for ( + let offset = 0; + offset < wanted.length; + offset += MAX_ASSET_LOOKUP_BATCH + ) { + const batch = wanted.slice(offset, offset + MAX_ASSET_LOOKUP_BATCH); + let lookup: Awaited>; + try { + lookup = await api.resolve( + { roomId, fileIds: batch }, + controller.signal, + ); + } catch { + for (const fileId of batch) deferRetry(fileId); + continue; + } + if (destroyed) return; + // The generation the records belong to is the one the key was derived + // for; a mismatch means the room rotated under us and these bytes are not + // ours to open. The session is torn down on rotation, so this only guards + // the window before that happens. + if (lookup.authGeneration !== authGeneration) { + for (const fileId of batch) abandoned.add(fileId); + continue; + } + + for (const fileId of lookup.missing) deferRetry(fileId); + + const opened: BinaryFileData[] = []; + await Promise.all( + lookup.assets.map((record) => + // Every download waits for a slot in the store-wide budget, so a + // second overlapping request cannot double the bytes in memory. + transfers.run(async () => { + available.add(record.excalidrawFileId); + const result = await openRecord(record); + if (destroyed) return; + if (result.outcome === "resolved" && result.file) { + resolved.add(record.excalidrawFileId); + forget(record.excalidrawFileId); + opened.push(result.file); + return; + } + if (result.outcome === "abandon") { + abandoned.add(record.excalidrawFileId); + forget(record.excalidrawFileId); + return; + } + deferRetry(record.excalidrawFileId); + }), + ), + ); + if (destroyed) return; + // One injection per batch: `addFiles` triggers an engine re-render, and a + // late joiner loading ten images must not cause ten of them. + if (opened.length > 0) onAssetsResolved(opened); + } + } finally { + for (const fileId of wanted) { + if (downloading.get(fileId) === claim) downloading.delete(fileId); + } + settle(); + } + } + + const publishOne = async (file: BinaryFileData): Promise => { + const encoded = encodeCollaborationAssetPayload({ + roomId, + excalidrawFileId: file.id, + mimeType: file.mimeType, + dataUrl: file.dataURL, + }); + // An oversize image or an unsupported type cannot become publishable by + // retrying, and the element referencing it still syncs — peers simply do not + // render it. + if (!encoded.ok) { + abandoned.add(file.id); + return; + } + const sealed = await codec.seal({ + excalidrawFileId: file.id, + plaintext: encoded.bytes, + }); + if (!sealed.ok) { + abandoned.add(file.id); + return; + } + if (destroyed) return; + + try { + await api.upload({ + roomId, + authGeneration, + excalidrawFileId: file.id, + cryptoVersion: codec.cryptoVersion, + ciphertext: sealed.ciphertext, + signal: controller.signal, + }); + available.add(file.id); + // Our own bytes are already on the canvas: recording the id as resolved is + // what stops this client from downloading the image it just uploaded. + resolved.add(file.id); + uploadAttempts.delete(file.id); + } catch { + const attempts = (uploadAttempts.get(file.id) ?? 0) + 1; + if (attempts >= MAX_PUBLISH_ATTEMPTS) { + abandoned.add(file.id); + uploadAttempts.delete(file.id); + return; + } + uploadAttempts.set(file.id, attempts); + // A timer, not "the next scene flush": a user who pastes an image and then + // stops drawing produces no further flush, so a transient upload failure + // would otherwise mean the image never reaches anybody. + schedulePublishRetry(attempts); + } + }; + + /** + * Asks the canvas to offer its files again after a failed upload. + * + * One timer for the store: several failed uploads share the round, and the round + * re-reads the scene rather than replaying a captured file set, so an image the + * user deleted in the meantime is simply not retried. + */ + const schedulePublishRetry = (attempts: number): void => { + if (destroyed || cancelPublishRetry || !onPublishRetryDue) return; + cancelPublishRetry = scheduleTimeout(() => { + cancelPublishRetry = undefined; + if (destroyed) return; + onPublishRetryDue(); + }, retryDelayMs(attempts)); + }; + + async function publish(files: readonly BinaryFileData[]): Promise { + if (destroyed) return; + const pending = files.filter( + (file) => + !available.has(file.id) && + !abandoned.has(file.id) && + !uploading.has(file.id), + ); + if (pending.length === 0) return; + + // Claimed and released *per file*, not per batch. A batch-wide claim would + // still be held by a slow sibling when the retry timer for a fast failure + // fires, and the retry would skip the very file it was scheduled for. + await Promise.all( + pending.map((file) => { + let settle = (): void => undefined; + const claim = new Promise((resolve) => { + settle = resolve; + }); + uploading.set(file.id, claim); + // Uploads share the download budget: peak memory is four transfers + // whatever mix they are. + return transfers.run(() => publishOne(file)).finally(() => { + if (uploading.get(file.id) === claim) uploading.delete(file.id); + settle(); + }); + }), + ); + } + + return { + publish, + request, + destroy() { + if (destroyed) return; + destroyed = true; + cancelRetry?.(); + cancelRetry = undefined; + cancelPublishRetry?.(); + cancelPublishRetry = undefined; + retryQueue = new Set(); + // Aborts fetches, uploads and lookups alike: every network call this store + // makes carries this signal. + controller.abort(); + downloading.clear(); + uploading.clear(); + retrying.clear(); + uploadAttempts.clear(); + }, + }; +} diff --git a/apps/web/src/lib/collab/asset-upload.ts b/apps/web/src/lib/collab/asset-upload.ts new file mode 100644 index 00000000..f4a9a321 --- /dev/null +++ b/apps/web/src/lib/collab/asset-upload.ts @@ -0,0 +1,52 @@ +import { genUploader } from "uploadthing/client"; + +import type { ASSET_CRYPTO_VERSION } from "@drawstuff/collaboration/asset"; + +import type { UploadRouter } from "@/app/api/uploadthing/core"; +import { normalizeToArrayBuffer } from "@/lib/array-buffer"; + +/** + * Transport for one sealed collaboration asset. + * + * Separated from the asset store so the store can be tested without an object + * store, and so the only thing that ever touches an upload endpoint is a function + * whose input is already ciphertext. `genUploader` rather than `useUploadThing`: + * the caller is a session object, not a React tree, and this variant rejects on + * failure instead of reporting through a callback — which is what the store's + * bounded retry needs. + */ +const { uploadFiles } = genUploader(); + +export async function uploadCollaborationAsset(input: { + roomId: string; + authGeneration: number; + excalidrawFileId: string; + cryptoVersion: typeof ASSET_CRYPTO_VERSION; + /** Sealed bytes; this module never sees a readable asset. */ + ciphertext: Uint8Array; + signal: AbortSignal; +}): Promise { + // The name and type are deliberately constant and meaningless: identity travels + // in the upload input, and the content is an opaque sealed envelope, not an + // image the storage layer could ever interpret. + const file = new File( + [normalizeToArrayBuffer(input.ciphertext)], + "collaboration-asset", + { type: "application/octet-stream" }, + ); + const uploaded = await uploadFiles("collaborationAssetUploader", { + files: [file], + input: { + roomId: input.roomId, + authGeneration: input.authGeneration, + excalidrawFileId: input.excalidrawFileId, + cryptoVersion: input.cryptoVersion, + }, + signal: input.signal, + }); + if (uploaded.length !== 1) { + throw new Error( + "Collaboration asset upload did not return exactly one file", + ); + } +} diff --git a/apps/web/src/lib/collab/collaboration-session.ts b/apps/web/src/lib/collab/collaboration-session.ts index db1cbb06..8a87401c 100644 --- a/apps/web/src/lib/collab/collaboration-session.ts +++ b/apps/web/src/lib/collab/collaboration-session.ts @@ -32,6 +32,10 @@ import { EXCALIDRAW_CAPTURE_UPDATE_ACTION, EXCALIDRAW_USER_IDLE_STATE, } from "@drawstuff/excalidraw-adapter/client"; +import { + collectReferencedFileIds, + filterReferencedFiles, +} from "@drawstuff/excalidraw-adapter/codec"; import { createChangedElementTracker, getSyncableElements, @@ -40,6 +44,8 @@ import { } from "@drawstuff/excalidraw-adapter/reconcile"; import type { AppState, + BinaryFileData, + BinaryFiles, Collaborator, ExcalidrawElement, ExcalidrawPointerUpdatePayload, @@ -48,6 +54,7 @@ import type { SocketId, } from "@drawstuff/excalidraw-adapter/types"; +import type { CollaborationAssetStore } from "@/lib/collab/asset-store"; import type { CollaborationSnapshotStore } from "@/lib/collab/snapshot-store"; /** @@ -121,6 +128,13 @@ export type CollaborationSceneApi = { updateScene( sceneData: Pick, ): void; + /** + * The engine's binary file store. It is the session's cache of decrypted + * assets — the asset store keeps ids only — so "which images do I still need" + * and "which images can I publish" are both answered from here. + */ + getFiles(): BinaryFiles; + addFiles(files: BinaryFileData[]): void; }; export type CollaborationSessionOptions = { @@ -139,6 +153,13 @@ export type CollaborationSessionOptions = { * live peers alone — used by tests that exercise peer sync in isolation. */ snapshotStore?: CollaborationSnapshotStore; + /** + * Encrypted transfer for the binary assets the scene's image elements + * reference. Absent means images are not exchanged — used by tests that + * exercise element sync in isolation, which is also the honest description of + * what a session without it does. + */ + assetStore?: CollaborationAssetStore; /** * Every canvas write triggered by remote input (scene deltas, snapshots and * presence) runs through this wrapper, so the host can suppress its own @@ -189,6 +210,18 @@ export type CollaborationSession = { ): void; /** Wire to the editor `onPointerUpdate`: sends bounded-throttle presence. */ handlePointerUpdate(payload: ExcalidrawPointerUpdatePayload): void; + /** + * Injects assets the asset store opened. Wired as the store's callback rather + * than pulled by the session, because a download settles whenever it settles — + * long after the element that referenced it was applied. + */ + applyRemoteAssets(files: readonly BinaryFileData[]): void; + /** + * Re-offers the canvas's current images to the asset store. Wired to the store's + * upload-retry timer: a retry has to read the scene again, because the image + * that failed to upload may have been deleted since. + */ + republishLocalAssets(): void; setIdleState(idleState: CollaborationIdleState): void; /** * Publishes the durable snapshot now, ignoring the cadence. Called when the @@ -247,6 +280,7 @@ export function createCollaborationSession( username, sceneApi, snapshotStore, + assetStore, wrapRemoteApply = (apply) => { apply(); }, @@ -374,6 +408,7 @@ export function createCollaborationSession( // is not yet the room's scene, and broadcasting it would push pre-join local // state into the room. if (barrier || !connected || !canEditScene()) return; + publishLocalAssets(); const currentNow = now(); const batch = tracker.extractChangedElements( sceneApi.getSceneElementsIncludingDeleted(), @@ -399,6 +434,7 @@ export function createCollaborationSession( // holds them (nothing was marked sent), and the snapshot broadcast that // follows the baseline carries them. if (barrier || !connected || !canEditScene()) return; + publishLocalAssets(); const currentNow = now(); // Throttled full resync (upstream SYNC_FULL_SCENE_INTERVAL_MS): a // snapshot supersedes the delta and heals any receiver-side gaps. @@ -492,6 +528,52 @@ export function createCollaborationSession( }); tracker.markAdoptedRemoteElements(reconciled, elements); }); + requestMissingAssets(elements); + }; + + /** + * Asks the asset store for the images the elements just applied reference and + * the canvas does not have. + * + * Driven by the *incoming* elements rather than by the whole scene, which is + * what keeps a delta cheap: a pointer-drag of an existing image references an + * id the canvas already holds and produces no request at all. A join baseline + * happens to be the whole scene, so the same call covers the late-joiner and + * page-refresh cases. + * + * Fire-and-forget on purpose. A missing or unopenable asset must never hold up + * element sync — the scene converges and the image either arrives later or does + * not. + */ + function requestMissingAssets(elements: readonly SyncedElement[]): void { + if (!assetStore || destroyed || !canSyncScene()) return; + const files = sceneApi.getFiles(); + const missing = collectReferencedFileIds(elements).filter( + (fileId) => !files[fileId], + ); + if (missing.length === 0) return; + void assetStore.request(missing); + } + + /** + * Publishes the images the local canvas holds and the room does not. + * + * Runs on the same coalesced flush as the outbound deltas, because that is when + * a newly added image is first broadcast: peers receive the element and the + * ciphertext lands moments later. The store decides what is actually new, so + * calling this repeatedly is how a failed upload is retried — and a scene with + * no files at all never walks its elements. + */ + const publishLocalAssets = (): void => { + if (!assetStore || !canEditScene()) return; + const files = sceneApi.getFiles(); + if (Object.keys(files).length === 0) return; + const referenced = filterReferencedFiles( + sceneApi.getSceneElementsIncludingDeleted(), + files, + ); + const pending = Object.values(referenced); + if (pending.length > 0) void assetStore.publish(pending); }; /** @@ -928,6 +1010,19 @@ export function createCollaborationSession( // coalesced onChange bursts serialize the scene at most once per frame. scheduleFlush(); }, + applyRemoteAssets(files) { + // Same guards as any other remote write: a canvas that no longer belongs to + // the room must not gain the room's images, and the write must not mark the + // scene dirty. + if (destroyed || files.length === 0 || !canSyncScene()) return; + wrapRemoteApply(() => { + sceneApi.addFiles([...files]); + }); + }, + republishLocalAssets() { + if (destroyed) return; + publishLocalAssets(); + }, handlePointerUpdate(payload) { if (destroyed) return; lastPointer = { diff --git a/apps/web/src/lib/collab/room-session.ts b/apps/web/src/lib/collab/room-session.ts index 2f993b9a..81c6934d 100644 --- a/apps/web/src/lib/collab/room-session.ts +++ b/apps/web/src/lib/collab/room-session.ts @@ -12,10 +12,15 @@ import type { OrderedExcalidrawElement, } from "@drawstuff/excalidraw-adapter/types"; +import { + createCollaborationAssetStore, + type AssetApi, +} from "@/lib/collab/asset-store"; import { createCollaborationSession, type BaselineOutcome, type CollaborationSceneApi, + type CollaborationSession, } from "@/lib/collab/collaboration-session"; import { createCollaborationSnapshotStore, @@ -88,6 +93,12 @@ export async function startCollaborationRoomSession(options: { username: string; /** Backend surface for the durable snapshot; the tRPC client satisfies it. */ snapshotApi: SnapshotApi; + /** + * Backend surface for encrypted assets: the tRPC client resolves where + * ciphertext lives, the upload route stores it. Both halves are authorization + * only — neither can read what they carry. + */ + assetApi: AssetApi; wrapRemoteApply: (apply: () => void) => void; /** * Synchronous check that the canvas still holds this room's scene. Scene @@ -111,6 +122,26 @@ export async function startCollaborationRoomSession(options: { const unsubscribe = transport.subscribe({ onConnectionStateChange: options.onConnectionStateChange, }); + + // Late-bound on purpose: the store hands opened assets to the session, and the + // session needs the store to ask for them. A download settles long after the + // element that referenced it was applied, so the dependency has to run in that + // direction — the alternative is the session polling for bytes that may never + // arrive. + let assetTarget: CollaborationSession | undefined; + const assetStore = await createCollaborationAssetStore({ + api: options.assetApi, + roomId: options.roomId, + roomKey: options.roomKey, + authGeneration: options.authGeneration, + onAssetsResolved: (files) => { + assetTarget?.applyRemoteAssets(files); + }, + onPublishRetryDue: () => { + assetTarget?.republishLocalAssets(); + }, + }); + const session = createCollaborationSession({ transport, roomId: options.roomId, @@ -124,10 +155,12 @@ export async function startCollaborationRoomSession(options: { roomKey: options.roomKey, authGeneration: options.authGeneration, }), + assetStore, wrapRemoteApply: options.wrapRemoteApply, canSyncScene: options.canSyncScene, onBaselineResolved: options.onBaselineResolved, }); + assetTarget = session; // Upstream-style idle detection: pointer activity arms an idle timeout, tab // visibility flips between away and active. @@ -180,6 +213,12 @@ export async function startCollaborationRoomSession(options: { const flushed = session.flushSnapshot(); unsubscribe(); session.destroy(); + // Aborts in-flight transfers and drops the retry timer. Unlike the snapshot + // flush there is nothing to finish: an upload that has not landed carries + // bytes still present on this canvas, and a download that has not landed is + // for a canvas that is going away. + assetStore.destroy(); + assetTarget = undefined; transport.close(); return flushed; }, diff --git a/apps/web/src/server/api/routers/collaboration-asset.ts b/apps/web/src/server/api/routers/collaboration-asset.ts index ad6dfeb8..fdda99de 100644 --- a/apps/web/src/server/api/routers/collaboration-asset.ts +++ b/apps/web/src/server/api/routers/collaboration-asset.ts @@ -3,33 +3,33 @@ import { z } from "zod"; import { canonicalizeAssetIds, - collaborationAssetManifestSchema, + collaborationAssetLookupSchema, excalidrawFileIdSchema, - MAX_ASSET_REGISTRATION_BATCH, + MAX_ASSET_LOOKUP_BATCH, } from "@drawstuff/collaboration/asset"; -import { - roomAuthGenerationSchema, - roomRoleCanEditScene, -} from "@drawstuff/collaboration/room-auth"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; -import { listRoomAssets, registerRoomAssets } from "@/server/collab/assets"; -import { - lockRoom, - resolveRoomAccess, - type RoomAccess, -} from "@/server/collab/rooms"; +import { resolveRoomAssets } from "@/server/collab/assets"; +import { resolveRoomAccess, type RoomAccess } from "@/server/collab/rooms"; /** - * Collaboration asset metadata API (Plan 16). + * Collaboration asset lookup API (Plan 16 identity, Plan 17 transfer). + * + * One question: *where are the bytes for these file ids, and which of them does + * this room not have yet?* A peer already knows which assets it needs — the file + * ids are on the image elements the realtime channel delivered — so it never + * needs the room's whole asset list, and there is no procedure that returns one. * - * This is the identity half of the asset pipeline: peers agree on *which* - * assets a room references before anything moves the bytes (Plan 17). The - * authorization model is the room's, unchanged — `resolveRoomAccess` is the only - * place a role is decided, reading the manifest requires room access, and adding - * to it additionally requires a role that may mutate the scene. A viewer that - * could extend the manifest would be editing the room's durable state through a - * door the relay keeps shut. + * The authorization model is the room's, unchanged: `resolveRoomAccess` is the + * only place a role is decided, and reading requires room access. Writing is not + * here at all — an asset enters the room through the upload route, which is the + * only path that can pair a stored object with a record. + * + * What authorization protects is *discovery*. The URL this returns is a capability + * that anybody holding it can fetch, and confidentiality does not depend on that: + * the bytes behind it are sealed under a key derived from the room key, which the + * backend never sees. So a leaked URL exposes ciphertext, and a member who loses + * access loses the ability to find new URLs. */ const roomIdInput = z.string().min(1).max(64); @@ -58,15 +58,28 @@ const accessError = (access: Exclude) => { export const collaborationAssetRouter = createTRPCRouter({ /** - * The current generation's manifest. + * Resolves a bounded batch of file ids for the room's current generation. * * `authGeneration` comes from the room row, never from the caller: a client - * cannot ask for a retired generation's asset set, and a rotation makes the - * answer legitimately empty rather than stale. + * cannot ask for a retired generation's assets, and it could not open them + * anyway — the asset key is derived from the generation. + * + * Ids the room has no ciphertext for come back in `missing` rather than as an + * error. A peer broadcasts an image element the moment it is added and the + * upload lands a beat later, so "not yet" is the ordinary state of a fresh + * image; the caller retries those and only those. */ - list: protectedProcedure - .input(z.object({ roomId: roomIdInput })) - .output(collaborationAssetManifestSchema) + resolve: protectedProcedure + .input( + z.object({ + roomId: roomIdInput, + fileIds: z + .array(excalidrawFileIdSchema) + .min(1) + .max(MAX_ASSET_LOOKUP_BATCH), + }), + ) + .output(collaborationAssetLookupSchema) .query(async ({ ctx, input }) => { const access = await resolveRoomAccess(ctx.db, { roomId: input.roomId, @@ -75,87 +88,19 @@ export const collaborationAssetRouter = createTRPCRouter({ }); if (access.status !== "ok") throw accessError(access); + const fileIds = canonicalizeAssetIds(input.fileIds); + const assets = await resolveRoomAssets(ctx.db, { + roomId: access.room.roomId, + authGeneration: access.room.authGeneration, + fileIds, + }); + const available = new Set(assets.map((asset) => asset.excalidrawFileId)); + return { roomId: access.room.roomId, authGeneration: access.room.authGeneration, - fileIds: await listRoomAssets(ctx.db, { - roomId: access.room.roomId, - authGeneration: access.room.authGeneration, - }), + assets, + missing: fileIds.filter((fileId) => !available.has(fileId)), }; }), - - /** - * Claims asset ids for the current generation. Idempotent: re-registering an - * id already in the manifest is success, which is what makes a retry after a - * dropped response safe. - */ - register: protectedProcedure - .input( - z.object({ - roomId: roomIdInput, - /** - * Generation the caller believes it is in, and required to still be - * current. A manifest entry filed under a rotated generation would - * describe assets sealed under a key no member can derive. - */ - authGeneration: roomAuthGenerationSchema, - fileIds: z - .array(excalidrawFileIdSchema) - .min(1) - .max(MAX_ASSET_REGISTRATION_BATCH), - }), - ) - .mutation(async ({ ctx, input }) => { - const now = new Date(); - const userId = ctx.auth.user.id; - const fileIds = canonicalizeAssetIds(input.fileIds); - - // Authorization and the write share one transaction under the room lock, - // the ordering every room lifecycle mutation uses (Plan 13): without it a - // revocation committing in between would let a removed editor still - // extend the room's manifest. - return ctx.db.transaction(async (tx) => { - await lockRoom(tx, input.roomId); - const access = await resolveRoomAccess(tx, { - roomId: input.roomId, - userId, - now, - }); - if (access.status !== "ok") throw accessError(access); - if (!roomRoleCanEditScene(access.role)) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "A viewer cannot register collaboration assets.", - }); - } - if (input.authGeneration !== access.room.authGeneration) { - throw new TRPCError({ - code: "PRECONDITION_FAILED", - message: - "This collaboration room's authorization generation has changed.", - }); - } - - const result = await registerRoomAssets(tx, { - roomId: access.room.roomId, - authGeneration: input.authGeneration, - fileIds, - userId, - now, - }); - if ("code" in result) { - throw new TRPCError({ - code: "PRECONDITION_FAILED", - message: `This room already references ${result.current} of ${result.limit} allowed assets.`, - }); - } - return { - authGeneration: input.authGeneration, - registered: result.registered, - alreadyPresent: result.alreadyPresent, - fileIds: result.fileIds, - }; - }); - }), }); diff --git a/apps/web/src/server/collab/assets.ts b/apps/web/src/server/collab/assets.ts index 524648a5..01d6ff4c 100644 --- a/apps/web/src/server/collab/assets.ts +++ b/apps/web/src/server/collab/assets.ts @@ -1,38 +1,64 @@ import "server-only"; -import { and, asc, eq, lt } from "drizzle-orm"; +import { and, asc, eq, inArray, lt } from "drizzle-orm"; import { + MAX_ASSET_CIPHERTEXT_BYTES, MAX_ROOM_ASSETS_PER_GENERATION, + type CollaborationAssetRecord, type ExcalidrawAssetId, } from "@drawstuff/collaboration/asset"; +import { roomRoleCanEditScene } from "@drawstuff/collaboration/room-auth"; -import { collaborationAsset } from "@/server/db/schema"; -import type { RoomDatabase } from "@/server/collab/rooms"; +import { collaborationAsset, deferredFileCleanup } from "@/server/db/schema"; +import { + lockRoom, + resolveRoomAccess, + type Database, + type RoomDatabase, +} from "@/server/collab/rooms"; /** - * The room's asset manifest: which Excalidraw file ids one generation claims. + * Where a room generation's encrypted assets live. * - * The store's whole job is identity. It records that a room generation - * references a file id, and it refuses to let one registration displace - * another — which is the property the old content-hash identity got wrong. Two - * images with identical bytes but different file ids are two assets here, and - * the same image registered twice is one. + * The store's job is identity plus a pointer. It records that a room generation + * has the ciphertext for a file id and where that ciphertext currently is, and it + * refuses to let one asset displace another — which is the property the old + * content-hash identity got wrong. Two images with identical bytes but different + * file ids are two assets here, and the same image uploaded twice is one. * - * Nothing in this module reads or writes bytes; Plan 17 adds the transfer. + * Nothing in this module can read an asset: the bytes are sealed in the browser + * under a key derived from the room key (`@drawstuff/collaboration/asset`), which + * never reaches the backend. What the server holds is a URL and a length. */ -export type AssetRegistrationResult = { - /** Ids that this call inserted. */ - registered: ExcalidrawAssetId[]; - /** Ids the generation already referenced; a retry lands entirely here. */ - alreadyPresent: ExcalidrawAssetId[]; - /** The generation's full manifest after the call, ascending. */ - fileIds: ExcalidrawAssetId[]; +/** One row as a client may see it: identity plus where the ciphertext is. */ +export type RoomAssetRecord = CollaborationAssetRecord; + +export type AssetStorageInput = { + cryptoVersion: number; + utFileKey: string; + url: string; + byteLength: number; }; -/** Ascending file ids one generation references, or `[]` when it has none. */ -export async function listRoomAssets( +export type AssetRecordResult = + /** The row is now this upload's; nothing else referenced this file id. */ + | { status: "recorded" } + /** + * The generation already had this file id, and the row was left alone. The + * bytes under it are equally valid — an asset's plaintext is fixed by its id — + * so the caller's job is to delete the object it just uploaded, not to retry. + */ + | { status: "duplicate"; utFileKey: string } + | { + status: "budget-exceeded"; + current: number; + limit: number; + }; + +/** Ascending file ids one generation has ciphertext for, or `[]` when it has none. */ +export async function listRoomAssetIds( db: RoomDatabase, params: { roomId: string; authGeneration: number }, ): Promise { @@ -49,92 +75,233 @@ export async function listRoomAssets( return rows.map((row) => row.excalidrawFileId); } -export type AssetBudgetError = { - code: "asset-budget-exceeded"; - current: number; - requested: number; - limit: number; -}; +/** + * Resolves a bounded batch of file ids to their download records. + * + * Only the ids the caller asked for, and only the current generation's: an asset + * sealed under a retired generation could not be opened by anybody, so pointing a + * client at it would only produce a decryption failure it cannot act on. + */ +export async function resolveRoomAssets( + db: RoomDatabase, + params: { + roomId: string; + authGeneration: number; + fileIds: readonly ExcalidrawAssetId[]; + }, +): Promise { + if (params.fileIds.length === 0) return []; + const rows = await db + .select({ + excalidrawFileId: collaborationAsset.excalidrawFileId, + cryptoVersion: collaborationAsset.cryptoVersion, + byteLength: collaborationAsset.byteLength, + url: collaborationAsset.url, + }) + .from(collaborationAsset) + .where( + and( + eq(collaborationAsset.roomId, params.roomId), + eq(collaborationAsset.authGeneration, params.authGeneration), + inArray(collaborationAsset.excalidrawFileId, [...params.fileIds]), + ), + ) + .orderBy(asc(collaborationAsset.excalidrawFileId)); + return rows; +} /** - * Registers asset ids for one generation. + * Records one uploaded asset. * - * `onConflictDoNothing` rather than read-then-insert: two peers that both see a - * newly pasted image race here by design, and the loser must get "already - * present" rather than a constraint error — an asset that exists is exactly the - * outcome it wanted. What comes back distinguishes the two cases only so the - * caller can report them; both are success. + * `onConflictDoNothing` rather than read-then-insert: two peers that both paste + * the same image race here by design, and the loser must get "duplicate" rather + * than a constraint error — the asset it wanted exists, which is the outcome. The + * distinction only matters because the loser owns an orphan storage object it has + * to delete. * - * The budget is checked inside the caller's transaction against the count that - * is actually committed, so concurrent registrations cannot each observe room - * under the limit and jointly exceed it. + * The budget is checked inside the caller's transaction against the count that is + * actually committed, so concurrent uploads cannot each observe room under the + * limit and jointly exceed it. */ -export async function registerRoomAssets( +export async function recordRoomAsset( db: RoomDatabase, params: { roomId: string; authGeneration: number; - fileIds: readonly ExcalidrawAssetId[]; + fileId: ExcalidrawAssetId; + storage: AssetStorageInput; userId: string; now: Date; }, -): Promise { - const existing = new Set(await listRoomAssets(db, params)); - const fresh = params.fileIds.filter((fileId) => !existing.has(fileId)); - if (existing.size + fresh.length > MAX_ROOM_ASSETS_PER_GENERATION) { +): Promise { + const { byteLength } = params.storage; + if (byteLength <= 0 || byteLength > MAX_ASSET_CIPHERTEXT_BYTES) { + throw new Error( + `Asset ciphertext must be 1..${MAX_ASSET_CIPHERTEXT_BYTES} bytes, received ${byteLength}`, + ); + } + + const existing = await listRoomAssetIds(db, params); + const alreadyPresent = existing.includes(params.fileId); + if (!alreadyPresent && existing.length >= MAX_ROOM_ASSETS_PER_GENERATION) { return { - code: "asset-budget-exceeded", - current: existing.size, - requested: fresh.length, + status: "budget-exceeded", + current: existing.length, limit: MAX_ROOM_ASSETS_PER_GENERATION, }; } - if (fresh.length > 0) { - await db - .insert(collaborationAsset) - .values( - fresh.map((fileId) => ({ - roomId: params.roomId, - authGeneration: params.authGeneration, - excalidrawFileId: fileId, - registeredBy: params.userId, - createdAt: params.now, - })), - ) - .onConflictDoNothing({ - target: [ - collaborationAsset.roomId, - collaborationAsset.authGeneration, - collaborationAsset.excalidrawFileId, - ], - }); - await retireOlderGenerations(db, params); + const [inserted] = await db + .insert(collaborationAsset) + .values({ + roomId: params.roomId, + authGeneration: params.authGeneration, + excalidrawFileId: params.fileId, + cryptoVersion: params.storage.cryptoVersion, + utFileKey: params.storage.utFileKey, + url: params.storage.url, + byteLength, + registeredBy: params.userId, + createdAt: params.now, + }) + .onConflictDoNothing({ + target: [ + collaborationAsset.roomId, + collaborationAsset.authGeneration, + collaborationAsset.excalidrawFileId, + ], + }) + .returning({ utFileKey: collaborationAsset.utFileKey }); + + if (!inserted) { + return { status: "duplicate", utFileKey: params.storage.utFileKey }; } + return { status: "recorded" }; +} + +/** + * What happened to one completed upload. Only `recorded` leaves the storage + * object referenced; every other outcome means the caller owns an orphan it has + * to delete. + */ +export type AssetUploadOutcome = + | "recorded" + | "duplicate" + | "budget-exceeded" + /** Access, role, or generation changed between the upload and this write. */ + | "rejected"; - return { - registered: fresh, - alreadyPresent: params.fileIds.filter((fileId) => existing.has(fileId)), - fileIds: await listRoomAssets(db, params), - }; +/** + * Commits one completed upload: authorization and the write in a single + * transaction under the room lock. + * + * The authorization check the upload route already made is not enough, and the + * gap is real rather than theoretical: an upload takes as long as the bytes take, + * and a membership revocation, a role downgrade, or a generation rotation can + * commit while it is in flight. Re-checking here — inside the lock, in the same + * transaction as the insert, the ordering every room lifecycle mutation uses + * (Plan 13) — is what makes "was allowed when it started" mean "is allowed now". + * + * The generation check is the sharpest of the three: the ciphertext is sealed + * against a specific generation, so filing it under any other one would produce a + * row nobody could ever open. + */ +export async function commitRoomAssetUpload( + db: Database, + params: { + roomId: string; + userId: string; + authGeneration: number; + fileId: ExcalidrawAssetId; + storage: AssetStorageInput; + now: Date; + }, +): Promise { + return db.transaction(async (tx) => { + await lockRoom(tx, params.roomId); + const access = await resolveRoomAccess(tx, { + roomId: params.roomId, + userId: params.userId, + now: params.now, + }); + if ( + access.status !== "ok" || + // A viewer receives assets but never adds one. The relay refuses its + // realtime mutations; this refuses the durable equivalent. + !roomRoleCanEditScene(access.role) || + access.room.authGeneration !== params.authGeneration + ) { + return "rejected"; + } + + const result = await recordRoomAsset(tx, { + roomId: params.roomId, + authGeneration: params.authGeneration, + fileId: params.fileId, + storage: params.storage, + userId: params.userId, + now: params.now, + }); + if (result.status !== "recorded") return result.status; + + // The only moment a newer generation is proven to have an asset of its own is + // the moment one lands, which is why retirement runs here rather than on a + // schedule. + await retireOlderAssetGenerations(tx, { + roomId: params.roomId, + authGeneration: params.authGeneration, + now: params.now, + }); + return "recorded"; + }); } +/** Reason recorded on cleanup tasks this module schedules. */ +export const RETIRED_ASSET_CLEANUP_REASON = "collab-asset-generation-retired"; + /** - * Drops manifests of generations the room has moved past, on the same trigger - * and for the same reason as snapshot retirement: a rotated generation's asset - * payloads are sealed under a key nobody can derive any more, so its manifest - * can only ever point at bytes that cannot be opened. + * Drops assets of generations the room has moved past, on the same trigger and + * for the same reason as snapshot retirement: a rotated generation's asset + * payloads are sealed under a key nobody can derive any more, so keeping them + * would only be storage nobody can ever open. + * + * Deleting a row is what makes its storage object unreachable, so the same + * statement that deletes it hands the object to the deferred cleanup worker — in + * the caller's transaction, which is the only way the two cannot diverge. The + * object store cannot participate in that transaction, which is precisely why the + * queue exists rather than a direct delete here. */ -async function retireOlderGenerations( +export async function retireOlderAssetGenerations( db: RoomDatabase, - params: { roomId: string; authGeneration: number }, -): Promise { - await db + params: { roomId: string; authGeneration: number; now: Date }, +): Promise<{ retiredObjects: number }> { + const retired = await db .delete(collaborationAsset) .where( and( eq(collaborationAsset.roomId, params.roomId), lt(collaborationAsset.authGeneration, params.authGeneration), ), - ); + ) + .returning({ + utFileKey: collaborationAsset.utFileKey, + authGeneration: collaborationAsset.authGeneration, + }); + if (retired.length === 0) return { retiredObjects: 0 }; + + await db.insert(deferredFileCleanup).values( + retired.map((row) => ({ + utFileKey: row.utFileKey, + reason: RETIRED_ASSET_CLEANUP_REASON, + context: JSON.stringify({ + roomId: params.roomId, + retiredGeneration: row.authGeneration, + currentGeneration: params.authGeneration, + }), + attempts: 0, + nextAttemptAt: params.now, + status: "pending" as const, + })), + ); + return { retiredObjects: retired.length }; } diff --git a/apps/web/src/server/db/schema.ts b/apps/web/src/server/db/schema.ts index 271d26d5..b7e1e6cc 100644 --- a/apps/web/src/server/db/schema.ts +++ b/apps/web/src/server/db/schema.ts @@ -14,6 +14,10 @@ import { import { relations, sql } from "drizzle-orm"; import { customType } from "drizzle-orm/pg-core"; import { DRAWSTUFF_DOCUMENT_VERSION } from "@drawstuff/excalidraw-adapter/codec"; +import { + MAX_ASSET_CIPHERTEXT_BYTES, + MAX_ASSET_URL_LENGTH, +} from "@drawstuff/collaboration/asset"; import { MAX_SNAPSHOT_CIPHERTEXT_BYTES } from "@drawstuff/collaboration/snapshot"; const createTable = pgTableCreator((name) => `excalidraw-ericts_${name}`); @@ -445,11 +449,20 @@ export const collaborationSnapshot = createTable( ); /** - * 共編 room 引用的 binary asset 身份(Plan 16)。 + * 共編 room 的 binary asset:身份(Plan 16)與密文所在位置(Plan 17)。 + * + * 一列代表「這個 room 的這個授權世代有這個 Excalidraw file id 的密文,存在這個 + * storage object」。身份是 (room, generation, `excalidraw_file_id`), + * `ut_file_key`/`url` 只是「現在存在哪裡」——重新上傳會得到新 key,所以它不是身份, + * 只能由身份反查出來。 * - * 這是一張**只有身份的 manifest**:一列代表「這個 room 的這個授權世代引用了這個 - * Excalidraw file id」。位元組傳輸由 Plan 17 負責,所以這裡沒有 storage key、URL、 - * 密文、長度或 MIME type——現在還不存在的東西不先開欄位。 + * 這張表**沒有純身份的列**:一列存在就代表位元組已經上傳完成。原因是可用性只有一種 + * 有意義的答案——peer 從 element 的 `fileId` 知道要哪張圖,需要問的是「位元組在哪、 + * 到了沒」。先寫一列「已註冊但還沒有 bytes」只會讓讀取端無法區分這兩件事。 + * + * 也刻意沒有 MIME type 與 content hash:兩者都在密文裡(payload metadata),伺服器 + * 看不到也不需要看到。把 MIME 複製到欄位上只會產生一份伺服器無法驗證、卻可能與 + * 密文不一致的斷言。 * * 為什麼不放進 `file_record`:那張表的 parent 是 scene/sharedScene、內容是明文 * 壓縮後上傳到 UploadThing、retention 跟著 scene 走。Room asset 的 parent 是 room、 @@ -476,7 +489,18 @@ export const collaborationAsset = createTable( authGeneration: integer("auth_generation").notNull(), /** 不可變的 Excalidraw file id;在 (room, generation) 內唯一。 */ excalidrawFileId: varchar("excalidraw_file_id", { length: 64 }).notNull(), - /** 首次註冊者;成員被刪除時保留 manifest(身份與註冊者無關)。 */ + /** Sealed envelope 版本,對應 `ASSET_CRYPTO_VERSION`。 */ + cryptoVersion: integer("crypto_version").notNull(), + /** 密文的 storage object 身份;清理與去重都用它。 */ + utFileKey: varchar("ut_file_key", { length: 256 }).notNull(), + /** + * 密文目前的下載位置;不是身份,重新上傳會變。長度與 + * `MAX_ASSET_URL_LENGTH` 同步:transfer contract 拒收的 URL 這裡也存不下。 + */ + url: varchar("url", { length: MAX_ASSET_URL_LENGTH }).notNull(), + /** 密文長度;下載前的上界檢查,且與 `MAX_ASSET_CIPHERTEXT_BYTES` 一起設限。 */ + byteLength: integer("byte_length").notNull(), + /** 上傳者;成員被刪除時保留資產(身份與上傳者無關)。 */ registeredBy: text("registered_by").references(() => user.id, { onDelete: "set null", }), @@ -497,6 +521,17 @@ export const collaborationAsset = createTable( "collaboration_asset_excalidraw_file_id_shape", sql`${table.excalidrawFileId} ~ '^[A-Za-z0-9_-]{1,64}$'`, ), + check( + "collaboration_asset_crypto_version_positive", + sql`${table.cryptoVersion} >= 1`, + ), + // 授權成員也不能靠 asset 無界地長大 storage:單一資產的密文長度有上界。 + check( + "collaboration_asset_byte_length_bounded", + sql`${table.byteLength} between 1 and ${sql.raw( + String(MAX_ASSET_CIPHERTEXT_BYTES), + )}`, + ), ], ); diff --git a/apps/web/tests/collab-asset-transfer.test.ts b/apps/web/tests/collab-asset-transfer.test.ts new file mode 100644 index 00000000..3a942b47 --- /dev/null +++ b/apps/web/tests/collab-asset-transfer.test.ts @@ -0,0 +1,578 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + MAX_ASSET_DATA_URL_BYTES, + MAX_ROOM_ASSETS_PER_GENERATION, +} from "@drawstuff/collaboration/asset"; +import type { + BinaryFileData, + DataURL, + FileId, +} from "@drawstuff/excalidraw-adapter/types"; + +import { + collabImage, + collabRectangle, + editedElement, +} from "./support/collab-scene-fixtures"; +import { + createAssetBackend, + createHarness, + createSnapshotBackend, + expectConverged, + type AssetBackend, + type AssetTestClient, +} from "./support/collab-session-harness"; + +/** + * Encrypted asset transfer, end to end (Plan 17). + * + * The elements and the bytes travel on two different paths, and that is the whole + * subject here: an image element goes through the relay as ordinary scene state, + * while its bytes are sealed in the browser, stored by a backend that cannot read + * them, and fetched by whoever needs them. So every test asserts on a *peer's + * canvas*, not on a call — what matters is that the other person sees the image, + * and that the scene keeps converging when they cannot. + * + * The backend is a fake, but the sealing is not: ciphertext here is produced by + * the real codec under a real room key, so "the server never sees plaintext", + * "tampering is refused" and "another generation cannot open this" are properties + * of the actual crypto rather than of a stub. + */ + +const FILE_A = "a".repeat(40); +const FILE_B = "b".repeat(40); + +/** A tiny but real PNG data URL; the payload only has to be a valid data URL. */ +const dataUrlFor = (marker: string): DataURL => + `data:image/png;base64,AAECAwQFBgcICQoLDA0OD${marker}` as DataURL; + +const imageFile = ( + fileId: string, + overrides: Partial = {}, +): BinaryFileData => ({ + id: fileId as FileId, + dataURL: dataUrlFor("w"), + mimeType: "image/png", + created: 1_710_000_000_000, + lastRetrieved: 1_710_000_000_000, + ...overrides, +}); + +/** Adds an image element plus its local bytes, the way a paste does. */ +const pasteImage = ( + client: AssetTestClient, + fileId: string, + file: BinaryFileData = imageFile(fileId), +): void => { + client.host.putLocalFile(file); + client.edit((elements) => [ + ...elements, + collabImage({ id: `img-${fileId.slice(0, 4)}`, fileId }), + ]); +}; + +const expectStored = (backend: AssetBackend, fileIds: string[]) => + vi.waitFor(() => { + expect(backend.storedIds()).toEqual([...fileIds].sort()); + }); + +const expectRendered = (client: AssetTestClient, fileId: string, url: string) => + vi.waitFor(() => { + expect(client.host.files[fileId]?.dataURL).toBe(url); + }); + +/** + * Waits for the store to arm its retry timer, then fires it. + * + * The wait is the point: the timer is armed only after the lookup that missed has + * come back, so advancing the clock before that would fire nothing and prove + * nothing. + */ +const runRetry = async (client: AssetTestClient): Promise => { + await vi.waitFor(() => { + expect(client.assetTimers.pendingCount).toBe(1); + }); + client.assetTimers.advance(60_000); +}; + +describe("encrypted collaboration asset transfer", () => { + let harness: ReturnType; + let backend: AssetBackend; + + beforeEach(() => { + harness = createHarness(); + backend = createAssetBackend(); + }); + + it("stores a pasted image as ciphertext the backend cannot read", async () => { + const alice = await harness.createAssetClient("client-alice", backend); + alice.session.connect(); + harness.settle(); + + pasteImage(alice, FILE_A, imageFile(FILE_A, { dataURL: dataUrlFor("z") })); + await expectStored(backend, [FILE_A]); + + const ciphertext = backend.ciphertextFor(FILE_A); + expect(ciphertext).toBeDefined(); + // The plaintext markers must not appear anywhere in what was uploaded: not + // the data URL, not the MIME type, not the file id. + const asText = new TextDecoder("latin1").decode(ciphertext); + expect(asText).not.toContain("data:image/png"); + expect(asText).not.toContain(dataUrlFor("z")); + expect(asText).not.toContain(FILE_A); + }); + + it("shows a pasted image to a peer that is already in the room", async () => { + const alice = await harness.createAssetClient("client-alice", backend); + const bob = await harness.createAssetClient("client-bob", backend); + alice.session.connect(); + bob.session.connect(); + harness.settle(); + + pasteImage(alice, FILE_A); + harness.settle(); + await expectStored(backend, [FILE_A]); + // The element always arrives before the bytes — sealing and uploading take as + // long as they take — so the receiver's first lookup may legitimately miss and + // the image appears on its retry. + if (bob.host.files[FILE_A] === undefined) await runRetry(bob); + + await expectRendered(bob, FILE_A, dataUrlFor("w")); + expectConverged(alice, bob); + // Bob asked for the id the element carried, and only that one. + expect(backend.fetchCalls).toBe(1); + }); + + it("shows room images to a client that joins later", async () => { + const alice = await harness.createAssetClient("client-alice", backend); + alice.session.connect(); + harness.settle(); + pasteImage(alice, FILE_A); + await expectStored(backend, [FILE_A]); + + const carol = await harness.createAssetClient("client-carol", backend); + carol.session.connect(); + harness.settle(); + + await expectRendered(carol, FILE_A, dataUrlFor("w")); + expectConverged(alice, carol); + }); + + it("restores room images from the durable snapshot after a refresh", async () => { + const snapshots = createSnapshotBackend(); + const alice = await harness.createAssetClient("client-alice", backend, { + snapshotStore: snapshots.createStore(), + }); + alice.session.connect(); + harness.settle(); + pasteImage(alice, FILE_A); + await expectStored(backend, [FILE_A]); + await alice.session.flushSnapshot(); + expect(snapshots.revision).toBeGreaterThan(0); + // The room empties out: the durable snapshot is now the only copy of the + // scene, which is the case a refresh has to recover from. + alice.session.disconnect(); + harness.settle(); + + // A refresh is a brand-new client with the same room link: no peers, nothing + // on the canvas, and the stored baseline as its only source of elements. + const reloaded = await harness.createAssetClient("client-reload", backend, { + snapshotStore: snapshots.createStore(), + }); + reloaded.session.connect(); + harness.settle(); + + await expectRendered(reloaded, FILE_A, dataUrlFor("w")); + expect(reloaded.baselineOutcomes).toContain("durable-snapshot"); + }); + + it("retries an image whose upload has not landed yet", async () => { + const alice = await harness.createAssetClient("client-alice", backend); + const bob = await harness.createAssetClient("client-bob", backend); + alice.session.connect(); + bob.session.connect(); + harness.settle(); + + // A slow upload: the element is broadcast the moment the image is pasted, and + // the ciphertext does not land until later. + backend.withholdUploads(); + pasteImage(alice, FILE_A); + harness.settle(); + await vi.waitFor(() => { + expect(backend.resolveCalls).toBe(1); + }); + expect(bob.host.files[FILE_A]).toBeUndefined(); + // Bob converged on the element without the bytes: a missing image never holds + // up element sync. + expectConverged(alice, bob); + + backend.releaseUploads(); + await runRetry(bob); + await expectRendered(bob, FILE_A, dataUrlFor("w")); + }); + + it("stops scheduling retries for an image the room never gets", async () => { + const alice = await harness.createAssetClient("client-alice", backend); + const bob = await harness.createAssetClient("client-bob", backend); + alice.session.connect(); + bob.session.connect(); + harness.settle(); + + backend.withholdUploads(); + pasteImage(alice, FILE_A); + harness.settle(); + + // The scheduled chain is bounded: four attempts and then no timer at all, so + // an asset that never arrives is not polled for. + for (let round = 0; round < 3; round += 1) await runRetry(bob); + await vi.waitFor(() => { + expect(backend.resolveCalls).toBe(4); + }); + expect(bob.assetTimers.pendingCount).toBe(0); + expect(bob.host.files[FILE_A]).toBeUndefined(); + + // But it is not given up on either: the id is rate limited, not abandoned, so + // the next traffic that references it tries once more — an upload that is + // merely slow must not cost the image permanently. The rate-limit window has + // to pass first, which is exactly what stops that traffic from becoming a + // lookup per message. + backend.releaseUploads(); + bob.assetTimers.advance(60_000); + expect(bob.assetTimers.pendingCount).toBe(0); + alice.edit((elements) => + elements.map((element) => + element.type === "image" ? editedElement(element) : element, + ), + ); + harness.settle(); + await expectRendered(bob, FILE_A, dataUrlFor("w")); + }); + + it("refuses tampered ciphertext without retrying it", async () => { + const alice = await harness.createAssetClient("client-alice", backend); + alice.session.connect(); + harness.settle(); + pasteImage(alice, FILE_A); + await expectStored(backend, [FILE_A]); + backend.corrupt(FILE_A); + + const bob = await harness.createAssetClient("client-bob", backend); + bob.session.connect(); + harness.settle(); + + await vi.waitFor(() => { + expect(backend.fetchCalls).toBe(1); + }); + // Authentication failure is terminal: the same bytes would fail again. + expect(bob.assetTimers.pendingCount).toBe(0); + expect(bob.host.files[FILE_A]).toBeUndefined(); + expect(bob.host.addedFileBatches).toEqual([]); + expectConverged(alice, bob); + }); + + it("refuses an oversize image and an unsupported type instead of uploading them", async () => { + const alice = await harness.createAssetClient("client-alice", backend); + alice.session.connect(); + harness.settle(); + + pasteImage( + alice, + FILE_A, + imageFile(FILE_A, { + dataURL: `data:image/png;base64,${"A".repeat( + MAX_ASSET_DATA_URL_BYTES, + )}` as DataURL, + }), + ); + pasteImage( + alice, + FILE_B, + // `BinaryFileData` admits this type; a room asset must not. + imageFile(FILE_B, { mimeType: "application/octet-stream" }), + ); + + await vi.waitFor(() => { + expect(alice.host.elements).toHaveLength(2); + }); + expect(backend.uploadCalls).toBe(0); + expect(backend.storedIds()).toEqual([]); + }); + + it("fetches one asset once however many elements reference it", async () => { + const alice = await harness.createAssetClient("client-alice", backend); + alice.session.connect(); + harness.settle(); + alice.host.putLocalFile(imageFile(FILE_A)); + alice.host.putLocalFile(imageFile(FILE_B, { dataURL: dataUrlFor("q") })); + alice.edit((elements) => [ + ...elements, + collabImage({ id: "img-1", fileId: FILE_A }), + collabImage({ id: "img-2", fileId: FILE_A }), + collabImage({ id: "img-3", fileId: FILE_B }), + collabRectangle({ id: "r1" }), + ]); + await expectStored(backend, [FILE_A, FILE_B]); + + const bob = await harness.createAssetClient("client-bob", backend); + bob.session.connect(); + harness.settle(); + + await expectRendered(bob, FILE_B, dataUrlFor("q")); + // One lookup for the batch, one download per distinct asset, and one + // injection — an `addFiles` per element would re-render the canvas per image. + expect(backend.resolveCalls).toBe(1); + expect(backend.fetchCalls).toBe(2); + expect(bob.host.addedFileBatches).toEqual([[FILE_A, FILE_B]]); + }); + + it("never uploads from a viewer session", async () => { + const viewer = await harness.createAssetClient("client-viewer", backend, { + role: "viewer", + }); + viewer.session.connect(); + harness.settle(); + + viewer.host.putLocalFile(imageFile(FILE_A)); + viewer.edit((elements) => [ + ...elements, + collabImage({ id: "img-1", fileId: FILE_A }), + ]); + await vi.waitFor(() => { + expect(viewer.host.elements).toHaveLength(1); + }); + expect(backend.uploadCalls).toBe(0); + }); + + it("does not download an asset it uploaded itself", async () => { + const alice = await harness.createAssetClient("client-alice", backend); + alice.session.connect(); + harness.settle(); + pasteImage(alice, FILE_A); + await expectStored(backend, [FILE_A]); + + // The canvas already holds these bytes; asking for them would be a download + // of an image the user is looking at. + await alice.assetStore.request([FILE_A]); + expect(backend.resolveCalls).toBe(0); + expect(backend.fetchCalls).toBe(0); + }); + + it("shares one download between concurrent requests for the same asset", async () => { + const alice = await harness.createAssetClient("client-alice", backend); + alice.session.connect(); + harness.settle(); + pasteImage(alice, FILE_A); + await expectStored(backend, [FILE_A]); + + const bob = await harness.createAssetClient("client-bob", backend); + await Promise.all([ + bob.assetStore.request([FILE_A]), + bob.assetStore.request([FILE_A]), + bob.assetStore.request([FILE_A]), + ]); + expect(backend.resolveCalls).toBe(1); + expect(backend.fetchCalls).toBe(1); + }); + + it("releases every transfer and timer on teardown", async () => { + const alice = await harness.createAssetClient("client-alice", backend); + alice.session.connect(); + harness.settle(); + pasteImage(alice, FILE_A); + await expectStored(backend, [FILE_A]); + + const bob = await harness.createAssetClient("client-bob", backend); + backend.setFailResolve(true); + await bob.assetStore.request([FILE_A]); + expect(bob.assetTimers.pendingCount).toBe(1); + + bob.assetStore.destroy(); + bob.session.destroy(); + expect(bob.assetTimers.pendingCount).toBe(0); + + // A request after teardown does nothing at all, so a late callback cannot + // write another room's images onto a canvas that has moved on. + backend.setFailResolve(false); + await bob.assetStore.request([FILE_A]); + expect(bob.host.files[FILE_A]).toBeUndefined(); + expect(bob.host.addedFileBatches).toEqual([]); + }); + + it("cancels a lookup that nobody answers when the room is left", async () => { + const bob = await harness.createAssetClient("client-bob", backend); + backend.hangResolve(); + const pending = bob.assetStore.request([FILE_A]); + await vi.waitFor(() => { + expect(backend.resolveCalls).toBe(1); + }); + + // Without a signal on the lookup, teardown would only take effect whenever the + // network happened to answer — which for a hung request is never. + bob.assetStore.destroy(); + await pending; + expect(backend.resolveAborted).toBe(true); + }); + + it("keeps the whole transfer budget, not one per request", async () => { + const alice = await harness.createAssetClient("client-alice", backend); + alice.session.connect(); + harness.settle(); + const fileIds = Array.from({ length: 8 }, (_, index) => + `${index}`.padEnd(40, "e"), + ); + for (const fileId of fileIds) { + alice.host.putLocalFile(imageFile(fileId)); + } + alice.edit((elements) => [ + ...elements, + ...fileIds.map((fileId, index) => + collabImage({ id: `img-${index}`, fileId }), + ), + ]); + await expectStored(backend, fileIds); + + const bob = await harness.createAssetClient("client-bob", backend); + // Two overlapping requests for disjoint assets: a per-request budget would let + // each open its own four downloads and hold eight ciphertexts at once. + await Promise.all([ + bob.assetStore.request(fileIds.slice(0, 4)), + bob.assetStore.request(fileIds.slice(4)), + ]); + expect(backend.peakConcurrentTransfers).toBeLessThanOrEqual(4); + expect(Object.keys(bob.host.files).sort()).toEqual([...fileIds].sort()); + }); + + it("keeps a retry deadline that has not arrived yet", async () => { + const alice = await harness.createAssetClient("client-alice", backend); + alice.session.connect(); + harness.settle(); + backend.withholdUploads(); + pasteImage(alice, FILE_A); + pasteImage(alice, FILE_B); + await vi.waitFor(() => { + expect(backend.uploadCalls).toBe(2); + }); + + // Two assets miss at different moments, so their backoff deadlines are ~900ms + // apart. The timer fires at the earlier one — and must take only that id: the + // other's chain has to survive, or it would wait for unrelated traffic. + const bob = await harness.createAssetClient("client-bob", backend); + await bob.assetStore.request([FILE_A]); + expect(bob.assetTimers.pendingCount).toBe(1); + bob.assetTimers.advance(900); + await bob.assetStore.request([FILE_B]); + backend.releaseUploads(); + + bob.assetTimers.advance(400); + await expectRendered(bob, FILE_A, dataUrlFor("w")); + // B's own deadline is still ahead, and it still has a timer of its own. + expect(bob.host.files[FILE_B]).toBeUndefined(); + expect(bob.assetTimers.pendingCount).toBe(1); + + bob.assetTimers.advance(60_000); + await expectRendered(bob, FILE_B, dataUrlFor("w")); + }); + + it("retries a failed upload without waiting for another edit", async () => { + const alice = await harness.createAssetClient("client-alice", backend); + alice.session.connect(); + harness.settle(); + + // The user pastes an image, the upload fails transiently, and then they stop + // drawing: with no further scene flush, only the store's own timer can make the + // image reach anybody. + backend.failNextUploads(1); + pasteImage(alice, FILE_A); + await vi.waitFor(() => { + expect(backend.uploadCalls).toBe(1); + }); + expect(backend.storedIds()).toEqual([]); + + await vi.waitFor(() => { + expect(alice.assetTimers.pendingCount).toBe(1); + }); + alice.assetTimers.advance(5_000); + await expectStored(backend, [FILE_A]); + }); + + it("ignores a malformed file id without losing the batch it arrived in", async () => { + const alice = await harness.createAssetClient("client-alice", backend); + alice.session.connect(); + harness.settle(); + pasteImage(alice, FILE_A); + await expectStored(backend, [FILE_A]); + + const bob = await harness.createAssetClient("client-bob", backend); + // The lookup API rejects a whole batch containing one invalid id, so a single + // bad `fileId` on a peer's element must not cost the valid images beside it. + await bob.assetStore.request(["not/a/valid/id", FILE_A]); + expect(bob.host.files[FILE_A]?.dataURL).toBe(dataUrlFor("w")); + expect(backend.resolveCalls).toBe(1); + + // And it is never asked for again. + await bob.assetStore.request(["not/a/valid/id"]); + expect(backend.resolveCalls).toBe(1); + }); + + it("retries a failed upload even while a slower sibling is still going", async () => { + const alice = await harness.createAssetClient("client-alice", backend); + alice.session.connect(); + harness.settle(); + + // Two images in one flush: the first upload fails immediately, the second is + // still in flight when the retry falls due. A batch-wide in-flight claim would + // make the retry skip exactly the file it was scheduled for. + backend.failNextUploads(1); + backend.holdNextUpload(); + alice.host.putLocalFile(imageFile(FILE_A)); + alice.host.putLocalFile(imageFile(FILE_B)); + alice.edit((elements) => [ + ...elements, + collabImage({ id: "img-a", fileId: FILE_A }), + collabImage({ id: "img-b", fileId: FILE_B }), + ]); + await vi.waitFor(() => { + expect(alice.assetTimers.pendingCount).toBe(1); + }); + + alice.assetTimers.advance(5_000); + await vi.waitFor(() => { + // The failed id was re-offered rather than skipped. + expect(backend.uploadCalls).toBeGreaterThanOrEqual(3); + }); + backend.releaseHeldUpload(); + await expectStored(backend, [FILE_A, FILE_B]); + }); + + it("does not spin when more assets are missing than it can track", async () => { + const bob = await harness.createAssetClient("client-bob", backend); + // More ids than the bookkeeping bound, all absent. Retry state is evicted + // FIFO, and an evicted id left in the retry queue would have no deadline — + // which reads as "due now" and turns the timer into a zero-delay request loop. + const fileIds = Array.from( + { length: MAX_ROOM_ASSETS_PER_GENERATION + 40 }, + (_, index) => `${index}`.padStart(40, "c"), + ); + await bob.assetStore.request(fileIds); + const lookupsPerRound = Math.ceil(fileIds.length / 64); + expect(backend.resolveCalls).toBe(lookupsPerRound); + + // Every armed retry has to be due in the *future*. A timer due at `now` is the + // shape of the spin: it fires, evicts more state, and re-arms at zero delay. + await vi.waitFor(() => { + expect(bob.assetTimers.nextDueAt).toBeDefined(); + }); + expect(bob.assetTimers.nextDueAt).toBeGreaterThan(bob.assetTimers.now); + + // And the same has to hold after a round actually runs. + bob.assetTimers.advance(60_000); + await vi.waitFor(() => { + expect(backend.resolveCalls).toBeGreaterThan(lookupsPerRound); + }); + await vi.waitFor(() => { + expect(bob.assetTimers.nextDueAt).toBeDefined(); + }); + expect(bob.assetTimers.nextDueAt).toBeGreaterThan(bob.assetTimers.now); + }); +}); diff --git a/apps/web/tests/collaboration-asset-identity.test.ts b/apps/web/tests/collaboration-asset-identity.test.ts index 28ba08b3..b00fab1d 100644 --- a/apps/web/tests/collaboration-asset-identity.test.ts +++ b/apps/web/tests/collaboration-asset-identity.test.ts @@ -34,28 +34,42 @@ const { pgClient, testDb } = await vi.hoisted(async () => { vi.mock("@/server/db/index", () => ({ db: testDb })); import { pushSchema } from "drizzle-kit/api"; -import { and, eq } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { - MAX_ASSET_REGISTRATION_BATCH, + ASSET_CRYPTO_VERSION, + MAX_ASSET_CIPHERTEXT_BYTES, + MAX_ASSET_LOOKUP_BATCH, MAX_ROOM_ASSETS_PER_GENERATION, } from "@drawstuff/collaboration/asset"; import * as schema from "@/server/db/schema"; import { createCaller } from "@/server/api/root"; import type { createTRPCContext } from "@/server/api/trpc"; +import { + commitRoomAssetUpload, + RETIRED_ASSET_CLEANUP_REASON, +} from "@/server/collab/assets"; +import type { Database } from "@/server/collab/rooms"; import { QUERIES } from "@/server/db/queries"; /** - * Asset identity as the storage boundary enforces it (Plan 16). + * Asset identity as the storage boundary enforces it (Plan 16), and the room + * asset writes that identity now governs (Plan 17). * - * The property under test is narrow and was previously wrong: an asset is + * The identity property is narrow and was previously wrong: an asset is * identified by *parent scope + Excalidraw file id*, and by nothing else. Before - * this plan an owned-scene asset was keyed by `(scene_id, content_hash)` over - * the compressed upload payload, which changes on every write — so the same - * image accumulated a row per save, and two images could in principle collide on - * a hash. Both directions are asserted here, against real Postgres DDL rather - * than a mock, because the guarantee is the constraint, not the call site. + * Plan 16 an owned-scene asset was keyed by `(scene_id, content_hash)` over the + * compressed upload payload, which changes on every write — so the same image + * accumulated a row per save, and two images could in principle collide on a + * hash. Both directions are asserted here, against real Postgres DDL rather than + * a mock, because the guarantee is the constraint, not the call site. + * + * The room half adds the two things a stored ciphertext needs: an authorization + * re-check at commit time (an upload takes as long as the bytes take, and access + * can be revoked while it is in flight) and bounded retention (a rotated + * generation's assets are unreadable, so their objects have to be handed to the + * cleanup worker in the same transaction that orphans them). */ type TRPCContext = Awaited>; @@ -317,154 +331,217 @@ describe("scene asset identity", () => { }); }); -describe("collaboration asset manifest", () => { - it("starts empty and reports the generation it answers for", async () => { +describe("collaboration room assets", () => { + const CIPHERTEXT_BYTES = 512; + + const upload = ( + roomId: string, + fileId: string, + options: { + userId?: string; + authGeneration?: number; + utFileKey?: string; + byteLength?: number; + cryptoVersion?: number; + } = {}, + ) => + commitRoomAssetUpload(testDb as unknown as Database, { + roomId, + userId: options.userId ?? OWNER, + authGeneration: options.authGeneration ?? 1, + fileId, + storage: { + cryptoVersion: options.cryptoVersion ?? ASSET_CRYPTO_VERSION, + utFileKey: options.utFileKey ?? `object-${fileId}`, + url: `https://storage.example.com/objects/${options.utFileKey ?? `object-${fileId}`}`, + byteLength: options.byteLength ?? CIPHERTEXT_BYTES, + }, + now: new Date(), + }); + + const roomAssetRows = (roomId: string) => + testDb + .select({ + fileId: schema.collaborationAsset.excalidrawFileId, + generation: schema.collaborationAsset.authGeneration, + utFileKey: schema.collaborationAsset.utFileKey, + registeredBy: schema.collaborationAsset.registeredBy, + }) + .from(schema.collaborationAsset) + .where(eq(schema.collaborationAsset.roomId, roomId)); + + it("answers for the current generation when the room has no assets", async () => { const room = await openRoom(); await expect( - callerFor(OWNER).collaborationAsset.list({ roomId: room.roomId }), + callerFor(OWNER).collaborationAsset.resolve({ + roomId: room.roomId, + fileIds: [FILE_A], + }), ).resolves.toEqual({ roomId: room.roomId, authGeneration: 1, - fileIds: [], + assets: [], + // Absence is an answer, not an error: a peer's image element arrives before + // its ciphertext does, and the caller retries exactly these. + missing: [FILE_A], }); }); - it("registers ids once, ascending, and treats a repeat as success", async () => { + it("records an upload and resolves it back to where the ciphertext is", async () => { const room = await openRoom(); - const first = await callerFor(OWNER).collaborationAsset.register({ - roomId: room.roomId, - authGeneration: 1, - fileIds: [FILE_B, FILE_A], - }); - expect(first).toEqual({ - authGeneration: 1, - registered: [FILE_A, FILE_B], - alreadyPresent: [], - fileIds: [FILE_A, FILE_B], - }); + expect(await upload(room.roomId, FILE_A, { utFileKey: "object-1" })).toBe( + "recorded", + ); - // A retry after a dropped response must not fail and must not duplicate. - const retry = await callerFor(OWNER).collaborationAsset.register({ + const lookup = await callerFor(OWNER).collaborationAsset.resolve({ roomId: room.roomId, - authGeneration: 1, - fileIds: [FILE_A], - }); - expect(retry).toEqual({ - authGeneration: 1, - registered: [], - alreadyPresent: [FILE_A], fileIds: [FILE_A, FILE_B], }); + expect(lookup.assets).toEqual([ + { + excalidrawFileId: FILE_A, + cryptoVersion: ASSET_CRYPTO_VERSION, + byteLength: CIPHERTEXT_BYTES, + url: "https://storage.example.com/objects/object-1", + }, + ]); + expect(lookup.missing).toEqual([FILE_B]); + }); + + it("keeps the first upload of a file id and tells the loser to delete its object", async () => { + const room = await openRoom(); + await addMember(room.roomId, EDITOR, "editor"); + expect(await upload(room.roomId, FILE_A, { utFileKey: "first" })).toBe( + "recorded", + ); + + // Two peers pasting the same image race here by design. The bytes are the + // same image either way, so the loser's object simply has no referent. expect( - await testDb - .select() - .from(schema.collaborationAsset) - .where(eq(schema.collaborationAsset.roomId, room.roomId)), - ).toHaveLength(2); + await upload(room.roomId, FILE_A, { + userId: EDITOR, + utFileKey: "second", + }), + ).toBe("duplicate"); + + const rows = await roomAssetRows(room.roomId); + expect(rows).toEqual([ + { + fileId: FILE_A, + generation: 1, + utFileKey: "first", + registeredBy: OWNER, + }, + ]); }); - it("collapses a batch that names the same asset twice", async () => { + it("treats different file ids as different assets", async () => { const room = await openRoom(); - const result = await callerFor(OWNER).collaborationAsset.register({ + await upload(room.roomId, FILE_A, { utFileKey: "object-a" }); + await upload(room.roomId, FILE_B, { utFileKey: "object-b" }); + + const lookup = await callerFor(OWNER).collaborationAsset.resolve({ roomId: room.roomId, - authGeneration: 1, - fileIds: [FILE_A, FILE_A, FILE_A], + fileIds: [FILE_B, FILE_A], }); - expect(result.registered).toEqual([FILE_A]); - expect(result.fileIds).toEqual([FILE_A]); + expect(lookup.assets.map((asset) => asset.excalidrawFileId)).toEqual([ + FILE_A, + FILE_B, + ]); + expect(lookup.missing).toEqual([]); }); - it("lets an editor register and refuses a viewer", async () => { + it("rejects an upload from a viewer, a stranger, and an unknown room", async () => { const room = await openRoom(); - await addMember(room.roomId, EDITOR, "editor"); await addMember(room.roomId, VIEWER, "viewer"); - await expect( - callerFor(EDITOR).collaborationAsset.register({ - roomId: room.roomId, - authGeneration: 1, - fileIds: [FILE_A], - }), - ).resolves.toMatchObject({ registered: [FILE_A] }); + expect(await upload(room.roomId, FILE_A, { userId: VIEWER })).toBe( + "rejected", + ); + expect(await upload(room.roomId, FILE_A, { userId: STRANGER })).toBe( + "rejected", + ); + expect(await upload("no-such-room", FILE_A)).toBe("rejected"); + expect(await roomAssetRows(room.roomId)).toEqual([]); - // A viewer receives the manifest but never extends it — the relay refuses - // its realtime mutations and this refuses the durable equivalent. + // A viewer still reads assets: it renders the room, it just does not add to it. + await upload(room.roomId, FILE_A); await expect( - callerFor(VIEWER).collaborationAsset.register({ + callerFor(VIEWER).collaborationAsset.resolve({ roomId: room.roomId, - authGeneration: 1, - fileIds: [FILE_B], + fileIds: [FILE_A], }), - ).rejects.toMatchObject({ code: "FORBIDDEN" }); - await expect( - callerFor(VIEWER).collaborationAsset.list({ roomId: room.roomId }), - ).resolves.toMatchObject({ fileIds: [FILE_A] }); - }); - - it("refuses a stranger and an unknown room", async () => { - const room = await openRoom(); - await expect( - callerFor(STRANGER).collaborationAsset.list({ roomId: room.roomId }), - ).rejects.toMatchObject({ code: "FORBIDDEN" }); - await expect( - callerFor(OWNER).collaborationAsset.list({ roomId: "no-such-room" }), - ).rejects.toMatchObject({ code: "NOT_FOUND" }); + ).resolves.toMatchObject({ missing: [] }); }); - it("refuses a registration filed under a stale generation", async () => { + it("rejects an upload sealed for a generation the room has moved past", async () => { const room = await openRoom(); await callerFor(OWNER).collaborationRoom.rotateGeneration({ roomId: room.roomId, }); - await expect( - callerFor(OWNER).collaborationAsset.register({ - roomId: room.roomId, - authGeneration: 1, - fileIds: [FILE_A], - }), - ).rejects.toMatchObject({ code: "PRECONDITION_FAILED" }); + // The ciphertext is bound to generation 1; filing it under 2 would produce a + // row nobody can ever open. + expect(await upload(room.roomId, FILE_A, { authGeneration: 1 })).toBe( + "rejected", + ); + expect(await upload(room.roomId, FILE_A, { authGeneration: 2 })).toBe( + "recorded", + ); }); - it("starts a rotated generation from an empty manifest and retires the old one", async () => { + it("starts a rotated generation empty and queues the old objects for deletion", async () => { const room = await openRoom(); - await callerFor(OWNER).collaborationAsset.register({ - roomId: room.roomId, - authGeneration: 1, - fileIds: [FILE_A], - }); - - const rotated = await callerFor(OWNER).collaborationRoom.rotateGeneration({ + await upload(room.roomId, FILE_A, { utFileKey: "old-object" }); + await callerFor(OWNER).collaborationRoom.rotateGeneration({ roomId: room.roomId, }); - expect(rotated.authGeneration).toBe(2); - // The previous generation's assets are sealed under a key nobody can derive - // any more, so the new generation legitimately references nothing yet. + // The previous generation's ciphertext is unreadable now, so the new + // generation legitimately has nothing. await expect( - callerFor(OWNER).collaborationAsset.list({ roomId: room.roomId }), - ).resolves.toEqual({ - roomId: room.roomId, + callerFor(OWNER).collaborationAsset.resolve({ + roomId: room.roomId, + fileIds: [FILE_A], + }), + ).resolves.toMatchObject({ authGeneration: 2, - fileIds: [], + assets: [], + missing: [FILE_A], }); - await callerFor(OWNER).collaborationAsset.register({ - roomId: room.roomId, + await upload(room.roomId, FILE_B, { authGeneration: 2, - fileIds: [FILE_B], + utFileKey: "new-object", }); - // Retention is bounded: the write that gave generation 2 a manifest is what - // retires generation 1's. + expect(await roomAssetRows(room.roomId)).toEqual([ + { + fileId: FILE_B, + generation: 2, + utFileKey: "new-object", + registeredBy: OWNER, + }, + ]); + // Deleting the row is what orphans the object, so the same transaction hands + // it to the cleanup worker. expect( await testDb - .select({ generation: schema.collaborationAsset.authGeneration }) - .from(schema.collaborationAsset) - .where(eq(schema.collaborationAsset.roomId, room.roomId)), - ).toEqual([{ generation: 2 }]); + .select({ + utFileKey: schema.deferredFileCleanup.utFileKey, + reason: schema.deferredFileCleanup.reason, + status: schema.deferredFileCleanup.status, + }) + .from(schema.deferredFileCleanup), + ).toEqual([ + { + utFileKey: "old-object", + reason: RETIRED_ASSET_CLEANUP_REASON, + status: "pending", + }, + ]); }); - it("bounds one request and the room's total asset count", async () => { + it("bounds the room's asset count and one lookup batch", async () => { const room = await openRoom(); const ids = (count: number, prefix: string) => Array.from({ length: count }, (_, index) => @@ -472,90 +549,68 @@ describe("collaboration asset manifest", () => { ); await expect( - callerFor(OWNER).collaborationAsset.register({ + callerFor(OWNER).collaborationAsset.resolve({ roomId: room.roomId, - authGeneration: 1, - fileIds: ids(MAX_ASSET_REGISTRATION_BATCH + 1, "batch"), + fileIds: ids(MAX_ASSET_LOOKUP_BATCH + 1, "batch"), }), ).rejects.toThrow(); - // Fill the generation to its ceiling in permitted batches, then prove the - // next asset is refused: an authorized member must not be able to grow the - // manifest without limit. - const all = ids(MAX_ROOM_ASSETS_PER_GENERATION, "fill"); - for ( - let index = 0; - index < all.length; - index += MAX_ASSET_REGISTRATION_BATCH - ) { - await callerFor(OWNER).collaborationAsset.register({ - roomId: room.roomId, - authGeneration: 1, - fileIds: all.slice(index, index + MAX_ASSET_REGISTRATION_BATCH), - }); + // Fill the generation to its ceiling, then prove the next upload is refused: + // an authorized member must not be able to grow object storage without limit. + for (const fileId of ids(MAX_ROOM_ASSETS_PER_GENERATION, "fill")) { + expect(await upload(room.roomId, fileId)).toBe("recorded"); } + expect(await upload(room.roomId, FILE_A)).toBe("budget-exceeded"); + + // The refused asset really is absent rather than silently swallowed. await expect( - callerFor(OWNER).collaborationAsset.register({ + callerFor(OWNER).collaborationAsset.resolve({ roomId: room.roomId, - authGeneration: 1, fileIds: [FILE_A], }), - ).rejects.toMatchObject({ code: "PRECONDITION_FAILED" }); + ).resolves.toMatchObject({ assets: [], missing: [FILE_A] }); + }); - // The refused asset really is absent, not silently swallowed. - const manifest = await callerFor(OWNER).collaborationAsset.list({ - roomId: room.roomId, - }); - expect(manifest.fileIds).toHaveLength(MAX_ROOM_ASSETS_PER_GENERATION); - expect(manifest.fileIds).not.toContain(FILE_A); + it("refuses a ciphertext length no sealed asset could have", async () => { + const room = await openRoom(); + await expect( + upload(room.roomId, FILE_A, { byteLength: 0 }), + ).rejects.toThrow(/1\.\./); + await expect( + upload(room.roomId, FILE_A, { + byteLength: MAX_ASSET_CIPHERTEXT_BYTES + 1, + }), + ).rejects.toThrow(); + expect(await roomAssetRows(room.roomId)).toEqual([]); }); - it("removes a room's manifest with the room", async () => { + it("removes a room's assets with the room", async () => { const room = await openRoom(); - await callerFor(OWNER).collaborationAsset.register({ - roomId: room.roomId, - authGeneration: 1, - fileIds: [FILE_A], - }); + await upload(room.roomId, FILE_A); await testDb .delete(schema.collaborationRoom) .where(eq(schema.collaborationRoom.roomId, room.roomId)); - expect( - await testDb - .select() - .from(schema.collaborationAsset) - .where(eq(schema.collaborationAsset.roomId, room.roomId)), - ).toEqual([]); + expect(await roomAssetRows(room.roomId)).toEqual([]); }); - it("keeps a manifest entry when the member who registered it is deleted", async () => { + it("keeps an asset when the member who uploaded it is deleted", async () => { const room = await openRoom(); await addMember(room.roomId, EDITOR, "editor"); - await callerFor(EDITOR).collaborationAsset.register({ - roomId: room.roomId, - authGeneration: 1, - fileIds: [FILE_A], - }); + await upload(room.roomId, FILE_A, { userId: EDITOR }); await testDb.delete(schema.user).where(eq(schema.user.id, EDITOR)); - // Identity does not belong to whoever happened to upload first: the room - // still references the asset, so the entry survives with no registrant. - expect( - await testDb - .select({ - fileId: schema.collaborationAsset.excalidrawFileId, - registeredBy: schema.collaborationAsset.registeredBy, - }) - .from(schema.collaborationAsset) - .where( - and( - eq(schema.collaborationAsset.roomId, room.roomId), - eq(schema.collaborationAsset.authGeneration, 1), - ), - ), - ).toEqual([{ fileId: FILE_A, registeredBy: null }]); + // Identity does not belong to whoever happened to upload first: the room still + // references the asset, so the row survives with no uploader. + expect(await roomAssetRows(room.roomId)).toEqual([ + { + fileId: FILE_A, + generation: 1, + utFileKey: `object-${FILE_A}`, + registeredBy: null, + }, + ]); }); }); diff --git a/apps/web/tests/support/collab-scene-fixtures.ts b/apps/web/tests/support/collab-scene-fixtures.ts index e4514263..d94fa329 100644 --- a/apps/web/tests/support/collab-scene-fixtures.ts +++ b/apps/web/tests/support/collab-scene-fixtures.ts @@ -59,6 +59,30 @@ export function collabRectangle( } as unknown as OrderedExcalidrawElement; } +/** + * A native-shaped image element in its saved state, so it carries a `fileId` the + * asset pipeline has to resolve. Same restore fixed-point requirement as + * `collabRectangle`: the element itself must survive a wire round-trip unchanged, + * because the bytes travel on a completely separate path. + */ +export function collabImage( + overrides: Record & { + readonly id: string; + readonly fileId: string; + }, +): OrderedExcalidrawElement { + return collabRectangle({ + ...overrides, + type: "image", + status: "saved", + scale: [1, 1], + crop: null, + roundness: null, + width: 100, + height: 80, + }); +} + /** A copy of `element` with one semantic edit and the upstream version bump. */ export function editedElement( element: OrderedExcalidrawElement, diff --git a/apps/web/tests/support/collab-session-harness.ts b/apps/web/tests/support/collab-session-harness.ts index 660a2b36..6c44020a 100644 --- a/apps/web/tests/support/collab-session-harness.ts +++ b/apps/web/tests/support/collab-session-harness.ts @@ -9,6 +9,7 @@ import { type SyncedElement, } from "@drawstuff/collaboration/protocol"; import type { JoinBarrierOptions } from "@drawstuff/collaboration/join-barrier"; +import { roomKeySchema } from "@drawstuff/collaboration/realtime-crypto"; import type { RoomRole } from "@drawstuff/collaboration/room-auth"; import { SNAPSHOT_NO_REVISION } from "@drawstuff/collaboration/snapshot"; import { @@ -16,12 +17,19 @@ import { type FakeCollaborationNetwork, } from "@drawstuff/collaboration/testing"; import type { + BinaryFileData, + BinaryFiles, Collaborator, OrderedExcalidrawElement, SceneData, SocketId, } from "@drawstuff/excalidraw-adapter/types"; +import { + createCollaborationAssetStore, + type AssetApi, + type CollaborationAssetStore, +} from "@/lib/collab/asset-store"; import { createCollaborationSession, type BaselineOutcome, @@ -38,6 +46,12 @@ import { export const ROOM_ID = roomIdSchema.parse("room-poc"); /** The fake network models delivery, not token verification. */ export const JOIN_TOKEN = "test-join-token"; +/** Authorization generation every client in these tests joined under. */ +export const AUTH_GENERATION = 1; +/** Shared room key: asset sealing is real, so the key has to be a real one. */ +export const ROOM_KEY = roomKeySchema.parse( + "T0PSTFR2c2hhcmVkLXRlc3Qtcm9vbS1rZXktMDAwMDA", +); export type SceneHost = { api: CollaborationSceneApi; @@ -46,12 +60,20 @@ export type SceneHost = { readonly collaborators: ReadonlyMap; /** captureUpdate of every element-carrying updateScene call, in order. */ readonly elementCaptureUpdates: readonly (string | undefined)[]; + /** The engine's file store; stands in for what `addFiles` writes into. */ + readonly files: BinaryFiles; + /** Adds a file the way a local paste does, without notifying the session. */ + putLocalFile(file: BinaryFileData): void; + /** One entry per `addFiles` call, holding the ids it carried. */ + readonly addedFileBatches: readonly (readonly string[])[]; }; export function createSceneHost(): SceneHost { let elements: readonly OrderedExcalidrawElement[] = []; let collaborators = new Map(); const elementCaptureUpdates: (string | undefined)[] = []; + const files: BinaryFiles = {}; + const addedFileBatches: string[][] = []; const localState = { editingTextElement: null, newElement: null, @@ -76,6 +98,11 @@ export function createSceneHost(): SceneHost { collaborators = sceneData.collaborators; } }, + getFiles: () => files, + addFiles(added) { + addedFileBatches.push(added.map((file) => file.id)); + for (const file of added) files[file.id] = file; + }, }, get elements() { return elements; @@ -87,6 +114,11 @@ export function createSceneHost(): SceneHost { return collaborators; }, elementCaptureUpdates, + files, + putLocalFile(file) { + files[file.id] = file; + }, + addedFileBatches, }; } @@ -156,6 +188,21 @@ export function createManualTimers() { get pendingCount() { return timers.length; }, + /** The clock these timers run on, for code that also reads the time. */ + get now() { + return now; + }, + /** + * Earliest scheduled time among live timers, or `undefined` when idle. A + * timer due at `now` is a zero-delay re-arm — the shape of a spin. + */ + get nextDueAt(): number | undefined { + let earliest: number | undefined; + for (const timer of timers) { + if (earliest === undefined || timer.at < earliest) earliest = timer.at; + } + return earliest; + }, }; } @@ -250,6 +297,217 @@ export function createSnapshotBackend() { }; } +/** + * In-memory stand-in for the asset backend: the room's asset records plus the + * object store the ciphertext lands in. + * + * Deliberately *not* a stand-in for the sealing. Unlike the snapshot backend + * above, what these tests need to establish is the whole round trip — a client + * seals, another client fetches and opens — so the bytes stored here are real + * ciphertext produced by the real codec against the room key, and `corrupt()` + * makes a real authentication failure rather than a simulated one. + * + * `withholdUploads` models the window that actually exists in production: a peer + * has broadcast an image element and its upload has not landed yet, which is + * exactly when a reader's lookup legitimately comes back `missing`. + */ +export function createAssetBackend() { + type StoredRecord = { + excalidrawFileId: string; + cryptoVersion: number; + byteLength: number; + url: string; + }; + const records = new Map(); + const objects = new Map(); + /** Uploads accepted by storage but not yet recorded; see `withholdUploads`. */ + const withheld: StoredRecord[] = []; + let resolveCalls = 0; + let uploadCalls = 0; + let fetchCalls = 0; + let withholdUploads = false; + let failResolve = false; + let failUploads = 0; + let nextKey = 0; + let peakConcurrentTransfers = 0; + let activeTransfers = 0; + let hangingResolve = false; + let resolveAborted = false; + let holdNextUpload = false; + let releaseUpload: (() => void) | undefined; + + const trackTransfer = async (task: () => Promise): Promise => { + activeTransfers += 1; + peakConcurrentTransfers = Math.max( + peakConcurrentTransfers, + activeTransfers, + ); + try { + return await task(); + } finally { + activeTransfers -= 1; + } + }; + + const urlFor = (key: string): string => + `https://storage.test.invalid/objects/${key}`; + const keyFromUrl = (url: string): string | undefined => url.split("/").pop(); + + return { + get resolveCalls() { + return resolveCalls; + }, + get uploadCalls() { + return uploadCalls; + }, + get fetchCalls() { + return fetchCalls; + }, + /** Ids the room currently has ciphertext for. */ + storedIds(): string[] { + return [...records.keys()].sort(); + }, + /** Ciphertext as stored, for assertions about what the server can see. */ + ciphertextFor(fileId: string): Uint8Array | undefined { + const record = records.get(fileId); + if (!record) return undefined; + const key = keyFromUrl(record.url); + return key ? objects.get(key) : undefined; + }, + /** Holds uploads out of the record set until `releaseUploads`. */ + withholdUploads(): void { + withholdUploads = true; + }, + /** Lands every withheld upload, as a slow upload finishing would. */ + releaseUploads(): void { + withholdUploads = false; + for (const record of withheld.splice(0)) { + if (!records.has(record.excalidrawFileId)) { + records.set(record.excalidrawFileId, record); + } + } + }, + /** Makes `resolve` throw, as a transport failure would. */ + setFailResolve(value: boolean): void { + failResolve = value; + }, + /** Rejects the next `count` uploads, as a transient transport failure would. */ + failNextUploads(count: number): void { + failUploads = count; + }, + /** Makes the next upload hang until `releaseHeldUpload`, as a slow one would. */ + holdNextUpload(): void { + holdNextUpload = true; + }, + releaseHeldUpload(): void { + holdNextUpload = false; + const release = releaseUpload; + releaseUpload = undefined; + release?.(); + }, + /** Highest number of transfers this backend ever saw in flight at once. */ + get peakConcurrentTransfers() { + return peakConcurrentTransfers; + }, + /** Makes every `resolve` hang, the way an unanswered request does. */ + hangResolve(): void { + hangingResolve = true; + }, + /** True once a hanging `resolve` was cancelled through its signal. */ + get resolveAborted() { + return resolveAborted; + }, + /** Flips a ciphertext byte in storage: tampering the reader must refuse. */ + corrupt(fileId: string): void { + const stored = this.ciphertextFor(fileId); + if (!stored) throw new Error(`no stored asset for ${fileId}`); + const last = stored.byteLength - 1; + stored[last] = (stored[last] ?? 0) ^ 0xff; + }, + + createApi(): AssetApi { + return { + resolve: ({ fileIds }, signal) => { + resolveCalls += 1; + if (failResolve) return Promise.reject(new Error("resolve failed")); + if (hangingResolve) { + // A request nothing answers: only the signal can end it, which is what + // teardown has to be able to do. + return new Promise((_, reject) => { + signal.addEventListener("abort", () => { + resolveAborted = true; + reject(new Error("aborted")); + }); + }); + } + const assets = fileIds + .map((fileId) => records.get(fileId)) + .filter((record): record is StoredRecord => record !== undefined); + const available = new Set( + assets.map((asset) => asset.excalidrawFileId), + ); + return Promise.resolve({ + authGeneration: AUTH_GENERATION, + assets, + missing: fileIds.filter((fileId) => !available.has(fileId)), + }); + }, + upload: ({ excalidrawFileId, cryptoVersion, ciphertext }) => + trackTransfer(async () => { + uploadCalls += 1; + if (failUploads > 0) { + failUploads -= 1; + throw new Error("upload failed"); + } + if (holdNextUpload) { + holdNextUpload = false; + await new Promise((resolve) => { + releaseUpload = resolve; + }); + } + nextKey += 1; + const key = `object-${nextKey}`; + objects.set(key, Uint8Array.from(ciphertext)); + const record: StoredRecord = { + excalidrawFileId, + cryptoVersion, + byteLength: ciphertext.byteLength, + url: urlFor(key), + }; + if (withholdUploads) { + withheld.push(record); + return; + } + // Identity wins over arrival order, the way the real insert does. + if (!records.has(excalidrawFileId)) { + records.set(excalidrawFileId, record); + } + await Promise.resolve(); + }), + }; + }, + + /** + * `fetch` over the object store; the store reads ciphertext through this. + * + * Deliberately takes a macrotask to answer, so overlapping downloads really do + * overlap and `peakConcurrentTransfers` measures something. + */ + createFetch(): typeof fetch { + return ((input: RequestInfo | URL) => + trackTransfer(async () => { + fetchCalls += 1; + const url = typeof input === "string" ? input : String(input); + const key = keyFromUrl(url); + const bytes = key ? objects.get(key) : undefined; + await new Promise((resolve) => setTimeout(resolve, 0)); + if (!bytes) return new Response(null, { status: 404 }); + return new Response(Uint8Array.from(bytes), { status: 200 }); + })) as typeof fetch; + }, + }; +} + export type TestClient = { host: SceneHost; session: CollaborationSession; @@ -274,6 +532,7 @@ export function createHarness() { options: { role?: RoomRole; snapshotStore?: CollaborationSnapshotStore; + assetStore?: CollaborationAssetStore; canSyncScene?: () => boolean; joinBarrier?: JoinBarrierOptions; } = {}, @@ -291,6 +550,7 @@ export function createHarness() { username: name, sceneApi: host.api, snapshotStore: options.snapshotStore, + assetStore: options.assetStore, canSyncScene: options.canSyncScene, joinBarrier: options.joinBarrier, scheduleSceneFlush: scheduler.schedule, @@ -330,9 +590,57 @@ export function createHarness() { throw new Error("collaboration exchange did not settle"); }; - return { network, clock, createClient, settle }; + /** + * A client with a real asset store attached to a fake backend. + * + * The store hands opened assets to the session and the session asks the store + * for them, so the wiring is the same late binding production uses + * (`room-session.ts`): the callback is installed the moment the session exists. + * Retry backoff runs on the returned manual timers, so no assertion waits. + */ + const createAssetClient = async ( + name: string, + backend: AssetBackend, + options: { + role?: RoomRole; + snapshotStore?: CollaborationSnapshotStore; + canSyncScene?: () => boolean; + joinBarrier?: JoinBarrierOptions; + } = {}, + ): Promise => { + const assetTimers = createManualTimers(); + let target: CollaborationSession | undefined; + const assetStore = await createCollaborationAssetStore({ + api: backend.createApi(), + roomId: ROOM_ID, + roomKey: ROOM_KEY, + authGeneration: AUTH_GENERATION, + onAssetsResolved: (files) => { + target?.applyRemoteAssets(files); + }, + onPublishRetryDue: () => { + target?.republishLocalAssets(); + }, + scheduleTimeout: assetTimers.schedule, + now: () => assetTimers.now, + fetchImpl: backend.createFetch(), + }); + const client = createClient(name, { ...options, assetStore }); + target = client.session; + return { ...client, assetStore, assetTimers }; + }; + + return { network, clock, createClient, createAssetClient, settle }; } +export type AssetBackend = ReturnType; + +export type AssetTestClient = TestClient & { + assetStore: CollaborationAssetStore; + /** Drives the asset store's retry backoff. */ + assetTimers: ReturnType; +}; + export function expectConverged(a: TestClient, b: TestClient): void { expect(sortSceneById(a.host.elements)).toEqual( sortSceneById(b.host.elements), diff --git a/docs/adr/0001-excalidraw-persistence-boundary.md b/docs/adr/0001-excalidraw-persistence-boundary.md index 29f55703..1316078d 100644 --- a/docs/adr/0001-excalidraw-persistence-boundary.md +++ b/docs/adr/0001-excalidraw-persistence-boundary.md @@ -115,19 +115,44 @@ Excalidraw file identity。 Room asset metadata 使用**獨立 relation `collaboration_asset`**,不在 `file_record` 增加第三個 nullable parent: -| 面向 | `file_record` | `collaboration_asset` | -| --------- | --------------------------------- | -------------------------------- | -| Parent | scene/sharedScene | room + `auth_generation` | -| Writer | scene owner | room 內任何可編輯成員 | -| 內容 | 明文壓縮後存於外部 object storage | 將由 room key 封裝(Plan 17) | -| Retention | 跟隨 scene 生命週期 | 跟隨授權世代,寫入時退休更舊世代 | -| Cascade | `scene` / `shared_scene` | `collaboration_room` | +| 面向 | `file_record` | `collaboration_asset` | +| --------- | --------------------------------- | -------------------------------------- | +| Parent | scene/sharedScene | room + `auth_generation` | +| Writer | scene owner | room 內任何可編輯成員 | +| 內容 | 明文壓縮後存於外部 object storage | room key 封裝後存於外部 object storage | +| Retention | 跟隨 scene 生命週期 | 跟隨授權世代,寫入時退休更舊世代 | +| Cascade | `scene` / `shared_scene` | `collaboration_room` | 四種 lifecycle 混在同一組 nullable-polymorphic constraint 內無法表達上述差異,因此 -分表。`collaboration_asset` 只存身份(room、generation、`excalidraw_file_id`)與 -註冊者:位元組傳輸屬 Plan 17,尚不存在的欄位不預先建立。它也刻意不存 content -hash——Excalidraw file id 本身就是明文位元組的摘要,再存一份只會給伺服器一個確認 -猜測明文的 oracle。 +分表。它也刻意不存 content hash——Excalidraw file id 本身就是明文位元組的摘要,再存 +一份只會給伺服器一個確認猜測明文的 oracle。 + +### Asset byte transfer boundary(Plan 17,2026-08-05) + +Room asset 的位元組走**與 owned-scene 相同的 object storage**,但內容是 client 封裝 +好的密文;`collaboration_asset` 在身份欄位之外只增加「密文現在在哪」所需的最小集合: +`crypto_version`、`ut_file_key`、`url`、`byte_length`。三個決策: + +- **一列存在即代表位元組已上傳。**沒有「已註冊但還沒有 bytes」的中間列。可用性只有 + 一種有意義的答案:peer 從 element 的 `fileId` 就知道要哪張圖,需要問的是「在哪、 + 到了沒」。因此 Plan 16 的 `collaborationAsset.list`/`register` 由單一 + `resolve`(bounded batch → records + missing)取代並刪除。 +- **MIME type 與 data URL 只存在密文裡。**伺服器不看、也不需要看。把 MIME 複製成欄位 + 只會多出一份伺服器無法驗證、卻可能與密文不一致的斷言。 +- **密文不放進 Postgres。**Snapshot 是每個 room generation 一列、有 4 MiB 上限的 + `bytea`;asset 是每個 generation 最多 512 個、每個近 3 MiB 的物件,放進 DB 會讓單一 + room 的資料列成長到 GB 級。Object storage 是這種形狀的正確位置,而 E2EE 讓「storage + provider 看得到位元組」不再是機密性問題。 + +授權保護的是**發現能力**:`resolve` 回傳的 URL 是取得密文的 capability,任何拿到它的 +人都能下載,機密性不依賴這一點——位元組由 room key 衍生的 asset key 封裝,後端與 +storage 都沒有金鑰。因此成員失去存取權後失去的是「找到新 URL 的能力」。這與 +readonly-share 資產的既有模型一致。 + +Retention 與 Plan 15 的 snapshot 同源:世代轉動後舊世代密文在密碼學上不可讀,所以 +新世代寫入成功的那一刻退休舊世代的列,並在**同一個 transaction** 內把它們的 +`ut_file_key` 寫進 `deferred_file_cleanup`。刪列才是讓物件變成孤兒的動作,object +storage 無法參與 transaction,佇列因此是唯一能讓兩者不脫勾的機制。 `file_record` 保留 `content_hash` 作為 storage 層 lookup/dedup 提示(可為 null、 無唯一性)。它不得再成為身份:hash 取自壓縮後的上傳 payload,payload metadata 帶 diff --git a/packages/collaboration/src/asset.ts b/packages/collaboration/src/asset.ts index 0ae009b8..0b83e1e7 100644 --- a/packages/collaboration/src/asset.ts +++ b/packages/collaboration/src/asset.ts @@ -1,10 +1,20 @@ import { z } from "zod"; -import { roomIdSchema } from "./messages.ts"; +import { + COLLABORATION_PROTOCOL_VERSION, + roomIdSchema, + type RoomId, +} from "./messages.ts"; +import { + AES_GCM_TAG_BYTES, + deriveRoomKey, + REALTIME_NONCE_BYTES, + type RoomKey, +} from "./realtime-crypto.ts"; import { roomAuthGenerationSchema } from "./room-auth.ts"; /** - * Collaboration asset identity (Plan 16). + * Collaboration assets: identity (Plan 16) and encrypted transfer (Plan 17). * * An asset is a binary file an image element points at. Its identity is the * pair *parent scope + Excalidraw file id*, and nothing else: @@ -26,13 +36,26 @@ import { roomAuthGenerationSchema } from "./room-auth.ts"; * and sealed with per-write metadata, so the same image hashes differently * every time it is stored — treating that as identity silently duplicates * assets instead of deduplicating them. - * - **A storage object key.** That identifies where bytes happen to live now, - * not which image an element references; re-uploading the same image yields a - * new key. + * - **A storage object key or URL.** Those identify where bytes happen to live + * now, not which image an element references; re-uploading the same image + * yields a new key. * - * This module carries identity and bounds only. Byte transfer and the - * client-side sealing of asset payloads belong to Plan 17, so nothing here - * describes ciphertext, size or MIME type. + * Plan 17 adds the bytes. Two formats live here, both versioned independently + * of the realtime frame format because they are sealed under a different derived + * key and evolve on their own schedule: + * + * - The **payload** is the plaintext an asset consists of: the engine's data URL + * plus the metadata needed to hand it back to the engine (`mimeType`) and to + * cross-check it against the record it was fetched under (`roomId`, `fileId`). + * - The **sealed envelope** is what storage holds: a version byte, a random IV + * and AES-GCM ciphertext under a key derived from the room key with purpose + * `asset`. The app backend and the object store therefore hold bytes neither + * can read, and neither ever sees the room key. + * + * The realtime channel never carries asset bytes: `syncedElementSchema` refuses + * embedded binary data (`FORBIDDEN_BINARY_ELEMENT_KEYS`), so what travels is the + * file id on the element, and availability is answered by the room's asset + * records. */ /** @@ -49,50 +72,685 @@ export const excalidrawFileIdSchema = z export type ExcalidrawAssetId = z.infer; /** - * Ceiling on how many distinct assets one room generation may claim. The room - * manifest is written by authorized members, so it needs a bound for the same - * reason a snapshot's byte length does: an authorized member must not be able - * to grow the database without limit. Well above what a real scene references — - * the large-scene performance fixture is 5,000 elements — and low enough that a - * whole manifest is one small round trip. + * Ceiling on how many distinct assets one room generation may claim. The room's + * asset set is written by authorized members, so it needs a bound for the same + * reason a snapshot's byte length does: an authorized member must not be able to + * grow the database — or the object store — without limit. Well above what a + * real scene references, and low enough that the whole set is one small round + * trip. */ export const MAX_ROOM_ASSETS_PER_GENERATION = 512; /** - * Ceiling per registration call. A client registers the assets a scene gained - * since it last synced, so this bounds one request without bounding how many + * Ceiling per lookup call. A client asks for the assets the elements it just + * received reference, so this bounds one request without bounding how many * assets a room can accumulate. */ -export const MAX_ASSET_REGISTRATION_BATCH = 64; +export const MAX_ASSET_LOOKUP_BATCH = 64; + +/** + * MIME types a room asset may declare. + * + * The engine's own image set (upstream `IMAGE_MIME_TYPES`), and nothing else: + * `BinaryFileData.mimeType` also admits `application/octet-stream`, but a room + * asset is an image an element renders, and arbitrary file sharing is explicitly + * out of scope. Validated on both sides of the transfer — the sealing client + * refuses to encode an unsupported type, and the receiving client refuses to + * decode one — so a peer cannot use the asset channel to hand another peer an + * arbitrary payload to render. + */ +export const COLLABORATION_ASSET_MIME_TYPES = [ + "image/svg+xml", + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "image/bmp", + "image/x-icon", + "image/avif", + "image/jfif", +] as const; + +export const collaborationAssetMimeTypeSchema = z.enum( + COLLABORATION_ASSET_MIME_TYPES, +); +export type CollaborationAssetMimeType = z.infer< + typeof collaborationAssetMimeTypeSchema +>; + +/** Asset payload version; bumped only on a breaking plaintext layout change. */ +export const ASSET_PAYLOAD_VERSION = 1; + +/** + * Sealed asset envelope version. Independent from `REALTIME_CRYPTO_VERSION` and + * `SNAPSHOT_CRYPTO_VERSION`: the three formats are sealed under different derived + * keys and evolve separately, so sharing a version number would couple them for + * no reason. + */ +export const ASSET_CRYPTO_VERSION = 1; + +const VERSION_BYTES = 1; +const METADATA_LENGTH_BYTES = 2; + +/** + * Plaintext layout — a fixed header, a bounded JSON metadata chunk, then the + * data URL bytes verbatim: + * + * ``` + * 0 payload version + * 1 .. 2 metadata byte length (uint16, big endian) + * 3 .. 3+n metadata JSON (UTF-8) + * rest data URL bytes (UTF-8) + * ``` + * + * The data URL is *not* wrapped in JSON, which is the whole reason for the + * framing. A data URL is already base64 and is the largest thing here by three + * orders of magnitude; `JSON.stringify` would copy it into a second multi-megabyte + * string and `JSON.parse` a third, for no gain — nothing in it needs escaping. + * The metadata is small, fixed-shape and worth validating, so it stays JSON. + */ +export const ASSET_PAYLOAD_HEADER_BYTES = VERSION_BYTES + METADATA_LENGTH_BYTES; + +/** Metadata chunk ceiling; the fields are all short and bounded by schema. */ +export const MAX_ASSET_METADATA_BYTES = 512; + +/** + * Data URL ceiling for one asset. + * + * A data URL is base64, so this admits an image of about three quarters of it — + * comfortably above the engine's own `MAX_ALLOWED_FILE_BYTES` image limit, and + * the same order as the owned-scene upload bound (`FILE_UPLOAD_MAX_BYTES`). It is + * enforced before any encode or decode work: an oversize payload is refused, not + * truncated. + */ +export const MAX_ASSET_DATA_URL_BYTES = 3 * 1_048_576; + +export const MAX_ASSET_PLAINTEXT_BYTES = + ASSET_PAYLOAD_HEADER_BYTES + + MAX_ASSET_METADATA_BYTES + + MAX_ASSET_DATA_URL_BYTES; + +/** + * Sealed asset layout — same shape as a realtime frame and a sealed snapshot, + * and for the same reason: fixed size, no variable fields, no sender identity. + * + * ``` + * 0 envelope version + * 1 .. 13 random IV + * rest AES-GCM ciphertext ‖ tag + * ``` + */ +export const ASSET_SEALED_HEADER_BYTES = VERSION_BYTES + REALTIME_NONCE_BYTES; + +export const ASSET_SEALED_OVERHEAD_BYTES = + ASSET_SEALED_HEADER_BYTES + AES_GCM_TAG_BYTES; + +/** Smallest byte length that could still be a sealed asset. */ +export const MIN_ASSET_CIPHERTEXT_BYTES = ASSET_SEALED_OVERHEAD_BYTES + 1; + +/** Wire/storage ceiling: the plaintext budget plus sealing overhead. */ +export const MAX_ASSET_CIPHERTEXT_BYTES = + MAX_ASSET_PLAINTEXT_BYTES + ASSET_SEALED_OVERHEAD_BYTES; + +/** + * Longest asset URL the transfer contract admits, and the same bound the storage + * column carries. Kept in step deliberately: a longer URL is one the store could + * never have persisted, so refusing it at the boundary is more honest than + * accepting a record no writer could produce. + */ +export const MAX_ASSET_URL_LENGTH = 512; + +/** + * What a client learns about one available asset. `url` is where the ciphertext + * currently lives, which is deliberately not identity: it changes on re-upload + * and it is only ever resolved *from* the identity pair. + */ +export const collaborationAssetRecordSchema = z.strictObject({ + excalidrawFileId: excalidrawFileIdSchema, + cryptoVersion: z.int().positive(), + byteLength: z.int().positive().max(MAX_ASSET_CIPHERTEXT_BYTES), + /** + * HTTPS only. The ciphertext is unreadable without the room key, so the + * transport does not protect confidentiality — but it does protect the URL + * itself, which is the capability that locates the bytes, and a plain-HTTP + * fetch would leak it to the network and break under mixed-content rules. + */ + url: z + .string() + .url() + .max(MAX_ASSET_URL_LENGTH) + .refine((value) => value.startsWith("https://"), { + message: "Asset URL must be https", + }), +}); +export type CollaborationAssetRecord = z.infer< + typeof collaborationAssetRecordSchema +>; /** - * What a room generation references, as the API returns it. + * Answer to "where are the bytes for these file ids". * * `authGeneration` is part of the answer rather than an input echo: a client - * that rotated generations mid-flight can tell that the manifest it just read - * belongs to the generation it is actually in, instead of applying an older - * room's asset set. + * that rotated generations mid-flight can tell that the records it just read + * belong to the generation its asset key is derived for, instead of trying to + * open ciphertext sealed under a key it no longer has. + * + * `missing` is a first-class outcome, not an error. A peer broadcasts an image + * element the moment it is added and the ciphertext lands a beat later, so "not + * yet" is the normal state for a fresh image and the caller's job is to retry — + * whereas an asset the room never had is one it must stop asking for. */ -export const collaborationAssetManifestSchema = z.strictObject({ +export const collaborationAssetLookupSchema = z.strictObject({ roomId: roomIdSchema, authGeneration: roomAuthGenerationSchema, - /** Ascending, deduplicated: one entry per asset the generation references. */ - fileIds: z.array(excalidrawFileIdSchema).max(MAX_ROOM_ASSETS_PER_GENERATION), + assets: z + .array(collaborationAssetRecordSchema) + .max(MAX_ROOM_ASSETS_PER_GENERATION), + missing: z.array(excalidrawFileIdSchema).max(MAX_ASSET_LOOKUP_BATCH), }); -export type CollaborationAssetManifest = z.infer< - typeof collaborationAssetManifestSchema +export type CollaborationAssetLookup = z.infer< + typeof collaborationAssetLookupSchema >; /** * Canonicalizes a batch of requested ids: deduplicated and sorted. * - * Registration is idempotent, so a batch that names the same asset twice is not - * an error — but it must not consume two slots of the room's budget or produce - * two conflicting inserts, and a stable order keeps a retried request from - * touching rows in a different sequence than the attempt it repeats. + * A batch that names the same asset twice is not an error — but it must not + * consume two slots of the room's budget or produce two conflicting inserts, and + * a stable order keeps a retried request from touching rows in a different + * sequence than the attempt it repeats. */ export function canonicalizeAssetIds( fileIds: readonly string[], ): ExcalidrawAssetId[] { return [...new Set(fileIds)].sort(); } + +const encoder = new TextEncoder(); +// Fatal so malformed UTF-8 is refused rather than repaired into a different +// (possibly valid) payload via U+FFFD replacement. +const decoder = new TextDecoder("utf-8", { fatal: true }); + +/** + * Web Crypto's `BufferSource` excludes `SharedArrayBuffer`-backed views, which + * TypeScript cannot prove for a plain `Uint8Array`. Every view here comes from + * `new Uint8Array`, `TextEncoder`, or a `fetch` response body, never from shared + * memory. + */ +const asBufferSource = (view: Uint8Array): BufferSource => view as BufferSource; + +/** + * Metadata travelling inside the sealed payload. + * + * `roomId` and `excalidrawFileId` are also bound into the seal, so they cannot + * be swapped by anybody without the room key. They are still carried and still + * checked, because the read-side cross-check is what catches the one failure the + * seal cannot: a storage object filed under the wrong record. That check is the + * accepted substitute for server-side identity verification (ADR 0001) — the + * server cannot verify a file id it has no key to compute. + */ +const assetPayloadMetadataSchema = z.strictObject({ + payloadVersion: z.literal(ASSET_PAYLOAD_VERSION), + /** The wire protocol the asset was produced under. */ + protocolVersion: z.literal(COLLABORATION_PROTOCOL_VERSION), + roomId: roomIdSchema, + excalidrawFileId: excalidrawFileIdSchema, + mimeType: collaborationAssetMimeTypeSchema, +}); + +/** + * Checks that a data URL is what its metadata says it is. + * + * Two fields describe the same bytes — the metadata `mimeType` the engine will be + * handed, and the media type inside the data URL itself — and a payload where they + * disagree is one where the reader would render something other than what it was + * told it was rendering. The engine produces them from one `File`, so they always + * match in practice (verified against this project's whole stored asset corpus); + * a mismatch therefore means the payload was assembled by something else. + * + * Deliberately *not* a content-hash check. An Excalidraw file id is the SHA-1 of + * the file the user picked, while the stored data URL is the engine's possibly + * *resized* re-encoding of it, so the digest legitimately differs for any image + * large enough to be downscaled — 41 of this project's 67 stored assets are in + * that state. Verifying it would reject correct images (ADR 0001 records the + * accepted limitation). + */ +const dataUrlMatchesMimeType = ( + dataUrl: string, + mimeType: CollaborationAssetMimeType, +): boolean => { + const prefix = `data:${mimeType};base64,`; + return dataUrl.startsWith(prefix) && dataUrl.length > prefix.length; +}; + +export type AssetPayload = { + excalidrawFileId: ExcalidrawAssetId; + mimeType: CollaborationAssetMimeType; + /** The engine's `BinaryFileData.dataURL`, verbatim. */ + dataUrl: string; +}; + +export type AssetPayloadError = + | { code: "oversize-asset"; byteLength: number; maxByteLength: number } + | { code: "unsupported-mime-type"; detail: string } + | { code: "malformed-asset"; detail: string } + | { code: "unknown-payload-version"; receivedVersion: number | undefined } + /** Decoded cleanly, but not the asset the caller asked for. */ + | { code: "wrong-asset"; receivedRoomId: string; receivedFileId: string }; + +export type EncodeAssetResult = + { ok: true; bytes: Uint8Array } | { ok: false; error: AssetPayloadError }; + +export type DecodeAssetResult = + { ok: true; payload: AssetPayload } | { ok: false; error: AssetPayloadError }; + +/** + * Builds the plaintext for one asset. + * + * The size check is on the data URL rather than on the finished buffer, so the + * limit a user could hit ("this image is too large for a room") is expressed in + * the units the failure is about, and an oversize image is refused before its + * bytes are copied anywhere. + */ +export function encodeCollaborationAssetPayload(input: { + roomId: RoomId; + excalidrawFileId: string; + mimeType: string; + dataUrl: string; +}): EncodeAssetResult { + const metadata = assetPayloadMetadataSchema.safeParse({ + payloadVersion: ASSET_PAYLOAD_VERSION, + protocolVersion: COLLABORATION_PROTOCOL_VERSION, + roomId: input.roomId, + excalidrawFileId: input.excalidrawFileId, + mimeType: input.mimeType, + }); + if (!metadata.success) { + const detail = z.prettifyError(metadata.error); + return { + ok: false, + error: COLLABORATION_ASSET_MIME_TYPES.includes( + input.mimeType as CollaborationAssetMimeType, + ) + ? { code: "malformed-asset", detail } + : { code: "unsupported-mime-type", detail }, + }; + } + if (!dataUrlMatchesMimeType(input.dataUrl, metadata.data.mimeType)) { + return { + ok: false, + error: { + code: "malformed-asset", + detail: `Asset is not a base64 data URL of type ${metadata.data.mimeType}`, + }, + }; + } + + const dataUrlBytes = encoder.encode(input.dataUrl); + if (dataUrlBytes.byteLength > MAX_ASSET_DATA_URL_BYTES) { + return { + ok: false, + error: { + code: "oversize-asset", + byteLength: dataUrlBytes.byteLength, + maxByteLength: MAX_ASSET_DATA_URL_BYTES, + }, + }; + } + const metadataBytes = encoder.encode(JSON.stringify(metadata.data)); + if (metadataBytes.byteLength > MAX_ASSET_METADATA_BYTES) { + return { + ok: false, + error: { + code: "malformed-asset", + detail: `Asset metadata must be at most ${MAX_ASSET_METADATA_BYTES} bytes, received ${metadataBytes.byteLength}`, + }, + }; + } + + const bytes = new Uint8Array( + ASSET_PAYLOAD_HEADER_BYTES + + metadataBytes.byteLength + + dataUrlBytes.byteLength, + ); + bytes[0] = ASSET_PAYLOAD_VERSION; + new DataView(bytes.buffer).setUint16(VERSION_BYTES, metadataBytes.byteLength); + bytes.set(metadataBytes, ASSET_PAYLOAD_HEADER_BYTES); + bytes.set( + dataUrlBytes, + ASSET_PAYLOAD_HEADER_BYTES + metadataBytes.byteLength, + ); + return { ok: true, bytes }; +} + +/** + * Reads a plaintext asset back, and refuses anything that is not exactly the + * asset the caller asked for. + * + * `expected` is the record the bytes were fetched under. Comparing it with the + * embedded identity is what stops a wrong object served under a right record from + * rendering one image where another belongs — the failure the seal cannot catch, + * because sealing happens before storage chooses a key. + */ +export function decodeCollaborationAssetPayload( + bytes: Uint8Array, + expected: { roomId: RoomId; excalidrawFileId: string }, +): DecodeAssetResult { + // Bounded before parsing: oversize input is never decoded, whatever it holds. + if (bytes.byteLength > MAX_ASSET_PLAINTEXT_BYTES) { + return { + ok: false, + error: { + code: "oversize-asset", + byteLength: bytes.byteLength, + maxByteLength: MAX_ASSET_PLAINTEXT_BYTES, + }, + }; + } + if (bytes.byteLength <= ASSET_PAYLOAD_HEADER_BYTES) { + return { + ok: false, + error: { + code: "malformed-asset", + detail: `Asset payload must be longer than ${ASSET_PAYLOAD_HEADER_BYTES} bytes, received ${bytes.byteLength}`, + }, + }; + } + const receivedVersion = bytes[0]; + if (receivedVersion !== ASSET_PAYLOAD_VERSION) { + return { + ok: false, + error: { code: "unknown-payload-version", receivedVersion }, + }; + } + + const metadataLength = new DataView( + bytes.buffer, + bytes.byteOffset, + bytes.byteLength, + ).getUint16(VERSION_BYTES); + const dataUrlOffset = ASSET_PAYLOAD_HEADER_BYTES + metadataLength; + if ( + metadataLength > MAX_ASSET_METADATA_BYTES || + dataUrlOffset >= bytes.byteLength + ) { + return { + ok: false, + error: { + code: "malformed-asset", + detail: `Asset metadata length ${metadataLength} does not fit a ${bytes.byteLength} byte payload`, + }, + }; + } + + let raw: unknown; + let dataUrl: string; + try { + raw = JSON.parse( + decoder.decode(bytes.subarray(ASSET_PAYLOAD_HEADER_BYTES, dataUrlOffset)), + ) as unknown; + dataUrl = decoder.decode(bytes.subarray(dataUrlOffset)); + } catch (error) { + return { + ok: false, + error: { + code: "malformed-asset", + detail: + error instanceof Error ? error.message : "Invalid asset payload", + }, + }; + } + + const embeddedVersion = + typeof raw === "object" && raw !== null && "payloadVersion" in raw + ? raw.payloadVersion + : undefined; + if (embeddedVersion !== ASSET_PAYLOAD_VERSION) { + return { + ok: false, + error: { + code: "unknown-payload-version", + receivedVersion: + typeof embeddedVersion === "number" ? embeddedVersion : undefined, + }, + }; + } + + const metadata = assetPayloadMetadataSchema.safeParse(raw); + if (!metadata.success) { + const detail = z.prettifyError(metadata.error); + const declaredMime = + typeof raw === "object" && raw !== null && "mimeType" in raw + ? raw.mimeType + : undefined; + return { + ok: false, + error: + typeof declaredMime === "string" && + !COLLABORATION_ASSET_MIME_TYPES.includes( + declaredMime as CollaborationAssetMimeType, + ) + ? { code: "unsupported-mime-type", detail } + : { code: "malformed-asset", detail }, + }; + } + if ( + metadata.data.roomId !== expected.roomId || + metadata.data.excalidrawFileId !== expected.excalidrawFileId + ) { + return { + ok: false, + error: { + code: "wrong-asset", + receivedRoomId: metadata.data.roomId, + receivedFileId: metadata.data.excalidrawFileId, + }, + }; + } + // The body has to be the kind of thing the metadata claims. Without this a + // payload could declare an image type and carry anything at all — the MIME + // allowlist would be checked against a field nothing else corroborates. + if (!dataUrlMatchesMimeType(dataUrl, metadata.data.mimeType)) { + return { + ok: false, + error: { + code: "malformed-asset", + detail: `Asset is not a base64 data URL of type ${metadata.data.mimeType}`, + }, + }; + } + + return { + ok: true, + payload: { + excalidrawFileId: metadata.data.excalidrawFileId, + mimeType: metadata.data.mimeType, + dataUrl, + }, + }; +} + +/** + * Derives the room's asset key. Separate purpose from realtime traffic and from + * durable snapshots, so a leaked realtime or snapshot key cannot open stored + * assets, and bound to the authorization generation, so rotating the generation + * makes every asset sealed under the previous one unreadable. + */ +export function deriveAssetKey(options: { + roomKey: RoomKey; + roomId: RoomId; + authGeneration: number; +}): Promise { + return deriveRoomKey({ ...options, purpose: "asset" }); +} + +export type AssetCryptoError = + | { code: "malformed-sealed-asset"; detail: string } + | { code: "unknown-crypto-version"; receivedVersion: number | undefined } + /** Wrong key, rotated generation, tampered bytes, or a mismatched file id. */ + | { code: "authentication-failed" }; + +export type SealAssetResult = + { ok: true; ciphertext: Uint8Array } | { ok: false; error: AssetCryptoError }; + +export type OpenAssetResult = + { ok: true; plaintext: Uint8Array } | { ok: false; error: AssetCryptoError }; + +/** + * Versioned asset codec: one instance per room generation, holding the single + * derived key. Deliberately separate from the realtime codec — that one counts + * messages against an IV-collision budget for a long-lived stream of small + * frames, while this one seals a handful of large, independently stored objects + * and has no stream to bound. + */ +export interface AssetCryptoCodec { + readonly cryptoVersion: typeof ASSET_CRYPTO_VERSION; + seal(input: { + excalidrawFileId: string; + plaintext: Uint8Array; + }): Promise; + open(input: { + excalidrawFileId: string; + ciphertext: Uint8Array; + }): Promise; +} + +/** + * Authenticated metadata. Everything a storage layer can see is bound to the + * ciphertext: envelope version, wire protocol version, room, authorization + * generation and the asset's own file id. Binding the file id is what makes + * serving asset A's bytes under asset B's record a decryption failure rather + * than a rendered image in the wrong place. + */ +const assetAdditionalData = (params: { + roomId: RoomId; + authGeneration: number; + excalidrawFileId: string; +}): BufferSource => + asBufferSource( + encoder.encode( + `drawstuff-asset/v${ASSET_CRYPTO_VERSION}/p${COLLABORATION_PROTOCOL_VERSION}/${params.roomId}/g${roomAuthGenerationSchema.parse( + params.authGeneration, + )}/${excalidrawFileIdSchema.parse(params.excalidrawFileId)}`, + ), + ); + +export async function createAssetCryptoCodec(options: { + roomKey: RoomKey; + roomId: RoomId; + authGeneration: number; + /** Injectable only for deterministic tests; production uses Web Crypto. */ + randomBytes?: (length: number) => Uint8Array; +}): Promise { + const { + roomId, + authGeneration, + randomBytes = (length) => crypto.getRandomValues(new Uint8Array(length)), + } = options; + // Derived once per session: the key is bound to (room, generation, purpose), + // and it is non-extractable, so it cannot end up in a log or an error payload. + const key = await deriveAssetKey({ + roomKey: options.roomKey, + roomId, + authGeneration, + }); + + return { + cryptoVersion: ASSET_CRYPTO_VERSION, + + async seal({ excalidrawFileId, plaintext }) { + if (plaintext.byteLength > MAX_ASSET_PLAINTEXT_BYTES) { + return { + ok: false, + error: { + code: "malformed-sealed-asset", + detail: `Asset plaintext must be at most ${MAX_ASSET_PLAINTEXT_BYTES} bytes, received ${plaintext.byteLength}`, + }, + }; + } + const iv = randomBytes(REALTIME_NONCE_BYTES); + if (iv.byteLength !== REALTIME_NONCE_BYTES) { + throw new Error( + `randomBytes must return ${REALTIME_NONCE_BYTES} bytes, received ${iv.byteLength}`, + ); + } + let sealed: ArrayBuffer; + try { + sealed = await crypto.subtle.encrypt( + { + name: "AES-GCM", + iv: asBufferSource(iv), + additionalData: assetAdditionalData({ + roomId, + authGeneration, + excalidrawFileId, + }), + }, + key, + asBufferSource(plaintext), + ); + } catch (error) { + // The error name, never the key or the plaintext: a caller may log this. + return { + ok: false, + error: { + code: "malformed-sealed-asset", + detail: error instanceof Error ? error.name : "Encryption failed", + }, + }; + } + const ciphertext = new Uint8Array( + ASSET_SEALED_HEADER_BYTES + sealed.byteLength, + ); + ciphertext[0] = ASSET_CRYPTO_VERSION; + ciphertext.set(iv, VERSION_BYTES); + ciphertext.set(new Uint8Array(sealed), ASSET_SEALED_HEADER_BYTES); + return { ok: true, ciphertext }; + }, + + async open({ excalidrawFileId, ciphertext }) { + if ( + ciphertext.byteLength < MIN_ASSET_CIPHERTEXT_BYTES || + ciphertext.byteLength > MAX_ASSET_CIPHERTEXT_BYTES + ) { + return { + ok: false, + error: { + code: "malformed-sealed-asset", + detail: `Sealed asset must be ${MIN_ASSET_CIPHERTEXT_BYTES}..${MAX_ASSET_CIPHERTEXT_BYTES} bytes, received ${ciphertext.byteLength}`, + }, + }; + } + const receivedVersion = ciphertext[0]; + if (receivedVersion !== ASSET_CRYPTO_VERSION) { + return { + ok: false, + error: { code: "unknown-crypto-version", receivedVersion }, + }; + } + try { + const opened = await crypto.subtle.decrypt( + { + name: "AES-GCM", + iv: asBufferSource( + ciphertext.subarray(VERSION_BYTES, ASSET_SEALED_HEADER_BYTES), + ), + additionalData: assetAdditionalData({ + roomId, + authGeneration, + excalidrawFileId, + }), + }, + key, + asBufferSource(ciphertext.subarray(ASSET_SEALED_HEADER_BYTES)), + ); + return { ok: true, plaintext: new Uint8Array(opened) }; + } catch { + // A wrong key, a rotated generation, tampered bytes and a file id that + // does not match the seal are all the same answer: these are not bytes + // this reader can trust. + return { ok: false, error: { code: "authentication-failed" } }; + } + }, + }; +} diff --git a/packages/collaboration/tests/asset.test.ts b/packages/collaboration/tests/asset.test.ts new file mode 100644 index 00000000..d18aeef5 --- /dev/null +++ b/packages/collaboration/tests/asset.test.ts @@ -0,0 +1,498 @@ +import { describe, expect, it } from "vitest"; + +import { + ASSET_CRYPTO_VERSION, + ASSET_PAYLOAD_HEADER_BYTES, + ASSET_PAYLOAD_VERSION, + ASSET_SEALED_OVERHEAD_BYTES, + canonicalizeAssetIds, + collaborationAssetLookupSchema, + collaborationAssetRecordSchema, + COLLABORATION_ASSET_MIME_TYPES, + createAssetCryptoCodec, + decodeCollaborationAssetPayload, + deriveAssetKey, + encodeCollaborationAssetPayload, + MAX_ASSET_CIPHERTEXT_BYTES, + MAX_ASSET_DATA_URL_BYTES, + MAX_ASSET_METADATA_BYTES, + MAX_ASSET_PLAINTEXT_BYTES, + MIN_ASSET_CIPHERTEXT_BYTES, + type AssetCryptoCodec, +} from "../src/asset.ts"; +import { roomIdSchema } from "../src/protocol.ts"; +import { deriveSnapshotKey } from "../src/snapshot.ts"; +import { roomKeySchema } from "../src/realtime-crypto.ts"; +import { ROOM_ID, ROOM_KEY } from "./helpers.ts"; + +/** + * Encrypted asset transfer (Plan 17). + * + * Two formats are under test and they fail differently, which is the point of + * separating them: + * + * - The **payload** is plaintext framing. Its failures are shape failures — an + * unsupported MIME type, an oversize image, a metadata length that does not fit + * the buffer — and every one of them has to be refused rather than repaired. + * - The **seal** is AES-GCM under a purpose-bound derived key. Its failures are + * all one answer (`authentication-failed`), and what the tests establish is + * *which* mismatches produce it: a different file id, a rotated generation, + * another room, a flipped bit. That set is the security contract — binding the + * file id is what makes "serve asset A's bytes under asset B's record" a + * decryption failure instead of a wrong image on somebody's canvas. + */ + +const OTHER_ROOM = roomIdSchema.parse("room-beta"); +const OTHER_KEY = roomKeySchema.parse( + "T1RIRVJ2c2hhcmVkLXRlc3Qtcm9vbS1rZXktMDAwMDA", +); + +const FILE_A = "a".repeat(40); +const FILE_B = "b".repeat(40); + +const PNG_DATA_URL = "data:image/png;base64,AAECAwQFBgcICQoLDA0ODw=="; + +const assetCodec = ( + overrides: { + roomId?: typeof ROOM_ID; + roomKey?: typeof ROOM_KEY; + authGeneration?: number; + } = {}, +): Promise => + createAssetCryptoCodec({ + roomKey: overrides.roomKey ?? ROOM_KEY, + roomId: overrides.roomId ?? ROOM_ID, + authGeneration: overrides.authGeneration ?? 1, + }); + +const payloadOf = ( + overrides: { + roomId?: typeof ROOM_ID; + excalidrawFileId?: string; + mimeType?: string; + dataUrl?: string; + } = {}, +): Uint8Array => { + const encoded = encodeCollaborationAssetPayload({ + roomId: overrides.roomId ?? ROOM_ID, + excalidrawFileId: overrides.excalidrawFileId ?? FILE_A, + mimeType: overrides.mimeType ?? "image/png", + dataUrl: overrides.dataUrl ?? PNG_DATA_URL, + }); + if (!encoded.ok) throw new Error(`encode failed: ${encoded.error.code}`); + return encoded.bytes; +}; + +describe("collaboration asset payload", () => { + it("round-trips a data URL with its MIME type and identity", () => { + const decoded = decodeCollaborationAssetPayload(payloadOf(), { + roomId: ROOM_ID, + excalidrawFileId: FILE_A, + }); + expect(decoded).toEqual({ + ok: true, + payload: { + excalidrawFileId: FILE_A, + mimeType: "image/png", + dataUrl: PNG_DATA_URL, + }, + }); + }); + + it("carries the data URL verbatim rather than JSON-escaped", () => { + // The framing exists so the largest field is copied once. If it were wrapped + // in JSON the payload would contain quotes around it and grow by escaping. + const bytes = payloadOf(); + const tail = new TextDecoder().decode( + bytes.subarray(bytes.byteLength - PNG_DATA_URL.length), + ); + expect(tail).toBe(PNG_DATA_URL); + }); + + it("accepts every MIME type the engine can render, and nothing else", () => { + for (const mimeType of COLLABORATION_ASSET_MIME_TYPES) { + const encoded = encodeCollaborationAssetPayload({ + roomId: ROOM_ID, + excalidrawFileId: FILE_A, + mimeType, + dataUrl: `data:${mimeType};base64,AAECAwQFBgcICQoLDA0ODw==`, + }); + expect(encoded.ok).toBe(true); + } + // `BinaryFileData.mimeType` also admits this one; a room asset must not. + const binary = encodeCollaborationAssetPayload({ + roomId: ROOM_ID, + excalidrawFileId: FILE_A, + mimeType: "application/octet-stream", + dataUrl: PNG_DATA_URL, + }); + expect(binary.ok).toBe(false); + if (!binary.ok) expect(binary.error.code).toBe("unsupported-mime-type"); + }); + + it("refuses a body that is not a base64 data URL of the declared type", () => { + for (const dataUrl of [ + "https://example.com/cat.png", + // Right shape, wrong media type: the allowlist would otherwise be checked + // against a metadata field nothing corroborates. + "data:text/html;base64,PHNjcmlwdD4=", + // Declared type, but not base64 — the reader would hand the engine bytes it + // cannot decode. + "data:image/png,notbase64", + // Empty body. + "data:image/png;base64,", + ]) { + const encoded = encodeCollaborationAssetPayload({ + roomId: ROOM_ID, + excalidrawFileId: FILE_A, + mimeType: "image/png", + dataUrl, + }); + expect(encoded.ok).toBe(false); + if (!encoded.ok) expect(encoded.error.code).toBe("malformed-asset"); + } + }); + + it("refuses a decoded body whose media type contradicts its metadata", () => { + // Assembled by hand: only something other than the encoder could produce a + // payload whose metadata and body disagree, which is exactly why it is checked. + const bytes = payloadOf(); + const rewritten = new TextDecoder() + .decode(bytes.subarray(ASSET_PAYLOAD_HEADER_BYTES)) + .replace("data:image/png;base64,", "data:image/gif;base64,"); + const metadataLength = new DataView( + bytes.buffer, + bytes.byteOffset, + bytes.byteLength, + ).getUint16(1); + const encoder = new TextEncoder(); + const tail = encoder.encode(rewritten); + const forged = new Uint8Array(ASSET_PAYLOAD_HEADER_BYTES + tail.byteLength); + forged.set(bytes.subarray(0, ASSET_PAYLOAD_HEADER_BYTES)); + forged.set(tail, ASSET_PAYLOAD_HEADER_BYTES); + new DataView(forged.buffer).setUint16(1, metadataLength); + + const decoded = decodeCollaborationAssetPayload(forged, { + roomId: ROOM_ID, + excalidrawFileId: FILE_A, + }); + expect(decoded.ok).toBe(false); + if (!decoded.ok) expect(decoded.error.code).toBe("malformed-asset"); + }); + + it("refuses an oversize data URL before copying it", () => { + const oversize = `data:image/png;base64,${"A".repeat( + MAX_ASSET_DATA_URL_BYTES, + )}`; + const encoded = encodeCollaborationAssetPayload({ + roomId: ROOM_ID, + excalidrawFileId: FILE_A, + mimeType: "image/png", + dataUrl: oversize, + }); + expect(encoded.ok).toBe(false); + if (!encoded.ok) { + expect(encoded.error).toEqual({ + code: "oversize-asset", + byteLength: oversize.length, + maxByteLength: MAX_ASSET_DATA_URL_BYTES, + }); + } + }); + + it("refuses an unknown payload version", () => { + const bytes = payloadOf(); + bytes[0] = ASSET_PAYLOAD_VERSION + 1; + expect( + decodeCollaborationAssetPayload(bytes, { + roomId: ROOM_ID, + excalidrawFileId: FILE_A, + }), + ).toEqual({ + ok: false, + error: { + code: "unknown-payload-version", + receivedVersion: ASSET_PAYLOAD_VERSION + 1, + }, + }); + }); + + it("refuses a metadata length that does not fit the buffer", () => { + const bytes = payloadOf(); + new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).setUint16( + 1, + bytes.byteLength, + ); + const decoded = decodeCollaborationAssetPayload(bytes, { + roomId: ROOM_ID, + excalidrawFileId: FILE_A, + }); + expect(decoded.ok).toBe(false); + if (!decoded.ok) expect(decoded.error.code).toBe("malformed-asset"); + }); + + it("refuses a truncated payload", () => { + const decoded = decodeCollaborationAssetPayload( + payloadOf().subarray(0, ASSET_PAYLOAD_HEADER_BYTES), + { roomId: ROOM_ID, excalidrawFileId: FILE_A }, + ); + expect(decoded.ok).toBe(false); + if (!decoded.ok) expect(decoded.error.code).toBe("malformed-asset"); + }); + + it("refuses a payload whose embedded identity is not the one requested", () => { + // The storage object served under the wrong record: the one failure the seal + // cannot catch, because sealing happens before storage chooses a key. + const decoded = decodeCollaborationAssetPayload(payloadOf(), { + roomId: ROOM_ID, + excalidrawFileId: FILE_B, + }); + expect(decoded).toEqual({ + ok: false, + error: { + code: "wrong-asset", + receivedRoomId: ROOM_ID, + receivedFileId: FILE_A, + }, + }); + }); + + it("refuses a payload sealed for another room", () => { + const decoded = decodeCollaborationAssetPayload( + payloadOf({ roomId: OTHER_ROOM }), + { roomId: ROOM_ID, excalidrawFileId: FILE_A }, + ); + expect(decoded.ok).toBe(false); + if (!decoded.ok) expect(decoded.error.code).toBe("wrong-asset"); + }); + + it("pins the byte budgets the storage layer is bounded by", () => { + expect(MAX_ASSET_PLAINTEXT_BYTES).toBe( + ASSET_PAYLOAD_HEADER_BYTES + + MAX_ASSET_METADATA_BYTES + + MAX_ASSET_DATA_URL_BYTES, + ); + expect(MAX_ASSET_CIPHERTEXT_BYTES).toBe( + MAX_ASSET_PLAINTEXT_BYTES + ASSET_SEALED_OVERHEAD_BYTES, + ); + expect(MIN_ASSET_CIPHERTEXT_BYTES).toBe(ASSET_SEALED_OVERHEAD_BYTES + 1); + // One asset must stay small enough that the whole per-generation budget is a + // plausible amount of storage rather than an unbounded one. + expect(MAX_ASSET_CIPHERTEXT_BYTES).toBeLessThan(4 * 1_048_576); + }); +}); + +describe("collaboration asset seal", () => { + it("round-trips through the codec", async () => { + const codec = await assetCodec(); + const plaintext = payloadOf(); + const sealed = await codec.seal({ + excalidrawFileId: FILE_A, + plaintext, + }); + expect(sealed.ok).toBe(true); + if (!sealed.ok) return; + expect(codec.cryptoVersion).toBe(ASSET_CRYPTO_VERSION); + expect(sealed.ciphertext[0]).toBe(ASSET_CRYPTO_VERSION); + expect(sealed.ciphertext.byteLength).toBe( + plaintext.byteLength + ASSET_SEALED_OVERHEAD_BYTES, + ); + + const opened = await codec.open({ + excalidrawFileId: FILE_A, + ciphertext: sealed.ciphertext, + }); + expect(opened.ok).toBe(true); + if (opened.ok) expect(new Uint8Array(opened.plaintext)).toEqual(plaintext); + }); + + it("never emits the same IV twice", async () => { + const codec = await assetCodec(); + const ivs = new Set(); + for (let index = 0; index < 16; index += 1) { + const sealed = await codec.seal({ + excalidrawFileId: FILE_A, + plaintext: payloadOf(), + }); + if (!sealed.ok) throw new Error("seal failed"); + ivs.add(sealed.ciphertext.subarray(1, 13).join(",")); + } + expect(ivs.size).toBe(16); + }); + + it("refuses to open bytes sealed for another file id", async () => { + const codec = await assetCodec(); + const sealed = await codec.seal({ + excalidrawFileId: FILE_A, + plaintext: payloadOf(), + }); + if (!sealed.ok) throw new Error("seal failed"); + expect( + await codec.open({ + excalidrawFileId: FILE_B, + ciphertext: sealed.ciphertext, + }), + ).toEqual({ ok: false, error: { code: "authentication-failed" } }); + }); + + it("refuses to open bytes from another room, generation, or room key", async () => { + const codec = await assetCodec(); + const sealed = await codec.seal({ + excalidrawFileId: FILE_A, + plaintext: payloadOf(), + }); + if (!sealed.ok) throw new Error("seal failed"); + + for (const reader of await Promise.all([ + assetCodec({ roomId: OTHER_ROOM }), + assetCodec({ authGeneration: 2 }), + assetCodec({ roomKey: OTHER_KEY }), + ])) { + expect( + await reader.open({ + excalidrawFileId: FILE_A, + ciphertext: sealed.ciphertext, + }), + ).toEqual({ ok: false, error: { code: "authentication-failed" } }); + } + }); + + it("refuses tampered ciphertext", async () => { + const codec = await assetCodec(); + const sealed = await codec.seal({ + excalidrawFileId: FILE_A, + plaintext: payloadOf(), + }); + if (!sealed.ok) throw new Error("seal failed"); + const tampered = Uint8Array.from(sealed.ciphertext); + const last = tampered.byteLength - 1; + tampered[last] = (tampered[last] ?? 0) ^ 0xff; + expect( + await codec.open({ excalidrawFileId: FILE_A, ciphertext: tampered }), + ).toEqual({ ok: false, error: { code: "authentication-failed" } }); + }); + + it("refuses a ciphertext that is too short or too long to be a sealed asset", async () => { + const codec = await assetCodec(); + for (const ciphertext of [ + new Uint8Array(MIN_ASSET_CIPHERTEXT_BYTES - 1), + new Uint8Array(MAX_ASSET_CIPHERTEXT_BYTES + 1), + ]) { + const opened = await codec.open({ + excalidrawFileId: FILE_A, + ciphertext, + }); + expect(opened.ok).toBe(false); + if (!opened.ok) expect(opened.error.code).toBe("malformed-sealed-asset"); + } + }); + + it("refuses an unknown envelope version", async () => { + const codec = await assetCodec(); + const sealed = await codec.seal({ + excalidrawFileId: FILE_A, + plaintext: payloadOf(), + }); + if (!sealed.ok) throw new Error("seal failed"); + sealed.ciphertext[0] = ASSET_CRYPTO_VERSION + 1; + expect( + await codec.open({ + excalidrawFileId: FILE_A, + ciphertext: sealed.ciphertext, + }), + ).toEqual({ + ok: false, + error: { + code: "unknown-crypto-version", + receivedVersion: ASSET_CRYPTO_VERSION + 1, + }, + }); + }); + + it("refuses to seal a plaintext beyond the payload budget", async () => { + const codec = await assetCodec(); + const sealed = await codec.seal({ + excalidrawFileId: FILE_A, + plaintext: new Uint8Array(MAX_ASSET_PLAINTEXT_BYTES + 1), + }); + expect(sealed.ok).toBe(false); + if (!sealed.ok) expect(sealed.error.code).toBe("malformed-sealed-asset"); + }); + + it("derives a key no other purpose can open", async () => { + // Same room, same generation, different purpose: the asset key and the + // snapshot key must not be interchangeable, or one leaked derived key would + // unlock both. + const iv = new Uint8Array(12).fill(7); + const assetKey = await deriveAssetKey({ + roomKey: ROOM_KEY, + roomId: ROOM_ID, + authGeneration: 1, + }); + const snapshotKey = await deriveSnapshotKey({ + roomKey: ROOM_KEY, + roomId: ROOM_ID, + authGeneration: 1, + }); + const ciphertext = await crypto.subtle.encrypt( + { name: "AES-GCM", iv }, + assetKey, + new Uint8Array([1, 2, 3]), + ); + await expect( + crypto.subtle.decrypt({ name: "AES-GCM", iv }, snapshotKey, ciphertext), + ).rejects.toThrow(); + await expect( + crypto.subtle.decrypt({ name: "AES-GCM", iv }, assetKey, ciphertext), + ).resolves.toBeDefined(); + }); +}); + +describe("collaboration asset lookup contract", () => { + const record = { + excalidrawFileId: FILE_A, + cryptoVersion: ASSET_CRYPTO_VERSION, + byteLength: 128, + url: "https://storage.example.com/objects/abc", + }; + + it("accepts a well-formed record", () => { + expect(collaborationAssetRecordSchema.parse(record)).toEqual(record); + }); + + it("refuses a plain-HTTP asset URL", () => { + expect( + collaborationAssetRecordSchema.safeParse({ + ...record, + url: "http://storage.example.com/objects/abc", + }).success, + ).toBe(false); + }); + + it("refuses a byte length beyond the ciphertext budget", () => { + expect( + collaborationAssetRecordSchema.safeParse({ + ...record, + byteLength: MAX_ASSET_CIPHERTEXT_BYTES + 1, + }).success, + ).toBe(false); + }); + + it("reports availability and absence in the same answer", () => { + const lookup = collaborationAssetLookupSchema.parse({ + roomId: ROOM_ID, + authGeneration: 3, + assets: [record], + missing: [FILE_B], + }); + expect(lookup.assets).toHaveLength(1); + expect(lookup.missing).toEqual([FILE_B]); + }); + + it("deduplicates and orders a requested batch", () => { + expect(canonicalizeAssetIds([FILE_B, FILE_A, FILE_B])).toEqual([ + FILE_A, + FILE_B, + ]); + }); +}); diff --git a/packages/collaboration/tests/package-contract.test.ts b/packages/collaboration/tests/package-contract.test.ts index 76860a0b..0421ecd4 100644 --- a/packages/collaboration/tests/package-contract.test.ts +++ b/packages/collaboration/tests/package-contract.test.ts @@ -96,10 +96,10 @@ describe("@drawstuff/collaboration package contract", () => { // a decryption key. Structural, so a future edit cannot quietly thread key // material through an envelope, a control frame, or a token claim. // - // Two modules qualify, and only because they *are* the crypto boundary: + // Three modules qualify, and only because they *are* the crypto boundary: // `realtime-crypto.ts` owns key derivation and realtime frames, and - // `snapshot.ts` seals durable snapshots under a second purpose-bound key it - // derives through that same module. + // `snapshot.ts` and `asset.ts` seal durable snapshots and binary assets + // under second and third purpose-bound keys they derive through it. const withKeyMaterial = listSourceFiles(sourceRoot) .filter((filePath) => /roomKey|RoomKey|getRandomValues|subtle/.test( @@ -108,7 +108,11 @@ describe("@drawstuff/collaboration package contract", () => { ) .map((filePath) => path.relative(sourceRoot, filePath)) .sort(); - expect(withKeyMaterial).toEqual(["realtime-crypto.ts", "snapshot.ts"]); + expect(withKeyMaterial).toEqual([ + "asset.ts", + "realtime-crypto.ts", + "snapshot.ts", + ]); }); it("rejects package deep imports", () => { diff --git a/packages/collaboration/vitest.config.ts b/packages/collaboration/vitest.config.ts index 23b87f91..f03b6b10 100644 --- a/packages/collaboration/vitest.config.ts +++ b/packages/collaboration/vitest.config.ts @@ -29,6 +29,7 @@ export default defineConfig({ // primitives (AES-GCM, HKDF, SHA-256, base64) and are stored, so a // browser divergence there would corrupt data rather than one frame. include: [ + "tests/asset.test.ts", "tests/realtime-crypto.test.ts", "tests/snapshot.test.ts", ], diff --git a/packages/excalidraw-adapter/src/codec.ts b/packages/excalidraw-adapter/src/codec.ts index 7ac8bf81..e620be07 100644 --- a/packages/excalidraw-adapter/src/codec.ts +++ b/packages/excalidraw-adapter/src/codec.ts @@ -13,6 +13,7 @@ export { } from "./document-v4.ts"; export { clearElementsForOfficialExport, + collectReferencedFileIds, EXCALIDRAW_PERSISTENCE_CONTRACT, filterReferencedFiles, OFFICIAL_SERVER_APP_STATE_KEYS, diff --git a/packages/excalidraw-adapter/src/persistence-contract.ts b/packages/excalidraw-adapter/src/persistence-contract.ts index f31ae43a..d681eb9c 100644 --- a/packages/excalidraw-adapter/src/persistence-contract.ts +++ b/packages/excalidraw-adapter/src/persistence-contract.ts @@ -40,18 +40,37 @@ export function clearElementsForOfficialExport( }); } -export function filterReferencedFiles( +/** + * Ids of the binary assets a set of elements still references, deduplicated. + * + * Reads elements opaquely, like the rest of this module: the caller may hand over + * engine elements, protocol elements, or a stored document's array, and the answer + * only depends on `fileId` and `isDeleted`. A deleted element references nothing — + * its image must not be fetched or kept alive by a tombstone. + */ +export function collectReferencedFileIds( elements: readonly ExcalidrawElement[] | readonly unknown[], - files: BinaryFiles, -): BinaryFiles { - const referencedFiles: BinaryFiles = {}; +): string[] { + const fileIds = new Set(); for (const element of elements) { const value = objectOrEmpty(element); const fileId = value.fileId; - if (!value.isDeleted && typeof fileId === "string" && fileId in files) { - referencedFiles[fileId] = files[fileId]!; + if (!value.isDeleted && typeof fileId === "string" && fileId.length > 0) { + fileIds.add(fileId); } } + return [...fileIds]; +} + +export function filterReferencedFiles( + elements: readonly ExcalidrawElement[] | readonly unknown[], + files: BinaryFiles, +): BinaryFiles { + const referencedFiles: BinaryFiles = {}; + for (const fileId of collectReferencedFileIds(elements)) { + const file = files[fileId]; + if (file) referencedFiles[fileId] = file; + } return referencedFiles; } diff --git a/packages/excalidraw-adapter/tests/package-contract.test.ts b/packages/excalidraw-adapter/tests/package-contract.test.ts index f70d4504..13f78684 100644 --- a/packages/excalidraw-adapter/tests/package-contract.test.ts +++ b/packages/excalidraw-adapter/tests/package-contract.test.ts @@ -65,6 +65,7 @@ describe("@drawstuff/excalidraw-adapter package contract", () => { "EXCALIDRAW_PERSISTENCE_CONTRACT", "OFFICIAL_SERVER_APP_STATE_KEYS", "clearElementsForOfficialExport", + "collectReferencedFileIds", "createDrawstuffDocumentV4", "createLocalExportDocument", "createOwnedSceneDocumentV4", diff --git a/packages/excalidraw-adapter/tests/persistence-contract.test.ts b/packages/excalidraw-adapter/tests/persistence-contract.test.ts index c8def661..ce1090e8 100644 --- a/packages/excalidraw-adapter/tests/persistence-contract.test.ts +++ b/packages/excalidraw-adapter/tests/persistence-contract.test.ts @@ -8,6 +8,7 @@ import type { AppState, BinaryFiles } from "@excalidraw/excalidraw/types"; import { describe, expect, it } from "vitest"; import { + collectReferencedFileIds, createLocalExportDocument, createOwnedSceneDocumentV4, createReadonlyShareDocumentV4, @@ -164,6 +165,18 @@ describe("Excalidraw persistence contract", () => { expect(JSON.stringify(readonly)).not.toContain("file-deleted"); expect(JSON.stringify(readonly)).not.toContain('"theme"'); }); + it("collects the asset ids live elements reference, and only those", () => { + // The asset pipeline asks this to decide what to fetch and what to publish, so + // a tombstone must not keep an image alive and an unreferenced file must not + // be transferred: `file-deleted` belongs to a deleted element and + // `file-orphan` to no element at all. + expect(collectReferencedFileIds(elements)).toEqual(["file-live"]); + // One id however many elements point at it: a scene with forty copies of one + // image is one download, not forty. + expect(collectReferencedFileIds([...elements, ...elements])).toEqual([ + "file-live", + ]); + }); }); function readFixture(name: string): T { diff --git a/plans/17-encrypted-asset-transfer.md b/plans/17-encrypted-asset-transfer.md index 99d1ac26..12b5a759 100644 --- a/plans/17-encrypted-asset-transfer.md +++ b/plans/17-encrypted-asset-transfer.md @@ -1,6 +1,6 @@ # Plan 17:實作加密 asset transfer -- Status: Ready +- Status: Completed(2026-08-05,見文末 Verification notes) - Depends on: Plan 16 - Expected change size: client codec、upload/download API 與 image E2E @@ -52,3 +52,145 @@ pnpm typecheck - 缺少或損壞的 asset 不會阻止 scene elements 繼續同步。 - Storage URL 不是 durable identity;所有 temporary URL/buffer/cache 在 scene switch、room leave、abort 和 unmount 後可確定釋放。 + +## Verification notes(2026-08-05) + +### 設計:兩條路徑,一個身份 + +Element 走 relay(`syncedElementSchema` 本來就拒絕內嵌 `dataURL`),位元組走 object +storage;兩者靠 Plan 16 的身份 `(room, generation, excalidraw_file_id)` 對齊。因此 +realtime 訊息不需要新的 message type:peer 從 element 的 `fileId` 就知道要哪張圖, +「在哪、到了沒」由 `collaborationAsset.resolve` 回答。 + +| 決策 | 理由 | +| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| 密文放 object storage,不放 DB | Snapshot 是每 generation 一列、上限 4 MiB;asset 是每 generation 最多 512 個、每個近 3 MiB,放 `bytea` 會讓單一 room 成長到 GB 級 | +| 沒有「已註冊但無 bytes」的列 | 可用性只有一種有意義的答案;中間列會讓讀取端無法區分「還沒上傳」與「沒有這個資產」 | +| MIME/data URL 只存在密文裡 | 伺服器無法驗證,複製成欄位只會多一份可能與密文不一致的斷言 | +| Payload 用 binary framing 而非 JSON | data URL 已是 base64 且是唯一的大欄位;`JSON.stringify`/`parse` 會多兩份 MB 級字串複本,而它不需要 escaping | +| Plan 16 的 `list`/`register` 刪除 | 兩者被 `resolve`(bounded batch → records + missing)與上傳 webhook 取代;客戶端從來不需要「這個 room 歷來所有資產」 | + +金鑰用 HKDF purpose `asset`(與 `realtime`、`snapshot` 並列),AAD 綁定 envelope +version、protocol version、room、generation 與 **file id**——綁 file id 是「把 A 的 +位元組放在 B 的紀錄底下」變成解密失敗、而不是畫錯圖的原因。ADR 0001 新增 +「Asset byte transfer boundary」記錄完整決策與授權模型(授權保護的是**發現 URL 的 +能力**,機密性來自 room key)。 + +### Schema 演進(`pnpm db:push`,非破壞性) + +| 項目 | 結果 | +| ----------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| Before 盤點 | `collaboration_asset` **0 列**(Plan 16 建表後未接客戶端),`collaboration_room` 1、`file_record` 67、`scene` 39 | +| Push | 新增 `crypto_version`、`ut_file_key`、`url`、`byte_length`(皆 NOT NULL)與 `crypto_version >= 1`、`byte_length between 1 and 3146272` | +| Prompt | 無資料遺失警告、無需 `--force`、無需 backfill(空表加 NOT NULL 欄位) | +| After 盤點 | 欄位與 constraint 如上;索引只有 PK;FK(room cascade、user set null)完整;`file_record` 67/`scene` 39 未受影響 | +| Query plan | `resolve`:`Index Scan using collaboration_asset_room_generation_file_pk`;世代退休的 `delete`:同一索引的 `Index Scan`(不需額外索引) | + +第一次 push 曾包含一個 `ut_file_key` 索引,發現沒有任何查詢以 storage key 為條件 +(退休是以 `(room, generation)` 刪除後 `returning` key)後移除並重新 push——只寫不讀的 +索引只有寫入成本。 + +### 有界性與清理 + +| 面向 | 機制 | +| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Request 數 | 每批 `MAX_ASSET_LOOKUP_BATCH`(64) 一次 lookup;每個 distinct 資產一次下載;每批一次 `addFiles` | +| 併發 | `MAX_CONCURRENT_TRANSFERS`(4) 是**整個 store**的預算(`createTransferGate`),上傳與下載共用,peak memory 因此是 4 份密文+明文而非整個 room | +| Response body | `readBoundedBody` 以紀錄宣告的長度為上界串流讀取,超過即 `reader.cancel()`;長度與紀錄不符直接放棄 | +| 記帳 | `resolved`/`abandoned`/`available` 三個 id set 與 `retrying`/`uploadAttempts` 兩個 map 都是 FIFO 有界(512);沒有解密位元組快取,engine 的 file store 就是快取 | +| 下載 retry | 排程鏈上限 4 次(1s→8s,上限 30s);timer 只取到期的 id,未到期者留在 queue 並重新 arm;鏈耗盡後不再排 timer,也不永久放棄——rate limit 由 `notBefore` 保證,之後只由新流量觸發 | +| 上傳 retry | 上限 3 次、有自己的 timer;重試透過 `republishLocalAssets()` **重讀畫布**而非重播捕捉到的位元組,所以不會重傳使用者已刪掉的圖,也不會 pin MB 級記憶體 | +| 外部 id | 每個 `fileId` 逐一以 `EXCALIDRAW_FILE_ID_PATTERN` 驗證後才進批次:一個畸形 id 不能讓同批合法資產一起失敗 | +| Cleanup | `destroy()` abort 全部 in-flight(fetch、upload **與 lookup**,三者共用同一個 signal)、取消兩個 timer、清空記帳;`room-session.ts` 在 teardown 呼叫它;scene switch 由 `canSyncScene` 擋住注入 | + +`missing` 與「打不開」刻意是兩種結果:前者是「上傳還沒落地」的正常狀態(會退避重試), +後者(版本不符、長度不符、認證失敗、decode 失敗)重試不會改變結果,直接放棄並讓場景 +繼續同步。 + +### 量測(Node 24 / M 系列,`plan17-measure.mts` 已刪除) + +| 情境 | seal | open + decode | 備註 | +| --------------------- | --------- | ------------- | ------------------------------ | +| 單一 3.00 MiB(上限) | 5.7 ms | 2.5 ms | 密文 3.00 MiB(overhead 29 B) | +| 40 × 64 KiB | 0.1 ms/個 | 0.1 ms/個 | RSS 88 → 129 MiB | +| 4 × 3.00 MiB | 4.2 ms/個 | 1.1 ms/個 | RSS 峰值 187 MiB | + +Request count 由測試直接斷言:3 個 image element 指向 2 個資產時 +`resolveCalls=1`、`fetchCalls=2`、`addFiles` 一次帶 2 個 id(不是每個 element 一次 +request、也不是每張圖一次 re-render)。 + +### Review 修正(Codex GPT-5.6 Sol) + +實作期間自行發現並修正的一項問題:retry timer 原本在 `request` 內、claim 尚未釋放時 +就 arm,於是 timer 觸發的重試會被自己的 in-flight claim 去重掉,而排程鏈已經消耗—— +資產會停在「等不相關流量」的狀態。修正為 (a) timer 改在 claim 釋放後 arm,(b) 因去重 +而跳過的 id 會 await 那次下載並重新 request 尚未取得的部分。 + +兩個 review pass 共 12 個 findings:9 個接受、1 個部分接受、2 個拒絕。 + +| Pass | Finding | 判定 | 處理 | +| ---- | ----------------------------------------------------------------------------------- | -------- | --------------------------------------------------------------------------------------- | +| 1 | retry timer 在最早 deadline 清空整個 queue,未到期者被 `request` 過濾後遺失 | 接受 | timer 只取到期 id;`request` 對 rate-limited id 重新入列;arm 移到 `request` 的 finally | +| 1 | 併發限制是 per-call,重疊 request 各開 4 個 worker | 接受 | `createTransferGate`,整個 store(上傳+下載)共用 4 個 slot | +| 1 | 上傳失敗只累加計數,使用者不再編輯就永不重試 | 接受 | 加上有界 timer 與 `republishLocalAssets()`(重讀畫布而非重播位元組) | +| 1 | payload 只檢查開頭是 `data:`,未驗證 media type 與內容雜湊 | 部分接受 | 加上 media-type/base64 形狀驗證;content hash 拒絕(見下) | +| 1 | `retrying`/`uploadAttempts` 兩個 map 無上界 | 接受 | `createBoundedIdMap`(FIFO 512),終態項目刪除 | +| 1 | `AssetApi.resolve` 無 signal,`destroy()` 無法取消進行中的 lookup | 接受 | signal 進入 contract,tRPC adapter 傳入 | +| 1 | HKDF info 寫死 `REALTIME_CRYPTO_VERSION` | 拒絕 | Plan 14 既有設計、snapshot 相同(見下) | +| 1 | `DrizzleQueryError` 的 message 帶 SQL 參數(URL、storage key)進 log | 接受 | 只記 `error.name` | +| 1 | 一個畸形 `fileId` 讓整批 lookup 被拒 | 接受 | 逐一驗證,畸形 id 個別放棄 | +| 2 | `retrying` 淘汰後 id 仍留在 `retryQueue` → 無 deadline 視為「立即到期」的零延遲迴圈 | 接受 | `createBoundedIdMap` 加 `onEvict` 連動;`armRetryTimer` 對無狀態 id 直接移除 | +| 2 | 上傳批次共用 in-flight claim → 快速失敗者被慢速同批夥伴的 claim 擋掉重試 | 接受 | claim 改為逐檔取得與釋放 | +| 2 | 清理失敗時共用 helper 仍會 log storage key | 拒絕 | 該值是處理孤兒物件的唯一線索,且指向的內容只有密文(見下) | + +未採納的三個判斷(兩個完整拒絕,加上 finding 4 的 hash 半邊): + +- **Content hash 驗證**:以生產資料反證。Excalidraw 的 file id 是「使用者選的檔案」的 + SHA-1,而存下來的 data URL 是 engine 縮圖後的重新編碼,兩者對任何需要縮圖的圖片本來 + 就不同——本專案 67 筆既有資產中有 41 筆如此。加上這個檢查會拒絕正確的圖片。ADR 0001 + 的 accepted limitation 已記錄此事,本次補上的是 media-type 一致性檢查(67 筆資產的 + header MIME 與 metadata 100% 相符,零誤判風險)。 +- **Asset 金鑰推導與 `REALTIME_CRYPTO_VERSION` 解耦**:`deriveRoomKey` 把 realtime + envelope 版本放進 HKDF info 是 Plan 14 的既有設計,snapshot(Plan 15)完全相同。只 + 改 asset 會讓同一個 room key 出現兩套推導慣例;這是 realtime crypto 的版本策略問題, + 屬於 realtime 版本升級時要一併處理的耦合,不在本 plan 範圍。 +- **清理失敗路徑的 storage key log**:`deleteFileWithRetry`/`enqueueDeferredCleanup` + 是四條上傳路徑共用的既有 helper,只有在「UT 刪除連續失敗 3 次、接著入佇列也失敗」時 + 才會記下 key。那個值正是人工處理孤兒物件唯一可用的線索,拿掉會讓故障無法處置;而它 + 指向的內容只有密文,plan 的「log 不含 plaintext bytes 或 room key」不受影響。已在 + route 註解寫明「會記 key、不會記 URL」。 + +### Checks + +`pnpm typecheck`(4/4)、`pnpm lint`(0 errors,5 個 adapter 既有 warnings)、 +`pnpm test`(757 passed:web 249、collaboration 328(node + Chromium + WebKit)、 +adapter 107、relay 73)、`pnpm knip`(4/4)、 +`pnpm --filter @drawstuff/web test:e2e`(17 passed、3 個既有 skip)。 +本次新增與修改的檔案全部符合 Prettier;`collaboration-session.ts` 與 +`collab-session-harness.ts` 在本次變更前即有不符處,未一併重排以免污染 diff。 + +### 殘留風險與 owner + +- **Room 結束/過期後沒有 retention(owner:Plan 19)**:世代退休只在「轉動世代且新世代 + 有資產寫入」時觸發,而 `collaborationRoom.end` 只把 `status` 設為 `ended`、過期只是 + `expiresAt` 比對,production 沒有刪除 room 列的路徑。因此每個結束的 room 會無限期留下 + 它的 asset 物件與 `collaboration_snapshot` 列。這不是本 plan 引入的形狀——Plan 15 的 + `retireOlderGenerations` 完全相同——所以 Plan 19 的 in scope 同時認領兩者。 +- **世代退休的物件刪除依賴既有 deferred worker(owner:Plan 23)**:刪列與入列同一交易, + 是強保證;實際刪除由 `/api/maintenance/cleanup` 執行,而它每週一次、每次 50 筆, + 一次轉動最多 512 個 key 需要約 10 週排空。Plan 23 已把這個 endpoint 的七個問題(含 + 「排空速度與 cron 頻率不匹配」)列入 in scope。 +- **`deriveRoomKey` 與 `REALTIME_CRYPTO_VERSION` 耦合(owner:Plan 19)**:realtime + envelope 升版會讓既有 room 的 asset 與 snapshot 同時不可讀,且失敗靜默。Blast radius + 被 room TTL(預設 12 小時、上限 24 小時)限制在部署當下還活著的 room。 + +### 接受的偏離 + +- **Image add/late join/refresh/missing/corrupt 以整合測試而非 Playwright 覆蓋**: + `apps/web/tests/collab-asset-transfer.test.ts`(20 個案例)跑真實 sealing、真實 + session、fake relay 與 fake object store,覆蓋 plan 列出的每一種情境。真正的瀏覽器 + E2E 需要 relay 行程、UploadThing 憑證與登入態,與 Plan 15/16 同樣的理由留給 + Plan 19/20 的環境。既有的 `test:e2e` 全數通過。 +- **首次出現在 peer 畫布上有最多約 1 秒延遲**:element 一定比位元組先到,接收端第一次 + lookup 命中 `missing` 時以退避重試(首次 1s)。不新增 availability broadcast 是刻意 + 的——那需要新的 realtime message type 與 relay 改動,而重試已經是收斂機制。 diff --git a/plans/19-production-hardening.md b/plans/19-production-hardening.md index 261a83a9..1e30b8e5 100644 --- a/plans/19-production-hardening.md +++ b/plans/19-production-hardening.md @@ -18,6 +18,21 @@ Relay 與 app backend 具備上線所需的資源限制、可觀測性與安全 - Structured logs 只含 opaque IDs,不含 keys、ciphertext body 或 plaintext。 - Dependency/security audit、abuse cases 與 load test。 - 建立 runbook:relay unavailable、error spike、snapshot failure。 +- **Room-scoped retention**(Plan 15/17 共同缺口,2026-08-05 於 Plan 17 review 期間 + 確認):`collaboration_snapshot` 與 `collaboration_asset` 目前只在**世代轉動**時退休 + 舊世代,而 `collaborationRoom.end` 只把 `status` 設為 `ended`、過期只是 `expiresAt` + 比對,production 沒有任何路徑刪除 room 列。因此每個結束或過期的 room 都會無限期留下 + 它的 snapshot 密文(Postgres)與 asset 物件(object storage)。需要一個有界、可重跑 + 的回收:單一 room 世代有界(1 個 snapshot、最多 `MAX_ROOM_ASSETS_PER_GENERATION` 個 + 資產),但跨 room 隨時間無界,而 room TTL 預設 12 小時、上限 24 小時,代表累積速度等 + 於開房速度。asset 物件必須沿用 `deferred_file_cleanup`(刪列與入列同一交易),因此 + 依賴 Plan 23 的 maintenance endpoint 拆分。 +- **`deriveRoomKey` 的版本耦合**(Plan 14 既有設計,Plan 17 review 拒絕在該 plan 內單獨 + 修改):HKDF info 寫死 `REALTIME_CRYPTO_VERSION`,因此 realtime envelope 一升版,既有 + room 的 snapshot 與 asset 密文會同時推導出不同金鑰、全部認證失敗,而失敗是**靜默的** + (畫面上就是圖不見、snapshot 打不開)。要嘛讓每個 purpose 帶自己的格式版本,要嘛明確 + 定義升版時的 rotation/migration 程序與使用者可見的失敗訊息;只改 asset 不可接受—— + 那會讓同一個 room key 出現兩套推導慣例。 - 實作並驗證 Plan 12 的 production `RoomFanout`:多 instance 間 room routing、 ordering scope、duplicate semantics 和 outage behavior 必須明確;若不支援水平 擴展,deployment 必須強制單 instance 並有容量/availability 上限,不能默默錯誤。 @@ -36,9 +51,14 @@ Relay 與 app backend 具備上線所需的資源限制、可觀測性與安全 p50/p95/p99 relay latency、event-loop lag、memory/connection、max payload、 client reconcile/frame budget 和 error/disconnect rate;不得測完後才調門檻。 4. 加入 privacy-safe metrics、alerts 和 dashboards contract。 -5. 執行至少 steady-state、burst、reconnect storm、slow consumer、large room、 +5. 決定 room-scoped retention 的觸發與界限(room `ended`/`expiresAt` 之後多久回收、 + 單次上限、如何重跑),並在稽核既有資料後才啟用;asset 走 `deferred_file_cleanup`, + snapshot 直接刪列。 +6. 決定 `deriveRoomKey` 的版本策略:purpose 各自帶格式版本,或記錄升版程序;兩者都要 + 有「既有密文變成不可讀」時的使用者可見行為,不得靜默。 +7. 執行至少 steady-state、burst、reconnect storm、slow consumer、large room、 fanout dependency outage 的 load test,記錄 CPU/memory/latency 與資源回收。 -6. 驗證 rolling restart/graceful drain、fanout partition 和 rollback。 +8. 驗證 rolling restart/graceful drain、fanout partition 和 rollback。 ## Verification @@ -58,3 +78,6 @@ pnpm audit:ci - On-call 可以依 runbook 停用共編而不影響一般單人 editor。 - 所有 SLO 由 implementation 前的已核准數字判定,沒有無界 buffer/cache、單點 process-local room state 假設或未處理的 backpressure。 +- 結束或過期的 room 不會無限期留下 snapshot 密文或 asset 物件;回收有界、可重跑, + 且有 before/after counts。 +- `REALTIME_CRYPTO_VERSION` 升版對既有 snapshot/asset 的影響有明確且非靜默的行為。 diff --git a/plans/23-owned-scene-asset-lifecycle.md b/plans/23-owned-scene-asset-lifecycle.md index e3eca11b..6ee83391 100644 --- a/plans/23-owned-scene-asset-lifecycle.md +++ b/plans/23-owned-scene-asset-lifecycle.md @@ -50,7 +50,10 @@ Owned scene 的資產生命週期在併發下不會遺失已提交場景引用 ## Out of scope -- 共編 room 資產的傳輸與加密(Plan 17)。 +- 共編 room 資產的傳輸與加密(Plan 17);room 結束/過期後的 asset 與 snapshot + retention 屬 Plan 19。但注意 Plan 17 的世代退休**會寫進同一個 `deferred_file_cleanup` + 佇列**(reason `collab-asset-generation-retired`),所以下方的 endpoint 拆分不能假設 + 只有 owned-scene GC 使用它。 - Relay/backend 的 limits、metrics 與 load test(Plan 19)。 - 改寫 stored document 或由伺服器產生 file id(違反 ADR 0001 native document boundary)。 diff --git a/plans/README.md b/plans/README.md index 60eb4433..a77530f9 100644 --- a/plans/README.md +++ b/plans/README.md @@ -46,7 +46,7 @@ merge algorithm,皆不在這組計畫內。 | [14](./14-e2ee-realtime-payloads.md) | Completed | Relay 只看得到密文 | 13 | | [15](./15-durable-collaboration-snapshots.md) | Completed | 建立獨立加密 snapshot | 14 | | [16](./16-collaboration-asset-identity.md) | Completed | 建立 collaboration asset metadata 邊界 | 15 | -| [17](./17-encrypted-asset-transfer.md) | Ready | 同步並保存圖片等 binary assets | 16 | +| [17](./17-encrypted-asset-transfer.md) | Completed | 同步並保存圖片等 binary assets(密文) | 16 | | [18](./18-reconnect-and-convergence.md) | Ready | 驗證斷線、重連與 server restart | 17 | | [19](./19-production-hardening.md) | Ready | 加入 limits、監控與 load/security checks | 18 | | [20](./20-staged-rollout.md) | Ready | 以 feature flag 漸進開放並可回滾 | 19 |