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
2 files src/popup/codex/pdf-import.test.ts+298 −11src/popup/codex/pdf-import.ts+102 −57
src/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 });

view file

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 }

view file