bb40658c5731
fix Codex PDF restore — accept v3 envelopes (generator emits v3, not v2); extract cellRectFor / decodeAztecFromArgb / imageDataToArgb; add 16 tests including 36-code full-page grid integration round-trip
- time
- 2026-05-21 14:33 UTC · 5 months ago
- machine
- koh2
- parent
- 1cd43b988080
↓ peach-browser-bb40658c.tar.gzatlas at this save
files changed2 files
src/popup/codex/pdf-import.test.ts+298 −11src/popup/codex/pdf-import.ts+102 −57src/popup/codex/pdf-import.test.ts+298 −11
@@ -1,19 +1,75 @@11 /**2- * pdf-import.test.ts — coverage for the parser surface only.2+ * pdf-import.test.ts — coverage for the testable surface of the PDF import3+ * path.34 *4- * The actual pdf.js rendering + Aztec scan loop in importCodexPDF requires5- * a DOM (HTMLCanvasElement) which vitest's node environment does not provide.6- * End-to-end PDF round-trip lives in the manual Codex verification matrix7- * (see peach_codex_reliability.md §5.2). Here we only test the bits we can:8- * the defensive envelope parser.5+ * What is tested here:6+ * 1. `parseChunkEnvelope` — defensive parser for raw decoded text. Must7+ * accept the v3 envelopes the live generator emits (earlier revisions8+ * only accepted v2, silently rejecting every current Codex).9+ * 2. `cellRectFor` — pixel geometry of each cell on a rendered page. Must10+ * produce rectangles that cover the regions the generator draws into;11+ * an off-by-one or off-by-margin here clips the Aztec and decoding12+ * fails. The earlier regression that motivated this file was an even13+ * `canvas / 6` slicer that ignored margins entirely.14+ * 3. `decodeAztecFromArgb` — DOM-free decode path. Must round-trip a real15+ * Aztec built from a real envelope. The earlier regression here was16+ * `BrowserAztecCodeReader.decode(canvas)` silently no-opping on every17+ * canvas — this test catches a similar regression because we no longer18+ * go through that API at all.19+ * 4. Page-grid integration — places 36 Aztec codes at the *exact* PDF20+ * grid positions onto a synthetic page buffer, then extracts each cell21+ * via `cellRectFor` and decodes it. End-to-end proof that the geometry22+ * and decode path agree with the generator. Adding a third bug in23+ * either layer would fail this test.24+ *25+ * The `importCodexPDF` entry point itself needs pdf.js + an `HTMLCanvasElement`26+ * and is exercised only in the manual reliability matrix27+ * (peach_codex_reliability.md §5.2). Everything else this module ships now28+ * has automated coverage.929 */1030 1131 import { describe, expect, it } from "vitest";12-import { parseChunkEnvelope } from "./pdf-import";1332 33+import type { BitMatrix } from "@zxing/library";34+import { buildAztecMatrix } from "./aztec";35+import { buildChunkEnvelope } from "./helpers";36+import {37+ cellRectFor,38+ decodeAztecFromArgb,39+ parseChunkEnvelope,40+} from "./pdf-import";41+import {42+ CODE_SIZE,43+ COL_GAP,44+ HEADER_GAP,45+ HEADER_HEIGHT,46+ MARGIN,47+ PAGE_HEIGHT,48+ PAGE_WIDTH,49+ ROW_STEP,50+} from "./pdf-layout";51+52+// ── parseChunkEnvelope ──────────────────────────────────────────────────────53+1454 describe("parseChunkEnvelope", () => {15- it("parses a well-formed envelope", () => {16- const env = {55+ const v3 = {56+ v: 3 as const,57+ c: "PX-TEST-1234",58+ s: 0,59+ S: 1,60+ i: 0,61+ t: 15,62+ p: false,63+ d: "AAAA",64+ h: "ABCDEF1234567890ABCDEF1234567890",65+ };66+67+ it("parses a v3 envelope (current generator format)", () => {68+ expect(parseChunkEnvelope(JSON.stringify(v3))).toEqual(v3);69+ });70+71+ it("parses a v2 envelope (legacy single-stripe)", () => {72+ const v2 = {1773 v: 2,1874 c: "PX-TEST-1234",1975 i: 0,@@ -22,9 +78,14 @@2278 d: "AAAA",2379 h: "ABCDEF1234567890ABCDEF1234567890",2480 };25- expect(parseChunkEnvelope(JSON.stringify(env))).toEqual(env);81+ expect(parseChunkEnvelope(JSON.stringify(v2))).toEqual(v2);2682 });2783 84+ it("rejects a v3 envelope missing stripe metadata", () => {85+ const broken = { ...v3, s: undefined, S: undefined };86+ expect(parseChunkEnvelope(JSON.stringify(broken))).toBeNull();87+ });88+2889 it("returns null for non-JSON input", () => {2990 expect(parseChunkEnvelope("not json at all")).toBeNull();3091 });@@ -45,8 +106,10 @@45106 expect(46107 parseChunkEnvelope(47108 JSON.stringify({48- v: 2,109+ v: 3,49110 c: "PX",111+ s: 0,112+ S: 1,50113 i: "0", // should be number51114 t: 5,52115 p: false,@@ -72,4 +135,228 @@72135 ),73136 ).toBeNull();74137 });138+});139+140+// ── cellRectFor ─────────────────────────────────────────────────────────────141+142+describe("cellRectFor", () => {143+ // pdf.js renders at scale 2.0; mirror that here so the test exercises the144+ // same dimensions the importer sees in production.145+ const SCALE = 2.0;146+ const canvasW = Math.ceil(PAGE_WIDTH * SCALE);147+ const canvasH = Math.ceil(PAGE_HEIGHT * SCALE);148+149+ it("covers every cell of the printed 6×6 grid", () => {150+ for (let row = 0; row < 6; row++) {151+ for (let col = 0; col < 6; col++) {152+ const rect = cellRectFor(canvasW, canvasH, row, col);153+ // The crop must enclose the full code rectangle the generator draws.154+ const codeXMin = (MARGIN + col * (CODE_SIZE + COL_GAP)) * SCALE;155+ const codeXMax = codeXMin + CODE_SIZE * SCALE;156+ const codeYMin =157+ (MARGIN + HEADER_HEIGHT + HEADER_GAP + row * ROW_STEP) * SCALE;158+ const codeYMax = codeYMin + CODE_SIZE * SCALE;159+ expect(rect.sx).toBeLessThanOrEqual(codeXMin);160+ expect(rect.sy).toBeLessThanOrEqual(codeYMin);161+ expect(rect.sx + rect.sw).toBeGreaterThanOrEqual(codeXMax);162+ expect(rect.sy + rect.sh).toBeGreaterThanOrEqual(codeYMax);163+ }164+ }165+ });166+167+ it("places adjacent columns far enough apart that crops do not overlap into the next code", () => {168+ // Each crop may include a quiet-zone halo, but it must never cross into169+ // the neighbour's printed code rectangle.170+ for (let row = 0; row < 6; row++) {171+ for (let col = 0; col < 5; col++) {172+ const left = cellRectFor(canvasW, canvasH, row, col);173+ const rightCodeXMin =174+ (MARGIN + (col + 1) * (CODE_SIZE + COL_GAP)) * SCALE;175+ expect(left.sx + left.sw).toBeLessThanOrEqual(rightCodeXMin);176+ }177+ }178+ });179+180+ it("clamps to canvas bounds", () => {181+ const rect = cellRectFor(canvasW, canvasH, 5, 5);182+ expect(rect.sx + rect.sw).toBeLessThanOrEqual(canvasW);183+ expect(rect.sy + rect.sh).toBeLessThanOrEqual(canvasH);184+ });185+});186+187+// ── decodeAztecFromArgb ─────────────────────────────────────────────────────188+189+const WHITE = -1;190+const BLACK = (0xff << 24) | 0;191+192+/**193+ * Render a ZXing BitMatrix into an ARGB Int32Array at the given module size194+ * with a `padModules` quiet zone on every side. Returns the buffer plus its195+ * pixel dimensions.196+ *197+ * Used by tests to synthesize the same pixel format the importer would see198+ * after pdf.js renders + the cell canvas paints, without needing a real DOM.199+ */200+function renderMatrixToArgb(201+ matrix: BitMatrix,202+ moduleSize: number,203+ padModules: number,204+): { argb: Int32Array; width: number; height: number } {205+ const modules = matrix.getWidth();206+ const pad = padModules * moduleSize;207+ const size = modules * moduleSize + pad * 2;208+ const argb = new Int32Array(size * size).fill(WHITE);209+ for (let y = 0; y < modules; y++) {210+ for (let x = 0; x < modules; x++) {211+ if (matrix.get(x, y)) {212+ const baseRow = (pad + y * moduleSize) * size + pad + x * moduleSize;213+ for (let dy = 0; dy < moduleSize; dy++) {214+ for (let dx = 0; dx < moduleSize; dx++) {215+ argb[baseRow + dy * size + dx] = BLACK;216+ }217+ }218+ }219+ }220+ }221+ return { argb, width: size, height: size };222+}223+224+/**225+ * Blit a smaller ARGB buffer onto a larger one at (dx, dy). Used to compose226+ * a synthetic full-page buffer from per-code Aztec renderings.227+ */228+function blitArgb(229+ src: Int32Array,230+ srcW: number,231+ srcH: number,232+ dst: Int32Array,233+ dstW: number,234+ dx: number,235+ dy: number,236+): void {237+ for (let y = 0; y < srcH; y++) {238+ const dstRow = (dy + y) * dstW + dx;239+ const srcRow = y * srcW;240+ for (let x = 0; x < srcW; x++) {241+ dst[dstRow + x] = src[srcRow + x]!;242+ }243+ }244+}245+246+describe("decodeAztecFromArgb", () => {247+ it("round-trips a real envelope JSON through the decode path", async () => {248+ // A real-shaped envelope (v3) so the test exercises payload sizes the249+ // live importer will actually see.250+ const envelope = await buildChunkEnvelope(251+ new TextEncoder().encode("hello world".repeat(20)),252+ 0,253+ 36,254+ false,255+ "PX-TEST-1234",256+ 0,257+ 1,258+ );259+ const payload = JSON.stringify(envelope);260+261+ const matrix = buildAztecMatrix(payload);262+ const { argb, width, height } = renderMatrixToArgb(matrix, 4, 2);263+264+ expect(decodeAztecFromArgb(argb, width, height)).toBe(payload);265+ });266+267+ it("returns null for an all-white buffer", () => {268+ const w = 100;269+ const h = 100;270+ const argb = new Int32Array(w * h).fill(WHITE);271+ expect(decodeAztecFromArgb(argb, w, h)).toBeNull();272+ });273+274+ it("returns null for an all-black buffer", () => {275+ const w = 100;276+ const h = 100;277+ const argb = new Int32Array(w * h).fill(BLACK);278+ expect(decodeAztecFromArgb(argb, w, h)).toBeNull();279+ });280+281+ it("returns null for random noise", () => {282+ const w = 200;283+ const h = 200;284+ const argb = new Int32Array(w * h);285+ // Deterministic pseudo-noise so the test never flakes.286+ let s = 0x12345678;287+ for (let i = 0; i < argb.length; i++) {288+ s = (s * 1664525 + 1013904223) | 0;289+ argb[i] = s & 1 ? BLACK : WHITE;290+ }291+ expect(decodeAztecFromArgb(argb, w, h)).toBeNull();292+ });293+});294+295+// ── End-to-end page-grid integration ────────────────────────────────────────296+297+describe("page-grid integration", () => {298+ it("decodes all 36 envelopes when laid out at the generator's grid positions", async () => {299+ const SCALE = 2.0;300+ const canvasW = Math.ceil(PAGE_WIDTH * SCALE);301+ const canvasH = Math.ceil(PAGE_HEIGHT * SCALE);302+ const page = new Int32Array(canvasW * canvasH).fill(WHITE);303+304+ // Build 36 envelopes — exactly one full page worth. Vary the data so each305+ // produces a distinct matrix, catching any "decoded the same code twice"306+ // confusion in the importer.307+ const envelopes = await Promise.all(308+ Array.from({ length: 36 }, (_, i) => {309+ const data = new TextEncoder().encode(`chunk-${i}-`.padEnd(200, "x"));310+ return buildChunkEnvelope(data, i, 36, false, "PX-TEST-1234", 0, 1);311+ }),312+ );313+314+ // Render each envelope's Aztec at the exact pixel position the generator315+ // would draw it. Module size is chosen to fit within CODE_SIZE * SCALE316+ // pixels with a ~2-module quiet zone, matching the live PNG layout.317+ const codeBoxPx = Math.floor(CODE_SIZE * SCALE);318+ for (let i = 0; i < envelopes.length; i++) {319+ const env = envelopes[i]!;320+ const matrix = buildAztecMatrix(JSON.stringify(env));321+ const modules = matrix.getWidth();322+ // Fit modules+4 (=quiet zone) into codeBoxPx pixels. Floor to ensure323+ // the rendered code fits within the box and never spills over.324+ const moduleSize = Math.max(1, Math.floor(codeBoxPx / (modules + 4)));325+ const { argb, width, height } = renderMatrixToArgb(matrix, moduleSize, 2);326+ const row = Math.floor(i / 6);327+ const col = i % 6;328+ const dx = Math.round((MARGIN + col * (CODE_SIZE + COL_GAP)) * SCALE);329+ const dy = Math.round(330+ (MARGIN + HEADER_HEIGHT + HEADER_GAP + row * ROW_STEP) * SCALE,331+ );332+ blitArgb(argb, width, height, page, canvasW, dx, dy);333+ }334+335+ // Walk the importer's grid math and decode each cell. Every envelope336+ // must reappear, in the right index, with the original JSON intact.337+ const recovered: string[] = [];338+ for (let row = 0; row < 6; row++) {339+ for (let col = 0; col < 6; col++) {340+ const { sx, sy, sw, sh } = cellRectFor(canvasW, canvasH, row, col);341+ const cell = new Int32Array(sw * sh);342+ for (let y = 0; y < sh; y++) {343+ const srcRow = (sy + y) * canvasW + sx;344+ const dstRow = y * sw;345+ for (let x = 0; x < sw; x++) cell[dstRow + x] = page[srcRow + x]!;346+ }347+ const text = decodeAztecFromArgb(cell, sw, sh);348+ expect(text, `cell (row=${row}, col=${col}) failed to decode`).not.toBeNull();349+ recovered.push(text!);350+ }351+ }352+353+ expect(recovered).toHaveLength(36);354+ for (let i = 0; i < 36; i++) {355+ const parsed = parseChunkEnvelope(recovered[i]!);356+ expect(parsed, `recovered envelope ${i} failed parse`).not.toBeNull();357+ expect(parsed!.i).toBe(envelopes[i]!.i);358+ expect(parsed!.h).toBe(envelopes[i]!.h);359+ expect(parsed!.d).toBe(envelopes[i]!.d);360+ }361+ }, 30_000);75362 });src/popup/codex/pdf-import.ts+102 −57
@@ -3,12 +3,19 @@33 *44 * The PDF is rendered page-by-page to a canvas (via pdf.js) and each page55 * canvas is divided into the same 6×6 grid the generator prints into. Each6- * cell is scanned individually with @zxing/library, which decodes far more7- * reliably than a full-page scan because adjacent Aztec codes no longer8- * compete for the binarizer's attention.6+ * cell is decoded individually via `RGBLuminanceSource` + `AztecCodeReader`,7+ * which is far more reliable than a full-page scan because adjacent Aztec8+ * codes don't compete for the binarizer's attention.99 *10- * This module is browser-only — it depends on canvas and pdf.js. It is never11- * imported from tests; PDF rendering needs a DOM.10+ * The top-level `importCodexPDF` entry point needs a DOM (canvas + pdf.js).11+ * The pure helpers below — `cellRectFor`, `imageDataToArgb`,12+ * `decodeAztecFromArgb`, `parseChunkEnvelope` — are deliberately separated13+ * out so the geometry math and the ZXing decode path can be exercised in14+ * vitest's node environment. That coverage exists because earlier revisions15+ * shipped two latent bugs (an even-grid slice that clipped every Aztec, and a16+ * `BrowserAztecCodeReader.decode(canvas)` call that ZXing silently no-ops on)17+ * which no unit test could have caught while everything DOM-adjacent was off18+ * limits.1219 *1320 * Why PDF import matters: a printed Codex requires a phone camera and good1421 * light. The PDF is a pristine vector source — the same Aztec codes without@@ -199,23 +206,6 @@199206 ): Promise<ChunkEnvelope[]> {200207 const found: ChunkEnvelope[] = [];201208 202- // The page was rendered at a uniform scale, so canvas pixels per PDF point203- // is just canvas.width / PAGE_WIDTH (≈ canvas.height / PAGE_HEIGHT for an204- // A4 source). Independent x/y scales keep us correct if the renderer ever205- // applies non-uniform scaling.206- const scaleX = canvas.width / PAGE_WIDTH;207- const scaleY = canvas.height / PAGE_HEIGHT;208-209- // Top edge of row 0 in PDF coords (bottom-origin) is just below the header.210- // Convert to canvas coords (top-origin): canvas_y = (PAGE_HEIGHT - pdf_y) * scaleY.211- const gridTopPdf = PAGE_HEIGHT - MARGIN - HEADER_HEIGHT - HEADER_GAP;212- const gridTopCanvas = (PAGE_HEIGHT - gridTopPdf) * scaleY;213-214- const codeWpx = CODE_SIZE * scaleX;215- const codeHpx = CODE_SIZE * scaleY;216- const quietX = Math.round(codeWpx * 0.12);217- const quietY = Math.round(codeHpx * 0.12);218-219209 // One scratch canvas reused across all cells — cheaper than allocating220210 // 36 canvases per page when a Codex spans dozens of pages.221211 const cellCanvas = document.createElement("canvas");@@ -224,19 +214,7 @@224214 225215 for (let row = 0; row < CODES_PER_COL; row++) {226216 for (let col = 0; col < CODES_PER_ROW; col++) {227- const codeX = (MARGIN + col * (CODE_SIZE + COL_GAP)) * scaleX;228- const codeY = gridTopCanvas + row * ROW_STEP * scaleY;229-230- const sx = Math.max(0, Math.floor(codeX - quietX));231- const sy = Math.max(0, Math.floor(codeY - quietY));232- const sw = Math.min(233- canvas.width - sx,234- Math.ceil(codeWpx + 2 * quietX),235- );236- const sh = Math.min(237- canvas.height - sy,238- Math.ceil(codeHpx + 2 * quietY),239- );217+ const { sx, sy, sw, sh } = cellRectFor(canvas.width, canvas.height, row, col);240218 if (sw <= 0 || sh <= 0) continue;241219 242220 cellCanvas.width = sw;@@ -248,7 +226,9 @@248226 cellCtx.fillRect(0, 0, sw, sh);249227 cellCtx.drawImage(canvas, sx, sy, sw, sh, 0, 0, sw, sh);250228 251- const text = decodeAztecFromCellPixels(cellCtx, sw, sh);229+ const imageData = cellCtx.getImageData(0, 0, sw, sh);230+ const argb = imageDataToArgb(imageData);231+ const text = decodeAztecFromArgb(argb, sw, sh);252232 if (!text) continue;253233 254234 const envelope = parseChunkEnvelope(text);@@ -265,8 +245,62 @@265245 }266246 267247 /**268- * Read the cell canvas's pixels into an ARGB Int32Array, wrap it in a269- * `RGBLuminanceSource`, and try to decode it as an Aztec code. Returns the248+ * Compute the canvas-pixel sub-rectangle for the printed Aztec at (row, col)249+ * on a page that was rendered at uniform scale into the given canvas250+ * dimensions.251+ *252+ * Exported (rather than inlined into `scanCanvasGrid`) so the geometry can be253+ * verified directly in tests — `scanCanvasGrid` itself needs an254+ * `HTMLCanvasElement` and `getImageData()` which vitest's node environment255+ * lacks, but this pure function does not.256+ */257+export function cellRectFor(258+ canvasW: number,259+ canvasH: number,260+ row: number,261+ col: number,262+): { sx: number; sy: number; sw: number; sh: number } {263+ // Canvas pixels per PDF point. Independent x/y scales keep us correct if264+ // the renderer ever applies non-uniform scaling.265+ const scaleX = canvasW / PAGE_WIDTH;266+ const scaleY = canvasH / PAGE_HEIGHT;267+268+ // Top edge of row 0 in PDF coords (bottom-origin) is just below the header.269+ // Convert to canvas coords (top-origin): canvas_y = (PAGE_HEIGHT - pdf_y) * scaleY.270+ const gridTopPdf = PAGE_HEIGHT - MARGIN - HEADER_HEIGHT - HEADER_GAP;271+ const gridTopCanvas = (PAGE_HEIGHT - gridTopPdf) * scaleY;272+273+ const codeWpx = CODE_SIZE * scaleX;274+ const codeHpx = CODE_SIZE * scaleY;275+ const quietX = Math.round(codeWpx * 0.12);276+ const quietY = Math.round(codeHpx * 0.12);277+278+ const codeX = (MARGIN + col * (CODE_SIZE + COL_GAP)) * scaleX;279+ const codeY = gridTopCanvas + row * ROW_STEP * scaleY;280+281+ const sx = Math.max(0, Math.floor(codeX - quietX));282+ const sy = Math.max(0, Math.floor(codeY - quietY));283+ const sw = Math.min(canvasW - sx, Math.ceil(codeWpx + 2 * quietX));284+ const sh = Math.min(canvasH - sy, Math.ceil(codeHpx + 2 * quietY));285+ return { sx, sy, sw, sh };286+}287+288+/**289+ * Convert an RGBA `ImageData.data` buffer into the packed ARGB `Int32Array`290+ * that `RGBLuminanceSource` expects. Exposed for tests that synthesize a cell291+ * buffer directly rather than going through a canvas.292+ */293+export function imageDataToArgb(imageData: ImageData): Int32Array {294+ const raw = imageData.data;295+ const out = new Int32Array(imageData.width * imageData.height);296+ for (let i = 0, j = 0; i < raw.length; i += 4, j++) {297+ out[j] = (0xff << 24) | (raw[i]! << 16) | (raw[i + 1]! << 8) | raw[i + 2]!;298+ }299+ return out;300+}301+302+/**303+ * Decode a single Aztec code from a packed ARGB pixel buffer. Returns the270304 * decoded text on success, or `null` if no code was found.271305 *272306 * We try `HybridBinarizer` first (good for rendered images with subtle@@ -274,21 +308,21 @@274308 * (better when the image is dominated by clean black/white modules). A fresh275309 * `AztecCodeReader` is allocated per attempt — the reader caches internal276310 * state that must be reset between bitmaps.311+ *312+ * This is the exact decode path the importer used to skip by handing canvas313+ * elements to `BrowserAztecCodeReader.decode()`, which silently fails on314+ * canvases — its `createCaptureCanvas` only recognizes video and image315+ * elements and produces a zero-sized capture for anything else. Keeping this316+ * function exported lets the test suite verify the path works on synthesized317+ * Aztec pixels without needing a real DOM canvas.277318 */278-function decodeAztecFromCellPixels(279- cellCtx: CanvasRenderingContext2D,280- sw: number,281- sh: number,319+export function decodeAztecFromArgb(320+ argb: Int32Array,321+ width: number,322+ height: number,282323 ): string | null {283- const imageData = cellCtx.getImageData(0, 0, sw, sh);284- const raw = imageData.data;285- const argb = new Int32Array(sw * sh);286- for (let i = 0, j = 0; i < raw.length; i += 4, j++) {287- argb[j] = (0xff << 24) | (raw[i]! << 16) | (raw[i + 1]! << 8) | raw[i + 2]!;288- }324+ const source = new RGBLuminanceSource(argb, width, height);289325 290- const source = new RGBLuminanceSource(argb, sw, sh);291-292326 try {293327 const bitmap = new BinaryBitmap(new HybridBinarizer(source));294328 return new AztecCodeReader().decode(bitmap).getText();@@ -306,8 +340,13 @@306340 307341 /**308342 * Parse a raw decoded string as a Peach ChunkEnvelope. Returns null for any309- * non-Peach payload — the same defensive contract as restore.ts/parseRawScan343+ * non-Peach payload — the same defensive contract as `restore.ts/parseRawScan`310344 * but inlined here so this module stays decoupled from the camera scan path.345+ *346+ * Accepts v2 (legacy single-stripe) and v3 (current multi-stripe) envelopes.347+ * The generator has emitted v3 envelopes since the multi-stripe rollout —348+ * earlier revisions of this function only accepted v2, which silently349+ * rejected every envelope produced by current builds.311350 */312351 export function parseChunkEnvelope(raw: string): ChunkEnvelope | null {313352 let parsed: unknown;@@ -318,16 +357,22 @@318357 }319358 if (typeof parsed !== "object" || parsed === null) return null;320359 const obj = parsed as Record<string, unknown>;321- if (322- obj.v === 2 &&360+361+ if (obj.v !== 2 && obj.v !== 3) return null;362+363+ const baseShapeOk =323364 typeof obj.c === "string" &&324365 typeof obj.i === "number" &&325366 typeof obj.t === "number" &&326367 typeof obj.p === "boolean" &&327368 typeof obj.d === "string" &&328- typeof obj.h === "string"329- ) {330- return obj as unknown as ChunkEnvelope;369+ typeof obj.h === "string";370+ if (!baseShapeOk) return null;371+372+ // v3 envelopes must also carry stripe metadata.373+ if (obj.v === 3 && (typeof obj.s !== "number" || typeof obj.S !== "number")) {374+ return null;331375 }332- return null;376+377+ return obj as unknown as ChunkEnvelope;333378 }