feat: direct-to-R2 Files uploads (client-sealed SPENC2) + chunked video dropzone + multi-file queue

For scale: payload bytes go browser → R2 → browser; the api handles keys,
authz and metadata only, so a cheap box can serve thousands of users.

- SPENC2 frame (browser-sealed, P-256 ECDH → HKDF wrap of a per-file CEK,
  10 MiG AES-GCM segments): storage.ts gains the reader (magic-branched
  getObject), presignPut/headObject, openDirectHeader; sealForDirect is the
  reference sealer for cross-implementation tests
- Files API: GET /files/direct/config, POST /files/direct/init (presigned
  PUT), POST /files/direct/complete (HEAD-gated commit), POST
  /files/:id/direct-download (presigned GET + CEK ticket)
- web: lib/direct-storage (WebCrypto seal/open), lib/files-direct (flows),
  dropzone rewritten with a multi-file sequential queue (per-file status,
  direct-first with server fallback), file download decrypts in-browser for
  direct objects (falls back to streaming)
- video dropzone wired to the chunked ingest API (>10 MiB → init/chunk/
  complete), fixing the Cloudflare ~100 MB body cap for large videos
- DIRECT_EC_PRIVATE_KEY (P-256, PKCS8 b64) wired through compose; cross-impl
  roundtrip tests prove web-seal ↔ server-open byte compatibility
This commit is contained in:
glass beadian 2026-10-16 08:12:11 +00:00
parent b3c2085436
commit 0a1c3b392b
21 changed files with 1444 additions and 83 deletions

View file

@ -1,6 +1,6 @@
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
import { keyForHash, sha256, seal, open, type StorageService } from "../storage/storage.js";
import { keyForHash, sha256, seal, open, type StorageService, directStorageDisabled } from "../storage/storage.js";
import { probeVideoFile } from "./ffprobe.js";
import { serviceWithDatabase, workspaceId } from "./test-fixtures.js";
@ -30,6 +30,7 @@ describe.skipIf(fixture === undefined)("real ffprobe roundtrip (encrypted in-mem
},
hasContent: async (hash, contentType) => objects.has(keyForHash(hash, contentType)),
presign: async () => { throw new Error("must decrypt, not presign"); },
...directStorageDisabled(),
};
const { service } = serviceWithDatabase({}, probeVideoFile, storage);
const uploaded = await service.uploadVideoBytes(workspaceId, bytes, "video/mp4");