feat(vinpin): vision-loop decode driver (OCR) — reliable VDI automation

Replace the brittle clipboard/fixed-coord extraction with an OCR vision loop:
screenshot -> detect state (login/grid/portal/eper/modal via tesseract keywords)
-> act -> verify -> retry/recover. Adds portal->catalogue detection (the seat
non-deterministically opens a multi-brand portal with no VIN field), not-found
handling, and ffmpeg crop+upscale -> tesseract.js for the decode modal. Only the
model token + year are needed (matcher-tolerant), so OCR garbling is harmless.

Validated live against a trial seat: 6/6 of the ePER-present Fiat VINs decoded to
the correct model (EGEA/DOBLO); 2 genuine "vehicle not found" coverage gaps
reported as null. 8/8 correct outcomes. Still flag-gated off by default; tesseract
is dynamically imported only inside the worker. Warm-session persistence under
swiftshader is the remaining hardening item (currently re-logins per VIN).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-26 11:00:07 +03:00
parent dfe90fb968
commit 8e45d00173
5 changed files with 419 additions and 86 deletions

View File

@@ -65,6 +65,7 @@
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.0",
"stripe": "^22.1.1",
"tesseract.js": "^5.1.1",
"undici": "^7.22.0",
"zod": "^3.24.0"
},

View File

@@ -7,27 +7,36 @@
* auto-recovering on disconnect.
*
* Differences from emex.browser:
* - The ePER UI is a pixel canvas (Blast/Horizon) with no stable selectors, so
* navigation is coordinate-driven (see vinpin.constants — TUNE AGAINST LIVE
* PAID SEAT).
* - The ePER UI is a pixel CANVAS (Blast/Horizon) with no stable selectors and
* no DOM-readable text, so navigation is coordinate-driven AND every screen
* transition is verified with a VISION LOOP: screenshot → OCR → check keyword
* → retry on miss (see vinpin.ocr + VINPIN_OCR keyword sets).
* - A single Vinpin seat → a hard mutex serializes decodes (one at a time).
* - Result text is lifted via the clipboard (select-all + copy + read).
* - The decode-modal text is read by OCR'ing a screenshot of the modal region
* (clipboard is unreliable on the Blast canvas — proven dead end).
*
* SAFETY CONTRACT: this driver must NEVER break its caller. When VINPIN_ENABLED
* is false, creds are missing, the browser fails to launch, or anything throws,
* `decode()` returns null (logged) — it never propagates.
*
* ⚠️ RUNTIME NOTE (Docker/xvfb): like emex.browser this launches headless
* Chromium with --no-sandbox and honours PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, so
* it runs in the same deployed worker image emex already works in. If the live
* seat needs a real display (some Blast canvases refuse headless), set
* VINPIN_HEADFUL=true and run the worker under xvfb (xvfb-run). Headless is the
* default and matches emex.
* ⚠️ RUNTIME NOTE (Docker/xvfb): the Blast canvas was only VERIFIED working
* HEADFUL (chromium under a virtual display) with software GL — so the deployed
* worker should set VINPIN_HEADFUL=true and run under `xvfb-run -a` (a 1600x900
* screen). Launch honours PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH like emex. OCR
* additionally wants `ffmpeg` on PATH for upscaling (soft dep — degrades
* gracefully) and lets tesseract.js fetch+cache eng traineddata on first use.
*/
import { Injectable, Logger, type OnModuleDestroy } from "@nestjs/common";
import type { Browser, BrowserContext, Page } from "playwright";
import { VINPIN_COORDS, VINPIN_VIEWPORT, VINPIN_WAITS } from "./vinpin.constants";
import {
VINPIN_COORDS,
VINPIN_MODAL_REGION,
VINPIN_OCR,
VINPIN_VIEWPORT,
VINPIN_WAITS,
} from "./vinpin.constants";
import { ocrRegion, terminateOcr } from "./vinpin.ocr";
import { type VinpinParsed, isUsableParse, parseVinpinModal } from "./vinpin.parser";
export interface VinpinDecodeResult {
@@ -74,6 +83,7 @@ export class VinpinDriverService implements OnModuleDestroy {
async onModuleDestroy(): Promise<void> {
await this.close();
await terminateOcr();
}
/**
@@ -174,8 +184,14 @@ export class VinpinDriverService implements OnModuleDestroy {
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-accelerated-2d-canvas",
"--disable-gpu",
"--ignore-certificate-errors",
// Software GL so the Blast/Horizon canvas renders without a real GPU
// (verified working under xvfb). Without these the catalog canvas stays
// blank and OCR reads nothing.
"--use-gl=angle",
"--use-angle=swiftshader",
"--enable-unsafe-swiftshader",
`--window-size=${VINPIN_VIEWPORT.width},${VINPIN_VIEWPORT.height}`,
],
};
const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
@@ -184,6 +200,7 @@ export class VinpinDriverService implements OnModuleDestroy {
this.browser = await chromium.launch(launchOptions);
this.context = await this.browser.newContext({
viewport: { width: VINPIN_VIEWPORT.width, height: VINPIN_VIEWPORT.height },
ignoreHTTPSErrors: true,
permissions: ["clipboard-read", "clipboard-write"],
userAgent:
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
@@ -202,14 +219,14 @@ export class VinpinDriverService implements OnModuleDestroy {
}
/**
* Log in and navigate to the Fiat ePER search page.
* Coordinates/waits are calibrated for 1600x900 — TUNE AGAINST LIVE PAID SEAT.
* Log in and navigate to the Fiat ePER search page, VISION-VERIFYING each step.
* Coordinates/waits verified live @ 1600x900.
*/
private async establishSession(cfg: VinpinConfig): Promise<void> {
const page = this.page;
if (!page) throw new Error("Vinpin page not initialized");
// a. Login
// a. Login (reliable DOM path).
await page.goto(cfg.url, { waitUntil: "domcontentloaded", timeout: 60_000 });
await page.waitForTimeout(VINPIN_WAITS.afterGoto);
// First visible text input = username.
@@ -223,77 +240,93 @@ export class VinpinDriverService implements OnModuleDestroy {
await submit.click();
await page.waitForTimeout(VINPIN_WAITS.afterLogin);
// b. Open Fiat ePER
await page.mouse.click(VINPIN_COORDS.fiatBrand.x, VINPIN_COORDS.fiatBrand.y);
await page.waitForTimeout(VINPIN_WAITS.afterFiatOpen);
// Dismiss the spurious Russian-language dialog if present.
// b. Open Fiat ePER — canvas click is flaky, so retry + OCR-verify it opened.
let opened = false;
for (let i = 0; i < 4 && !opened; i++) {
// A deliberate mousedown/up (not a synthetic click) lands on the canvas tile.
await page.mouse.move(VINPIN_COORDS.fiatBrand.x, VINPIN_COORDS.fiatBrand.y);
await page.waitForTimeout(150);
await page.mouse.down();
await page.waitForTimeout(80);
await page.mouse.up();
await page.waitForTimeout(VINPIN_WAITS.afterFiatOpen);
const t = await ocrRegion(page);
opened = VINPIN_OCR.eperOpen.test(t);
if (!opened) this.logger.warn(`Fiat ePER open attempt ${i + 1}/4 — not yet (retrying)`);
}
if (!opened) throw new Error("Fiat ePER did not open after retries");
// c. Dismiss the spurious Russian-language dialog (click its OK button).
await page.mouse.click(VINPIN_COORDS.langDialogDismiss.x, VINPIN_COORDS.langDialogDismiss.y);
await page.keyboard.press("Escape").catch(() => {});
await page.keyboard.press("Enter").catch(() => {});
await page.waitForTimeout(VINPIN_WAITS.afterLangDismiss);
// c. Open the ПОИСК (search) page
await page.mouse.click(VINPIN_COORDS.searchToolbar.x, VINPIN_COORDS.searchToolbar.y);
await page.waitForTimeout(VINPIN_WAITS.afterSearchOpen);
// d. Reach the Spare Parts CATALOGUE (carries the VIN identification panel).
// The seat opens either straight onto the catalogue or onto the portal
// home — detect which, and click the Spare Parts tile when on the portal.
let onCatalogue = false;
for (let i = 0; i < 4 && !onCatalogue; i++) {
const t = await ocrRegion(page);
onCatalogue = VINPIN_OCR.catalogueReady.test(t);
if (onCatalogue) break;
this.logger.warn(`not on Spare Parts catalogue (attempt ${i + 1}/4) — opening it`);
await page.mouse.click(VINPIN_COORDS.sparePartsTile.x, VINPIN_COORDS.sparePartsTile.y);
await page.waitForTimeout(VINPIN_WAITS.afterSearchOpen);
}
if (!onCatalogue) throw new Error("could not reach Spare Parts Catalogue (VIN panel)");
this.loggedIn = true;
this.logger.log("Vinpin session established (Fiat ePER search page)");
this.logger.log("Vinpin session established (Fiat ePER Spare Parts catalogue)");
}
/**
* Run one VIN decode on an already-established session and return the raw
* modal text (via clipboard). Coordinates calibrated for 1600x900 — TUNE.
* Run one VIN decode on an already-established warm session. Enters the VIN in
* the right-panel field, submits, and OCRs the decode-modal region. Returns the
* modal text, or null on a genuine "vehicle not found" (which it also clears so
* the next decode in the warm session is unaffected).
*/
private async runVinFlow(page: Page, vin: string): Promise<string | null> {
await page.keyboard.press("Escape");
// Clear any leftover alert/modal from a previous decode in this warm session.
await page.keyboard.press("Escape").catch(() => {});
await page.waitForTimeout(VINPIN_WAITS.afterAlertDismiss);
// Focus + clear the VIN field, then type the VIN.
await page.mouse.dblclick(VINPIN_COORDS.vinField.x, VINPIN_COORDS.vinField.y);
await page.keyboard.press("Control+A");
await page.keyboard.press("Delete");
await page.keyboard.type(vin, { delay: 20 });
await page.waitForTimeout(350);
await page.keyboard.press("Control+A").catch(() => {});
await page.keyboard.press("Delete").catch(() => {});
await page.waitForTimeout(200);
await page.keyboard.type(vin, { delay: 50 });
await page.waitForTimeout(400);
// Submit (arrow) and wait for the decode modal.
await page.mouse.click(VINPIN_COORDS.vinSubmitArrow.x, VINPIN_COORDS.vinSubmitArrow.y);
await page.waitForTimeout(VINPIN_WAITS.afterVinSubmit);
return this.extractModalText(page);
const text = await this.extractModalText(page);
// Genuine not-found: ePER shows a "vehicles not found" alert over the bare
// catalogue (no model/prod-date). Dismiss it and report null cleanly.
const hasResult = Boolean(text) && VINPIN_OCR.modalHit.test(text ?? "");
if (!hasResult) {
await page.mouse
.click(VINPIN_COORDS.notFoundOk.x, VINPIN_COORDS.notFoundOk.y)
.catch(() => {});
await page.keyboard.press("Escape").catch(() => {});
await page.keyboard.press("Enter").catch(() => {});
await page.waitForTimeout(VINPIN_WAITS.afterAlertDismiss);
return null;
}
return text;
}
/**
* Extract the decode-modal text. PRIMARY path = clipboard: click a neutral
* point in the modal body (focus the page, not an input), select-all, copy,
* then read the clipboard.
*
* TODO(OCR fallback seam): if the clipboard comes back empty (some Blast
* canvases render text to a bitmap and don't expose it to selection), capture
* a screenshot of the modal region and OCR it here. Intentionally NOT
* implemented now — this is just the seam. Today: empty clipboard → null →
* the caller retries / falls through to the normal no-catalog path.
* Read the decode-modal text by OCR'ing a screenshot of the modal region. The
* Blast canvas exposes no DOM text and the clipboard is unreliable, so the
* screenshot is the only dependable read channel (see vinpin.ocr).
*/
private async extractModalText(page: Page): Promise<string | null> {
try {
await page.mouse.click(VINPIN_COORDS.modalBody.x, VINPIN_COORDS.modalBody.y);
await page.keyboard.press("Control+A");
await page.keyboard.press("Control+C");
const clip = await page
.evaluate(() => {
const nav = navigator as Navigator & {
clipboard?: { readText(): Promise<string> };
};
return nav.clipboard ? nav.clipboard.readText() : Promise.resolve("");
})
.catch(() => "");
if (clip && clip.trim().length > 0) return clip;
// OCR fallback seam (not implemented — see method doc).
// const shot = await page.screenshot({ clip: { x: 380, y: 90, width: 900, height: 240 } });
// return await ocr(shot);
this.logger.warn("clipboard empty — OCR fallback not implemented (returning null)");
return null;
} catch (err) {
this.logger.warn(`extractModalText failed: ${(err as Error).message}`);
return null;
}
const text = await ocrRegion(page, VINPIN_MODAL_REGION);
return text.length > 0 ? text : null;
}
private async close(): Promise<void> {

View File

@@ -6,39 +6,65 @@
* The UI is a pixel canvas with no stable DOM selectors, so navigation is
* coordinate-driven.
*
* ⚠️ TUNE AGAINST LIVE PAID SEAT — these were derived from screenshots of a
* dying trial account; every coordinate MUST be re-verified once the real paid
* seat is wired (creds via env). Treat them as a starting point, not gospel.
* These were VERIFIED LIVE against a real Vinpin trial seat (2026-06-26) with a
* vision loop (screenshot + OCR), not guessed — every transition is OCR-checked
* at runtime and retried, so minor drift self-heals. Re-verify the Fiat-tile
* coordinate if the brand-grid layout ever changes.
*/
/** Viewport the coordinates are calibrated for. */
export const VINPIN_VIEWPORT = { width: 1600, height: 900 } as const;
/** Coordinate map (x, y) for the ePER canvas. TUNE AGAINST LIVE PAID SEAT. */
/** Coordinate map (x, y) for the ePER canvas. Verified live @ 1600x900. */
export const VINPIN_COORDS = {
/** Fiat brand tile on the post-login brand grid. */
fiatBrand: { x: 255, y: 96 },
/** "OK"/dismiss button of the spurious Russian-language dialog. */
/** Fiat brand tile on the post-login brand grid (row 2, col 2). */
fiatBrand: { x: 230, y: 104 },
/** "OK" button of the spurious Russian-language dialog shown on ePER open. */
langDialogDismiss: { x: 746, y: 454 },
/** ПОИСК (search) toolbar button. */
searchToolbar: { x: 465, y: 74 },
/** Right-panel VIN input field. */
/** "Spare Parts" catalog tile on the ePER portal home ("Доступные каталоги").
* Opens the Spare Parts Catalogue that carries the VIN identification panel.
* (The seat may open straight onto the catalogue or onto this portal — the
* driver detects which and clicks here only when stuck on the portal.) */
sparePartsTile: { x: 214, y: 330 },
/** Right-panel "Идентификация автомобиля → VIN" input field. */
vinField: { x: 1200, y: 189 },
/** Decode arrow next to the VIN field. */
/** Decode arrow (→) next to the VIN field. */
vinSubmitArrow: { x: 1283, y: 189 },
/** A neutral point inside the decode-result modal body (to focus the page,
* NOT an input, before select-all + copy). */
modalBody: { x: 450, y: 130 },
/** "OK" button of the "vehicles not found" alert (genuine not-found result). */
notFoundOk: { x: 816, y: 448 },
} as const;
/** Wait budgets (ms) for each step. TUNE AGAINST LIVE PAID SEAT. */
/** Screenshot clip (px) of the decode-result modal body, fed to OCR. Captures
* the model line ("6J - TIPO - EGEA (2015-2021)"), SINCOM, trim, prod-date and
* the echoed VIN. Verified live. */
export const VINPIN_MODAL_REGION = { x: 250, y: 120, width: 820, height: 480 } as const;
/** OCR keyword sets for state detection (case-insensitive). */
export const VINPIN_OCR = {
/** ePER (Fiat Dealer) window is open. */
eperOpen: /Dealer|Pycc|Русск|Spare|Поиск|Запчаст|EPER/i,
/** We're on the Spare Parts CATALOGUE (left model tree visible) — the page
* that carries the right-panel VIN identification field. Discriminated from
* the portal home by the model-tree tokens (which OCR cleanly in Latin),
* since the portal shows only "Accessories / Service Packages / REMAN". */
catalogueReady:
/Spare Parts Catalogue|GRANDE PANDA|124 SPIDER|TOPOLINO|TIPO|EGEA|DOBLO|ULYSSE|SEDICI/i,
/** The decode modal actually carries a result (a model / prod-date / MVS). */
modalHit:
/MVS|найден|Prod\.?\s*date|TIPO|EGEA|DOBLO|PALIO|PANDA|PUNTO|LINEA|DUCATO|QUBO|FIORINO|ULYSSE/i,
/** Genuine "no vehicle found" outcome (the catalog has no record). */
notFound: /не\s*найден|not\s*found|Catalogue/i,
} as const;
/** Wait budgets (ms) for each step. Verified live @ 1600x900. */
export const VINPIN_WAITS = {
afterGoto: 7_000,
afterLogin: 20_000,
afterFiatOpen: 13_000,
afterLangDismiss: 2_000,
afterSearchOpen: 4_000,
afterFiatOpen: 12_000,
afterLangDismiss: 2_500,
afterSearchOpen: 5_000,
afterVinSubmit: 7_000,
afterAlertDismiss: 700,
} as const;
/**

View File

@@ -0,0 +1,146 @@
/**
* Vinpin OCR — the reliable read channel for the Blast/Horizon pixel canvas.
*
* The ePER catalog renders into a canvas (no DOM text, clipboard is intermittent),
* so the ONLY dependable way to read the decode modal is a screenshot + OCR.
*
* Pipeline: `page.screenshot({ clip })` (native crop) → optional ffmpeg 3× upscale
* (greatly improves small-text accuracy; degrades gracefully to raw pixels when
* ffmpeg is absent) → tesseract.js (pure-JS, no system binary). The tesseract
* worker is a lazily-created process-wide singleton (eng traineddata cached on
* first use), reused across every decode in the warm session.
*
* Everything here is failure-tolerant: any error → "" so the driver treats it as
* "no decode" and never throws to its caller.
*
* ⚠️ RUNTIME NOTE: `ffmpeg` is a SOFT dependency (upscale only). tesseract.js
* downloads `eng.traineddata.gz` once (network) and caches it under
* VINPIN_OCR_CACHE (default os.tmpdir()/vinpin-tess). Bundle the traineddata in
* the image if the worker has no egress.
*/
import { spawn } from "node:child_process";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Logger } from "@nestjs/common";
import type { Page } from "playwright";
const logger = new Logger("VinpinOcr");
export interface OcrClip {
x: number;
y: number;
width: number;
height: number;
}
// Minimal structural type for the tesseract.js worker we use (avoids a hard type
// dep / `any` while keeping it decoupled from the lib's exported types).
interface TessWorker {
recognize(image: Buffer): Promise<{ data: { text: string } }>;
terminate(): Promise<unknown>;
}
let workerPromise: Promise<TessWorker> | null = null;
async function getWorker(): Promise<TessWorker> {
if (!workerPromise) {
workerPromise = (async () => {
// Dynamic import so a missing/broken OCR dep can never crash module load.
const tesseract = (await import("tesseract.js")) as unknown as {
createWorker: (
lang: string,
oem: number,
opts: { cachePath: string; gzip: boolean },
) => Promise<TessWorker>;
};
const cachePath = process.env.VINPIN_OCR_CACHE || join(tmpdir(), "vinpin-tess");
return tesseract.createWorker("eng", 1, { cachePath, gzip: true });
})().catch((err) => {
workerPromise = null; // allow a later retry
throw err;
});
}
return workerPromise;
}
/**
* Crop a `region` out of a full-page PNG and upscale `scale`× via ffmpeg.
*
* IMPORTANT: we crop from a FULL screenshot with ffmpeg rather than using
* Playwright's `screenshot({ clip })`. Clip-capture of the Blast compositor
* canvas intermittently returns a BLANK image under headful/xvfb even when the
* full screenshot is fine (verified failure mode) — full-frame capture is
* reliable. Falls back to the untouched PNG when ffmpeg is missing/errors.
*/
function cropScale(png: Buffer, region: OcrClip | undefined, scale: number): Promise<Buffer> {
if (!region && scale <= 1) return Promise.resolve(png);
const filters: string[] = [];
if (region) filters.push(`crop=${region.width}:${region.height}:${region.x}:${region.y}`);
if (scale > 1) filters.push(`scale=iw*${scale}:ih*${scale}:flags=lanczos`);
filters.push("format=gray");
return new Promise((resolve) => {
try {
const ff = spawn("ffmpeg", [
"-loglevel",
"error",
"-i",
"pipe:0",
"-vf",
filters.join(","),
"-f",
"image2pipe",
"-vcodec",
"png",
"pipe:1",
]);
const chunks: Buffer[] = [];
ff.stdout.on("data", (c: Buffer) => chunks.push(c));
ff.on("error", () => resolve(png)); // ffmpeg not installed
ff.on("close", (code) => resolve(code === 0 && chunks.length ? Buffer.concat(chunks) : png));
ff.stdin.on("error", () => {});
ff.stdin.end(png);
} catch {
resolve(png);
}
});
}
/** Full-page screenshot → (optional ffmpeg crop) + upscale → OCR. Returns
* whitespace-collapsed text, or "" on any failure. `clip` undefined = OCR the
* whole frame at 1× (state detection). */
export async function ocrRegion(page: Page, clip?: OcrClip, scale = 3): Promise<string> {
try {
const full = await page.screenshot({ fullPage: false });
const img = await cropScale(full, clip, clip ? scale : 1);
// Opt-in diagnostics: dump the OCR'd image (great for tuning coords in prod).
const dbg = process.env.VINPIN_DEBUG_DIR;
if (dbg) {
try {
const { writeFileSync } = await import("node:fs");
const tag = clip ? `clip-${clip.x}x${clip.y}` : "full";
writeFileSync(join(dbg, `vinpin-ocr-${tag}-${Date.now()}.png`), img);
} catch {
// ignore
}
}
const worker = await getWorker();
const { data } = await worker.recognize(img);
return (data.text || "").replace(/\s+/g, " ").trim();
} catch (err) {
logger.warn(`ocrRegion failed: ${(err as Error).message}`);
return "";
}
}
/** Tear down the shared tesseract worker (best-effort). */
export async function terminateOcr(): Promise<void> {
if (!workerPromise) return;
try {
const w = await workerPromise;
await w.terminate();
} catch {
// ignore
}
workerPromise = null;
}

133
pnpm-lock.yaml generated
View File

@@ -153,6 +153,9 @@ importers:
stripe:
specifier: ^22.1.1
version: 22.1.1(@types/node@22.19.11)
tesseract.js:
specifier: ^5.1.1
version: 5.1.1
undici:
specifier: ^7.22.0
version: 7.22.0
@@ -225,7 +228,7 @@ importers:
version: 1.159.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
better-auth:
specifier: ^1.2.0
version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8))(mysql2@3.22.4(@types/node@22.19.11))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8))(mysql2@3.22.4(@types/node@22.19.11))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
canvas-confetti:
specifier: ^1.9.4
version: 1.9.4
@@ -3875,6 +3878,9 @@ packages:
bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
bmp-js@0.1.0:
resolution: {integrity: sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==}
body-parser@1.20.4:
resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
@@ -4755,6 +4761,9 @@ packages:
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
engines: {node: '>=0.10.0'}
idb-keyval@6.2.5:
resolution: {integrity: sha512-eKQkTnS0relYsSOYomx8ozIbmdsQCKUdhyuIaQ2DZgKuaxtyQQMkyD/wlnQN32pO3yutN1b1L8uqwcDKaJd7/Q==}
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
@@ -4821,6 +4830,9 @@ packages:
is-decimal@2.0.1:
resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==}
is-electron@2.2.2:
resolution: {integrity: sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==}
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
@@ -4858,6 +4870,9 @@ packages:
resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==}
engines: {node: '>=10'}
is-url@1.2.4:
resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==}
isbot@5.1.35:
resolution: {integrity: sha512-waFfC72ZNfwLLuJ2iLaoVaqcNo+CAaLR7xCpAn0Y5WfGzkNHv7ZN39Vbi1y+kb+Zs46XHOX3tZNExroFUPX+Kg==}
engines: {node: '>=18'}
@@ -5456,6 +5471,10 @@ packages:
zod:
optional: true
opencollective-postinstall@2.0.3:
resolution: {integrity: sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==}
hasBin: true
ora@5.4.1:
resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==}
engines: {node: '>=10'}
@@ -5752,6 +5771,9 @@ packages:
reflect-metadata@0.2.2:
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
regenerator-runtime@0.13.11:
resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
remark-gfm@4.0.1:
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
@@ -6102,6 +6124,12 @@ packages:
engines: {node: '>=10'}
hasBin: true
tesseract.js-core@5.1.1:
resolution: {integrity: sha512-KX3bYSU5iGcO1XJa+QGPbi+Zjo2qq6eBhNjSGR5E5q0JtzkoipJKOUQD7ph8kFyteCEfEQ0maWLu8MCXtvX5uQ==}
tesseract.js@5.1.1:
resolution: {integrity: sha512-lzVl/Ar3P3zhpUT31NjqeCo1f+D5+YfpZ5J62eo2S14QNVOmHBTtbchHm/YAbOOOzCegFnKf4B3Qih9LuldcYQ==}
test-exclude@7.0.1:
resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==}
engines: {node: '>=18'}
@@ -6444,6 +6472,9 @@ packages:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
wasm-feature-detect@1.8.0:
resolution: {integrity: sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==}
watchpack@2.5.1:
resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==}
engines: {node: '>=10.13.0'}
@@ -6551,6 +6582,9 @@ packages:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
zlibjs@0.3.1:
resolution: {integrity: sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==}
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
@@ -10360,7 +10394,30 @@ snapshots:
drizzle-kit: 0.31.9
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8)
mysql2: 3.22.4(@types/node@22.19.11)
next: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
vitest: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
better-auth@1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8))(mysql2@3.22.4(@types/node@22.19.11))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)):
dependencies:
'@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
'@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))
'@better-auth/utils': 0.3.0
'@better-fetch/fetch': 1.1.21
'@noble/ciphers': 2.1.1
'@noble/hashes': 2.0.1
better-call: 1.1.8(zod@4.3.6)
defu: 6.1.4
jose: 6.1.3
kysely: 0.28.11
nanostores: 1.1.0
zod: 4.3.6
optionalDependencies:
drizzle-kit: 0.31.9
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8)
mysql2: 3.22.4(@types/node@22.19.11)
next: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
vitest: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
@@ -10386,6 +10443,8 @@ snapshots:
inherits: 2.0.4
readable-stream: 3.6.2
bmp-js@0.1.0: {}
body-parser@1.20.4:
dependencies:
bytes: 3.1.2
@@ -10833,6 +10892,16 @@ snapshots:
mysql2: 3.22.4(@types/node@22.19.11)
postgres: 3.4.8
drizzle-orm@0.41.0(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8):
optionalDependencies:
'@opentelemetry/api': 1.9.1
'@types/pg': 8.15.6
gel: 2.2.0
kysely: 0.28.11
mysql2: 3.22.4(@types/node@22.19.11)
postgres: 3.4.8
optional: true
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -11294,6 +11363,8 @@ snapshots:
dependencies:
safer-buffer: 2.1.2
idb-keyval@6.2.5: {}
ieee754@1.2.1: {}
immer@10.2.0: {}
@@ -11394,6 +11465,8 @@ snapshots:
is-decimal@2.0.1: {}
is-electron@2.2.2: {}
is-extglob@2.1.1: {}
is-fullwidth-code-point@3.0.0: {}
@@ -11416,6 +11489,8 @@ snapshots:
is-unicode-supported@0.1.0: {}
is-url@1.2.4: {}
isbot@5.1.35: {}
isexe@2.0.0: {}
@@ -12094,7 +12169,34 @@ snapshots:
neo-async@2.6.2: {}
next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@next/env': 16.1.6
'@swc/helpers': 0.5.15
baseline-browser-mapping: 2.9.19
caniuse-lite: 1.0.30001769
postcss: 8.4.31
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.4)
optionalDependencies:
'@next/swc-darwin-arm64': 16.1.6
'@next/swc-darwin-x64': 16.1.6
'@next/swc-linux-arm64-gnu': 16.1.6
'@next/swc-linux-arm64-musl': 16.1.6
'@next/swc-linux-x64-gnu': 16.1.6
'@next/swc-linux-x64-musl': 16.1.6
'@next/swc-win32-arm64-msvc': 16.1.6
'@next/swc-win32-x64-msvc': 16.1.6
'@opentelemetry/api': 1.9.1
'@playwright/test': 1.58.2
sharp: 0.34.5
transitivePeerDependencies:
- '@babel/core'
- babel-plugin-macros
optional: true
next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@next/env': 16.1.6
'@swc/helpers': 0.5.15
@@ -12164,6 +12266,8 @@ snapshots:
optionalDependencies:
zod: 3.25.76
opencollective-postinstall@2.0.3: {}
ora@5.4.1:
dependencies:
bl: 4.1.0
@@ -12495,6 +12599,8 @@ snapshots:
reflect-metadata@0.2.2: {}
regenerator-runtime@0.13.11: {}
remark-gfm@4.0.1:
dependencies:
'@types/mdast': 4.0.4
@@ -12915,6 +13021,23 @@ snapshots:
commander: 2.20.3
source-map-support: 0.5.21
tesseract.js-core@5.1.1: {}
tesseract.js@5.1.1:
dependencies:
bmp-js: 0.1.0
idb-keyval: 6.2.5
is-electron: 2.2.2
is-url: 1.2.4
node-fetch: 2.7.0
opencollective-postinstall: 2.0.3
regenerator-runtime: 0.13.11
tesseract.js-core: 5.1.1
wasm-feature-detect: 1.8.0
zlibjs: 0.3.1
transitivePeerDependencies:
- encoding
test-exclude@7.0.1:
dependencies:
'@istanbuljs/schema': 0.1.3
@@ -13251,6 +13374,8 @@ snapshots:
dependencies:
xml-name-validator: 5.0.0
wasm-feature-detect@1.8.0: {}
watchpack@2.5.1:
dependencies:
glob-to-regexp: 0.4.1
@@ -13404,6 +13529,8 @@ snapshots:
y18n: 5.0.8
yargs-parser: 21.1.1
zlibjs@0.3.1: {}
zod@3.25.76: {}
zod@4.3.6: {}