From 351d375aac35e94185c2b0e69b95d789b52c99db Mon Sep 17 00:00:00 2001 From: Eric Tsai Date: Wed, 5 Aug 2026 18:53:29 +0800 Subject: [PATCH] feat(collab): add encrypted asset transfer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Room images now travel as ciphertext. The browser seals every asset under an HKDF-derived key (purpose `asset`, bound to room, authorization generation and the Excalidraw file id) before upload, so the app backend and the object store only ever hold bytes neither can read. Elements keep going through the relay unchanged; the two paths are aligned solely by the Plan 16 identity pair, which is why no new realtime message type was needed. - `@drawstuff/collaboration/asset`: versioned payload framing (data URL carried verbatim rather than JSON-escaped), MIME allowlist, byte budgets and `AssetCryptoCodec`; replaces Plan 16's manifest schema - `collaboration_asset` gains crypto_version, ut_file_key, url, byte_length (non-destructive `db:push`; the table held 0 rows) - `collaborationAsset.resolve` replaces `list`/`register`; assets enter a room only through a new uploadthing route that re-checks room access, role and generation inside the room lock, because an upload takes as long as its bytes - client asset store: store-wide transfer budget, batched lookups, deduplicated downloads, bounded body reads, backoff retry for "not uploaded yet" and permanent abandon for bytes that cannot be opened - retiring a generation enqueues its storage keys for the cleanup worker in the same transaction that deletes the rows, since object storage cannot join it Two Codex GPT-5.6 Sol review passes produced 12 findings: 9 accepted, 1 partially accepted, 2 rejected. Content-hash verification was rejected on evidence — an Excalidraw file id is the digest of the file the user picked while the stored data URL is the engine's resized re-encoding, so 41 of this project's 67 real assets would have been refused. The `deriveRoomKey`/`REALTIME_CRYPTO_VERSION` coupling was rejected as Plan 14 behaviour shared identically by snapshots; it and room-scoped retention are now owned by Plan 19. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/app/api/uploadthing/core.ts | 137 +++- .../excalidraw/use-collaboration-room.ts | 11 + apps/web/src/lib/collab/asset-store.ts | 753 ++++++++++++++++++ apps/web/src/lib/collab/asset-upload.ts | 52 ++ .../src/lib/collab/collaboration-session.ts | 95 +++ apps/web/src/lib/collab/room-session.ts | 39 + .../server/api/routers/collaboration-asset.ts | 153 ++-- apps/web/src/server/collab/assets.ts | 321 ++++++-- apps/web/src/server/db/schema.ts | 45 +- apps/web/tests/collab-asset-transfer.test.ts | 578 ++++++++++++++ .../collaboration-asset-identity.test.ts | 387 +++++---- .../tests/support/collab-scene-fixtures.ts | 24 + .../tests/support/collab-session-harness.ts | 310 ++++++- .../0001-excalidraw-persistence-boundary.md | 47 +- packages/collaboration/src/asset.ts | 718 ++++++++++++++++- packages/collaboration/tests/asset.test.ts | 498 ++++++++++++ .../tests/package-contract.test.ts | 12 +- packages/collaboration/vitest.config.ts | 1 + packages/excalidraw-adapter/src/codec.ts | 1 + .../src/persistence-contract.ts | 31 +- .../tests/package-contract.test.ts | 1 + .../tests/persistence-contract.test.ts | 13 + plans/17-encrypted-asset-transfer.md | 144 +++- plans/19-production-hardening.md | 27 +- plans/23-owned-scene-asset-lifecycle.md | 5 +- plans/README.md | 2 +- 26 files changed, 3995 insertions(+), 410 deletions(-) create mode 100644 apps/web/src/lib/collab/asset-store.ts create mode 100644 apps/web/src/lib/collab/asset-upload.ts create mode 100644 apps/web/tests/collab-asset-transfer.test.ts create mode 100644 packages/collaboration/tests/asset.test.ts 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 |