From 76b4fae729e1a2daf5aa609bc80ead11b4c5153a Mon Sep 17 00:00:00 2001 From: snackman Date: Thu, 4 Jun 2026 23:02:27 -0400 Subject: [PATCH 1/4] =?UTF-8?q?diavola-88607:=20Phase=207=20=E2=80=94=20Ha?= =?UTF-8?q?rdening=20for=20production?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a real automated test suite, observability scaffolding, RLS harness, training mode, and production-readiness docs — all runnable now against the mock/pure logic with zero env vars. No live service wiring (final phase). Tests (Vitest, 106 tests / 15 files, zero env): - money/pricing incl. half-and-half (left+right==whole), size deltas, integer cents; platform fee (bps+flat, rounding, clamp, card-only) - payment idempotency/no-double-charge, settled-vs-covered, split → zero, crypto pending→confirm, refund/void - offline queue idempotent upsert-by-UUID (double-flush + reconnect retry) - delivery zone gating + below-minimum + pickProvider; scheduling hours/prep - entitlements/plan gating; reports aggregation (mix, tip de-dup, voids, rollup); inventory depletion + low-stock; drawer/Z-report + idempotent close - app-layer tenant isolation (driver-scoped reads) Observability: structured logger, request/trace ids, error seam (Sentry behind SENTRY_DSN, no-op otherwise), /api/health + new /api/ready; wired into /api/orders. Training/demo mode flag (TRAINING_MODE / mock driver). RLS: expanded supabase/tests/rls_isolation.sql (memberships/users/no-orphan write) + run-rls-isolation.sh harness + README; optional non-blocking Postgres CI job. Added required vitest CI job. Docs: docs/PRODUCTION_READINESS.md + docs/IDEMPOTENCY_REVIEW.md; env inventory in .env.example. No double-charge/dupe bugs found. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 16 + .github/workflows/ci.yml | 59 + docs/IDEMPOTENCY_REVIEW.md | 65 + docs/PRODUCTION_READINESS.md | 211 ++ package-lock.json | 3280 +++++++++++++++---- package.json | 7 +- plans/diavola-88607-phase-7-hardening.md | 92 + src/app/api/health/route.ts | 16 +- src/app/api/orders/route.ts | 43 +- src/app/api/ready/route.ts | 63 + src/lib/db/mock-drawer.test.ts | 137 + src/lib/db/mock-inventory.test.ts | 124 + src/lib/db/tenant-isolation.test.ts | 157 + src/lib/delivery/service.test.ts | 94 + src/lib/delivery/zones.test.ts | 75 + src/lib/demo/mode.test.ts | 34 + src/lib/demo/mode.ts | 62 + src/lib/observability/errors.ts | 69 + src/lib/observability/index.ts | 8 + src/lib/observability/logger.ts | 100 + src/lib/observability/observability.test.ts | 98 + src/lib/observability/trace.ts | 56 + src/lib/offline/queue.test.ts | 76 + src/lib/offline/sync.test.ts | 66 + src/lib/payments/fees.test.ts | 103 + src/lib/payments/service.test.ts | 280 ++ src/lib/pricing.test.ts | 248 ++ src/lib/reports.test.ts | 296 ++ src/lib/saas/entitlements.test.ts | 92 + src/lib/shop/scheduling.test.ts | 117 + supabase/README.md | 35 +- supabase/tests/rls_isolation.sql | 37 +- supabase/tests/run-rls-isolation.sh | 37 + vitest.config.ts | 25 + 34 files changed, 5580 insertions(+), 698 deletions(-) create mode 100644 docs/IDEMPOTENCY_REVIEW.md create mode 100644 docs/PRODUCTION_READINESS.md create mode 100644 plans/diavola-88607-phase-7-hardening.md create mode 100644 src/app/api/ready/route.ts create mode 100644 src/lib/db/mock-drawer.test.ts create mode 100644 src/lib/db/mock-inventory.test.ts create mode 100644 src/lib/db/tenant-isolation.test.ts create mode 100644 src/lib/delivery/service.test.ts create mode 100644 src/lib/delivery/zones.test.ts create mode 100644 src/lib/demo/mode.test.ts create mode 100644 src/lib/demo/mode.ts create mode 100644 src/lib/observability/errors.ts create mode 100644 src/lib/observability/index.ts create mode 100644 src/lib/observability/logger.ts create mode 100644 src/lib/observability/observability.test.ts create mode 100644 src/lib/observability/trace.ts create mode 100644 src/lib/offline/queue.test.ts create mode 100644 src/lib/offline/sync.test.ts create mode 100644 src/lib/payments/fees.test.ts create mode 100644 src/lib/payments/service.test.ts create mode 100644 src/lib/pricing.test.ts create mode 100644 src/lib/reports.test.ts create mode 100644 src/lib/saas/entitlements.test.ts create mode 100644 src/lib/shop/scheduling.test.ts create mode 100644 supabase/tests/run-rls-isolation.sh create mode 100644 vitest.config.ts diff --git a/.env.example b/.env.example index ba90def..67c8028 100644 --- a/.env.example +++ b/.env.example @@ -122,6 +122,22 @@ DOORDASH_BASE_URL= # token. The link is built from NEXT_PUBLIC_APP_URL (below) or the request # origin when unset. +# ---- Observability (Phase 7) ------------------------------------------------ +# ALL OPTIONAL. The app emits structured JSON logs and threads a request/trace +# id through API routes with NONE of these set. +# • LOG_LEVEL: minimum log level (debug|info|warn|error). Defaults to `info`. +# • SENTRY_DSN: when set, the error-tracking seam (src/lib/observability/errors) +# routes captured errors to an external tracker; UNSET → structured-log no-op. +# The Sentry SDK is intentionally not yet a dependency (live wiring deferred). +LOG_LEVEL= +SENTRY_DSN= + +# ---- Training / demo mode (Phase 7) ----------------------------------------- +# OPTIONAL. TRAINING_MODE=1 (or running on the mock driver, the current default) +# marks the deployment as NOT taking real orders/money so staff can train. See +# docs/PRODUCTION_READINESS.md. Accepts 1|true|on|yes. +TRAINING_MODE= + # ---- App --------------------------------------------------------------------- # Public base URL of this deployment (used for callbacks/webhooks + magic links). NEXT_PUBLIC_APP_URL= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c54461..781ea89 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,3 +32,62 @@ jobs: - name: Build # Must build with NO env vars set (Supabase deferred). run: npm run build + + test: + name: vitest (unit + mock-driver, zero env) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run test suite + # The whole suite runs against pure logic + the in-memory mock driver, + # so it must pass with NO env vars set (mirrors the build invariant). + run: npm run test:run + + rls-isolation: + name: RLS isolation (Postgres) — optional, non-blocking + runs-on: ubuntu-latest + # This job spins up a throwaway Postgres, applies the tenancy migrations, and + # runs supabase/tests/rls_isolation.sql. It is OPTIONAL: a live DB is not a CI + # dependency for the platform, so a failure here must NOT block a PR. The + # required gates are `build` + `test` above. + continue-on-error: true + services: + postgres: + image: postgres:16 + env: + POSTGRES_PASSWORD: postgres + POSTGRES_DB: pos_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + PGPASSWORD: postgres + DATABASE_URL: postgres://postgres:postgres@localhost:5432/pos_test + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Apply tenancy migrations + run: | + for f in supabase/migrations/*.sql; do + echo "Applying $f" + psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f "$f" + done + + - name: Run RLS isolation test + run: psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f supabase/tests/rls_isolation.sql diff --git a/docs/IDEMPOTENCY_REVIEW.md b/docs/IDEMPOTENCY_REVIEW.md new file mode 100644 index 0000000..59216dc --- /dev/null +++ b/docs/IDEMPOTENCY_REVIEW.md @@ -0,0 +1,65 @@ +# Idempotency & Double-Charge Review (Phase 7) + +A focused review of every place a retry, replay, double-tap, or lost response +could create a duplicate order, a double charge, or an incorrect settlement — +plus the automated tests that pin each guarantee. All money is integer minor +units (cents / USDC base units); there is no floating-point money path. + +## The end-to-end idempotency key + +A single **client-generated UUID** flows through the whole lifecycle: + +- It is the **order** primary key (`orders.id`) and the offline-queue key. +- It is the **payment tender** primary key (`payments.id`) **and** the rail + `Idempotency-Key` forwarded to Stripe/Coinbase/onchain. + +Because the same id is the DB key and the rail key, a retry maps to exactly one +row and one upstream charge. + +## Order intake + +| Path | Guarantee | Where | Test | +|---|---|---|---| +| `POST /api/orders` → `driver.createOrder` | Upsert-by-UUID; existing id returns the stored order unchanged, order number assigned once. | `src/lib/db/mock.ts` `createOrder` | `src/lib/offline/sync.test.ts` | +| Offline queue enqueue | Keyed by order UUID; re-enqueue of a synced/pending order does not duplicate. | `src/lib/offline/queue.ts` | `src/lib/offline/queue.test.ts` | +| Offline flush | Re-entrant-guarded; safe under reconnect + interval + manual triggers; POSTs are idempotent server-side. | `src/lib/offline/sync.ts` | covered via the upsert test | + +## Payments + +| Path | Guarantee | Where | Test | +|---|---|---|---| +| `takePayment` | Repeat `paymentId` returns the existing tender → **no second charge** (checked **before** calling the rail). | `src/lib/payments/service.ts` | `payments/service.test.ts` (idempotency, one tender) | +| Settlement | Order → `paid` only when **settled** (captured/authorized) tenders cover the total; never downgrades a paid order. | `maybeMarkOrderPaid` | `payments/service.test.ts` (settled-covers, split) | +| Split payment | Balance driven to zero across multiple tenders/rails; `getOrderBalance` never negative. | `getOrderBalance` / `coveredCents` | `payments/service.test.ts` (split) | +| Crypto pending | Pending crypto counts toward the displayed balance but **not** toward `paid`; confirm → settle. | `coveredCents` vs `settledCents` | `payments/service.test.ts` (crypto pending → confirm) | +| Refund | Tender `refunded` only when fully refunded; order `refunded` only when **all** tenders refunded; partial refund does neither. | `refundPayment` | `payments/service.test.ts` (refund full + partial) | +| Platform fee | Card-only `application_fee`; `pct(bps)+flat`, round-half-up, clamped to the charge. Integer cents. | `payments/fees.ts` | `payments/fees.test.ts` | + +## Deliveries & end-of-day + +| Path | Guarantee | Where | Test | +|---|---|---|---| +| `dispatchDelivery` | Idempotent on the order id (existing delivery returned). | `src/lib/delivery/service.ts` | exercised via service tests | +| `closeBusinessDay` | Idempotent: re-closing returns the frozen Z-report snapshot. | `src/lib/db/mock.ts` | `db/mock-drawer.test.ts` | + +## Findings while writing the tests + +- **No double-charge / duplicate-order bugs were found.** The end-to-end UUID + idempotency model held under every retry/replay/split scenario tested. +- **Documented behavioural nuance (not a bug):** `refreshPaymentStatus` + early-returns when a tender is *already* `captured`/`refunded` and therefore + does **not** re-run `maybeMarkOrderPaid`. This is correct for the intended flow + (the watcher/webhook transitions pending → captured *through* + `refreshPaymentStatus`, which settles the order in the same call). If a future + live webhook captures a tender by writing the status **directly** via + `upsertPayment`, it must also call `maybeMarkOrderPaid` (or route through + `refreshPaymentStatus`) so the order settles. The crypto-confirm test exercises + the correct watcher path (`payments/service.test.ts`). Noted here for the + live-wiring phase. + +## Live-wiring follow-ups + +- Forward the tender UUID as the Stripe `Idempotency-Key` (already wired in the + rails) and as the crypto deposit reference/memo. +- Make all webhook handlers idempotent on the rail charge id, and have any + direct-capture webhook settle the order (see the nuance above). diff --git a/docs/PRODUCTION_READINESS.md b/docs/PRODUCTION_READINESS.md new file mode 100644 index 0000000..86c8a7b --- /dev/null +++ b/docs/PRODUCTION_READINESS.md @@ -0,0 +1,211 @@ +# Production Readiness — Pizzeria POS (SaaS) + +Phase 7 hardening. This document is the go-live checklist + the standing record +of the platform's correctness/security posture. It covers what is proven **now** +(against the mock driver + pure logic, zero env vars) and what remains gated on +the **final live-wiring phase** (real Supabase + Stripe + crypto + DoorDash). + +> Status of the build today: everything runs on the in-memory **mock driver** +> (`getPosDriver()`), every payment rail **simulates** settlement when its keys +> are absent, and the full automated test suite + production build pass with +> **no environment variables**. No live services are wired in this phase. + +--- + +## 1. Tenant-isolation audit (highest priority) + +Tenant data leakage is the #1 risk for a multi-tenant SaaS. Isolation is enforced +at **two layers**, both with automated tests: + +| Layer | Mechanism | Test | +|---|---|---| +| **Database (authoritative)** | Postgres **RLS** on every tenant table, keyed to the `memberships` table; `FORCE ROW LEVEL SECURITY` so even the table owner is default-denied; platform-admin bypass via `is_platform_admin()`. | `supabase/tests/rls_isolation.sql` (+ `run-rls-isolation.sh`, optional non-blocking CI job) | +| **Application** | Every `PosDriver` read is scoped by `tenant_id` (+ `location_id`). No call site issues an un-scoped query. | `src/lib/db/tenant-isolation.test.ts` (runs in required CI) | + +The app-layer test proves a second tenant created via `createTenant` cannot have +its orders, menu, inventory, reports, or locations surfaced through another +tenant's driver calls. The SQL test proves a member of tenant A cannot read or +write tenant B's tenants/locations/memberships/users, that a blocked cross-tenant +write leaves no row, and that a platform admin sees everything. + +**Go-live dependencies:** +- [ ] Provision Supabase; apply `supabase/migrations/*` and confirm RLS is ON + + FORCED on every table (existing + future orders/payments/etc. tables get + the same `memberships`-keyed policies before they hold tenant data). +- [ ] Wire Supabase Auth so `auth.uid() == public.users.id` (the RLS assumption). +- [ ] Confirm the **service-role key is never** used for tenant-scoped + reads/writes without an explicit `tenant_id` filter (server code runs as + the `authenticated` role through PostgREST). +- [ ] Run `run-rls-isolation.sh` against the live DB; expect "RLS isolation test PASSED". + +--- + +## 2. Idempotency & double-charge guarantees + +See `docs/IDEMPOTENCY_REVIEW.md` for the full review. Summary of the guarantees, +each covered by tests: + +- **Orders** — `createOrder` is an idempotent **upsert-by-client-UUID**. The + offline queue keys entries by the order UUID and the flush POSTs to + `/api/orders`; a double-flush / reconnect-retry returns the existing order and + assigns the order number **once**. (`offline/sync.test.ts`, `offline/queue.test.ts`) +- **Payments** — every tender carries a client UUID that is **both** the rail + idempotency key **and** the payment row primary key. `takePayment` returns the + existing tender on a repeat id → **no second charge**. (`payments/service.test.ts`) +- **Order settlement** — the order flips to `paid` only when **settled** tenders + (captured/authorized) cover the total; pending crypto counts toward the + displayed balance but **not** toward `paid`. Split payments drive the balance + to zero across tenders. (`payments/service.test.ts`) +- **Refund/void** — a tender is marked `refunded` when fully refunded; the order + flips to `refunded` only when **all** tenders are refunded; a partial refund + does not. (`payments/service.test.ts`) +- **Deliveries** — `dispatchDelivery` is idempotent on the order id. +- **End-of-day** — `closeBusinessDay` is idempotent: re-closing returns the + frozen Z-report snapshot. (`db/mock-drawer.test.ts`) + +**Go-live dependencies:** forward the same client UUID as the Stripe +`Idempotency-Key` (already wired in the rails) and as the crypto deposit +reference; verify webhook handlers are idempotent on the charge id. + +--- + +## 3. Offline / store-and-forward risk notes + +- The terminal **always-queues** placed orders in IndexedDB (Dexie) keyed by the + order UUID, then flushes idempotently — safe across reconnect + interval + + manual triggers (the flush is re-entrant-guarded). +- **Offline card** uses Stripe Terminal **store-and-forward** (reader secure + element queues the txn, forwards on reconnect). Known trade-offs, surface them + in UX: per-txn cap + time window, **card-present only**, **no offline refunds**, + and the **merchant carries decline risk**. Crypto + online card need connectivity. +- Mitigations in place: idempotent upsert end-to-end (no dupes on replay), clear + pending-sync count in the terminal, and a balance model that won't ask a + cashier to collect twice while a tender is in flight. + +--- + +## 4. Connect / PCI posture + +- **We never custody tenant card revenue.** Card charges (Terminal + online) are + created on the tenant's **Stripe Connect connected account**; funds settle to + **their** account. Our revenue is (1) **subscription** via Stripe Billing and + (2) a per-order **`application_fee`** off card charges. +- **We never store a PAN / card data.** Card entry is handled by Stripe + (Terminal hardware secure element / Stripe.js + PaymentIntents). No card number + touches our servers or DB → **SAQ-A-level** scope. The `payments` table stores + only rail-native ids (PaymentIntent id, charge id), amounts, and status. +- The platform fee is **disclosed to tenants at onboarding** and is **card-only** + (cash + crypto carry no `application_fee` in v1). Fee math is clamped to the + charge amount and proven in `payments/fees.test.ts`. + +**Go-live dependencies:** complete Stripe **Connect KYC onboarding** per tenant +(Stripe handles identity/KYC); confirm payouts route to the tenant's bank; never +log card data; keep the service-role key server-only. + +--- + +## 5. Crypto finality handling + +- Quote + settle in **USDC** (USD-pegged → no FX risk for USD orders). USDC has 6 + decimals; cents → base units is integer math (no float drift). +- **Onchain (Base):** an order is **not** marked paid until the tx reaches + `REQUIRED_CONFIRMATIONS` (3). The tender stays `pending` until then; the watcher + (`status`) / webhook flips it to `captured`, which then settles the order. + Reverted txs → `failed`. (`payments/service.test.ts` covers pending-not-paid → + confirm-settles.) +- **Coinbase Commerce:** charge stays `pending` until a signature-verified + `charge:confirmed`/`charge:resolved` webhook. +- Pending crypto counts toward the **displayed** balance (so the cashier isn't + asked to collect twice) but never toward `paid`. + +**Go-live dependencies:** set `BASE_RPC_URL` (+ Privy for per-order deposit +addresses) and/or `COINBASE_COMMERCE_API_KEY` + webhook secret; handle +under/overpay + quote expiry; decide platform-fee-on-crypto (v1: subscription-only). + +--- + +## 6. Backups & data durability (Supabase) + +- Use Supabase **Point-in-Time Recovery (PITR)** on the production project (paid + tier) for continuous WAL backups; otherwise rely on daily automated backups. +- [ ] Enable PITR (or daily backups) on the prod project; document RPO/RTO. +- [ ] Periodically test a **restore** into a scratch project. +- [ ] Treat `supabase/migrations/*` as the source of truth; never hand-edit prod. +- [ ] Keep the `service_role` key in a secret manager; rotate on exposure. + +--- + +## 7. Observability + +Scaffolding added this phase, all **env-optional** and no-op-safe: + +- **Structured logging** (`src/lib/observability/logger.ts`) — single-line JSON to + stdout/stderr, `LOG_LEVEL`-aware (default `info`), with per-request child + loggers carrying a correlation id. +- **Request/trace ids** (`src/lib/observability/trace.ts`) — reuse upstream + `x-request-id` / `x-trace-id` / W3C `traceparent`, else mint one; echoed on the + response. Wired into `/api/orders` (representative) + the health/ready endpoints. +- **Error-tracking seam** (`src/lib/observability/errors.ts`) — `captureError` + always emits a structured error log and routes to an external tracker (Sentry) + **only when `SENTRY_DSN` is set**; a no-op otherwise (SDK intentionally not yet + a dependency). +- **Health/readiness** — `/api/health` (liveness) and `/api/ready` (readiness: + probes the driver, reports training-mode + 503 if a future dependency is down). + +**Go-live dependencies:** add the Sentry SDK behind `SENTRY_DSN`; point a log +drain at the JSON logs; alert on `/api/ready` 503s + error-rate. + +--- + +## 8. Training / demo mode + +`src/lib/demo/mode.ts` formalizes a `TRAINING_MODE` flag (also implied whenever +the **mock driver** is active — the current default, where nothing is real). In +training mode, payments simulate and orders are disposable, so a tenant can train +staff without real charges. `/api/ready` and `demoModeInfo()` expose a badge +(`"TRAINING MODE — orders and payments are simulated and not charged."`). + +**Go-live dependencies:** surface the training badge in the terminal/admin +chrome; ensure flipping `TRAINING_MODE` off (with live keys) is the explicit +"now taking real orders" switch. + +--- + +## 9. Environment variable inventory + +Full annotated list in `.env.example` (all blank — **public repo, never commit +secrets**). Everything is optional; the app builds + the suite passes with none. + +| Area | Vars | Unset behaviour | +|---|---|---| +| Supabase | `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `DATABASE_URL` | Mock driver | +| Stripe (Connect/Terminal/online) | `STRIPE_SECRET_KEY`, `STRIPE_*PUBLISHABLE_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_CONNECT_CLIENT_ID` | Simulated charges + Connect | +| Platform fee | `PLATFORM_FEE_BPS`, `PLATFORM_FEE_FLAT_CENTS` | 250 bps + 10¢ | +| Stripe Billing | `STRIPE_PRICE_STARTER/_PRO/_MULTI`, `STRIPE_BILLING_WEBHOOK_SECRET` | Simulated subscriptions | +| Crypto onchain | `BASE_RPC_URL`, `NEXT_PUBLIC_PRIVY_APP_ID`, `PRIVY_APP_SECRET`, `USDC_*`, `CRYPTO_PAY_TO_ADDRESS` | Simulated auto-confirm | +| Crypto Coinbase | `COINBASE_COMMERCE_API_KEY`, `COINBASE_COMMERCE_WEBHOOK_SECRET` | Simulated auto-confirm | +| KDS | `KDS_POLL_INTERVAL_MS`, `KITCHEN_PRINTER_URL` | Polling realtime + browser print | +| Delivery | `DOORDASH_DEVELOPER_ID`, `DOORDASH_KEY_ID`, `DOORDASH_SIGNING_SECRET`, `DOORDASH_BASE_URL` | In-house real, DoorDash simulated | +| Observability | `LOG_LEVEL`, `SENTRY_DSN` | info logs, error-tracking no-op | +| Training | `TRAINING_MODE` | Mock driver ⇒ training on | +| App | `NEXT_PUBLIC_APP_URL` | Request origin | + +--- + +## 10. Go-live checklist (depends on the final live-wiring phase) + +- [ ] Provision Supabase; apply migrations; enable + verify **RLS/FORCE** on all + tenant tables; run the RLS isolation harness green. +- [ ] Wire Supabase Auth (`auth.uid() == users.id`); swap `getPosDriver()` to the + Supabase driver (single switch in `src/lib/db/client.ts`, no call-site changes). +- [ ] Enable **PITR/backups**; test a restore. +- [ ] Stripe: live keys + **Connect onboarding per tenant** (KYC); Billing Prices + per tier; verify webhooks (payments, Connect, Billing) signature-checked. +- [ ] Crypto: `BASE_RPC_URL` (+ Privy) and/or Coinbase keys + webhook secret; + confirm confirmation thresholds + under/overpay handling. +- [ ] DoorDash Drive keys (optional); confirm in-house dispatch. +- [ ] Observability: Sentry behind `SENTRY_DSN`; log drain; alerts on `/api/ready`. +- [ ] Flip `TRAINING_MODE` off for real stores; confirm the training badge clears. +- [ ] Hardware field test (reader, printer, drawer, tablet) — separate from this + code phase. +- [ ] Re-run the full Vitest suite + production build with the live env; both green. diff --git a/package-lock.json b/package-lock.json index f810389..809e184 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,12 +30,15 @@ "eslint": "^9.17.0", "eslint-config-next": "15.5.19", "eslint-config-prettier": "^9.1.0", + "fake-indexeddb": "^6.2.5", + "jsdom": "^25.0.1", "postcss": "^8.4.49", "prettier": "^3.4.2", "prettier-plugin-tailwindcss": "^0.6.9", "serwist": "^9.5.11", "tailwindcss": "^3.4.17", - "typescript": "^5.7.2" + "typescript": "^5.7.2", + "vitest": "^2.1.9" }, "engines": { "node": ">=20" @@ -53,6 +56,142 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -86,957 +225,1698 @@ "tslib": "^2.4.0" } }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + "node": ">=12" } }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=12" } }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + "node": ">=12" } }, - "node_modules/@eslint/config-array": { - "version": "0.21.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", - "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.5" - }, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=12" } }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=12" } }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=12" } }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", - "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.5", - "strip-json-comments": "^3.1.1" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=12" } }, - "node_modules/@eslint/js": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", - "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" + "node": ">=12" } }, - "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=12" } }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", - "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/types": "^0.15.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.8", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", - "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.2", - "@humanfs/types": "^0.15.0", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/types": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", - "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@img/colour": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", - "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "license": "MIT", "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", "cpu": [ - "arm64" + "ia32" ], - "license": "Apache-2.0", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "darwin" + "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" + "node": ">=12" } }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", "cpu": [ - "x64" + "loong64" ], - "license": "Apache-2.0", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "darwin" + "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" + "node": ">=12" } }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", "cpu": [ - "arm64" + "mips64el" ], - "license": "LGPL-3.0-or-later", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "darwin" + "linux" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=12" } }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", "cpu": [ - "x64" + "ppc64" ], - "license": "LGPL-3.0-or-later", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "darwin" + "linux" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=12" } }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", "cpu": [ - "arm" + "riscv64" ], - "license": "LGPL-3.0-or-later", + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=12" } }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", "cpu": [ - "arm64" + "s390x" ], - "license": "LGPL-3.0-or-later", + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=12" } }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", "cpu": [ - "ppc64" + "x64" ], - "license": "LGPL-3.0-or-later", + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=12" } }, - "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", "cpu": [ - "riscv64" + "x64" ], - "license": "LGPL-3.0-or-later", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "linux" + "netbsd" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=12" } }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", "cpu": [ - "s390x" + "x64" ], - "license": "LGPL-3.0-or-later", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "linux" + "openbsd" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=12" } }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", "cpu": [ "x64" ], - "license": "LGPL-3.0-or-later", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "linux" + "sunos" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=12" } }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", "cpu": [ "arm64" ], - "license": "LGPL-3.0-or-later", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=12" } }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", "cpu": [ - "x64" + "ia32" ], - "license": "LGPL-3.0-or-later", - "optional": true, + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@next/env": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.19.tgz", + "integrity": "sha512-sWWluFvcv5v3Fxznmf2ZfjyoVQt/64oCnYqS90inQWGzMPK1VjvekPiz3OPHKmFT30EnHrjlbyaHLt3M0vWabw==", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.19.tgz", + "integrity": "sha512-Ctwb4qYuMbHN/1oXLlTdMchwG8h8Xzwq+wGZZMgF3o6+uwyBKAI2c96bdOsl+C62PaUD0Jkh+QpNkhUeDlam0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "3.3.1" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.19.tgz", + "integrity": "sha512-jx9wWlTKueHKPvVOndyr7WuaevWCkuYqsQ8gC0TMPKAVWG3MhcdMrjfo9tvIZNXd0QOUYXXvAcZ325y8Uq7uzg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, "os": [ - "linux" + "darwin" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">= 10" } }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "node_modules/@next/swc-darwin-x64": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.19.tgz", + "integrity": "sha512-291KFcsIQ3OenRdiUDFOR6W3wezzH4auENXm1gbm1Bjd4ANMMRgxPrWTUztQN43BnVoVuMnHCrLeECIMwgFKbA==", "cpu": [ - "arm" + "x64" ], - "license": "Apache-2.0", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.19.tgz", + "integrity": "sha512-WeH+nelQyyMeE2f8FxBRZNrGipya5zHZV2vjzfCOAYyiI6am+NbnWAAldOBFQBB2w0DjJcsvrKqoFT2b7+5YoA==", + "cpu": [ + "arm64" + ], + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" + "node": ">= 10" } }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.19.tgz", + "integrity": "sha512-5xTOE0lDlDCSSfp+BAif7j17VRRCjWp//ZPZy6NI0QpdrhxtQnsZguSx0xAAZ0c9XZLrLLwCe/XVe5YPrRilKw==", "cpu": [ "arm64" ], - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" + "node": ">= 10" } }, - "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.19.tgz", + "integrity": "sha512-LTxRmMgqqMv05Had879W00Fm53quiJd3Zuz8h1JSNJ3nGSlbZ/7Tjs1tKyScgN3Au3t3MyPsjPlq60fMmSHLsg==", "cpu": [ - "ppc64" + "x64" ], - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" + "node": ">= 10" } }, - "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.19.tgz", + "integrity": "sha512-eoNQSpA5PQfB9wBO4RA47MTDXWz1fizy9Y3Z6e4DetYIF3dvjuu8sj7aIGn/bFCU6lnFzTK34NtCaffP4NsQ7Q==", "cpu": [ - "riscv64" + "x64" ], - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.19.tgz", + "integrity": "sha512-6UNt2dFuCHOe446sm/Kp69nUe8/wIhnh9bm6Xcqw4qEWCOppLMOvhTBVgvM7invVUNr4SPpP6NOQsACtn2IN9Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.19.tgz", + "integrity": "sha512-PhmojAHyqMne56HBLGu9dhDnHPuFmEjrXSQMM/nW0J6j849lk3ESrVtqNJcCk8CKOV7brpTTbaYAjwKPzKM69w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" }, - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", + "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", + "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", + "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", "cpu": [ - "s390x" + "arm64" ], - "license": "Apache-2.0", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" - } + "darwin" + ] }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", + "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", "cpu": [ "x64" ], - "license": "Apache-2.0", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" - } + "darwin" + ] }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", + "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", "cpu": [ "arm64" ], - "license": "Apache-2.0", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" - } + "freebsd" + ] }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", + "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", "cpu": [ "x64" ], - "license": "Apache-2.0", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" - } + "freebsd" + ] }, - "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", + "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", "cpu": [ - "wasm32" + "arm" ], - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "dev": true, + "license": "MIT", "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.7.0" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } + "os": [ + "linux" + ] }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", + "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", "cpu": [ - "arm64" + "arm" ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } + "linux" + ] }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", + "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", "cpu": [ - "ia32" + "arm64" ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } + "linux" + ] }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz", + "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==", "cpu": [ - "x64" + "arm64" ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } + "linux" + ] }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz", + "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==", + "cpu": [ + "loong64" + ], + "dev": true, "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "license": "MIT" + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz", + "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==", + "cpu": [ + "loong64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz", + "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, - "node_modules/@next/env": { - "version": "15.5.19", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.19.tgz", - "integrity": "sha512-sWWluFvcv5v3Fxznmf2ZfjyoVQt/64oCnYqS90inQWGzMPK1VjvekPiz3OPHKmFT30EnHrjlbyaHLt3M0vWabw==", - "license": "MIT" + "os": [ + "linux" + ] }, - "node_modules/@next/eslint-plugin-next": { - "version": "15.5.19", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.19.tgz", - "integrity": "sha512-Ctwb4qYuMbHN/1oXLlTdMchwG8h8Xzwq+wGZZMgF3o6+uwyBKAI2c96bdOsl+C62PaUD0Jkh+QpNkhUeDlam0Q==", + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz", + "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", - "dependencies": { - "fast-glob": "3.3.1" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@next/swc-darwin-arm64": { - "version": "15.5.19", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.19.tgz", - "integrity": "sha512-jx9wWlTKueHKPvVOndyr7WuaevWCkuYqsQ8gC0TMPKAVWG3MhcdMrjfo9tvIZNXd0QOUYXXvAcZ325y8Uq7uzg==", + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz", + "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==", "cpu": [ - "arm64" + "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } + "linux" + ] }, - "node_modules/@next/swc-darwin-x64": { - "version": "15.5.19", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.19.tgz", - "integrity": "sha512-291KFcsIQ3OenRdiUDFOR6W3wezzH4auENXm1gbm1Bjd4ANMMRgxPrWTUztQN43BnVoVuMnHCrLeECIMwgFKbA==", + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz", + "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==", "cpu": [ - "x64" + "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } + "linux" + ] }, - "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.5.19", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.19.tgz", - "integrity": "sha512-WeH+nelQyyMeE2f8FxBRZNrGipya5zHZV2vjzfCOAYyiI6am+NbnWAAldOBFQBB2w0DjJcsvrKqoFT2b7+5YoA==", + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz", + "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==", "cpu": [ - "arm64" + "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ], - "engines": { - "node": ">= 10" - } + ] }, - "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.5.19", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.19.tgz", - "integrity": "sha512-5xTOE0lDlDCSSfp+BAif7j17VRRCjWp//ZPZy6NI0QpdrhxtQnsZguSx0xAAZ0c9XZLrLLwCe/XVe5YPrRilKw==", + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz", + "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==", "cpu": [ - "arm64" + "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ], - "engines": { - "node": ">= 10" - } + ] }, - "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.5.19", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.19.tgz", - "integrity": "sha512-LTxRmMgqqMv05Had879W00Fm53quiJd3Zuz8h1JSNJ3nGSlbZ/7Tjs1tKyScgN3Au3t3MyPsjPlq60fMmSHLsg==", + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz", + "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ], - "engines": { - "node": ">= 10" - } + ] }, - "node_modules/@next/swc-linux-x64-musl": { - "version": "15.5.19", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.19.tgz", - "integrity": "sha512-eoNQSpA5PQfB9wBO4RA47MTDXWz1fizy9Y3Z6e4DetYIF3dvjuu8sj7aIGn/bFCU6lnFzTK34NtCaffP4NsQ7Q==", + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz", + "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } + "openbsd" + ] }, - "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.5.19", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.19.tgz", - "integrity": "sha512-6UNt2dFuCHOe446sm/Kp69nUe8/wIhnh9bm6Xcqw4qEWCOppLMOvhTBVgvM7invVUNr4SPpP6NOQsACtn2IN9Q==", + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz", + "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } + "openharmony" + ] }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.5.19", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.19.tgz", - "integrity": "sha512-PhmojAHyqMne56HBLGu9dhDnHPuFmEjrXSQMM/nW0J6j849lk3ESrVtqNJcCk8CKOV7brpTTbaYAjwKPzKM69w==", + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz", + "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==", "cpu": [ - "x64" + "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } + ] }, - "node_modules/@nolyfill/is-core-module": { - "version": "1.0.39", - "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", - "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz", + "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==", + "cpu": [ + "ia32" + ], "dev": true, "license": "MIT", - "engines": { - "node": ">=12.4.0" - } + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", - "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz", + "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@radix-ui/react-slot": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", - "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz", + "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } + "optional": true, + "os": [ + "win32" + ] }, "node_modules/@rtsao/scc": { "version": "1.1.0", @@ -1861,33 +2741,146 @@ "win32" ] }, - "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", - "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", - "cpu": [ - "ia32" - ], + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } }, - "node_modules/@unrs/resolver-binding-win32-x64-msvc": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", - "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", - "cpu": [ - "x64" - ], + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } }, "node_modules/acorn": { "version": "8.16.0", @@ -1912,6 +2905,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -2147,6 +3150,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -2164,6 +3177,13 @@ "node": ">= 0.4" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/autoprefixer": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", @@ -2326,6 +3346,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -2415,6 +3445,23 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2432,6 +3479,16 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -2515,6 +3572,19 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -2568,6 +3638,27 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2582,6 +3673,57 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -2654,6 +3796,23 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2697,6 +3856,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2767,6 +3936,19 @@ "dev": true, "license": "MIT" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-abstract": { "version": "1.24.2", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", @@ -2883,6 +4065,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -2943,6 +4132,45 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3376,6 +4604,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3386,6 +4624,26 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3525,6 +4783,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -3878,6 +5153,60 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/idb": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz", @@ -4202,6 +5531,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -4418,6 +5754,84 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -4580,6 +5994,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "11.5.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", @@ -4599,6 +6020,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -4631,6 +6062,29 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -4851,6 +6305,13 @@ "node": ">=0.10.0" } }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5063,6 +6524,19 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -5106,6 +6580,23 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5644,6 +7135,58 @@ "node": ">=0.10.0" } }, + "node_modules/rollup": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", + "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.61.1", + "@rollup/rollup-android-arm64": "4.61.1", + "@rollup/rollup-darwin-arm64": "4.61.1", + "@rollup/rollup-darwin-x64": "4.61.1", + "@rollup/rollup-freebsd-arm64": "4.61.1", + "@rollup/rollup-freebsd-x64": "4.61.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", + "@rollup/rollup-linux-arm-musleabihf": "4.61.1", + "@rollup/rollup-linux-arm64-gnu": "4.61.1", + "@rollup/rollup-linux-arm64-musl": "4.61.1", + "@rollup/rollup-linux-loong64-gnu": "4.61.1", + "@rollup/rollup-linux-loong64-musl": "4.61.1", + "@rollup/rollup-linux-ppc64-gnu": "4.61.1", + "@rollup/rollup-linux-ppc64-musl": "4.61.1", + "@rollup/rollup-linux-riscv64-gnu": "4.61.1", + "@rollup/rollup-linux-riscv64-musl": "4.61.1", + "@rollup/rollup-linux-s390x-gnu": "4.61.1", + "@rollup/rollup-linux-x64-gnu": "4.61.1", + "@rollup/rollup-linux-x64-musl": "4.61.1", + "@rollup/rollup-openbsd-x64": "4.61.1", + "@rollup/rollup-openharmony-arm64": "4.61.1", + "@rollup/rollup-win32-arm64-msvc": "4.61.1", + "@rollup/rollup-win32-ia32-msvc": "4.61.1", + "@rollup/rollup-win32-x64-gnu": "4.61.1", + "@rollup/rollup-win32-x64-msvc": "4.61.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -5722,6 +7265,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -5953,6 +7516,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map": { "version": "0.8.0-beta.0", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", @@ -5983,6 +7553,20 @@ "dev": true, "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -6203,6 +7787,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tagged-tag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", @@ -6342,6 +7933,20 @@ "node": ">=0.8" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -6387,6 +7992,56 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -6399,6 +8054,19 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tr46": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", @@ -6679,6 +8347,168 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/webidl-conversions": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", @@ -6686,6 +8516,30 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-url": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", @@ -6803,6 +8657,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -6813,6 +8684,45 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index e6abf3c..2025a13 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "start": "next start", "lint": "next lint", "typecheck": "tsc --noEmit", + "test": "vitest", + "test:run": "vitest run", "format": "prettier --write .", "format:check": "prettier --check ." }, @@ -35,12 +37,15 @@ "eslint": "^9.17.0", "eslint-config-next": "15.5.19", "eslint-config-prettier": "^9.1.0", + "fake-indexeddb": "^6.2.5", + "jsdom": "^25.0.1", "postcss": "^8.4.49", "prettier": "^3.4.2", "prettier-plugin-tailwindcss": "^0.6.9", "serwist": "^9.5.11", "tailwindcss": "^3.4.17", - "typescript": "^5.7.2" + "typescript": "^5.7.2", + "vitest": "^2.1.9" }, "engines": { "node": ">=20" diff --git a/plans/diavola-88607-phase-7-hardening.md b/plans/diavola-88607-phase-7-hardening.md new file mode 100644 index 0000000..bd56e77 --- /dev/null +++ b/plans/diavola-88607-phase-7-hardening.md @@ -0,0 +1,92 @@ +# diavola-88607 — Phase 7: Hardening for Production + +Make the codebase production-trustworthy: a real automated test suite over the +risk areas, observability scaffolding, RLS harness, training mode, and +documented production readiness — all runnable **now** against the mock/pure +logic with **zero env vars**. No live Supabase/Stripe/crypto/DoorDash wiring +(that is the separate final phase). + +## What was built + +### 1. Automated test suite (Vitest) +- Added **Vitest 2** (+ `fake-indexeddb`, `jsdom`) as dev deps; wired + `npm test` (watch) and `npm run test:run` (CI). `vitest.config.ts` mirrors the + tsconfig `@/*` alias and runs in a Node environment. +- **106 tests across 15 files**, all passing with no env vars: + + | Risk area | File | Coverage | + |---|---|---| + | Money / pricing | `src/lib/pricing.test.ts` (14) | half-and-half (left+right == whole, ceil-rounded; no leak), size deltas, discount-before-tax, integer-cents invariants, round-half-up | + | Platform fee | `src/lib/payments/fees.test.ts` (9) | bps+flat, round-half-up, clamp to charge, card-only, non-negative | + | Payment idempotency / settlement | `src/lib/payments/service.test.ts` (8) | same UUID → one tender, paid-only-when-settled, split → zero balance, crypto pending-not-paid → confirm-settles, refund full/partial/void | + | Offline queue | `src/lib/offline/queue.test.ts` (3), `sync.test.ts` (2) | idempotent upsert-by-UUID; double-flush + reconnect retry never duplicates | + | Delivery | `src/lib/delivery/zones.test.ts` (8), `service.test.ts` (6) | out-of-zone + below-minimum rejected, fee/ETA quote, `pickProvider` selection (incl. pickup-only → null) | + | Scheduling | `src/lib/shop/scheduling.test.ts` (12) | open/closed + midnight-wrap, ASAP gate, scheduled lead/horizon/hours rejections | + | Entitlements | `src/lib/saas/entitlements.test.ts` (9) | over-limit location block on Starter / allowed on Pro / unlimited Multi, online-ordering + advanced-reports gates, canceled → blocked | + | Reports | `src/lib/reports.test.ts` (7) | payment mix, **tip de-dup** (online order-level vs in-store tender), voids, failed-tender exclusion, per-location vs tenant rollup, date range | + | Inventory | `src/lib/db/mock-inventory.test.ts` (5) | depletion on order (qty × line), low-stock flag, voided no-deplete, clamp ≥ 0 | + | Drawer / Z-report | `src/lib/db/mock-drawer.test.ts` (4) | over/short math (over + short), idempotent business-day close | + | App-layer tenant isolation | `src/lib/db/tenant-isolation.test.ts` (5) | a tenant can't read another's orders/menu/inventory/reports/locations via the driver | + | Observability | `src/lib/observability/observability.test.ts` (11) | logger record/level, trace-id reuse/mint/traceparent, error seam log↔sentry | + | Training mode | `src/lib/demo/mode.test.ts` (3) | env flag + mock-driver implies training | + +### 2. RLS isolation harness +- Expanded `supabase/tests/rls_isolation.sql`: added assertions that the blocked + cross-tenant write left **no row**, that **memberships** are tenant-scoped, and + that a member can't read another tenant's **user** row. +- Added `supabase/tests/run-rls-isolation.sh` (one-command harness: apply + migrations + run the test against any Postgres) and documented it in + `supabase/README.md`. +- CI: added an **optional, non-blocking** `rls-isolation` job (Postgres service, + `continue-on-error: true`) — a live DB is never a required CI gate. + +### 3. Idempotency / double-charge review +- `docs/IDEMPOTENCY_REVIEW.md`: full review of order/payment/delivery/EOD + idempotency with the test that pins each guarantee. +- **No double-charge/dupe bugs found.** Documented one behavioural nuance (not a + bug): a live webhook that captures a tender by writing status *directly* via + `upsertPayment` must also settle the order (the watcher path via + `refreshPaymentStatus` already does). Noted for live wiring. + +### 4. Observability scaffolding (all env-optional, no-op safe) +- `src/lib/observability/logger.ts` — structured JSON logs, `LOG_LEVEL`-aware, + per-request child loggers. +- `src/lib/observability/trace.ts` — request/trace id (reuse `x-request-id` / + `x-trace-id` / W3C `traceparent`, else mint), echoed on responses. +- `src/lib/observability/errors.ts` — `captureError` seam: structured-log no-op, + routes to Sentry only when `SENTRY_DSN` set (SDK intentionally not a dep yet). +- Wired into `src/app/api/orders/route.ts` (representative route). +- `src/app/api/health/route.ts` (liveness, now echoes request id) + + **new** `src/app/api/ready/route.ts` (readiness: probes the driver, reports + training mode, 503 if a future dependency is down). + +### 5. Production-readiness doc +- `docs/PRODUCTION_READINESS.md`: tenant-isolation audit (two-layer), idempotency + guarantees, offline/store-and-forward risks, Connect/PCI posture (no PAN, SAQ-A + scope, Connect application_fee), crypto finality, Supabase PITR/backups, full + env-var inventory, and the go-live checklist gated on the live-wiring phase. + +### 6. Training / demo mode +- `src/lib/demo/mode.ts`: `TRAINING_MODE` flag (also implied by the mock driver) + + `demoModeInfo()` banner, surfaced by `/api/ready`. Documented in the readiness + doc + `.env.example`. + +### 7. CI +- Added a required **`test`** job (`npm run test:run`, zero env) to + `.github/workflows/ci.yml` alongside `build`, plus the optional `rls-isolation` + job. + +### 8. Env +- `.env.example` extended with `LOG_LEVEL`, `SENTRY_DSN`, `TRAINING_MODE` (all + blank — public repo). + +## Local results (zero env vars) +- `npm run test:run` → **106 passed / 15 files** +- `npm run typecheck` → clean +- `npm run lint` → no warnings/errors +- `npm run build` → success (incl. new `/api/ready`) + +## Scope discipline +No live services wired, no new product features. Tests, observability, docs, and +the small surface additions (ready endpoint, training flag) only. Everything is +green with no env vars. diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index 25c6929..d692794 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -1,8 +1,16 @@ import { NextResponse } from "next/server"; +import { resolveRequestId, traceResponseHeaders } from "@/lib/observability"; -/** Liveness/health endpoint. Returns 200 with a static body — no env vars required. */ -export const dynamic = "force-static"; +/** + * Liveness endpoint — is the process up and serving? Cheap, dependency-free, and + * always 200 with no env vars. Echoes the request id for trace continuity. + */ +export const runtime = "nodejs"; -export function GET() { - return NextResponse.json({ ok: true }); +export function GET(request: Request) { + const requestId = resolveRequestId(request.headers); + return NextResponse.json( + { ok: true, status: "live", time: new Date().toISOString(), requestId }, + { headers: traceResponseHeaders(requestId) }, + ); } diff --git a/src/app/api/orders/route.ts b/src/app/api/orders/route.ts index 5d367ae..904d4b8 100644 --- a/src/app/api/orders/route.ts +++ b/src/app/api/orders/route.ts @@ -12,6 +12,12 @@ */ import { NextResponse } from "next/server"; import { getPosDriver, type CreateOrderInput } from "@/lib/db"; +import { + captureError, + childLogger, + resolveRequestId, + traceResponseHeaders, +} from "@/lib/observability"; // In-memory mock state lives in the Node runtime; force it (not edge). export const runtime = "nodejs"; @@ -31,23 +37,50 @@ function isValidPayload(body: unknown): body is CreateOrderInput { } export async function POST(request: Request) { + const requestId = resolveRequestId(request.headers); + const log = childLogger({ requestId, route: "POST /api/orders" }); + const headers = traceResponseHeaders(requestId); + let body: unknown; try { body = await request.json(); } catch { - return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 }); + return NextResponse.json( + { error: "Invalid JSON body." }, + { status: 400, headers }, + ); } if (!isValidPayload(body)) { return NextResponse.json( { error: "Malformed order payload." }, - { status: 422 }, + { status: 422, headers }, ); } - const driver = getPosDriver(); - const order = await driver.createOrder(body); - return NextResponse.json({ order }, { status: 201 }); + try { + const driver = getPosDriver(); + // createOrder is an idempotent upsert-by-UUID — retries return the existing + // order, so this log line may report the same order id more than once. + const order = await driver.createOrder(body); + log.info("order_upserted", { + orderId: order.id, + tenantId: order.tenant_id, + locationId: order.location_id, + total_cents: order.totals.total_cents, + }); + return NextResponse.json({ order }, { status: 201, headers }); + } catch (err) { + captureError(err, { + requestId, + scope: "orders", + tenantId: (body as CreateOrderInput).tenant_id, + }); + return NextResponse.json( + { error: "Failed to place order." }, + { status: 500, headers }, + ); + } } export async function GET(request: Request) { diff --git a/src/app/api/ready/route.ts b/src/app/api/ready/route.ts new file mode 100644 index 0000000..3ebb0e3 --- /dev/null +++ b/src/app/api/ready/route.ts @@ -0,0 +1,63 @@ +/** + * Readiness endpoint (Phase 7 observability). + * + * Liveness (`/api/health`) says "the process is up"; readiness says "the app's + * dependencies are usable, so it can serve traffic". Today the only data + * dependency is the driver (mock or, later, Supabase). We probe it with a cheap + * call and report each dependency's status. It returns 200 when ready (the mock + * driver is always ready, so the zero-env preview/CI is green) and 503 if a + * future live dependency is unreachable. + * + * Also surfaces whether the deployment is in TRAINING/demo mode so operators can + * tell a live store from a training one at a glance. + */ +import { NextResponse } from "next/server"; +import { getPosDriver } from "@/lib/db"; +import { demoModeInfo } from "@/lib/demo/mode"; +import { + captureError, + resolveRequestId, + traceResponseHeaders, +} from "@/lib/observability"; + +export const runtime = "nodejs"; + +interface Check { + name: string; + ok: boolean; + detail?: string; +} + +export async function GET(request: Request) { + const requestId = resolveRequestId(request.headers); + const checks: Check[] = []; + + // Driver probe: a trivial read proves the data layer is wired + responsive. + try { + const driver = getPosDriver(); + await driver.listTenants(); + checks.push({ name: "driver", ok: true, detail: driver.name }); + } catch (err) { + captureError(err, { requestId, scope: "ready" }); + checks.push({ + name: "driver", + ok: false, + detail: err instanceof Error ? err.message : "unavailable", + }); + } + + const ready = checks.every((c) => c.ok); + const demo = demoModeInfo(); + return NextResponse.json( + { + ok: ready, + status: ready ? "ready" : "not_ready", + checks, + trainingMode: demo.trainingMode, + driver: demo.driver, + time: new Date().toISOString(), + requestId, + }, + { status: ready ? 200 : 503, headers: traceResponseHeaders(requestId) }, + ); +} diff --git a/src/lib/db/mock-drawer.test.ts b/src/lib/db/mock-drawer.test.ts new file mode 100644 index 0000000..f1b6690 --- /dev/null +++ b/src/lib/db/mock-drawer.test.ts @@ -0,0 +1,137 @@ +/** + * Drawer reconciliation + end-of-day (Z-report) tests against the mock driver. + * + * Over/short = counted − expected, where expected = float + cash sales + paid-in + * − payouts. Closing a business day is idempotent: re-closing returns the frozen + * snapshot rather than recomputing. + */ +import { describe, it, expect, beforeEach } from "vitest"; +import { + getPosDriver, + DEMO_TENANT_ID, + DEMO_LOCATION_DOWNTOWN_ID, +} from "@/lib/db"; +import { resetMockOrders } from "@/lib/db/mock"; + +const STAFF = "80000000-0000-0000-0000-000000000001"; + +beforeEach(() => resetMockOrders()); + +async function openShiftWithEvents(float: number) { + const driver = getPosDriver(); + const staffId = `${STAFF}-${Math.random().toString(36).slice(2, 8)}`; + // Ensure the staff row exists (unique per test to avoid an already-open shift). + await driver.upsertStaff({ + id: staffId, + tenant_id: DEMO_TENANT_ID, + name: "Cashier", + role: "cashier", + active: true, + created_at: new Date().toISOString(), + }); + const shift = await driver.openShift({ + tenantId: DEMO_TENANT_ID, + locationId: DEMO_LOCATION_DOWNTOWN_ID, + staffId, + openingFloatCents: float, + }); + return shift; +} + +describe("drawer reconciliation over/short math", () => { + it("computes expected = float + sales + paid_in − payouts and over/short", async () => { + const driver = getPosDriver(); + const shift = await openShiftWithEvents(10000); // $100 float + + const ev = (type: "sale" | "paid_in" | "payout", amount: number) => + driver.addShiftCashEvent({ + id: "", + shift_id: shift.id, + tenant_id: DEMO_TENANT_ID, + location_id: DEMO_LOCATION_DOWNTOWN_ID, + type, + amount_cents: amount, + order_id: null, + note: null, + created_at: new Date().toISOString(), + }); + + await ev("sale", 5000); // +$50 cash sales + await ev("paid_in", 2000); // +$20 paid in + await ev("payout", -1500); // −$15 payout (stored signed) + + // expected = 10000 + 5000 + 2000 − 1500 = 15500 + const recBefore = await driver.getDrawerReconciliation(shift.id); + expect(recBefore.expected_cents).toBe(15500); + expect(recBefore.over_short_cents).toBeNull(); // not counted yet + + // Close counting $156.00 → over by $1.00. + await driver.closeShift({ shiftId: shift.id, countedCents: 15600 }); + const rec = await driver.getDrawerReconciliation(shift.id); + expect(rec.counted_cents).toBe(15600); + expect(rec.over_short_cents).toBe(100); + }); + + it("reports a shortage as a negative over/short", async () => { + const driver = getPosDriver(); + const shift = await openShiftWithEvents(10000); + await driver.addShiftCashEvent({ + id: "", + shift_id: shift.id, + tenant_id: DEMO_TENANT_ID, + location_id: DEMO_LOCATION_DOWNTOWN_ID, + type: "sale", + amount_cents: 5000, + order_id: null, + note: null, + created_at: new Date().toISOString(), + }); + // expected 15000, count 14900 → short $1. + await driver.closeShift({ shiftId: shift.id, countedCents: 14900 }); + const rec = await driver.getDrawerReconciliation(shift.id); + expect(rec.over_short_cents).toBe(-100); + }); +}); + +describe("end-of-day Z-report — idempotent close", () => { + it("returns the SAME frozen snapshot when a business day is re-closed", async () => { + const driver = getPosDriver(); + const day = "2026-06-04"; + const first = await driver.closeBusinessDay( + DEMO_TENANT_ID, + DEMO_LOCATION_DOWNTOWN_ID, + day, + ); + const second = await driver.closeBusinessDay( + DEMO_TENANT_ID, + DEMO_LOCATION_DOWNTOWN_ID, + day, + ); + expect(second.id).toBe(first.id); + expect(second.closed_at).toBe(first.closed_at); + expect(second.report).toEqual(first.report); + }); + + it("getBusinessDayClose returns null before a close and the snapshot after", async () => { + const driver = getPosDriver(); + const day = "2026-06-05"; + expect( + await driver.getBusinessDayClose( + DEMO_TENANT_ID, + DEMO_LOCATION_DOWNTOWN_ID, + day, + ), + ).toBeNull(); + const close = await driver.closeBusinessDay( + DEMO_TENANT_ID, + DEMO_LOCATION_DOWNTOWN_ID, + day, + ); + const fetched = await driver.getBusinessDayClose( + DEMO_TENANT_ID, + DEMO_LOCATION_DOWNTOWN_ID, + day, + ); + expect(fetched?.id).toBe(close.id); + }); +}); diff --git a/src/lib/db/mock-inventory.test.ts b/src/lib/db/mock-inventory.test.ts new file mode 100644 index 0000000..5951e54 --- /dev/null +++ b/src/lib/db/mock-inventory.test.ts @@ -0,0 +1,124 @@ +/** + * Inventory depletion + low-stock tests against the mock driver. + * + * Placing an order walks each line + its modifiers, resolves the per-location + * inventory row for every linked recipe component, decrements it, and records a + * `depletion` movement. A row at/under its threshold flags `low`. + */ +import { describe, it, expect, beforeEach } from "vitest"; +import { + getPosDriver, + DEMO_TENANT_ID, + DEMO_LOCATION_DOWNTOWN_ID, + type CreateOrderInput, + type OrderItem, +} from "@/lib/db"; +import { resetMockOrders } from "@/lib/db/mock"; + +const ITEM_MARGHERITA = "30000000-0000-0000-0000-000000000001"; +const ITEM_PEPPERONI = "30000000-0000-0000-0000-000000000002"; + +function line(itemId: string, qty: number): OrderItem { + return { + id: `l-${itemId}-${qty}`, + item_id: itemId, + item_name: "Pizza", + size_id: "s1", + size_name: "L", + base_price_cents: 1800, + quantity: qty, + modifiers: [], + notes: null, + voided: false, + unit_price_cents: 1800, + line_total_cents: 1800 * qty, + }; +} + +let seq = 0; +async function placeOrder( + items: OrderItem[], + status?: CreateOrderInput["status"], +) { + seq += 1; + const input: CreateOrderInput = { + id: `inv-order-${Date.now()}-${seq}`, + tenant_id: DEMO_TENANT_ID, + location_id: DEMO_LOCATION_DOWNTOWN_ID, + channel: "in_store", + currency: "USD", + items, + discount_cents: 0, + totals: { + subtotal_cents: 0, + discount_cents: 0, + taxable_cents: 0, + tax_cents: 0, + tip_cents: 0, + total_cents: 0, + }, + notes: null, + status, + }; + return getPosDriver().createOrder(input); +} + +async function onHand(name: string): Promise { + const inv = await getPosDriver().listInventory( + DEMO_TENANT_ID, + DEMO_LOCATION_DOWNTOWN_ID, + ); + return inv.find((i) => i.name === name)?.on_hand ?? -1; +} + +beforeEach(() => resetMockOrders()); + +describe("inventory depletion on order placement", () => { + it("decrements linked recipe components by qty * line quantity", async () => { + const doughBefore = await onHand("Pizza dough ball"); + const cheeseBefore = await onHand("Mozzarella"); + + // Margherita consumes 1 dough + 150g cheese per pizza; order 2. + await placeOrder([line(ITEM_MARGHERITA, 2)]); + + expect(await onHand("Pizza dough ball")).toBe(doughBefore - 2); + expect(await onHand("Mozzarella")).toBe(cheeseBefore - 300); + }); + + it("records a depletion movement linked to the order", async () => { + const order = await placeOrder([line(ITEM_MARGHERITA, 1)]); + const moves = await getPosDriver().listInventoryMovements( + DEMO_TENANT_ID, + DEMO_LOCATION_DOWNTOWN_ID, + ); + const forOrder = moves.filter((m) => m.order_id === order.id); + expect(forOrder.length).toBeGreaterThan(0); + expect(forOrder.every((m) => m.reason === "depletion")).toBe(true); + expect(forOrder.every((m) => m.delta < 0)).toBe(true); + }); + + it("does NOT deplete for an order created already voided", async () => { + const doughBefore = await onHand("Pizza dough ball"); + await placeOrder([line(ITEM_MARGHERITA, 3)], "voided"); + expect(await onHand("Pizza dough ball")).toBe(doughBefore); + }); + + it("flags low stock once on-hand crosses the threshold", async () => { + // Downtown pepperoni starts at 600g, threshold 500g, 80g/pizza. + // Two pepperoni pizzas = 160g → 440g, below threshold → low. + await placeOrder([line(ITEM_PEPPERONI, 2)]); + const inv = await getPosDriver().listInventory( + DEMO_TENANT_ID, + DEMO_LOCATION_DOWNTOWN_ID, + ); + const pep = inv.find((i) => i.name === "Pepperoni"); + expect(pep?.on_hand).toBeLessThanOrEqual(pep!.low_threshold); + expect(pep?.low).toBe(true); + }); + + it("never drives on-hand below zero", async () => { + // Far more than stock — clamps at 0. + await placeOrder([line(ITEM_PEPPERONI, 1000)]); + expect(await onHand("Pepperoni")).toBe(0); + }); +}); diff --git a/src/lib/db/tenant-isolation.test.ts b/src/lib/db/tenant-isolation.test.ts new file mode 100644 index 0000000..03cd4c9 --- /dev/null +++ b/src/lib/db/tenant-isolation.test.ts @@ -0,0 +1,157 @@ +/** + * App-layer tenant isolation (the complement to DB RLS). + * + * Even though the mock driver holds all tenants' data in shared maps, every + * read method is scoped by tenant_id (+ location_id). This proves a tenant can + * never see another tenant's orders, menu, reports, or inventory THROUGH THE + * DRIVER — the same access pattern the app uses. The Postgres RLS layer + * (supabase/tests/rls_isolation.sql) enforces this again at the database. + */ +import { describe, it, expect, beforeEach } from "vitest"; +import { + getPosDriver, + DEMO_TENANT_ID, + DEMO_LOCATION_DOWNTOWN_ID, + type CreateOrderInput, +} from "@/lib/db"; +import { resetMockOrders, resetMockPayments } from "@/lib/db/mock"; + +beforeEach(() => { + resetMockOrders(); + resetMockPayments(); +}); + +function orderFor( + id: string, + tenantId: string, + locationId: string, +): CreateOrderInput { + return { + id, + tenant_id: tenantId, + location_id: locationId, + channel: "in_store", + currency: "USD", + items: [], + discount_cents: 0, + totals: { + subtotal_cents: 1000, + discount_cents: 0, + taxable_cents: 1000, + tax_cents: 0, + tip_cents: 0, + total_cents: 1000, + }, + notes: null, + status: "paid", + }; +} + +async function makeOtherTenant() { + const driver = getPosDriver(); + const { tenant } = await driver.createTenant({ + businessName: `Rival Pizza ${Math.random().toString(36).slice(2, 7)}`, + ownerEmail: `owner-${Math.random().toString(36).slice(2, 7)}@rival.example`, + }); + const location = await driver.createLocation({ + tenant_id: tenant.id, + name: "Rival Main", + }); + await driver.importStarterMenu(tenant.id); + return { tenantId: tenant.id, locationId: location.id }; +} + +describe("app-layer tenant isolation", () => { + it("listOrders never returns another tenant's orders", async () => { + const driver = getPosDriver(); + const other = await makeOtherTenant(); + + await driver.createOrder( + orderFor("demo-order", DEMO_TENANT_ID, DEMO_LOCATION_DOWNTOWN_ID), + ); + await driver.createOrder( + orderFor("rival-order", other.tenantId, other.locationId), + ); + + const demoOrders = await driver.listOrders( + DEMO_TENANT_ID, + DEMO_LOCATION_DOWNTOWN_ID, + ); + const rivalOrders = await driver.listOrders( + other.tenantId, + other.locationId, + ); + + expect(demoOrders.some((o) => o.id === "rival-order")).toBe(false); + expect(rivalOrders.some((o) => o.id === "demo-order")).toBe(false); + expect(rivalOrders.map((o) => o.id)).toContain("rival-order"); + // The rival tenant's orders all carry the rival tenant_id. + expect(rivalOrders.every((o) => o.tenant_id === other.tenantId)).toBe(true); + }); + + it("getSalesReport scopes gross to the requesting tenant", async () => { + const driver = getPosDriver(); + const other = await makeOtherTenant(); + await driver.createOrder( + orderFor("demo-rep", DEMO_TENANT_ID, DEMO_LOCATION_DOWNTOWN_ID), + ); + await driver.createOrder( + orderFor("rival-rep", other.tenantId, other.locationId), + ); + + const rivalReport = await driver.getSalesReport(other.tenantId, null, { + from: null, + to: null, + }); + // The rival rollup contains only the rival location, never the demo location. + expect( + rivalReport.byLocation.some((b) => b.key === DEMO_LOCATION_DOWNTOWN_ID), + ).toBe(false); + expect(rivalReport.byLocation.some((b) => b.key === other.locationId)).toBe( + true, + ); + }); + + it("getMenu returns only the requesting tenant's categories", async () => { + const driver = getPosDriver(); + const other = await makeOtherTenant(); + + const otherMenu = await driver.getMenu(other.tenantId, other.locationId); + // The starter menu has categories, and none belong to the demo tenant. + expect(otherMenu.categories.length).toBeGreaterThan(0); + // Demo-tenant menu must not leak the rival's categories and vice-versa. + const demoMenu = await driver.getMenu( + DEMO_TENANT_ID, + DEMO_LOCATION_DOWNTOWN_ID, + ); + const demoCatIds = new Set(demoMenu.categories.map((c) => c.id)); + expect(otherMenu.categories.every((c) => !demoCatIds.has(c.id))).toBe(true); + }); + + it("listInventory never returns another tenant's stock rows", async () => { + const driver = getPosDriver(); + const other = await makeOtherTenant(); + const demoInv = await driver.listInventory( + DEMO_TENANT_ID, + DEMO_LOCATION_DOWNTOWN_ID, + ); + // Demo inventory rows are all demo-tenant scoped. + expect(demoInv.every((i) => i.tenant_id === DEMO_TENANT_ID)).toBe(true); + // The rival tenant has no demo inventory. + const rivalInv = await driver.listInventory( + other.tenantId, + other.locationId, + ); + expect(rivalInv.every((i) => i.tenant_id === other.tenantId)).toBe(true); + }); + + it("listLocations is tenant-scoped", async () => { + const driver = getPosDriver(); + const other = await makeOtherTenant(); + const rivalLocs = await driver.listLocations(other.tenantId); + expect(rivalLocs.every((l) => l.tenant_id === other.tenantId)).toBe(true); + expect(rivalLocs.some((l) => l.id === DEMO_LOCATION_DOWNTOWN_ID)).toBe( + false, + ); + }); +}); diff --git a/src/lib/delivery/service.test.ts b/src/lib/delivery/service.test.ts new file mode 100644 index 0000000..ba4c600 --- /dev/null +++ b/src/lib/delivery/service.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from "vitest"; +import { pickProvider, quoteDelivery } from "@/lib/delivery/service"; +import { DeliveryUnavailableError } from "@/lib/delivery/errors"; +import { + DEMO_TENANT_ID, + DEMO_LOCATION_DOWNTOWN_ID, + DEMO_LOCATION_UPTOWN_ID, + type DeliveryAddress, +} from "@/lib/db"; + +const pickup: DeliveryAddress = { + line1: "123 Main St", + city: "Springfield", + region: "NY", + postal_code: "10001", + country: "US", +}; + +function dropoff(postal: string): DeliveryAddress { + return { ...pickup, line1: "9 Elm St", postal_code: postal }; +} + +describe("pickProvider", () => { + it("selects the first available configured provider (in-house for downtown)", async () => { + const provider = await pickProvider( + DEMO_TENANT_ID, + DEMO_LOCATION_DOWNTOWN_ID, + ); + expect(provider).toBe("in_house_manual"); + }); + + it("returns null when the location offers no providers (uptown is pickup-only)", async () => { + const provider = await pickProvider( + DEMO_TENANT_ID, + DEMO_LOCATION_UPTOWN_ID, + ); + expect(provider).toBeNull(); + }); +}); + +describe("quoteDelivery", () => { + it("quotes a fee + ETA for an in-zone address", async () => { + const { provider, quote } = await quoteDelivery({ + tenantId: DEMO_TENANT_ID, + locationId: DEMO_LOCATION_DOWNTOWN_ID, + pickup, + dropoff: dropoff("10001"), + subtotalCents: 2500, + currency: "USD", + }); + expect(provider).toBe("in_house_manual"); + expect(quote.fee?.amount).toBe(399); + expect(quote.etaMinutes).toBe(30); + }); + + it("rejects an out-of-zone address with DeliveryUnavailableError", async () => { + await expect( + quoteDelivery({ + tenantId: DEMO_TENANT_ID, + locationId: DEMO_LOCATION_DOWNTOWN_ID, + pickup, + dropoff: dropoff("99999"), + subtotalCents: 2500, + currency: "USD", + }), + ).rejects.toBeInstanceOf(DeliveryUnavailableError); + }); + + it("rejects an in-zone order below the zone minimum", async () => { + await expect( + quoteDelivery({ + tenantId: DEMO_TENANT_ID, + locationId: DEMO_LOCATION_DOWNTOWN_ID, + pickup, + dropoff: dropoff("10010"), // far zone, $20 minimum + subtotalCents: 1500, + currency: "USD", + }), + ).rejects.toBeInstanceOf(DeliveryUnavailableError); + }); + + it("throws a plain error when delivery is unavailable at the location", async () => { + await expect( + quoteDelivery({ + tenantId: DEMO_TENANT_ID, + locationId: DEMO_LOCATION_UPTOWN_ID, + pickup, + dropoff: dropoff("10001"), + subtotalCents: 2500, + currency: "USD", + }), + ).rejects.toThrow(/not available/i); + }); +}); diff --git a/src/lib/delivery/zones.test.ts b/src/lib/delivery/zones.test.ts new file mode 100644 index 0000000..4d2935f --- /dev/null +++ b/src/lib/delivery/zones.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from "vitest"; +import { + checkDeliverable, + normalizePostal, + resolveZone, +} from "@/lib/delivery/zones"; +import type { DeliveryZone } from "@/lib/db"; + +const zones: DeliveryZone[] = [ + { + id: "near", + name: "Core", + postal_codes: ["10001", "10002"], + fee_cents: 399, + eta_minutes: 30, + min_subtotal_cents: 0, + }, + { + id: "far", + name: "Greater", + postal_codes: ["10010"], + fee_cents: 699, + eta_minutes: 45, + min_subtotal_cents: 2000, + }, +]; + +describe("normalizePostal", () => { + it("trims, uppercases, and strips spaces", () => { + expect(normalizePostal(" m5v 2t6 ")).toBe("M5V2T6"); + }); +}); + +describe("resolveZone", () => { + it("matches a served postal code", () => { + expect(resolveZone(zones, { postal_code: "10001" })?.id).toBe("near"); + }); + it("returns null for an unserved postal code", () => { + expect(resolveZone(zones, { postal_code: "99999" })).toBeNull(); + }); + it("returns null for an empty postal code", () => { + expect(resolveZone(zones, { postal_code: "" })).toBeNull(); + }); +}); + +describe("checkDeliverable — zone gating", () => { + it("accepts an in-zone address above the minimum, returning fee + ETA", () => { + const res = checkDeliverable(zones, { postal_code: "10001" }, 1500); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.zone.fee_cents).toBe(399); + expect(res.zone.eta_minutes).toBe(30); + } + }); + + it("rejects an out-of-zone address", () => { + const res = checkDeliverable(zones, { postal_code: "88888" }, 5000); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.reason).toBe("out_of_zone"); + }); + + it("rejects an in-zone order below the zone minimum", () => { + const res = checkDeliverable(zones, { postal_code: "10010" }, 1500); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.reason).toBe("below_minimum"); + expect(res.zone?.id).toBe("far"); + } + }); + + it("accepts the far zone exactly at its minimum", () => { + const res = checkDeliverable(zones, { postal_code: "10010" }, 2000); + expect(res.ok).toBe(true); + }); +}); diff --git a/src/lib/demo/mode.test.ts b/src/lib/demo/mode.test.ts new file mode 100644 index 0000000..a6de4fb --- /dev/null +++ b/src/lib/demo/mode.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { + demoModeInfo, + isTrainingMode, + isTrainingModeEnv, +} from "@/lib/demo/mode"; + +afterEach(() => { + delete process.env.TRAINING_MODE; +}); + +describe("training / demo mode", () => { + it("reads the TRAINING_MODE env flag (off by default)", () => { + expect(isTrainingModeEnv()).toBe(false); + process.env.TRAINING_MODE = "1"; + expect(isTrainingModeEnv()).toBe(true); + process.env.TRAINING_MODE = "true"; + expect(isTrainingModeEnv()).toBe(true); + process.env.TRAINING_MODE = "off"; + expect(isTrainingModeEnv()).toBe(false); + }); + + it("treats the mock driver as training mode (no live data)", () => { + // The current build runs on the mock driver → training mode is on. + expect(isTrainingMode()).toBe(true); + }); + + it("exposes a banner + driver name for surfaces", () => { + const info = demoModeInfo(); + expect(info.driver).toBe("mock"); + expect(info.trainingMode).toBe(true); + expect(info.banner).toMatch(/TRAINING/i); + }); +}); diff --git a/src/lib/demo/mode.ts b/src/lib/demo/mode.ts new file mode 100644 index 0000000..6f9a35f --- /dev/null +++ b/src/lib/demo/mode.ts @@ -0,0 +1,62 @@ +/** + * Training / demo mode (Phase 7). + * + * The platform already runs entirely on the seeded in-memory mock driver today, + * so EVERY order placed in the current build is non-production "demo" data. This + * module formalizes that into an explicit, documented flag a tenant can flip to + * train staff without real orders once live services are wired. + * + * Semantics (documented in docs/PRODUCTION_READINESS.md): + * - `TRAINING_MODE=1` (or the mock driver being active) => the environment is + * NOT taking real money/orders. Surfaces should badge "TRAINING". + * - In training mode, payment rails run simulated and orders are seed/disposable + * — exactly the current zero-env behaviour, now nameable + assertable. + * + * Lazy env read, no throw on unset, safe to import anywhere. + */ +import { getPosDriver } from "@/lib/db"; + +/** True when `TRAINING_MODE` is explicitly enabled via env. */ +export function isTrainingModeEnv(): boolean { + const v = (process.env.TRAINING_MODE ?? "").toLowerCase(); + return v === "1" || v === "true" || v === "on" || v === "yes"; +} + +/** + * Whether the running environment is in training/demo mode. True when either the + * `TRAINING_MODE` env flag is set, OR the active data driver is the mock/seed + * driver (no live DB) — in which case nothing is real production data. + */ +export function isTrainingMode(): boolean { + if (isTrainingModeEnv()) return true; + try { + return getPosDriver().name === "mock"; + } catch { + return false; + } +} + +export interface DemoModeInfo { + trainingMode: boolean; + driver: "mock" | "supabase"; + /** Customer/staff-facing banner copy when in training mode. */ + banner: string | null; +} + +/** A small status object for surfaces/health endpoints to render a badge. */ +export function demoModeInfo(): DemoModeInfo { + let driver: "mock" | "supabase" = "mock"; + try { + driver = getPosDriver().name; + } catch { + driver = "mock"; + } + const trainingMode = isTrainingModeEnv() || driver === "mock"; + return { + trainingMode, + driver, + banner: trainingMode + ? "TRAINING MODE — orders and payments are simulated and not charged." + : null, + }; +} diff --git a/src/lib/observability/errors.ts b/src/lib/observability/errors.ts new file mode 100644 index 0000000..0659b3e --- /dev/null +++ b/src/lib/observability/errors.ts @@ -0,0 +1,69 @@ +/** + * Error-tracking seam (Phase 7 observability). + * + * A provider-agnostic `captureError` that is a structured-log no-op by default + * and lights up a real error tracker (e.g. Sentry) ONLY when `SENTRY_DSN` is + * present. This mirrors the payment/billing env-guard pattern: the integration + * code path exists but stays dormant with zero env vars, so the app builds and + * tests run without any secrets or network calls. + * + * The actual Sentry SDK is intentionally NOT a dependency here (live wiring is a + * later phase). When a DSN is configured we still record the error via the + * structured logger tagged `sink: "sentry"`, and document where the real + * `Sentry.captureException` call slots in. Swapping in the SDK later requires no + * call-site changes. + */ +import { logger } from "./logger"; + +/** Whether an external error tracker is configured (lazy env read). */ +export function isErrorTrackingConfigured(): boolean { + return Boolean(process.env.SENTRY_DSN); +} + +export interface ErrorContext { + /** Correlation id so the captured error joins the request's log trail. */ + requestId?: string; + /** Tenant scope, when known (never include PII / card data). */ + tenantId?: string; + locationId?: string; + /** Coarse area tag (e.g. "payments", "orders") for grouping. */ + scope?: string; + [key: string]: unknown; +} + +/** Normalize any thrown value into a `{ message, stack }` shape for logging. */ +export function normalizeError(err: unknown): { + message: string; + stack?: string; + name?: string; +} { + if (err instanceof Error) { + return { message: err.message, stack: err.stack, name: err.name }; + } + return { message: typeof err === "string" ? err : JSON.stringify(err) }; +} + +/** + * Capture an error. Always emits a structured `error` log; additionally routes + * to the external tracker when configured. Returns the sink used so callers/tests + * can assert behaviour without a live SDK. + */ +export function captureError( + err: unknown, + context: ErrorContext = {}, +): "log" | "sentry" { + const normalized = normalizeError(err); + const tracked = isErrorTrackingConfigured(); + logger.error("captured_error", { + ...context, + error: normalized.message, + error_name: normalized.name, + stack: normalized.stack, + sink: tracked ? "sentry" : "log", + }); + // When SENTRY_DSN is set, the real SDK call slots in here: + // import * as Sentry from "@sentry/nextjs"; + // Sentry.captureException(err, { tags: context }); + // Kept dormant (no dependency) until the live-wiring phase. + return tracked ? "sentry" : "log"; +} diff --git a/src/lib/observability/index.ts b/src/lib/observability/index.ts new file mode 100644 index 0000000..7de7ccb --- /dev/null +++ b/src/lib/observability/index.ts @@ -0,0 +1,8 @@ +/** + * Observability scaffolding (Phase 7). One import surface for the structured + * logger, request/trace ids, and the error-tracking seam. All env-optional and + * no-op-safe with zero configuration. + */ +export * from "./logger"; +export * from "./trace"; +export * from "./errors"; diff --git a/src/lib/observability/logger.ts b/src/lib/observability/logger.ts new file mode 100644 index 0000000..9ac7c2d --- /dev/null +++ b/src/lib/observability/logger.ts @@ -0,0 +1,100 @@ +/** + * Structured logging helper (Phase 7 observability). + * + * Emits single-line JSON to stdout/stderr so logs are machine-parseable in any + * log aggregator (Vercel, Datadog, etc.) with NO configuration and NO env vars. + * A `requestId`/`traceId` can be threaded through via {@link childLogger} so + * every line for one request shares a correlation id. + * + * Design rules (match the rest of the codebase): + * - No env reads at module load. `LOG_LEVEL` is consulted lazily, defaults to + * `info`, and an unset/invalid value never throws. + * - Pure + side-effect-free except for the final `console.*` write, so it is + * safe to import anywhere (server routes, domain layer). + */ + +export type LogLevel = "debug" | "info" | "warn" | "error"; + +const LEVEL_WEIGHT: Record = { + debug: 10, + info: 20, + warn: 30, + error: 40, +}; + +/** Resolve the active minimum level from env, lazily; defaults to `info`. */ +export function activeLevel(): LogLevel { + const raw = (process.env.LOG_LEVEL ?? "").toLowerCase(); + if (raw === "debug" || raw === "info" || raw === "warn" || raw === "error") { + return raw; + } + return "info"; +} + +export type LogFields = Record; + +export interface LogRecord extends LogFields { + level: LogLevel; + msg: string; + time: string; +} + +/** + * Build the structured record for a log line. Exposed (and pure) so tests can + * assert the shape without capturing console output. + */ +export function buildRecord( + level: LogLevel, + msg: string, + fields: LogFields = {}, +): LogRecord { + return { + level, + msg, + time: new Date().toISOString(), + ...fields, + }; +} + +/** Whether a record at `level` should be emitted given the active level. */ +export function shouldLog(level: LogLevel): boolean { + return LEVEL_WEIGHT[level] >= LEVEL_WEIGHT[activeLevel()]; +} + +function write(record: LogRecord): void { + const line = JSON.stringify(record); + if (record.level === "error") console.error(line); + else if (record.level === "warn") console.warn(line); + else console.log(line); +} + +export interface Logger { + debug(msg: string, fields?: LogFields): void; + info(msg: string, fields?: LogFields): void; + warn(msg: string, fields?: LogFields): void; + error(msg: string, fields?: LogFields): void; + /** Derive a logger that stamps `bound` fields on every line. */ + child(bound: LogFields): Logger; +} + +function make(bound: LogFields): Logger { + const emit = (level: LogLevel, msg: string, fields?: LogFields) => { + if (!shouldLog(level)) return; + write(buildRecord(level, msg, { ...bound, ...fields })); + }; + return { + debug: (m, f) => emit("debug", m, f), + info: (m, f) => emit("info", m, f), + warn: (m, f) => emit("warn", m, f), + error: (m, f) => emit("error", m, f), + child: (more) => make({ ...bound, ...more }), + }; +} + +/** The root logger. Use `logger.child({ requestId })` per request. */ +export const logger: Logger = make({}); + +/** Create a request/trace-scoped child logger. */ +export function childLogger(fields: LogFields): Logger { + return logger.child(fields); +} diff --git a/src/lib/observability/observability.test.ts b/src/lib/observability/observability.test.ts new file mode 100644 index 0000000..eb59aa3 --- /dev/null +++ b/src/lib/observability/observability.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, afterEach, vi } from "vitest"; +import { buildRecord, shouldLog } from "@/lib/observability/logger"; +import { + newRequestId, + parseTraceparent, + resolveRequestId, + REQUEST_ID_HEADER, +} from "@/lib/observability/trace"; +import { + captureError, + isErrorTrackingConfigured, + normalizeError, +} from "@/lib/observability/errors"; + +afterEach(() => { + delete process.env.LOG_LEVEL; + delete process.env.SENTRY_DSN; + vi.restoreAllMocks(); +}); + +describe("structured logger", () => { + it("builds a JSON-serializable record with level, msg, time, and fields", () => { + const rec = buildRecord("info", "hello", { orderId: "o1" }); + expect(rec.level).toBe("info"); + expect(rec.msg).toBe("hello"); + expect(typeof rec.time).toBe("string"); + expect(rec.orderId).toBe("o1"); + expect(() => JSON.stringify(rec)).not.toThrow(); + }); + + it("respects LOG_LEVEL when deciding whether to emit", () => { + process.env.LOG_LEVEL = "warn"; + expect(shouldLog("info")).toBe(false); + expect(shouldLog("warn")).toBe(true); + expect(shouldLog("error")).toBe(true); + }); + + it("defaults to info level with no env set", () => { + expect(shouldLog("debug")).toBe(false); + expect(shouldLog("info")).toBe(true); + }); +}); + +describe("trace ids", () => { + function headers(map: Record) { + return { get: (n: string) => map[n.toLowerCase()] ?? null }; + } + + it("mints a fresh request id when none is provided", () => { + const id = resolveRequestId(headers({})); + expect(id).toBeTruthy(); + expect(typeof id).toBe("string"); + }); + + it("reuses an upstream x-request-id for trace continuity", () => { + const id = resolveRequestId(headers({ [REQUEST_ID_HEADER]: "abc-123" })); + expect(id).toBe("abc-123"); + }); + + it("derives the trace id from a W3C traceparent header", () => { + const tp = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"; + expect(parseTraceparent(tp)).toBe("0af7651916cd43dd8448eb211c80319c"); + const id = resolveRequestId(headers({ traceparent: tp })); + expect(id).toBe("0af7651916cd43dd8448eb211c80319c"); + }); + + it("returns null for a malformed traceparent", () => { + expect(parseTraceparent("garbage")).toBeNull(); + expect(parseTraceparent(null)).toBeNull(); + }); + + it("generates distinct request ids", () => { + expect(newRequestId()).not.toBe(newRequestId()); + }); +}); + +describe("error-tracking seam", () => { + it("is a structured-log no-op when SENTRY_DSN is absent", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(isErrorTrackingConfigured()).toBe(false); + const sink = captureError(new Error("boom"), { scope: "test" }); + expect(sink).toBe("log"); + expect(spy).toHaveBeenCalledOnce(); + }); + + it("reports the sentry sink when SENTRY_DSN is configured", () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + process.env.SENTRY_DSN = "https://example@sentry.io/123"; + expect(isErrorTrackingConfigured()).toBe(true); + expect(captureError(new Error("boom"))).toBe("sentry"); + }); + + it("normalizes Errors and non-Errors", () => { + expect(normalizeError(new Error("x")).message).toBe("x"); + expect(normalizeError("plain").message).toBe("plain"); + expect(normalizeError({ a: 1 }).message).toBe('{"a":1}'); + }); +}); diff --git a/src/lib/observability/trace.ts b/src/lib/observability/trace.ts new file mode 100644 index 0000000..a4f0732 --- /dev/null +++ b/src/lib/observability/trace.ts @@ -0,0 +1,56 @@ +/** + * Request/trace id helpers (Phase 7 observability). + * + * Every API route can derive a correlation id from an inbound request — reusing + * an upstream `x-request-id` / `x-trace-id` / W3C `traceparent` when present, or + * minting a fresh one — and echo it back on the response so a single request can + * be followed end-to-end across logs and client/server boundaries. + * + * No env, no I/O, browser/edge/node safe. + */ + +export const REQUEST_ID_HEADER = "x-request-id"; +export const TRACE_ID_HEADER = "x-trace-id"; + +/** Generate a fresh request id (UUID where available, else a random token). */ +export function newRequestId(): string { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return crypto.randomUUID(); + } + return `req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`; +} + +/** Extract the W3C trace id (the middle segment) from a `traceparent` header. */ +export function parseTraceparent(traceparent: string | null): string | null { + if (!traceparent) return null; + const parts = traceparent.split("-"); + // version-traceid-spanid-flags ; traceid is 32 hex chars. + if (parts.length >= 2 && parts[1] && /^[0-9a-f]{32}$/i.test(parts[1])) { + return parts[1]; + } + return null; +} + +/** + * Resolve the correlation id for a request: prefer an upstream id so a trace is + * continuous, otherwise mint a new one. Header lookup is case-insensitive (the + * Headers API normalizes), and a bare `{ get }` shape is accepted so this works + * with `Request`, `NextRequest`, and plain test doubles. + */ +export function resolveRequestId(headers: { + get(name: string): string | null; +}): string { + return ( + headers.get(REQUEST_ID_HEADER) || + headers.get(TRACE_ID_HEADER) || + parseTraceparent(headers.get("traceparent")) || + newRequestId() + ); +} + +/** Headers to merge onto a response so the caller sees the correlation id. */ +export function traceResponseHeaders( + requestId: string, +): Record { + return { [REQUEST_ID_HEADER]: requestId }; +} diff --git a/src/lib/offline/queue.test.ts b/src/lib/offline/queue.test.ts new file mode 100644 index 0000000..bcd6d3a --- /dev/null +++ b/src/lib/offline/queue.test.ts @@ -0,0 +1,76 @@ +/** + * @vitest-environment jsdom + * + * Offline-queue idempotency tests. Uses a real (fake) IndexedDB so the Dexie + * upsert-by-UUID behaviour is exercised exactly as in the browser. The queue is + * the first half of the store-and-forward guarantee; the second half (the + * driver's `createOrder` upsert) is covered in offline-sync.test.ts. + */ +import { describe, it, expect, beforeEach } from "vitest"; +import "fake-indexeddb/auto"; +import type { CreateOrderInput } from "@/lib/db"; +import { + enqueueOrder, + getPendingOrders, + getPendingCount, + markSynced, +} from "@/lib/offline/queue"; + +function payload(id: string): CreateOrderInput { + return { + id, + tenant_id: "t1", + location_id: "l1", + channel: "in_store", + currency: "USD", + items: [], + discount_cents: 0, + totals: { + subtotal_cents: 1000, + discount_cents: 0, + taxable_cents: 1000, + tax_cents: 0, + tip_cents: 0, + total_cents: 1000, + }, + notes: null, + }; +} + +beforeEach(async () => { + // Clear any prior entries so each test starts from an empty queue. + for (const e of await getPendingOrders()) { + await markSynced(e.id); + } +}); + +describe("offline queue — idempotent upsert by order UUID", () => { + it("enqueuing the same order id twice keeps a single pending entry", async () => { + const id = `q-${Date.now()}-1`; + await enqueueOrder(payload(id)); + await enqueueOrder(payload(id)); + const pending = (await getPendingOrders()).filter((e) => e.id === id); + expect(pending).toHaveLength(1); + }); + + it("does not re-enqueue an order that already synced", async () => { + const id = `q-${Date.now()}-2`; + await enqueueOrder(payload(id)); + await markSynced(id); + await enqueueOrder(payload(id)); // double-flush / refresh mid-flight + const stillPending = (await getPendingOrders()).filter((e) => e.id === id); + expect(stillPending).toHaveLength(0); + }); + + it("tracks multiple distinct orders independently", async () => { + const a = `q-${Date.now()}-a`; + const b = `q-${Date.now()}-b`; + await enqueueOrder(payload(a)); + await enqueueOrder(payload(b)); + const count = await getPendingCount(); + expect(count).toBeGreaterThanOrEqual(2); + const ids = (await getPendingOrders()).map((e) => e.id); + expect(ids).toContain(a); + expect(ids).toContain(b); + }); +}); diff --git a/src/lib/offline/sync.test.ts b/src/lib/offline/sync.test.ts new file mode 100644 index 0000000..c1aecd1 --- /dev/null +++ b/src/lib/offline/sync.test.ts @@ -0,0 +1,66 @@ +/** + * Store-and-forward end-to-end idempotency at the DB layer. + * + * The offline flush POSTs each queued order to /api/orders, which calls + * `driver.createOrder` — an idempotent upsert-by-UUID. This test simulates the + * server side of a double-flush + reconnect-retry by calling `createOrder` + * repeatedly with the same client UUID and asserting exactly one order ever + * exists. (The IndexedDB half is covered in queue.test.ts.) + */ +import { describe, it, expect, beforeEach } from "vitest"; +import { getPosDriver, type CreateOrderInput } from "@/lib/db"; +import { resetMockOrders } from "@/lib/db/mock"; + +function payload(id: string): CreateOrderInput { + return { + id, + tenant_id: "10000000-0000-0000-0000-000000000001", + location_id: "10000000-0000-0000-0000-000000000101", + channel: "in_store", + currency: "USD", + items: [], + discount_cents: 0, + totals: { + subtotal_cents: 1500, + discount_cents: 0, + taxable_cents: 1500, + tax_cents: 0, + tip_cents: 0, + total_cents: 1500, + }, + notes: null, + }; +} + +beforeEach(() => resetMockOrders()); + +describe("offline sync — createOrder upsert never duplicates", () => { + it("re-submitting the same order UUID returns the same order, assigns the number once", async () => { + const driver = getPosDriver(); + const id = "offline-order-1"; + + const first = await driver.createOrder(payload(id)); + const number = first.order_number; + + // Double-flush + a later reconnect retry: same payload, same id, 3x. + const again = await driver.createOrder(payload(id)); + const yetAgain = await driver.createOrder(payload(id)); + + expect(again.id).toBe(first.id); + expect(again.order_number).toBe(number); + expect(yetAgain.order_number).toBe(number); + + const all = await driver.listOrders( + payload(id).tenant_id, + payload(id).location_id, + ); + expect(all.filter((o) => o.id === id)).toHaveLength(1); + }); + + it("assigns DISTINCT order numbers to distinct client UUIDs", async () => { + const driver = getPosDriver(); + const a = await driver.createOrder(payload("offline-a")); + const b = await driver.createOrder(payload("offline-b")); + expect(a.order_number).not.toBe(b.order_number); + }); +}); diff --git a/src/lib/payments/fees.test.ts b/src/lib/payments/fees.test.ts new file mode 100644 index 0000000..aecb2a9 --- /dev/null +++ b/src/lib/payments/fees.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from "vitest"; +import { + computeApplicationFeeCents, + railChargesApplicationFee, + roundHalfUp, +} from "@/lib/payments/fees"; + +describe("computeApplicationFeeCents", () => { + it("computes pct(bps) + flat, round-half-up on the pct", () => { + // 2.5% of $20.00 = 50¢, + 10¢ flat = 60¢. + expect( + computeApplicationFeeCents({ + amountCents: 2000, + feeBps: 250, + feeFlatCents: 10, + }), + ).toBe(60); + }); + + it("rounds the percentage component half-up", () => { + // 250 bps of 1333 = 33.325 → 33; + 0 flat. + expect( + computeApplicationFeeCents({ + amountCents: 1333, + feeBps: 250, + feeFlatCents: 0, + }), + ).toBe(33); + // 250 bps of 1340 = 33.5 → 34. + expect( + computeApplicationFeeCents({ + amountCents: 1340, + feeBps: 250, + feeFlatCents: 0, + }), + ).toBe(34); + }); + + it("returns an integer number of cents (no float drift)", () => { + const fee = computeApplicationFeeCents({ + amountCents: 9999, + feeBps: 290, + feeFlatCents: 30, + }); + expect(Number.isInteger(fee)).toBe(true); + }); + + it("clamps the fee to the charge amount (never exceeds what settles)", () => { + const fee = computeApplicationFeeCents({ + amountCents: 50, + feeBps: 100000, // absurd 1000% + feeFlatCents: 0, + }); + expect(fee).toBe(50); + }); + + it("is zero for a non-positive amount", () => { + expect( + computeApplicationFeeCents({ + amountCents: 0, + feeBps: 250, + feeFlatCents: 10, + }), + ).toBe(0); + expect( + computeApplicationFeeCents({ + amountCents: -100, + feeBps: 250, + feeFlatCents: 10, + }), + ).toBe(0); + }); + + it("ignores a negative flat fee (clamped to 0)", () => { + expect( + computeApplicationFeeCents({ + amountCents: 1000, + feeBps: 250, + feeFlatCents: -50, + }), + ).toBe(25); + }); +}); + +describe("railChargesApplicationFee — card-only", () => { + it("charges a platform fee only on card rails", () => { + expect(railChargesApplicationFee("stripe_terminal")).toBe(true); + expect(railChargesApplicationFee("stripe_online")).toBe(true); + }); + + it("does NOT charge a platform fee on cash or crypto", () => { + expect(railChargesApplicationFee("cash")).toBe(false); + expect(railChargesApplicationFee("crypto_onchain_usdc")).toBe(false); + expect(railChargesApplicationFee("crypto_coinbase")).toBe(false); + }); +}); + +describe("roundHalfUp (fees)", () => { + it("matches the pricing module's rounding", () => { + expect(roundHalfUp(33.5)).toBe(34); + expect(roundHalfUp(33.49)).toBe(33); + }); +}); diff --git a/src/lib/payments/service.test.ts b/src/lib/payments/service.test.ts new file mode 100644 index 0000000..3f29975 --- /dev/null +++ b/src/lib/payments/service.test.ts @@ -0,0 +1,280 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; +import { + getPosDriver, + type CreateOrderInput, + type OrderTotals, + type OrderItem, +} from "@/lib/db"; +import { + getOrderBalance, + refreshPaymentStatus, + refundPayment, + takePayment, +} from "@/lib/payments/service"; +import { resetMockOrders, resetMockPayments } from "@/lib/db/mock"; + +const TENANT = "10000000-0000-0000-0000-000000000001"; +const LOCATION = "10000000-0000-0000-0000-000000000101"; + +let orderSeq = 0; + +function totals(total: number): OrderTotals { + return { + subtotal_cents: total, + discount_cents: 0, + taxable_cents: total, + tax_cents: 0, + tip_cents: 0, + total_cents: total, + }; +} + +function lineFor(total: number): OrderItem { + return { + id: `line-${total}`, + item_id: "i1", + item_name: "Pizza", + size_id: "s1", + size_name: "L", + base_price_cents: total, + quantity: 1, + modifiers: [], + notes: null, + voided: false, + unit_price_cents: total, + line_total_cents: total, + }; +} + +async function seedOrder(total: number): Promise { + orderSeq += 1; + const id = `order-${Date.now()}-${orderSeq}`; + const input: CreateOrderInput = { + id, + tenant_id: TENANT, + location_id: LOCATION, + channel: "in_store", + currency: "USD", + items: [lineFor(total)], + discount_cents: 0, + totals: totals(total), + notes: null, + status: "placed", + }; + await getPosDriver().createOrder(input); + return id; +} + +beforeEach(() => { + resetMockOrders(); + resetMockPayments(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("payment idempotency — no double charge", () => { + it("returns the same tender (one charge) when the same paymentId is taken twice", async () => { + const orderId = await seedOrder(2000); + const paymentId = "pay-dupe-1"; + const first = await takePayment({ + paymentId, + orderId, + tenantId: TENANT, + locationId: LOCATION, + rail: "cash", + amountCents: 2000, + tipCents: 0, + currency: "USD", + }); + const second = await takePayment({ + paymentId, + orderId, + tenantId: TENANT, + locationId: LOCATION, + rail: "cash", + amountCents: 2000, + tipCents: 0, + currency: "USD", + }); + + expect(second.id).toBe(first.id); + expect(second.charge_id).toBe(first.charge_id); + const all = await getPosDriver().listPaymentsForOrder(orderId); + expect(all).toHaveLength(1); + }); + + it("flips the order to paid only once settled tenders cover the total", async () => { + const orderId = await seedOrder(3000); + await takePayment({ + paymentId: "pay-full", + orderId, + tenantId: TENANT, + locationId: LOCATION, + rail: "cash", + amountCents: 3000, + tipCents: 0, + currency: "USD", + }); + const order = await getPosDriver().getOrder(orderId); + expect(order?.status).toBe("paid"); + expect(await getOrderBalance(order!)).toBe(0); + }); +}); + +describe("split payment", () => { + it("drives the balance to zero across multiple tenders and pays only when covered", async () => { + const orderId = await seedOrder(5000); + + await takePayment({ + paymentId: "split-a", + orderId, + tenantId: TENANT, + locationId: LOCATION, + rail: "cash", + amountCents: 2000, + tipCents: 0, + currency: "USD", + }); + let order = await getPosDriver().getOrder(orderId); + expect(order?.status).not.toBe("paid"); + expect(await getOrderBalance(order!)).toBe(3000); + + await takePayment({ + paymentId: "split-b", + orderId, + tenantId: TENANT, + locationId: LOCATION, + rail: "stripe_terminal", + amountCents: 3000, + tipCents: 0, + currency: "USD", + }); + order = await getPosDriver().getOrder(orderId); + expect(order?.status).toBe("paid"); + expect(await getOrderBalance(order!)).toBe(0); + + const all = await getPosDriver().listPaymentsForOrder(orderId); + expect(all).toHaveLength(2); + }); +}); + +describe("platform fee on card tenders (wired through the rail)", () => { + it("records a non-zero application fee for a card rail and zero for cash", async () => { + const orderId = await seedOrder(2000); + const card = await takePayment({ + paymentId: "fee-card", + orderId, + tenantId: TENANT, + locationId: LOCATION, + rail: "stripe_online", + amountCents: 2000, + tipCents: 0, + currency: "USD", + }); + expect(card.application_fee_cents).toBeGreaterThan(0); + + const orderId2 = await seedOrder(2000); + const cash = await takePayment({ + paymentId: "fee-cash", + orderId: orderId2, + tenantId: TENANT, + locationId: LOCATION, + rail: "cash", + amountCents: 2000, + tipCents: 0, + currency: "USD", + }); + expect(cash.application_fee_cents).toBe(0); + }); +}); + +describe("crypto pending → confirm", () => { + it("does NOT settle the order while a crypto tender is only pending", async () => { + const orderId = await seedOrder(2500); + const tender = await takePayment({ + paymentId: "crypto-1", + orderId, + tenantId: TENANT, + locationId: LOCATION, + rail: "crypto_onchain_usdc", + amountCents: 2500, + tipCents: 0, + currency: "USD", + }); + // Simulated onchain charge starts pending. + expect(tender.status).toBe("pending"); + const order = await getPosDriver().getOrder(orderId); + // Pending counts toward the displayed balance (so the cashier isn't asked + // to collect twice) but the order is NOT yet paid (not settled). + expect(order?.status).not.toBe("paid"); + expect(await getOrderBalance(order!)).toBe(0); + }); + + it("settles the order once the crypto tender confirms (captured) via the watcher", async () => { + const orderId = await seedOrder(2500); + await takePayment({ + paymentId: "crypto-2", + orderId, + tenantId: TENANT, + locationId: LOCATION, + rail: "crypto_onchain_usdc", + amountCents: 2500, + tipCents: 0, + currency: "USD", + }); + const driver = getPosDriver(); + // Advance wall-clock past the simulated confirmation window so the rail's + // status() reports `captured`; refreshPaymentStatus then settles the order. + const realNow = Date.now(); + vi.spyOn(Date, "now").mockReturnValue(realNow + 60_000); + const updated = await refreshPaymentStatus("crypto-2"); + expect(updated?.status).toBe("captured"); + const order = await driver.getOrder(orderId); + expect(order?.status).toBe("paid"); + }); +}); + +describe("refund / void paths", () => { + it("marks a tender refunded and flips the order to refunded when all are refunded", async () => { + const orderId = await seedOrder(2000); + await takePayment({ + paymentId: "refund-1", + orderId, + tenantId: TENANT, + locationId: LOCATION, + rail: "stripe_terminal", + amountCents: 2000, + tipCents: 0, + currency: "USD", + }); + const refunded = await refundPayment({ paymentId: "refund-1" }); + expect(refunded?.status).toBe("refunded"); + expect(refunded?.refunded_cents).toBe(2000); + const order = await getPosDriver().getOrder(orderId); + expect(order?.status).toBe("refunded"); + }); + + it("does NOT mark the order refunded on a partial refund of a single tender", async () => { + const orderId = await seedOrder(2000); + await takePayment({ + paymentId: "refund-partial", + orderId, + tenantId: TENANT, + locationId: LOCATION, + rail: "stripe_terminal", + amountCents: 2000, + tipCents: 0, + currency: "USD", + }); + const refunded = await refundPayment({ + paymentId: "refund-partial", + amountCents: 500, + }); + expect(refunded?.refunded_cents).toBe(500); + expect(refunded?.status).toBe("captured"); // not fully refunded + const order = await getPosDriver().getOrder(orderId); + expect(order?.status).not.toBe("refunded"); + }); +}); diff --git a/src/lib/pricing.test.ts b/src/lib/pricing.test.ts new file mode 100644 index 0000000..7b454f2 --- /dev/null +++ b/src/lib/pricing.test.ts @@ -0,0 +1,248 @@ +import { describe, it, expect } from "vitest"; +import { + computeOrderTotals, + computeSubtotalCents, + computeUnitPriceCents, + roundHalfUp, + withLinePricing, +} from "@/lib/pricing"; +import { buildLine, placementPriceCents } from "@/lib/build-line"; +import type { + ItemSize, + MenuItemDetail, + Modifier, + ModifierGroup, + OrderItem, + OrderItemModifier, +} from "@/lib/db"; + +/** Integer-minor-unit invariant: every money field is a safe integer. */ +function expectIntegerCents(...values: number[]): void { + for (const v of values) { + expect(Number.isInteger(v), `expected integer cents, got ${v}`).toBe(true); + } +} + +const mod = ( + price: number, + placement: OrderItemModifier["placement"], +): OrderItemModifier => ({ + group_id: "g1", + group_name: "Toppings", + modifier_id: `m-${price}-${placement}`, + modifier_name: "Topping", + price_cents: price, + placement, +}); + +const line = (over: Partial = {}): OrderItem => + withLinePricing({ + id: "l1", + item_id: "i1", + item_name: "Pizza", + size_id: "s1", + size_name: "L", + base_price_cents: 1800, + quantity: 1, + modifiers: [], + notes: null, + voided: false, + unit_price_cents: 0, + line_total_cents: 0, + ...over, + }); + +describe("computeUnitPriceCents", () => { + it("sums base price and all modifier prices", () => { + const unit = computeUnitPriceCents(1800, [ + mod(200, "whole"), + mod(150, "whole"), + ]); + expect(unit).toBe(2150); + expectIntegerCents(unit); + }); + + it("is base price with no modifiers", () => { + expect(computeUnitPriceCents(1500, [])).toBe(1500); + }); +}); + +describe("half-and-half charging", () => { + it("charges a half topping at ceil(half) the whole price", () => { + // $3.00 whole topping → $1.50 per half. + expect(placementPriceCents(300, "left")).toBe(150); + expect(placementPriceCents(300, "right")).toBe(150); + expect(placementPriceCents(300, "whole")).toBe(300); + }); + + it("rounds an odd half price UP to the cent", () => { + // $2.75 whole → 137.5 → ceil → 138 per half. + expect(placementPriceCents(275, "left")).toBe(138); + }); + + it("left + right of the SAME topping equals (or exceeds by ≤1¢) a whole", () => { + for (const whole of [100, 101, 199, 200, 275, 333, 999]) { + const halves = + placementPriceCents(whole, "left") + + placementPriceCents(whole, "right"); + // Half is rounded up, so two halves are within 1¢ of a whole and never + // CHEAPER than the whole (no revenue leak from splitting). + expect(halves).toBeGreaterThanOrEqual(whole); + expect(halves - whole).toBeLessThanOrEqual(1); + } + }); + + it("prices a half-and-half pizza line via buildLine (left + right toppings)", () => { + const size: ItemSize = { + id: "s-l", + item_id: "i-marg", + name: "Large", + price_cents: 1800, + sort_order: 1, + }; + const group: ModifierGroup = { + id: "g-top", + tenant_id: "t1", + name: "Toppings", + min_select: 0, + max_select: 10, + supports_half: true, + }; + const pepperoni: Modifier = { + id: "mod-pep", + group_id: "g-top", + name: "Pepperoni", + price_cents: 300, + sort_order: 1, + }; + const mushroom: Modifier = { + id: "mod-mush", + group_id: "g-top", + name: "Mushroom", + price_cents: 250, + sort_order: 2, + }; + const item = { + id: "i-marg", + name: "Build-your-own", + station: "oven", + } as unknown as MenuItemDetail; + + const built = buildLine({ + item, + size, + quantity: 1, + notes: "", + selections: [ + { group, modifier: pepperoni, placement: "left" }, // 150 + { group, modifier: mushroom, placement: "right" }, // 125 + ], + }); + + // 1800 base + 150 (½ pepperoni) + 125 (½ mushroom) = 2075 + expect(built.unit_price_cents).toBe(2075); + expect(built.line_total_cents).toBe(2075); + expectIntegerCents(built.unit_price_cents, built.line_total_cents); + }); +}); + +describe("size deltas", () => { + it("a larger size raises the base price by exactly its price delta", () => { + const small = line({ base_price_cents: 1200 }); + const large = line({ base_price_cents: 1800 }); + expect(large.unit_price_cents - small.unit_price_cents).toBe(600); + }); +}); + +describe("computeSubtotalCents + line totals", () => { + it("multiplies unit by quantity and sums non-voided lines", () => { + const items = [ + line({ id: "a", base_price_cents: 1000, quantity: 2 }), // 2000 + line({ id: "b", base_price_cents: 500, quantity: 3 }), // 1500 + ]; + expect(computeSubtotalCents(items)).toBe(3500); + }); + + it("excludes voided lines from subtotal and zeroes their line total", () => { + const voided = line({ + id: "v", + base_price_cents: 1000, + quantity: 2, + voided: true, + }); + expect(voided.line_total_cents).toBe(0); + expect( + computeSubtotalCents([voided, line({ id: "k", base_price_cents: 800 })]), + ).toBe(800); + }); +}); + +describe("computeOrderTotals", () => { + it("applies discount before tax and never drifts off integer cents", () => { + const items = [line({ base_price_cents: 2000, quantity: 1 })]; + const totals = computeOrderTotals({ + items, + discountCents: 500, + taxRateBps: 825, // 8.25% + tipCents: 0, + }); + // subtotal 2000, discount 500 → taxable 1500, tax = round(1500*825/10000)=124 + expect(totals.subtotal_cents).toBe(2000); + expect(totals.discount_cents).toBe(500); + expect(totals.taxable_cents).toBe(1500); + expect(totals.tax_cents).toBe(124); + expect(totals.total_cents).toBe(1624); + expectIntegerCents( + totals.subtotal_cents, + totals.discount_cents, + totals.taxable_cents, + totals.tax_cents, + totals.tip_cents, + totals.total_cents, + ); + }); + + it("clamps a discount larger than the subtotal", () => { + const items = [line({ base_price_cents: 1000 })]; + const totals = computeOrderTotals({ + items, + discountCents: 9999, + taxRateBps: 0, + }); + expect(totals.discount_cents).toBe(1000); + expect(totals.taxable_cents).toBe(0); + expect(totals.total_cents).toBe(0); + }); + + it("adds a tip on top of taxable + tax", () => { + const items = [line({ base_price_cents: 1000 })]; + const totals = computeOrderTotals({ + items, + discountCents: 0, + taxRateBps: 1000, + tipCents: 300, + }); + // taxable 1000, tax 100, tip 300 → 1400 + expect(totals.total_cents).toBe(1400); + }); + + it("never produces fractional tax (round-half-up at the bps multiply)", () => { + // 1333 * 825 / 10000 = 109.97… → 110 + const totals = computeOrderTotals({ + items: [line({ base_price_cents: 1333 })], + discountCents: 0, + taxRateBps: 825, + }); + expect(totals.tax_cents).toBe(110); + expectIntegerCents(totals.tax_cents); + }); +}); + +describe("roundHalfUp", () => { + it("rounds .5 up and stays integer", () => { + expect(roundHalfUp(0.5)).toBe(1); + expect(roundHalfUp(1.4)).toBe(1); + expect(roundHalfUp(2.5)).toBe(3); + expectIntegerCents(roundHalfUp(123.49), roundHalfUp(123.5)); + }); +}); diff --git a/src/lib/reports.test.ts b/src/lib/reports.test.ts new file mode 100644 index 0000000..1e27b91 --- /dev/null +++ b/src/lib/reports.test.ts @@ -0,0 +1,296 @@ +import { describe, it, expect } from "vitest"; +import { buildSalesReport } from "@/lib/reports"; +import type { Order, OrderItem, Payment } from "@/lib/db"; + +const RANGE = { from: "2026-06-01", to: "2026-06-30" }; + +function item(over: Partial): OrderItem { + return { + id: "li", + item_id: "i1", + item_name: "Margherita", + size_id: "s1", + size_name: "L", + base_price_cents: 1000, + quantity: 1, + modifiers: [], + notes: null, + voided: false, + unit_price_cents: 1000, + line_total_cents: 1000, + ...over, + }; +} + +function order(over: Partial): Order { + return { + id: "o", + tenant_id: "t1", + location_id: "locA", + status: "paid", + channel: "in_store", + currency: "USD", + items: [item({})], + discount_cents: 0, + totals: { + subtotal_cents: 1000, + discount_cents: 0, + taxable_cents: 1000, + tax_cents: 80, + tip_cents: 0, + total_cents: 1080, + }, + notes: null, + order_number: "A-0001", + created_at: "2026-06-10T12:00:00.000Z", + updated_at: "2026-06-10T12:00:00.000Z", + ...over, + }; +} + +function payment(over: Partial): Payment { + return { + id: "p", + order_id: "o", + tenant_id: "t1", + location_id: "locA", + rail: "cash", + status: "captured", + amount_cents: 1080, + tip_cents: 0, + application_fee_cents: 0, + currency: "USD", + charge_id: null, + connect_account_id: null, + crypto_tx_hash: null, + crypto_chain: null, + cash_tendered_cents: null, + cash_change_cents: null, + refunded_cents: 0, + simulated: true, + raw: null, + created_at: "2026-06-10T12:00:00.000Z", + updated_at: "2026-06-10T12:00:00.000Z", + ...over, + }; +} + +const locationName = (id: string) => + ({ locA: "Downtown", locB: "Uptown" })[id] ?? id; +const categoryOf = () => ({ id: "cat1", name: "Pizza" }); + +describe("buildSalesReport — aggregation correctness", () => { + it("aggregates gross, tax, payment mix, and fees on a fixed fixture", () => { + const orders: Order[] = [ + order({ id: "o1", location_id: "locA" }), + order({ + id: "o2", + location_id: "locA", + items: [item({ quantity: 2, line_total_cents: 2000 })], + totals: { + subtotal_cents: 2000, + discount_cents: 0, + taxable_cents: 2000, + tax_cents: 165, + tip_cents: 0, + total_cents: 2165, + }, + }), + ]; + const payments: Payment[] = [ + payment({ id: "p1", order_id: "o1", rail: "cash", amount_cents: 1080 }), + payment({ + id: "p2", + order_id: "o2", + rail: "stripe_terminal", + amount_cents: 2165, + application_fee_cents: 64, + }), + ]; + + const r = buildSalesReport({ + tenantId: "t1", + locationId: "locA", + range: RANGE, + orders, + payments, + categoryOf, + locationName, + }); + + expect(r.order_count).toBe(2); + expect(r.gross_cents).toBe(3000); // 1000 + 2000 + expect(r.tax_cents).toBe(245); // 80 + 165 + expect(r.fees_cents).toBe(64); + // Payment mix: two rails, summed amounts. + const cash = r.paymentMix.find((m) => m.rail === "cash"); + const card = r.paymentMix.find((m) => m.rail === "stripe_terminal"); + expect(cash?.amount_cents).toBe(1080); + expect(card?.amount_cents).toBe(2165); + expect(card?.application_fee_cents).toBe(64); + }); + + it("de-duplicates tips: online order-level tip is NOT double-counted with the tender tip", () => { + // Online order carries the tip in the order total; the tender also records it. + const orders: Order[] = [ + order({ + id: "online", + channel: "online_pickup", + totals: { + subtotal_cents: 1000, + discount_cents: 0, + taxable_cents: 1000, + tax_cents: 80, + tip_cents: 300, + total_cents: 1380, + }, + }), + ]; + const payments: Payment[] = [ + payment({ + id: "po", + order_id: "online", + rail: "stripe_online", + amount_cents: 1080, + tip_cents: 300, + }), + ]; + const r = buildSalesReport({ + tenantId: "t1", + locationId: "locA", + range: RANGE, + orders, + payments, + categoryOf, + locationName, + }); + // Tip counted ONCE (order-level), not 600. + expect(r.tip_cents).toBe(300); + }); + + it("counts an in-store tender tip when the order itself carries no tip", () => { + const orders: Order[] = [order({ id: "instore" })]; // tip_cents 0 + const payments: Payment[] = [ + payment({ + id: "pi", + order_id: "instore", + rail: "stripe_terminal", + tip_cents: 250, + }), + ]; + const r = buildSalesReport({ + tenantId: "t1", + locationId: "locA", + range: RANGE, + orders, + payments, + categoryOf, + locationName, + }); + expect(r.tip_cents).toBe(250); + }); + + it("excludes voided orders from gross but tallies them as voids", () => { + const orders: Order[] = [ + order({ id: "good" }), + order({ id: "bad", status: "voided" }), + ]; + const payments: Payment[] = [payment({ id: "pg", order_id: "good" })]; + const r = buildSalesReport({ + tenantId: "t1", + locationId: "locA", + range: RANGE, + orders, + payments, + categoryOf, + locationName, + }); + expect(r.order_count).toBe(1); + expect(r.gross_cents).toBe(1000); + expect(r.void_count).toBe(1); + expect(r.void_cents).toBe(1080); + }); + + it("excludes failed/canceled tenders from the payment mix and fees", () => { + const orders: Order[] = [order({ id: "o1" })]; + const payments: Payment[] = [ + payment({ id: "ok", order_id: "o1", rail: "cash" }), + payment({ + id: "dead", + order_id: "o1", + rail: "stripe_terminal", + status: "failed", + application_fee_cents: 99, + }), + ]; + const r = buildSalesReport({ + tenantId: "t1", + locationId: "locA", + range: RANGE, + orders, + payments, + categoryOf, + locationName, + }); + expect(r.fees_cents).toBe(0); + expect( + r.paymentMix.find((m) => m.rail === "stripe_terminal"), + ).toBeUndefined(); + }); + + it("rolls up across locations when locationId is null vs scoping to one", () => { + const orders: Order[] = [ + order({ id: "a", location_id: "locA" }), + order({ id: "b", location_id: "locB" }), + ]; + const payments: Payment[] = [ + payment({ id: "pa", order_id: "a", location_id: "locA" }), + payment({ id: "pb", order_id: "b", location_id: "locB" }), + ]; + + const rollup = buildSalesReport({ + tenantId: "t1", + locationId: null, + range: RANGE, + orders, + payments, + categoryOf, + locationName, + }); + expect(rollup.order_count).toBe(2); + expect(rollup.byLocation.map((b) => b.label).sort()).toEqual([ + "Downtown", + "Uptown", + ]); + + // Scoped report (caller filters orders) sees only one location. + const scoped = buildSalesReport({ + tenantId: "t1", + locationId: "locA", + range: RANGE, + orders: orders.filter((o) => o.location_id === "locA"), + payments: payments.filter((p) => p.location_id === "locA"), + categoryOf, + locationName, + }); + expect(scoped.order_count).toBe(1); + expect(scoped.byLocation).toHaveLength(1); + }); + + it("ignores orders outside the date range", () => { + const orders: Order[] = [ + order({ id: "in", created_at: "2026-06-10T00:00:00.000Z" }), + order({ id: "out", created_at: "2026-05-10T00:00:00.000Z" }), + ]; + const r = buildSalesReport({ + tenantId: "t1", + locationId: "locA", + range: RANGE, + orders, + payments: [], + categoryOf, + locationName, + }); + expect(r.order_count).toBe(1); + }); +}); diff --git a/src/lib/saas/entitlements.test.ts b/src/lib/saas/entitlements.test.ts new file mode 100644 index 0000000..e64ab38 --- /dev/null +++ b/src/lib/saas/entitlements.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from "vitest"; +import { + canAddLocation, + canUseAdvancedReports, + canUseOnlineOrdering, + resolveEntitlements, +} from "@/lib/saas/entitlements"; +import type { Subscription } from "@/lib/db"; + +function sub(over: Partial): Subscription { + return { + id: "sub_1", + tenant_id: "t1", + tier: "starter", + status: "active", + current_period_end: "2026-12-31T00:00:00.000Z", + trial_end: null, + cancel_at_period_end: false, + simulated: true, + stripe_customer_id: null, + stripe_subscription_id: null, + created_at: "2026-01-01T00:00:00.000Z", + updated_at: "2026-01-01T00:00:00.000Z", + ...over, + }; +} + +describe("resolveEntitlements", () => { + it("grants the Starter feature set with no subscription (pre-checkout)", () => { + const ent = resolveEntitlements(null); + expect(ent.tier).toBe("starter"); + expect(ent.active).toBe(true); + expect(ent.entitlements.online_ordering).toBe(false); + expect(ent.entitlements.max_locations).toBe(1); + }); + + it("treats trialing / active / past_due as in good standing", () => { + expect(resolveEntitlements(sub({ status: "trialing" })).active).toBe(true); + expect(resolveEntitlements(sub({ status: "active" })).active).toBe(true); + const pd = resolveEntitlements(sub({ status: "past_due" })); + expect(pd.active).toBe(true); + expect(pd.past_due).toBe(true); + }); + + it("collapses a canceled subscription to a blocked, single-location floor", () => { + const ent = resolveEntitlements(sub({ tier: "pro", status: "canceled" })); + expect(ent.active).toBe(false); + expect(ent.entitlements.max_locations).toBe(1); + expect(ent.entitlements.online_ordering).toBe(false); + expect(ent.entitlements.advanced_reports).toBe(false); + }); +}); + +describe("canAddLocation — over-limit gating", () => { + it("blocks a second location on Starter", () => { + const ent = resolveEntitlements(sub({ tier: "starter", status: "active" })); + expect(canAddLocation(ent, 1).allowed).toBe(false); + expect(canAddLocation(ent, 0).allowed).toBe(true); + }); + + it("allows up to 3 locations on Pro and blocks the 4th", () => { + const ent = resolveEntitlements(sub({ tier: "pro", status: "active" })); + expect(canAddLocation(ent, 2).allowed).toBe(true); + expect(canAddLocation(ent, 3).allowed).toBe(false); + }); + + it("allows unlimited locations on Multi", () => { + const ent = resolveEntitlements(sub({ tier: "multi", status: "active" })); + expect(canAddLocation(ent, 99).allowed).toBe(true); + }); + + it("blocks adding a location when the subscription is inactive", () => { + const ent = resolveEntitlements(sub({ tier: "pro", status: "canceled" })); + const res = canAddLocation(ent, 0); + expect(res.allowed).toBe(false); + expect(res.reason).toMatch(/inactive/i); + }); +}); + +describe("feature gates — online ordering + advanced reports", () => { + it("blocks online ordering + advanced reports on Starter", () => { + const ent = resolveEntitlements(sub({ tier: "starter", status: "active" })); + expect(canUseOnlineOrdering(ent).allowed).toBe(false); + expect(canUseAdvancedReports(ent).allowed).toBe(false); + }); + + it("allows online ordering + advanced reports on Pro", () => { + const ent = resolveEntitlements(sub({ tier: "pro", status: "active" })); + expect(canUseOnlineOrdering(ent).allowed).toBe(true); + expect(canUseAdvancedReports(ent).allowed).toBe(true); + }); +}); diff --git a/src/lib/shop/scheduling.test.ts b/src/lib/shop/scheduling.test.ts new file mode 100644 index 0000000..57d0264 --- /dev/null +++ b/src/lib/shop/scheduling.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from "vitest"; +import { + asapAvailability, + checkScheduledTime, + isOpenAt, +} from "@/lib/shop/scheduling"; +import type { DayHours, FulfillmentSettings } from "@/lib/db"; + +/** Every day 11:00–22:00. */ +function dailyHours(open = "11:00", close = "22:00"): DayHours[] { + return Array.from({ length: 7 }, (_, weekday) => ({ + weekday, + open, + close, + closed: false, + })); +} + +function settings( + over: Partial = {}, +): FulfillmentSettings { + return { + pickup_enabled: true, + delivery_enabled: false, + prep_minutes: 20, + scheduling_lead_minutes: 15, + scheduling_horizon_days: 5, + hours: dailyHours(), + delivery_zones: [], + delivery_providers: [], + ...over, + }; +} + +// A Wednesday (2026-06-03) used as a stable wall-clock frame. +const wed = (h: number, m = 0) => new Date(2026, 5, 3, h, m, 0, 0); + +describe("isOpenAt", () => { + it("is open inside the window", () => { + expect(isOpenAt(dailyHours(), wed(12))).toBe(true); + }); + it("is closed before open and after close", () => { + expect(isOpenAt(dailyHours(), wed(9))).toBe(false); + expect(isOpenAt(dailyHours(), wed(23))).toBe(false); + }); + it("is closed on a day flagged closed", () => { + const hours = dailyHours().map((h) => + h.weekday === 3 ? { ...h, closed: true } : h, + ); + expect(isOpenAt(hours, wed(12))).toBe(false); + }); + it("handles a window that wraps past midnight", () => { + // Open 18:00, close 02:00 (next day). + const hours = dailyHours("18:00", "02:00"); + expect(isOpenAt(hours, wed(20))).toBe(true); // evening of today + expect(isOpenAt(hours, wed(1))).toBe(true); // early morning spillover + expect(isOpenAt(hours, wed(15))).toBe(false); // afternoon gap + }); +}); + +describe("asapAvailability", () => { + it("is available when open and ready time is still before close", () => { + const res = asapAvailability(settings(), wed(12)); + expect(res.available).toBe(true); + expect(res.promisedAt).toBeTruthy(); + }); + + it("is unavailable when the store is closed now", () => { + const res = asapAvailability(settings(), wed(9)); + expect(res.available).toBe(false); + expect(res.reason).toMatch(/closed/i); + }); + + it("is unavailable too close to closing (ready time falls after close)", () => { + // 21:50 + 20 prep = 22:10, past 22:00 close. + const res = asapAvailability(settings({ prep_minutes: 20 }), wed(21, 50)); + expect(res.available).toBe(false); + expect(res.reason).toMatch(/closing/i); + }); +}); + +describe("checkScheduledTime", () => { + it("accepts a valid future time inside hours and horizon", () => { + const res = checkScheduledTime(settings(), wed(12), wed(14)); + expect(res.ok).toBe(true); + expect(res.promisedAt).toBe(wed(14).toISOString()); + }); + + it("rejects a time inside the lead window (prep + lead = 35 min)", () => { + // now 12:00, lead 35 min → earliest 12:35; 12:20 is too soon. + const res = checkScheduledTime(settings(), wed(12), wed(12, 20)); + expect(res.ok).toBe(false); + expect(res.reason).toMatch(/from now/i); + }); + + it("rejects a time beyond the booking horizon", () => { + const far = new Date(wed(12).getTime() + 10 * 24 * 60 * 60 * 1000); + const res = checkScheduledTime( + settings({ scheduling_horizon_days: 5 }), + wed(12), + far, + ); + expect(res.ok).toBe(false); + expect(res.reason).toMatch(/within/i); + }); + + it("rejects a time when the store is closed at that moment", () => { + const res = checkScheduledTime(settings(), wed(12), wed(23)); + expect(res.ok).toBe(false); + expect(res.reason).toMatch(/closed/i); + }); + + it("rejects an invalid date", () => { + const res = checkScheduledTime(settings(), wed(12), new Date(NaN)); + expect(res.ok).toBe(false); + }); +}); diff --git a/supabase/README.md b/supabase/README.md index af95074..012df71 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -87,9 +87,40 @@ psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f supabase/tests/rls_isolation.sql # expect: result = "RLS isolation test PASSED" ``` +The script asserts: + +- tenant A's member sees exactly tenant A's tenant/location and **never** tenant B's; +- tenant B's member sees only tenant B's; +- a cross-tenant **write** is blocked **and leaves no row behind**; +- **memberships** are tenant-scoped (no cross-tenant staff visibility); +- a member **cannot read another tenant's user row** but can read their own; +- a platform admin sees **both** tenants. + > Run this as a role that owns the tables (e.g. the migration/`postgres` role). -> It switches to `authenticated` internally to exercise RLS. In CI this becomes -> part of an automated tenant-isolation suite (Phase 7 hardening). +> It switches to `authenticated` internally to exercise RLS. + +### One-command harness + +`supabase/tests/run-rls-isolation.sh` applies the migrations and runs the test +in one step against any Postgres. Point it at a **fresh** database (a throwaway +container or a provisioned Supabase project): + +```bash +# Local throwaway Postgres: +docker run -d --name pos-pg -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:16 +DATABASE_URL="postgres://postgres:postgres@localhost:5432/postgres" \ + bash supabase/tests/run-rls-isolation.sh +``` + +### CI + +A **non-blocking** `rls-isolation` job in `.github/workflows/ci.yml` spins up a +Postgres service, applies the migrations, and runs this test on every PR. It is +marked `continue-on-error: true` because **a live DB must NOT be a required CI +dependency** for the platform (the app builds and the full Vitest suite passes +with zero env vars). The required gates are `build` + `test` (Vitest). The +app-layer complement to this DB-level test lives in +`src/lib/db/tenant-isolation.test.ts` and **does** run in the required suite. ## Note on `seed.sql` and the menu schema diff --git a/supabase/tests/rls_isolation.sql b/supabase/tests/rls_isolation.sql index 4a9b423..5517147 100644 --- a/supabase/tests/rls_isolation.sql +++ b/supabase/tests/rls_isolation.sql @@ -106,7 +106,42 @@ begin end $$; reset role; --- 4) Platform admin sees BOTH tenants. +-- 4) The blocked cross-tenant write (assertion 3) left NO row behind. Check as +-- the table owner (RLS reset) so we see the true persisted state. +do $$ +declare n int; +begin + select count(*) into n from public.locations where slug = 'hacked'; + assert n = 0, format('Blocked cross-tenant insert must leave no row, found %s', n); +end $$; + +-- 5) memberships are tenant-scoped: Alice sees only tenant A's membership rows, +-- never Bob's (no cross-tenant staff visibility). +select pg_temp.act_as('11111111-1111-1111-1111-111111111111'); +do $$ +declare n int; +begin + select count(*) into n from public.memberships; + assert n = 1, format('Alice should see 1 membership (her own tenant), saw %s', n); + perform 1 from public.memberships + where tenant_id = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; + assert not found, 'Alice must NOT see tenant B memberships'; +end $$; +reset role; + +-- 6) Alice cannot read Bob's user row (no shared tenant); she can read her own. +select pg_temp.act_as('11111111-1111-1111-1111-111111111111'); +do $$ +declare n int; +begin + perform 1 from public.users where email = 'bob@tenant-b.example'; + assert not found, 'Alice must NOT see Bob''s user row'; + select count(*) into n from public.users where id = '11111111-1111-1111-1111-111111111111'; + assert n = 1, 'Alice must be able to read her own user row'; +end $$; +reset role; + +-- 7) Platform admin sees BOTH tenants. select pg_temp.act_as('33333333-3333-3333-3333-333333333333'); do $$ declare n int; diff --git a/supabase/tests/run-rls-isolation.sh b/supabase/tests/run-rls-isolation.sh new file mode 100644 index 0000000..6c27fb7 --- /dev/null +++ b/supabase/tests/run-rls-isolation.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# ============================================================================ +# RLS isolation harness — applies the tenancy migrations to a Postgres and runs +# the tenant-isolation assertions (supabase/tests/rls_isolation.sql). +# +# This CANNOT run without a live Postgres, so it is NOT part of the required CI +# gates. Use it against: +# * a local throwaway Postgres (e.g. `docker run -e POSTGRES_PASSWORD=postgres +# -p 5432:5432 postgres:16`), or +# * a provisioned Supabase project (once the live-wiring phase lands). +# +# Usage: +# DATABASE_URL="postgres://postgres:postgres@localhost:5432/pos_test" \ +# bash supabase/tests/run-rls-isolation.sh +# +# It applies migrations idempotently-ish (create-or-replace functions; tables +# will error if already present — point it at a FRESH database). The isolation +# test itself runs in a transaction and rolls back, mutating nothing persistent. +# ============================================================================ +set -euo pipefail + +: "${DATABASE_URL:?Set DATABASE_URL to a Postgres connection string}" + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +MIGRATIONS_DIR="$ROOT/supabase/migrations" +TEST_FILE="$ROOT/supabase/tests/rls_isolation.sql" + +echo "==> Applying tenancy migrations from $MIGRATIONS_DIR" +for f in "$MIGRATIONS_DIR"/*.sql; do + echo " - $(basename "$f")" + psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f "$f" +done + +echo "==> Running RLS isolation test" +psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f "$TEST_FILE" + +echo "==> RLS isolation harness complete." diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..21d44db --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from "vitest/config"; +import { fileURLToPath } from "node:url"; + +/** + * Vitest config (Phase 7 hardening). + * + * Runs the pure-logic + mock-driver test suite in a Node environment with ZERO + * env vars required — mirrors CI and the env-free build invariant. The `@` + * alias matches tsconfig's `paths` so tests import domain modules the same way + * the app does. + */ +export default defineConfig({ + resolve: { + alias: { + "@": fileURLToPath(new URL("./src", import.meta.url)), + }, + }, + test: { + environment: "node", + include: ["src/**/*.test.ts", "tests/**/*.test.ts"], + // Keep mock driver module-level state from leaking across files. + isolate: true, + globals: false, + }, +}); From 696f3ed8c98d0866ae8c18ef8e7abb9ea3cd4b55 Mon Sep 17 00:00:00 2001 From: snackman Date: Thu, 4 Jun 2026 23:16:53 -0400 Subject: [PATCH 2/4] Wire auth.uid() shim into RLS Postgres CI job + harness The optional RLS-isolation job ran against vanilla Postgres, which lacks Supabase's auth schema / auth.uid() the tenancy policies depend on. Apply the auth_shim.sql before migrations (CI job + harness, skippable via SKIP_AUTH_SHIM=1 for real Supabase) so the optional job exercises real RLS and goes green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 7 +++++++ supabase/tests/auth_shim.sql | 27 +++++++++++++++++++++++++++ supabase/tests/run-rls-isolation.sh | 10 ++++++++++ 3 files changed, 44 insertions(+) create mode 100644 supabase/tests/auth_shim.sql diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 781ea89..3c1ac0c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,6 +82,13 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Apply auth.uid() shim + # Vanilla Postgres lacks Supabase's `auth` schema / `auth.uid()`, which the + # tenancy RLS policies depend on. This shim recreates that exact contract so + # the migrations apply and the isolation test exercises real RLS off-platform. + # NOT applied on a real Supabase project (the platform supplies auth.uid()). + run: psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f supabase/tests/auth_shim.sql + - name: Apply tenancy migrations run: | for f in supabase/migrations/*.sql; do diff --git a/supabase/tests/auth_shim.sql b/supabase/tests/auth_shim.sql new file mode 100644 index 0000000..ba29360 --- /dev/null +++ b/supabase/tests/auth_shim.sql @@ -0,0 +1,27 @@ +-- ============================================================================ +-- auth.uid() shim for running the RLS isolation test on a VANILLA Postgres. +-- +-- Supabase provides the `auth` schema and `auth.uid()` (which reads the current +-- request's JWT `sub` claim). A plain Postgres (local container / CI service) +-- has neither, so the tenancy RLS migration — and the isolation test that sets +-- `request.jwt.claims` — fail with `schema "auth" does not exist`. +-- +-- This shim recreates EXACTLY the Supabase contract the policies rely on: +-- auth.uid() = (current_setting('request.jwt.claims')::json ->> 'sub')::uuid +-- so the migrations apply and the isolation test exercises real RLS off-platform. +-- +-- Apply this BEFORE the migrations. On a real Supabase project it is unnecessary +-- (the platform supplies `auth.uid()`); do NOT apply it there. +-- ============================================================================ +create schema if not exists auth; + +create or replace function auth.uid() +returns uuid +language sql +stable +as $$ + select nullif( + current_setting('request.jwt.claims', true)::json ->> 'sub', + '' + )::uuid; +$$; diff --git a/supabase/tests/run-rls-isolation.sh b/supabase/tests/run-rls-isolation.sh index 6c27fb7..bfbdd9e 100644 --- a/supabase/tests/run-rls-isolation.sh +++ b/supabase/tests/run-rls-isolation.sh @@ -16,6 +16,10 @@ # It applies migrations idempotently-ish (create-or-replace functions; tables # will error if already present — point it at a FRESH database). The isolation # test itself runs in a transaction and rolls back, mutating nothing persistent. +# +# On a VANILLA Postgres (local/CI) the auth.uid() shim is applied first so the +# policies resolve. Against a REAL Supabase project, set SKIP_AUTH_SHIM=1 — the +# platform already supplies auth.uid() and the shim must NOT overwrite it. # ============================================================================ set -euo pipefail @@ -24,6 +28,12 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/../.." && pwd)" MIGRATIONS_DIR="$ROOT/supabase/migrations" TEST_FILE="$ROOT/supabase/tests/rls_isolation.sql" +SHIM_FILE="$ROOT/supabase/tests/auth_shim.sql" + +if [ -z "${SKIP_AUTH_SHIM:-}" ]; then + echo "==> Applying auth.uid() shim (vanilla Postgres; set SKIP_AUTH_SHIM=1 for real Supabase)" + psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f "$SHIM_FILE" +fi echo "==> Applying tenancy migrations from $MIGRATIONS_DIR" for f in "$MIGRATIONS_DIR"/*.sql; do From 0a71c2cc65fd7d15a134ed74c60e5037eb5a1b91 Mon Sep 17 00:00:00 2001 From: snackman Date: Thu, 4 Jun 2026 23:19:01 -0400 Subject: [PATCH 3/4] Add anon/authenticated/service_role roles to auth shim The RLS isolation test does `set local role authenticated`; vanilla Postgres lacks Supabase's standard roles. Create them (no-login) in the shim so the optional Postgres RLS job can switch roles and exercise the policies. Co-Authored-By: Claude Opus 4.8 (1M context) --- supabase/tests/auth_shim.sql | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/supabase/tests/auth_shim.sql b/supabase/tests/auth_shim.sql index ba29360..348fe45 100644 --- a/supabase/tests/auth_shim.sql +++ b/supabase/tests/auth_shim.sql @@ -11,8 +11,26 @@ -- so the migrations apply and the isolation test exercises real RLS off-platform. -- -- Apply this BEFORE the migrations. On a real Supabase project it is unnecessary --- (the platform supplies `auth.uid()`); do NOT apply it there. +-- (the platform supplies `auth.uid()` and these roles); do NOT apply it there. -- ============================================================================ + +-- Supabase ships these Postgres roles; the isolation test does `set local role +-- authenticated` (and policies may target anon/authenticated). Vanilla Postgres +-- has none of them, so recreate the no-login role contract. +do $$ +begin + if not exists (select from pg_roles where rolname = 'anon') then + create role anon nologin noinherit; + end if; + if not exists (select from pg_roles where rolname = 'authenticated') then + create role authenticated nologin noinherit; + end if; + if not exists (select from pg_roles where rolname = 'service_role') then + create role service_role nologin noinherit bypassrls; + end if; +end +$$; + create schema if not exists auth; create or replace function auth.uid() From ad143383e4c6ce89b8c84b74ee4d4d6f6283c1e9 Mon Sep 17 00:00:00 2001 From: snackman Date: Thu, 4 Jun 2026 23:21:55 -0400 Subject: [PATCH 4/4] Add explicit least-privilege grants to tenancy RLS migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RLS gates rows but table GRANTs gate table access; the migration relied on Supabase's implicit default privileges, so `authenticated` was denied on vanilla Postgres. Grant the tenancy tables to `authenticated` (rows still restricted by the policies) and only schema usage to `anon` — explicit, portable, least-privilege. Makes the RLS isolation test pass off-platform. Co-Authored-By: Claude Opus 4.8 (1M context) --- supabase/migrations/20260601000100_tenancy_rls.sql | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/supabase/migrations/20260601000100_tenancy_rls.sql b/supabase/migrations/20260601000100_tenancy_rls.sql index e15da6c..556b231 100644 --- a/supabase/migrations/20260601000100_tenancy_rls.sql +++ b/supabase/migrations/20260601000100_tenancy_rls.sql @@ -230,3 +230,17 @@ create policy platform_admins_all on public.platform_admins for all using (public.is_platform_admin()) with check (public.is_platform_admin()); + +-- ---------------------------------------------------------------------------- +-- Grants. RLS decides WHICH ROWS a role may see; table GRANTs decide whether the +-- role may touch the table at all. Supabase normally applies default privileges +-- to anon/authenticated implicitly — we make them explicit so the schema is +-- self-contained and portable (vanilla Postgres) and access is least-privilege +-- by intent: `authenticated` gets table access (rows still gated by the policies +-- above), `anon` gets none of the tenancy tables. +-- ---------------------------------------------------------------------------- +grant usage on schema public to anon, authenticated; +grant select, insert, update, delete + on public.tenants, public.locations, public.users, + public.memberships, public.platform_admins + to authenticated;