diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 183f0cb7..28895023 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -19,3 +19,4 @@ {"id":"int-3d831c89","kind":"field_change","created_at":"2026-05-01T14:43:10.775569Z","actor":"Jonathan Gill","issue_id":"batchivo-sc7p","extra":{"field":"status","new_value":"in_progress","old_value":"open"}} {"id":"int-790f7156","kind":"field_change","created_at":"2026-05-01T14:57:59.843399Z","actor":"Jonathan Gill","issue_id":"batchivo-sc7p","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented manual admin order creation: backend POST /api/v1/orders requires admin auth and tenant-scoped product/channel validation; frontend /orders/new creates manual orders with recorded payment status/reference and navigates to detail; Playwright covers creation and existing order lifecycle."}} {"id":"int-b5c1d92a","kind":"field_change","created_at":"2026-05-05T08:29:14.461784Z","actor":"Jonathan Gill","issue_id":"batchivo-qe8p","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Completed: Gmail send-as for hello@mystmereforge.co.uk configured via Resend SMTP and verified by received Gmail alias test."}} +{"id":"int-46bb38a0","kind":"field_change","created_at":"2026-09-07T11:35:32.215146Z","actor":"Jonathan Gill","issue_id":"batchivo-z85l","extra":{"field":"status","new_value":"in_progress","old_value":"open"}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 610f41ec..bb07d115 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -19,6 +19,7 @@ {"id":"nozzly.app-2wp","title":"CRITICAL: Missing tenant_id in product_categories association table","description":"File: backend/app/models/category.py (lines 17-22)\n\nThe many-to-many association table product_categories has NO tenant_id. This is a severe multi-tenant security issue.\n\nImpact: A tenant's categories could be linked to another tenant's products via direct database queries.\n\nFix: Add tenant_id column and composite primary key.","status":"closed","priority":0,"issue_type":"bug","created_at":"2025-12-31T17:32:35Z","created_by":"jonathan","updated_at":"2026-01-01T17:02:59Z","closed_at":"2026-01-01T17:02:59Z","close_reason":"Added tenant_id to both tables with migration and test fixes","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"batchivo-im3","title":"CRITICAL: Hardcoded database password in K8s manifests","description":"File: infrastructure/k8s/postgres/statefulset.yaml (line 9) and backend ConfigMap (line 7)\n\nIssue: Postgres uses hardcoded password 'changeme-in-production' in plaintext ConfigMap.\nDATABASE_URL with password visible in repo: postgresql+psycopg://nozzly:changeme-in-production@postgres:5432/nozzly\n\nFix: Move to Kubernetes Secret, rotate immediately.","status":"closed","priority":0,"issue_type":"bug","created_at":"2025-12-31T17:32:34Z","created_by":"jonathan","updated_at":"2025-12-31T17:52:12Z","closed_at":"2025-12-31T17:52:12Z","close_reason":"Removed hardcoded password from repo, now uses kubectl secret creation","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"nozzly.app-im3","title":"CRITICAL: Hardcoded database password in K8s manifests","description":"File: infrastructure/k8s/postgres/statefulset.yaml (line 9) and backend ConfigMap (line 7)\n\nIssue: Postgres uses hardcoded password 'changeme-in-production' in plaintext ConfigMap.\nDATABASE_URL with password visible in repo: postgresql+psycopg://nozzly:changeme-in-production@postgres:5432/nozzly\n\nFix: Move to Kubernetes Secret, rotate immediately.","status":"closed","priority":0,"issue_type":"bug","created_at":"2025-12-31T17:32:34Z","created_by":"jonathan","updated_at":"2025-12-31T17:52:12Z","closed_at":"2025-12-31T17:52:12Z","close_reason":"Removed hardcoded password from repo, now uses kubectl secret creation","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"batchivo-z85l","title":"Finish production-capable commerce adapter and lifecycle acceptance","description":"Current app/commerce_bridge.py is an isolated acceptance bridge only. Complete reviewed production adapter, migrations, stock reservations before payment, partial refunds and fulfilment callbacks, replay/recovery and no duplicate manufacturing. Never remove isolation guard as a shortcut. Coordinate with shop commerce/docs/DELIVERY.md.","notes":"2026-09-09: Added operator-only audited ownership fence with session advisory locks spanning full legacy requests, preserving callbacks and read routes. Disposable actual PostgreSQL plus native ASGI route acceptance passes 34 checks including callback DB outage recovery. Checkout/webhook/order-lookup regressions 105 passed; prior broader run 142 passed with one stale unsigned-event expectation now corrected. New migration and guards not yet deployed; production ownership remains legacy. Build/canary and combined promotion rehearsal remain.","status":"in_progress","priority":1,"issue_type":"task","owner":"jonathan@techize.co.uk","created_at":"2026-09-07T11:26:10Z","created_by":"Jonathan Gill","updated_at":"2026-09-09T16:38:56Z","started_at":"2026-09-07T11:35:32Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"batchivo-gtx9","title":"Audit API authentication and tenant authorization coverage","description":"Review existing API routes for public exposure, authentication requirements, role requirements, tenant isolation checks, and webhook/payment-specific signature or idempotency protections. User explicitly flagged that both new and existing APIs must be considered for auth/security exposure while adding admin order creation.","status":"open","priority":1,"issue_type":"task","owner":"jonathan@techize.co.uk","created_at":"2026-05-01T14:57:54Z","created_by":"Jonathan Gill","updated_at":"2026-05-01T14:57:54Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"batchivo-7vgj","title":"Validate spool material type foreign keys","description":"Post/put spool API accepts a nonexistent material_type_id, causing backend regression tests to fail after pulling latest main. Return 400 before writing when the supplied material type is not found for the current tenant.","acceptance_criteria":"tests/api/test_spools.py::TestFKConstraintErrors passes and full backend pytest passes.","status":"closed","priority":1,"issue_type":"bug","owner":"jonathan@techize.co.uk","created_at":"2026-05-01T12:45:32Z","created_by":"Jonathan Gill","updated_at":"2026-05-01T12:52:12Z","started_at":"2026-05-01T12:45:37Z","closed_at":"2026-05-01T12:52:12Z","close_reason":"Added explicit material_type_id existence validation for spool create/update; targeted FK regression tests and full backend pytest pass.","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"batchivo-3c5h","title":"Patch frontend production dependency vulnerabilities","description":"npm audit --omit=dev reports 6 production dependency vulnerabilities: protobufjs critical, axios high, picomatch high, dompurify moderate, follow-redirects moderate, postcss moderate. Update lockfile/dependencies safely and verify frontend gates.","acceptance_criteria":"npm audit --omit=dev has no high/critical production vulnerabilities; frontend typecheck/lint/test/build pass.","status":"closed","priority":1,"issue_type":"bug","owner":"jonathan@techize.co.uk","created_at":"2026-05-01T12:22:07Z","created_by":"Jonathan Gill","updated_at":"2026-05-01T12:35:29Z","started_at":"2026-05-01T12:34:28Z","closed_at":"2026-05-01T12:35:29Z","close_reason":"Applied npm audit fixes; npm audit --omit=dev now reports 0 vulnerabilities and frontend typecheck/lint/test/build pass.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/.woodpecker/build.yml b/.woodpecker/build.yml index 559c2e41..2cf7fff4 100644 --- a/.woodpecker/build.yml +++ b/.woodpecker/build.yml @@ -1,24 +1,14 @@ -# Woodpecker CI — Build + Deploy Pipeline -# -# Purpose: Get code live as fast as possible. -# This pipeline does ONE thing: build images and update manifests. -# Tests, dep scans, and image scans all run concurrently in test.yml. -# -# Architecture: -# build-backend ──┐ -# build-frontend ──┴──> deploy-staging ──> deploy-production -# -# Expected time: ~4-7 min on warm Kaniko cache (code-only change) -# ~8-12 min on cold cache (dep change) -# -# Triggered by: push to main (excluding k8s-only changes — ArgoCD handles those directly) +# Owner-triggered image publishing, after all validation succeeds. +# Promotion uses a separate manifest PR; this workflow never edits live manifests. when: - - event: push + - event: deployment branch: main - path: - exclude: - - 'infrastructure/k8s/**' + evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "build-release"' + +depends_on: + - test-fast + - test-integration steps: # ============================================ @@ -70,92 +60,24 @@ steps: --export-cache type=registry,ref=registry.techize.co.uk/batchivo/batchivo-frontend-cache,mode=max \ --output type=image,name=registry.techize.co.uk/batchivo/batchivo-frontend:${CI_COMMIT_SHA},push=true - # ============================================ - # Deploy to Staging - # Updates k8s manifest → ArgoCD auto-syncs the rest - # ============================================ - deploy-staging: - image: registry.techize.co.uk/dockerhub-cache/alpine/git:latest - depends_on: - - build-backend - - build-frontend + scan-image-backend: + image: registry.techize.co.uk/dockerhub-cache/aquasec/trivy:0.55.0 + depends_on: [build-backend] environment: - GIT_AUTHOR_NAME: woodpecker-ci[bot] - GIT_AUTHOR_EMAIL: woodpecker-ci[bot]@batchivo - GIT_COMMITTER_NAME: woodpecker-ci[bot] - GIT_COMMITTER_EMAIL: woodpecker-ci[bot]@batchivo - GITHUB_TOKEN: - from_secret: github_token + TRIVY_USERNAME: + from_secret: docker_username + TRIVY_PASSWORD: + from_secret: docker_password commands: - - apk add --no-cache sed curl - - cd /woodpecker/src/github.com/techize/batchivo - - echo "=== Deploying to STAGING ===" - - 'sed -i "s|image: registry.techize.co.uk/batchivo/batchivo-backend:.*|image: registry.techize.co.uk/batchivo/batchivo-backend:$CI_COMMIT_SHA|g" infrastructure/k8s/staging/deployment.yaml' - - git config --global --add safe.directory /woodpecker/src/github.com/techize/batchivo - - git remote set-url origin https://x-access-token:$GITHUB_TOKEN@github.com/techize/batchivo.git - - git add infrastructure/k8s/staging/deployment.yaml - - | - for i in 1 2 3; do - git pull --rebase origin main || true - git diff --staged --quiet && echo "No staging changes to commit" && exit 0 - if git commit -m "cd(staging): update backend image to $CI_COMMIT_SHA" && git push origin main; then - echo "Successfully pushed staging manifest update" - exit 0 - fi - echo "Attempt $i failed, retrying in 5s..." - git reset HEAD~1 2>/dev/null || true - sleep 5 - done - echo "Failed after 3 attempts" - exit 1 + - trivy image --severity HIGH,CRITICAL --exit-code 1 --ignore-unfixed registry.techize.co.uk/batchivo/batchivo-backend:${CI_COMMIT_SHA} - # ============================================ - # Deploy to Production - # Updates both backend + frontend manifests → ArgoCD syncs - # ============================================ - deploy-production: - image: registry.techize.co.uk/dockerhub-cache/alpine/git:latest - depends_on: - - deploy-staging + scan-image-frontend: + image: registry.techize.co.uk/dockerhub-cache/aquasec/trivy:0.55.0 + depends_on: [build-frontend] environment: - GIT_AUTHOR_NAME: woodpecker-ci[bot] - GIT_AUTHOR_EMAIL: woodpecker-ci[bot]@batchivo - GIT_COMMITTER_NAME: woodpecker-ci[bot] - GIT_COMMITTER_EMAIL: woodpecker-ci[bot]@batchivo - GITHUB_TOKEN: - from_secret: github_token + TRIVY_USERNAME: + from_secret: docker_username + TRIVY_PASSWORD: + from_secret: docker_password commands: - - apk add --no-cache sed - - cd /woodpecker/src/github.com/techize/batchivo - - echo "=== Deploying to PRODUCTION ===" - - BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") - - 'sed -i "s|image: registry.techize.co.uk/batchivo/batchivo-backend:.*|image: registry.techize.co.uk/batchivo/batchivo-backend:$CI_COMMIT_SHA|g" infrastructure/k8s/backend/deployment.yaml' - - 'sed -i "s|image: registry.techize.co.uk/batchivo/batchivo-frontend:.*|image: registry.techize.co.uk/batchivo/batchivo-frontend:$CI_COMMIT_SHA|g" infrastructure/k8s/frontend/deployment.yaml' - - 'sed -i "/name: BUILD_SHA/{n;s|value:.*|value: \"$CI_COMMIT_SHA\"|}" infrastructure/k8s/backend/deployment.yaml' - - 'sed -i "/name: BUILD_DATE/{n;s|value:.*|value: \"$BUILD_DATE\"|}" infrastructure/k8s/backend/deployment.yaml' - - 'sed -i "/name: VITE_BUILD_SHA/{n;s|value:.*|value: \"$CI_COMMIT_SHA\"|}" infrastructure/k8s/frontend/deployment.yaml' - - 'sed -i "/name: VITE_BUILD_DATE/{n;s|value:.*|value: \"$BUILD_DATE\"|}" infrastructure/k8s/frontend/deployment.yaml' - - git config --global --add safe.directory /woodpecker/src/github.com/techize/batchivo - - git remote set-url origin https://x-access-token:$GITHUB_TOKEN@github.com/techize/batchivo.git - - git add infrastructure/k8s/backend/deployment.yaml infrastructure/k8s/frontend/deployment.yaml - - | - for i in 1 2 3; do - git pull --rebase origin main || true - git diff --staged --quiet && echo "No production changes to commit" && exit 0 - if git commit -m "cd(prod): update image tags to $CI_COMMIT_SHA" && git push origin main; then - echo "Successfully pushed production manifest update" - exit 0 - fi - echo "Attempt $i failed, retrying in 5s..." - git reset HEAD~1 2>/dev/null || true - sleep 5 - done - echo "Failed after 3 attempts" - exit 1 - -# Notes: -# - Staging health check removed — staging.batchivo.com DNS not yet configured, was wasting 60s per run. -# Re-add once staging is live: poll https://staging.batchivo.com/health between the two deploy steps. -# - Image security scans (Trivy) run in test.yml after tests complete — not a deploy gate here. -# - volumes disabled 2026-03-17 — Woodpecker agent next-bef0abaa4a DNS name bug -# - 2026-03-22 — if pipelines fail with "cannot ack workflow", restart woodpecker-server pod (SQLite queue corruption after 5+ days uptime) + - trivy image --severity HIGH,CRITICAL --exit-code 1 --ignore-unfixed registry.techize.co.uk/batchivo/batchivo-frontend:${CI_COMMIT_SHA} diff --git a/.woodpecker/test-fast.yml b/.woodpecker/test-fast.yml index e0f88486..b6d9822a 100644 --- a/.woodpecker/test-fast.yml +++ b/.woodpecker/test-fast.yml @@ -1,26 +1,27 @@ -# Woodpecker CI — Fast Test Pipeline (no database) -# -# Purpose: Fast signal on correctness — lint, unit tests, frontend checks. -# No postgres service needed. Completes in ~3-5 min. -# -# Architecture: -# scan-deps-backend ──> backend-unit ────┐ -# scan-deps-frontend ──> frontend ────────┘ -# -# Triggered by: same conditions as build.yml +# PR and main validation. No publishing, deployment credentials or production services. when: + - event: pull_request + branch: main - event: push branch: main - path: - exclude: - - 'infrastructure/k8s/**' + - event: deployment + branch: main + evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "build-release"' variables: - &ci_python_image registry.techize.co.uk/batchivo/ci-python:3.12 - - &node_image registry.techize.co.uk/dockerhub-cache/node:20-slim + - &node_image registry.techize.co.uk/dockerhub-cache/node@sha256:ab07539e0988b63558ff621f5fbe1077054c39d9809112974fb79993949d41cd - &trivy_image registry.techize.co.uk/dockerhub-cache/aquasec/trivy:0.55.0 +services: + postgres: + image: registry.techize.co.uk/dockerhub-cache/postgres@sha256:20edbde7749f822887a1a022ad526fde0a47d6b2be9a8364433605cf65099416 + environment: + POSTGRES_USER: test + POSTGRES_PASSWORD: test + POSTGRES_DB: test_batchivo + steps: scan-deps-backend: image: *trivy_image @@ -36,13 +37,14 @@ steps: # ============================================ # Backend: Lint + Unit Tests (~3-5 min) - # Pure logic tests — no database required. + # Includes database-backed unit fixtures in an ephemeral PostgreSQL service. # ============================================ backend-unit: image: *ci_python_image - depends_on: - - scan-deps-backend + depends_on: [] environment: + PGPASSWORD: test + DATABASE_URL: postgresql+psycopg://test:test@postgres:5432/test_batchivo SECRET_KEY: test-secret-key-for-ci-testing-minimum-32-chars commands: - cd backend @@ -52,6 +54,9 @@ steps: - poetry run ruff check . - poetry run ruff format --check . - echo "=== Unit tests ===" + - timeout 60 sh -c 'until pg_isready -h postgres -U test -q; do sleep 1; done' + # Scratch databases only: avoid synchronous disk flushes for each test DDL. + - psql -h postgres -U test -d test_batchivo -c "ALTER SYSTEM SET fsync = 'off'" -c "ALTER SYSTEM SET synchronous_commit = 'off'" -c "SELECT pg_reload_conf()" - poetry run pytest tests/unit/ --tb=short -q # ============================================ @@ -59,11 +64,11 @@ steps: # ============================================ frontend: image: *node_image - depends_on: - - scan-deps-frontend + depends_on: [] commands: - cd frontend - npm ci --silent - npm run lint - npm run typecheck - npm run test:ci + - npm run build:check diff --git a/.woodpecker/test-integration.yml b/.woodpecker/test-integration.yml index 7b168b88..41cc61d9 100644 --- a/.woodpecker/test-integration.yml +++ b/.woodpecker/test-integration.yml @@ -1,21 +1,13 @@ -# Woodpecker CI — Integration Test Pipeline (requires postgres) -# -# Purpose: Full backend coverage with real database. -# Runs concurrently with build.yml and test-fast.yml. -# Slower (~10-15 min) — does not gate deployments. -# -# Architecture: -# scan-deps-backend ──> backend-integration ──> scan-image-backend -# scan-image-frontend -# -# Triggered by: same conditions as build.yml +# Isolated PostgreSQL integration tests required before release builds. when: + - event: pull_request + branch: main - event: push branch: main - path: - exclude: - - 'infrastructure/k8s/**' + - event: deployment + branch: main + evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "build-release"' variables: - &ci_python_image registry.techize.co.uk/batchivo/ci-python:3.12 @@ -23,7 +15,7 @@ variables: services: postgres: - image: registry.techize.co.uk/dockerhub-cache/postgres:15 + image: registry.techize.co.uk/dockerhub-cache/postgres@sha256:20edbde7749f822887a1a022ad526fde0a47d6b2be9a8364433605cf65099416 ports: - 5432 environment: @@ -32,20 +24,12 @@ services: POSTGRES_DB: test_batchivo steps: - scan-deps-backend: - image: *trivy_image - commands: - - trivy fs --severity HIGH,CRITICAL --exit-code 1 --ignore-unfixed --scanners vuln backend/ - - echo "Backend dependency scan complete" - # ============================================ # Backend: Integration + API Tests (~8-12 min) # Runs everything outside tests/unit/ with 4 parallel workers. # ============================================ backend-integration: image: *ci_python_image - depends_on: - - scan-deps-backend environment: DATABASE_URL: postgresql+psycopg://test:test@postgres:5432/test_batchivo SECRET_KEY: test-secret-key-for-ci-testing-minimum-32-chars @@ -58,40 +42,8 @@ steps: - until pg_isready -h postgres -U test -q 2>/dev/null; do sleep 1; done - echo "Postgres ready" - echo "=== Creating worker databases ===" - - for i in 0 1 2 3; do psql -h postgres -U test -d test_batchivo -c "CREATE DATABASE test_batchivo_gw$i" || true; done + # Scratch databases only: avoid synchronous disk flushes for each test DDL. + - psql -h postgres -U test -d test_batchivo -c "ALTER SYSTEM SET fsync = 'off'" -c "ALTER SYSTEM SET synchronous_commit = 'off'" -c "SELECT pg_reload_conf()" + - for i in 0 1 2 3; do psql -h postgres -U test -d test_batchivo -c "CREATE DATABASE test_batchivo_gw$i"; done - echo "=== Integration + API tests (4 workers) ===" - poetry run pytest tests/ --ignore=tests/unit/ -n 4 --dist worksteal --tb=short -q - - # ============================================ - # Image Security Scans (after integration tests + builds are done) - # ============================================ - scan-image-backend: - image: *trivy_image - depends_on: - - backend-integration - environment: - TRIVY_USERNAME: - from_secret: docker_username - TRIVY_PASSWORD: - from_secret: docker_password - commands: - - trivy image --severity HIGH,CRITICAL --exit-code 1 --ignore-unfixed registry.techize.co.uk/batchivo/batchivo-backend:${CI_COMMIT_SHA} - - trivy image --format spdx-json --output /tmp/sbom-backend.json registry.techize.co.uk/batchivo/batchivo-backend:${CI_COMMIT_SHA} - - echo "Backend image scan passed" - - scan-image-frontend: - image: *trivy_image - environment: - TRIVY_USERNAME: - from_secret: docker_username - TRIVY_PASSWORD: - from_secret: docker_password - commands: - - trivy image --severity HIGH,CRITICAL --exit-code 1 --ignore-unfixed registry.techize.co.uk/batchivo/batchivo-frontend:${CI_COMMIT_SHA} - - trivy image --format spdx-json --output /tmp/sbom-frontend.json registry.techize.co.uk/batchivo/batchivo-frontend:${CI_COMMIT_SHA} - - echo "Frontend image scan passed" - -# Notes: -# - postgres service only starts for this pipeline — test-fast.yml has no database dependency -# - Image scans run after integration tests — builds are long done by that point -# - E2E tests (Playwright) remain commented out until staging DNS is live diff --git a/backend/alembic/versions/20260909_commerce_handover.py b/backend/alembic/versions/20260909_commerce_handover.py new file mode 100644 index 00000000..26030b2f --- /dev/null +++ b/backend/alembic/versions/20260909_commerce_handover.py @@ -0,0 +1,43 @@ +"""Durable legacy-write ownership and final-sync fence. + +Revision ID: commerce_handover_v1 +Revises: commerce_bridge_v1 +""" + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +revision = "commerce_handover_v1" +down_revision = "commerce_bridge_v1" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "commerce_handover", + sa.Column("tenant_id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("owner", sa.String(20), nullable=False), + sa.Column("revision", sa.BigInteger(), nullable=False, server_default="0"), + sa.Column( + "updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now() + ), + sa.Column( + "audit", postgresql.JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb") + ), + sa.CheckConstraint( + "owner IN ('legacy','fenced','woocommerce')", name="commerce_handover_owner" + ), + ) + op.execute( + "INSERT INTO commerce_handover (tenant_id,owner) VALUES ('ad62b515-17b3-4830-bcb4-3f4c470c26e2','legacy')" + ) + op.execute("REVOKE ALL ON commerce_handover FROM PUBLIC") + + +def downgrade(): + raise RuntimeError( + "Handover history and ownership must survive storefront rollback; do not drop this table" + ) diff --git a/backend/alembic/versions/commerce_bridge_v1_add_commerce_ledgers.py b/backend/alembic/versions/commerce_bridge_v1_add_commerce_ledgers.py new file mode 100644 index 00000000..870af2da --- /dev/null +++ b/backend/alembic/versions/commerce_bridge_v1_add_commerce_ledgers.py @@ -0,0 +1,85 @@ +"""Add commerce ledgers without modifying existing manufacturing/customer tables. + +Revision ID: commerce_bridge_v1 +Revises: data_filament_type_migration +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision = "commerce_bridge_v1" +down_revision = "data_filament_type_migration" +branch_labels = None +depends_on = None + + +def identity(): + return [ + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + ] + + +def upgrade(): + # Explicitly new tables. Do not silently adopt unknown existing commerce ledgers. + op.create_table( + "commerce_receipts", + sa.Column("event_id", sa.String(64), nullable=False, unique=True), + sa.Column("body_sha256", sa.String(64), nullable=False), + sa.Column("woo_order_id", sa.Integer(), nullable=False), + sa.Column("outcome", sa.String(32), nullable=False), + *identity(), + ) + op.create_index("ix_commerce_receipts_woo_order_id", "commerce_receipts", ["woo_order_id"]) + op.create_table( + "commerce_orders", + sa.Column("woo_order_id", sa.Integer(), nullable=False, unique=True), + sa.Column("batchivo_order_id", sa.String(36), nullable=False), + sa.Column("version", sa.Integer(), nullable=False), + sa.Column("state", sa.String(32), nullable=False), + sa.Column("snapshot", postgresql.JSONB(), nullable=False), + sa.Column("effects", postgresql.JSONB(), nullable=False), + *identity(), + ) + op.create_index( + "commerce_orders_payment_unique", + "commerce_orders", + [sa.text("(snapshot ->> 'payment_id')")], + unique=True, + ) + op.create_table( + "commerce_fulfilment_events", + sa.Column("event_id", sa.String(36), nullable=False, unique=True), + sa.Column("woo_order_id", sa.Integer(), nullable=False), + sa.Column("version", sa.Integer(), nullable=False), + sa.Column("command_sha256", sa.String(64), nullable=False), + sa.Column("payload", postgresql.JSONB(), nullable=False), + sa.Column("state", sa.String(16), nullable=False), + *identity(), + sa.UniqueConstraint("woo_order_id", "version"), + ) + op.create_index( + "ix_commerce_fulfilment_events_woo_order_id", "commerce_fulfilment_events", ["woo_order_id"] + ) + op.create_index("ix_commerce_fulfilment_events_state", "commerce_fulfilment_events", ["state"]) + op.create_table( + "commerce_reservations", + sa.Column("reservation_id", sa.String(36), nullable=False, unique=True), + sa.Column("woo_order_id", sa.Integer(), nullable=False), + sa.Column("state", sa.String(32), nullable=False), + sa.Column("snapshot", postgresql.JSONB(), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + *identity(), + ) + op.create_index( + "ix_commerce_reservations_woo_order_id", "commerce_reservations", ["woo_order_id"] + ) + op.create_index("ix_commerce_reservations_state", "commerce_reservations", ["state"]) + + +def downgrade(): + raise RuntimeError( + "Retain commerce payment/order ledgers during rollback; destructive downgrade is prohibited" + ) diff --git a/backend/app/api/v1/customer_account.py b/backend/app/api/v1/customer_account.py index 5b3fb2d1..7d550626 100644 --- a/backend/app/api/v1/customer_account.py +++ b/backend/app/api/v1/customer_account.py @@ -11,7 +11,7 @@ logger = logging.getLogger(__name__) from fastapi import APIRouter, Depends, HTTPException, Query, status -from sqlalchemy import select, desc, func +from sqlalchemy import desc, func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -31,8 +31,9 @@ CustomerUpdate, CustomerWithAddresses, ) +from app.services.commerce_handover import legacy_customer_write_guard -router = APIRouter() +router = APIRouter(dependencies=[Depends(legacy_customer_write_guard)]) # ============================================ diff --git a/backend/app/api/v1/customer_auth.py b/backend/app/api/v1/customer_auth.py index 3f44bfff..ea105159 100644 --- a/backend/app/api/v1/customer_auth.py +++ b/backend/app/api/v1/customer_auth.py @@ -7,7 +7,7 @@ from datetime import datetime, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy import select, func +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.auth.customer_dependencies import ( @@ -33,12 +33,22 @@ CustomerTokenResponse, CustomerVerifyEmail, ) +from app.services.commerce_handover import ( + legacy_customer_write_guard, + legacy_refresh_write_guard, + legacy_shop_write_guard, +) from app.services.email_service import get_email_service router = APIRouter() -@router.post("/register", response_model=CustomerTokenResponse, status_code=201) +@router.post( + "/register", + response_model=CustomerTokenResponse, + status_code=201, + dependencies=[Depends(legacy_shop_write_guard)], +) async def register_customer( data: CustomerRegister, tenant: ShopTenant, @@ -114,7 +124,9 @@ async def register_customer( ) -@router.post("/login", response_model=CustomerTokenResponse) +@router.post( + "/login", response_model=CustomerTokenResponse, dependencies=[Depends(legacy_shop_write_guard)] +) async def login_customer( data: CustomerLogin, tenant: ShopTenant, @@ -172,7 +184,11 @@ async def login_customer( ) -@router.post("/refresh", response_model=CustomerTokenResponse) +@router.post( + "/refresh", + response_model=CustomerTokenResponse, + dependencies=[Depends(legacy_refresh_write_guard)], +) async def refresh_token( data: CustomerRefreshToken, db: AsyncSession = Depends(get_db), @@ -232,7 +248,7 @@ async def refresh_token( ) -@router.post("/forgot-password", status_code=200) +@router.post("/forgot-password", status_code=200, dependencies=[Depends(legacy_shop_write_guard)]) async def forgot_password( data: CustomerForgotPassword, tenant: ShopTenant, @@ -276,7 +292,7 @@ async def forgot_password( } -@router.post("/reset-password", status_code=200) +@router.post("/reset-password", status_code=200, dependencies=[Depends(legacy_shop_write_guard)]) async def reset_password( data: CustomerResetPassword, tenant: ShopTenant, @@ -324,7 +340,7 @@ async def reset_password( } -@router.post("/verify-email", status_code=200) +@router.post("/verify-email", status_code=200, dependencies=[Depends(legacy_shop_write_guard)]) async def verify_email( data: CustomerVerifyEmail, tenant: ShopTenant, @@ -375,7 +391,9 @@ async def verify_email( return {"message": "Email verified successfully"} -@router.post("/resend-verification", status_code=200) +@router.post( + "/resend-verification", status_code=200, dependencies=[Depends(legacy_customer_write_guard)] +) async def resend_verification( customer: CurrentCustomer, db: AsyncSession = Depends(get_db), @@ -408,7 +426,9 @@ async def resend_verification( return {"message": "Verification email sent"} -@router.post("/change-password", status_code=200) +@router.post( + "/change-password", status_code=200, dependencies=[Depends(legacy_customer_write_guard)] +) async def change_password( data: CustomerChangePassword, customer: CurrentCustomer, diff --git a/backend/app/api/v1/orders.py b/backend/app/api/v1/orders.py index 1d6ac93e..6e76e75d 100644 --- a/backend/app/api/v1/orders.py +++ b/backend/app/api/v1/orders.py @@ -16,6 +16,10 @@ from app.models.product import Product from app.models.sales_channel import SalesChannel from app.auth.dependencies import CurrentTenant, RequireAdmin +from app.services.commerce_order_ownership import ( + dispatch_native_commerce, + reject_native_commerce_mutation, +) router = APIRouter() @@ -538,6 +542,12 @@ async def update_order( if not order: raise HTTPException(status_code=404, detail="Order not found") + if any( + getattr(request, field, None) is not None + for field in ("status", "tracking_number", "tracking_url") + ): + await reject_native_commerce_mutation(db, order) + # Update fields if request.status is not None: valid_statuses = [ @@ -639,6 +649,12 @@ async def ship_order( if not order: raise HTTPException(status_code=404, detail="Order not found") + commerce = await dispatch_native_commerce( + db, order, tenant, "shipped", request.tracking_number, request.tracking_url + ) + if commerce is not None: + return commerce + if order.status not in [OrderStatus.PENDING, OrderStatus.PROCESSING]: raise HTTPException( status_code=400, detail=f"Cannot ship order with status: {order.status}" @@ -809,6 +825,10 @@ async def deliver_order( if not order: raise HTTPException(status_code=404, detail="Order not found") + commerce = await dispatch_native_commerce(db, order, tenant, "delivered") + if commerce is not None: + return commerce + if order.status != OrderStatus.SHIPPED: raise HTTPException( status_code=400, @@ -873,6 +893,8 @@ async def fulfill_order( if not order: raise HTTPException(status_code=404, detail="Order not found") + await reject_native_commerce_mutation(db, order) + # Only pending/processing orders can be fulfilled if order.status not in [OrderStatus.PENDING, OrderStatus.PROCESSING]: raise HTTPException( @@ -993,6 +1015,8 @@ async def refund_order( if not order: raise HTTPException(status_code=404, detail="Order not found") + await reject_native_commerce_mutation(db, order) + # Validate order can be refunded if order.status == OrderStatus.REFUNDED: raise HTTPException(status_code=400, detail="Order has already been refunded") @@ -1125,6 +1149,8 @@ async def cancel_order( if not order: raise HTTPException(status_code=404, detail="Order not found") + await reject_native_commerce_mutation(db, order) + # Only pending/processing orders can be cancelled if order.status not in [OrderStatus.PENDING, OrderStatus.PROCESSING]: raise HTTPException( @@ -1206,6 +1232,8 @@ async def resend_order_email( if not order: raise HTTPException(status_code=404, detail="Order not found") + await reject_native_commerce_mutation(db, order) + email_service = get_email_service() email_sent = False diff --git a/backend/app/api/v1/payments.py b/backend/app/api/v1/payments.py index 0c081bc6..82a703ee 100644 --- a/backend/app/api/v1/payments.py +++ b/backend/app/api/v1/payments.py @@ -12,17 +12,20 @@ from app.auth.dependencies import ShopContext from app.config import get_settings from app.database import get_db -from app.models.order import Order as OrderModel, OrderItem as OrderItemModel, OrderStatus +from app.models.order import Order as OrderModel +from app.models.order import OrderItem as OrderItemModel +from app.models.order import OrderStatus from app.models.product import Product from app.schemas.payment import ( HostedCheckoutRequest, HostedCheckoutResponse, + PaymentError, PaymentRequest, PaymentResponse, - PaymentError, ) -from app.services.square_payment import SquarePaymentService, get_payment_service +from app.services.commerce_handover import legacy_shop_write_guard from app.services.email_service import get_email_service +from app.services.square_payment import SquarePaymentService, get_payment_service router = APIRouter() settings = get_settings() @@ -216,7 +219,11 @@ async def get_payment_config( ) -@router.post("/hosted-checkout", response_model=HostedCheckoutResponse) +@router.post( + "/hosted-checkout", + response_model=HostedCheckoutResponse, + dependencies=[Depends(legacy_shop_write_guard)], +) async def create_hosted_checkout( request: HostedCheckoutRequest, shop_context: ShopContext, @@ -287,7 +294,9 @@ async def create_hosted_checkout( ) -@router.post("/process", response_model=PaymentResponse) +@router.post( + "/process", response_model=PaymentResponse, dependencies=[Depends(legacy_shop_write_guard)] +) async def process_payment( request: PaymentRequest, shop_context: ShopContext, @@ -557,7 +566,7 @@ async def square_webhook( - payment.created, payment.updated, payment.failed - refund.created, refund.updated - Returns 200 immediately to acknowledge receipt (Square requires this). + Acknowledge completed or duplicate processing; return 503 on failure so Square retries. """ import json import logging @@ -604,20 +613,23 @@ async def square_webhook( signature_valid=signature_valid, ) - return WebhookResponse( - status=result.get("status", "received"), - event_id=result.get("event_id"), - message=result.get("message"), - ) + except Exception: + # A database failure may precede creation of the receipt. Never claim + # durable acceptance without evidence; let the provider retry delivery. + logger.error("Square webhook processing unavailable; requesting provider retry") + raise HTTPException( + status_code=503, detail="Webhook processing unavailable", headers={"Retry-After": "60"} + ) from None - except Exception as e: - # Log error but return 200 - we've received the webhook - # The webhook event is recorded and can be retried - logger.error(f"Error in webhook processing: {e}") - return WebhookResponse( - status="received", - message="Event recorded, will be processed asynchronously", + if result.get("status") in {"failed", "processing"}: + raise HTTPException( + status_code=503, detail="Webhook processing incomplete", headers={"Retry-After": "60"} ) + return WebhookResponse( + status=result.get("status", "received"), + event_id=result.get("event_id"), + message=result.get("message"), + ) # Legacy handler functions kept for test compatibility diff --git a/backend/app/api/v1/shop.py b/backend/app/api/v1/shop.py index e8ebd593..bd0acaea 100644 --- a/backend/app/api/v1/shop.py +++ b/backend/app/api/v1/shop.py @@ -14,30 +14,33 @@ from typing import Optional from uuid import UUID, uuid4 -from fastapi import APIRouter, HTTPException, Depends, Request, Response +from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi.responses import PlainTextResponse from pydantic import BaseModel, EmailStr, Field -from sqlalchemy import func, select, desc +from sqlalchemy import desc, func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.auth.dependencies import ShopContext, ShopTenant +from app.core.rate_limit import limiter from app.database import get_db from app.models.category import Category, product_categories from app.models.designer import Designer -from app.models.order import Order as OrderModel, OrderItem as OrderItemModel, OrderStatus +from app.models.order import Order as OrderModel +from app.models.order import OrderItem as OrderItemModel +from app.models.order import OrderStatus from app.models.product import Product from app.models.review import Review -from app.services.cart import CartService, get_cart_service, CartItem +from app.services.cart import CartItem, CartService, get_cart_service from app.services.checkout_session import CheckoutSessionService, get_checkout_session_service +from app.services.commerce_handover import legacy_product_write_guard, legacy_shop_write_guard +from app.services.search_service import SearchService, get_search_service +from app.services.shipping_service import ShippingService, get_shipping_service from app.services.stock_reservation import ( + ReservationItem, StockReservationService, get_stock_reservation_service, - ReservationItem, ) -from app.services.shipping_service import ShippingService, get_shipping_service -from app.services.search_service import SearchService, get_search_service -from app.core.rate_limit import limiter router = APIRouter() @@ -1216,7 +1219,7 @@ async def validate_discount( ) -@router.post("/checkout/create-payment") +@router.post("/checkout/create-payment", dependencies=[Depends(legacy_shop_write_guard)]) @limiter.limit("10/minute") async def create_checkout_session( http_request: Request, @@ -1337,7 +1340,7 @@ async def create_checkout_session( } -@router.post("/checkout/complete") +@router.post("/checkout/complete", dependencies=[Depends(legacy_shop_write_guard)]) @limiter.limit("5/minute") async def complete_checkout( http_request: Request, @@ -1351,10 +1354,14 @@ async def complete_checkout( """Complete checkout with payment. Tenant resolved from X-Shop-Hostname header.""" shop_tenant, channel = shop_context from app.schemas.payment import ( - PaymentRequest, + CartItem as PaymentCartItem, + ) + from app.schemas.payment import ( CustomerDetails, + PaymentRequest, + ) + from app.schemas.payment import ( ShippingAddress as PaymentShippingAddress, - CartItem as PaymentCartItem, ) from app.services.square_payment import get_payment_service @@ -1408,7 +1415,7 @@ async def complete_checkout( if not result.success: # Record payment failure metric try: - from app.observability.metrics import record_payment_processed, record_error + from app.observability.metrics import record_error, record_payment_processed record_payment_processed( tenant_id="", # Unknown at this point @@ -1505,7 +1512,7 @@ async def complete_checkout( # Record discount usage if a discount was applied if session.discount_code and session.discount_amount > 0: try: - from app.api.v1.discounts import record_discount_usage, get_discount_code_by_code + from app.api.v1.discounts import get_discount_code_by_code, record_discount_usage discount_code_record = await get_discount_code_by_code( db=db, @@ -1644,16 +1651,22 @@ class PublicOrder(BaseModel): async def get_order( order_number: str, email: str, + shop_context: ShopContext, + response: Response, db: AsyncSession = Depends(get_db), ): - """Get order by number and email (for verification).""" - # Find order with matching order_number and email + """Look up an order only in the resolved storefront and sales channel.""" + tenant, channel = shop_context + response.headers["Cache-Control"] = "private, no-store" + # Number/email verification never permits crossing storefront boundaries. result = await db.execute( select(OrderModel) .options(selectinload(OrderModel.items)) .where( OrderModel.order_number == order_number, OrderModel.customer_email == email.lower().strip(), + OrderModel.tenant_id == tenant.id, + OrderModel.sales_channel_id == channel.id, ) ) db_order = result.scalar_one_or_none() @@ -1664,7 +1677,7 @@ async def get_order( return { "data": PublicOrder( order_number=db_order.order_number, - status=db_order.status.value, + status=db_order.status, customer_name=db_order.customer_name, shipping_method=db_order.shipping_method or "Standard", shipping_cost=db_order.shipping_cost, @@ -1706,10 +1719,11 @@ async def get_product_image( This endpoint proxies image requests through /api/v1/shop/images/ and works with both local storage and S3/MinIO. """ - from app.services.image_storage import get_image_storage, ImageStorageError - from app.models.product_image import ProductImage - from email.utils import formatdate, parsedate_to_datetime import calendar + from email.utils import formatdate, parsedate_to_datetime + + from app.models.product_image import ProductImage + from app.services.image_storage import ImageStorageError, get_image_storage image_url = f"/uploads/products/{product_id}/{image_filename}" @@ -2017,7 +2031,11 @@ async def get_product_reviews( ) -@router.post("/products/{product_id}/reviews", response_model=ShopReviewSubmitResponse) +@router.post( + "/products/{product_id}/reviews", + response_model=ShopReviewSubmitResponse, + dependencies=[Depends(legacy_product_write_guard)], +) @limiter.limit("10/minute") async def submit_product_review( request: Request, @@ -2104,7 +2122,10 @@ async def submit_product_review( ) -@router.post("/products/{product_id}/reviews/{review_id}/helpful") +@router.post( + "/products/{product_id}/reviews/{review_id}/helpful", + dependencies=[Depends(legacy_product_write_guard)], +) async def mark_review_helpful( product_id: str, review_id: str, diff --git a/backend/app/commerce_bridge.py b/backend/app/commerce_bridge.py new file mode 100644 index 00000000..dc7c2606 --- /dev/null +++ b/backend/app/commerce_bridge.py @@ -0,0 +1,1364 @@ +"""WooCommerce bridge with explicit isolated and separately activated production profiles.""" + +import asyncio +from contextlib import asynccontextmanager +from datetime import datetime, timezone, timedelta +from decimal import Decimal +import hashlib +import hmac +import json +import os +import sys +import time +from uuid import UUID, uuid4 +from typing import Literal +from urllib.parse import urlsplit + +import httpx +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import Response +from pydantic import BaseModel, ConfigDict, Field, model_validator +from sqlalchemy import ( + event, + inspect, + DateTime, + Index, + Integer, + String, + UniqueConstraint, + and_, + or_, + select, + text, +) +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Session, Mapped, mapped_column, raiseload +from sqlalchemy.ext.asyncio import async_sessionmaker + +from app.commerce_runtime import CommerceRuntime + +RUNTIME = CommerceRuntime.from_environment(os.environ) +from app.database import Base, engine as native_engine +from app.services.commerce_database import commerce_engine, verify_commerce_role +from app.services.commerce_sales_channel import commerce_sales_channel + +engine = commerce_engine(RUNTIME, os.environ, native_engine) +from app.models import Product, ProductVariant, Tenant, Order, OrderItem, PrintJob, JobStatus +from app.models.base import UUIDMixin, TimestampMixin + +TENANT = UUID(os.environ["COMMERCE_TENANT_ID"]) +SECRET = os.environ["COMMERCE_BRIDGE_SECRET"].encode() +# The actual engine target must agree with the configuration guard, including RLS URLs. +CommerceRuntime.from_environment( + { + **os.environ, + "DATABASE_URL": engine.url.render_as_string(hide_password=False), + "RLS_ENABLED": "false", + } +) + + +class CommerceSession(Session): + pass + + +@event.listens_for(CommerceSession, "do_orm_execute") +def commerce_explicit_relationships(state): + # Catalogue ORM defaults eagerly load pricing, designers and other native + # data that payment/stock writes do not use. Only explicit relationships are + # available in commerce sessions; native application sessions are unchanged. + if state.is_select: + state.statement = state.statement.options(raiseload("*")) + + +@event.listens_for(CommerceSession, "after_begin") +def commerce_tenant_context(session, transaction, connection): + if os.environ.get("COMMERCE_DATABASE_URL") or not RUNTIME.isolated: + verify_commerce_role(connection) + connection.execute( + text("SELECT set_config('app.current_tenant_id',:tenant,true)"), {"tenant": str(TENANT)} + ) + + +# Only bridge sessions get this fixed tenant; native application sessions retain their own context. +async_session_maker = async_sessionmaker( + engine, expire_on_commit=False, autoflush=False, sync_session_class=CommerceSession +) + + +class CommerceReceipt(Base, UUIDMixin, TimestampMixin): + __tablename__ = "commerce_receipts" + event_id: Mapped[str] = mapped_column(String(64), unique=True) + body_sha256: Mapped[str] = mapped_column(String(64)) + woo_order_id: Mapped[int] = mapped_column(Integer, index=True) + outcome: Mapped[str] = mapped_column(String(32)) + + +class CommerceOrder(Base, UUIDMixin, TimestampMixin): + __tablename__ = "commerce_orders" + woo_order_id: Mapped[int] = mapped_column(Integer, unique=True) + batchivo_order_id: Mapped[str] = mapped_column(String(36)) + version: Mapped[int] = mapped_column(Integer) + state: Mapped[str] = mapped_column(String(32)) + snapshot: Mapped[dict] = mapped_column(JSONB) + effects: Mapped[dict] = mapped_column(JSONB, default=dict) + + +class CommerceFulfilmentEvent(Base, UUIDMixin, TimestampMixin): + __tablename__ = "commerce_fulfilment_events" + __table_args__ = (UniqueConstraint("woo_order_id", "version"),) + event_id: Mapped[str] = mapped_column(String(36), unique=True) + command_sha256: Mapped[str] = mapped_column(String(64)) + woo_order_id: Mapped[int] = mapped_column(Integer, index=True) + version: Mapped[int] = mapped_column(Integer) + payload: Mapped[dict] = mapped_column(JSONB) + state: Mapped[str] = mapped_column(String(16), default="pending", index=True) + + +class DispatchCommand(BaseModel): + model_config = ConfigDict(extra="forbid") + event_id: UUID + order_id: int = Field(gt=0) + state: Literal["shipped", "delivered"] + tracking_number: str = Field(default="", max_length=100) + tracking_url: str = Field(default="", max_length=500) + + @model_validator(mode="after") + def valid(self): + if any(ord(c) < 32 for c in self.tracking_number + self.tracking_url): + raise ValueError("Invalid tracking characters") + if self.tracking_url: + value = urlsplit(self.tracking_url) + if value.scheme != "https" or not value.hostname or value.username or value.password: + raise ValueError("Tracking links must be HTTPS without embedded credentials") + return self + + +payment_identity_index = Index( + "commerce_orders_payment_unique", CommerceOrder.snapshot["payment_id"].astext, unique=True +) + + +class Line(BaseModel): + model_config = ConfigDict(extra="forbid") + line_id: int = Field(gt=0) + product_id: UUID + variant_id: UUID | None = None + sku: str = Field(min_length=1, max_length=100) + name: str = Field(min_length=1, max_length=255) + option: str = "" + quantity: int = Field(gt=0, le=1000) + total_pence: int = Field(ge=0) + + +class Event(BaseModel): + model_config = ConfigDict(extra="forbid") + event_id: UUID + order_id: int = Field(gt=0) + version: int = Field(gt=0) + state: str + payment_id: str = Field(min_length=1, max_length=192) + reservation_id: UUID | None = None + refunded_pence: int | None = Field(default=None, ge=0) + refund_ids: list[str] = Field(default_factory=list, max_length=100) + currency: str = "GBP" + subtotal_pence: int = Field(ge=0) + shipping_pence: int = Field(ge=0) + discount_pence: int = Field(ge=0) + total_pence: int = Field(ge=0) + lines: list[Line] = Field(min_length=1, max_length=100) + customer: dict + shipping: dict + + @model_validator(mode="after") + def valid(self): + if self.currency != "GBP" or self.state not in [ + "processing", + "completed", + "cancelled", + "refunded", + ]: + raise ValueError("Unsupported currency or state") + if self.refunded_pence is not None and ( + self.refunded_pence > self.total_pence + or (self.state == "refunded" and self.refunded_pence != self.total_pence) + ): + raise ValueError("Invalid cumulative refund amount") + if len(set(self.refund_ids)) != len(self.refund_ids) or any( + not v or len(v) > 192 for v in self.refund_ids + ): + raise ValueError("Invalid refund identities") + if sum(x.total_pence for x in self.lines) + self.shipping_pence != self.total_pence: + raise ValueError("Line/shipping totals do not reconcile") + if not RUNTIME.isolated: + for fields, required in [ + (self.customer, ["email", "name"]), + (self.shipping, ["address_1", "city", "postcode"]), + ]: + if any( + not isinstance(fields.get(key), str) or not fields[key].strip() + for key in required + ): + raise ValueError("Production orders require customer and delivery details") + if "@" not in self.customer["email"] or self.shipping.get("country") != "GB": + raise ValueError( + "Production orders require a valid email and supported delivery country" + ) + return self + + +async def enrich_job_options(db, event: Event): + """Fill absent display labels from the validated variant; never change work or stock.""" + changed = 0 + for line in event.lines: + if not line.variant_id: + continue + variant = await db.get(ProductVariant, line.variant_id) + if ( + not variant + or variant.tenant_id != TENANT + or variant.product_id != line.product_id + or variant.sku != line.sku + ): + raise HTTPException(409, "Variant label reconciliation requires a valid mapping") + jobs = ( + await db.scalars( + select(PrintJob) + .where( + PrintJob.tenant_id == TENANT, + PrintJob.reference == RUNTIME.job_reference(event.order_id, line.line_id), + ) + .with_for_update() + ) + ).all() + for job in jobs: + notes = json.loads(job.notes or "{}") + if not notes.get("option"): + if ( + job.product_id != line.product_id + or notes.get("variant_id") != str(line.variant_id) + or notes.get("sku") != line.sku + ): + raise HTTPException(409, "Manufacturing label mapping mismatch") + notes["option"] = variant.size + notes["option_source"] = "validated_batchivo_variant" + job.notes = json.dumps(notes) + changed += 1 + return changed + + +class CommerceReservation(Base, UUIDMixin, TimestampMixin): + __tablename__ = "commerce_reservations" + reservation_id: Mapped[str] = mapped_column(String(36), unique=True) + woo_order_id: Mapped[int] = mapped_column(Integer, index=True) + state: Mapped[str] = mapped_column(String(32), index=True) + snapshot: Mapped[dict] = mapped_column(JSONB) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + + +class ReservationRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + reservation_id: UUID + order_id: int = Field(gt=0) + total_pence: int = Field(ge=0) + currency: str = "GBP" + lines: list[Line] = Field(min_length=1, max_length=100) + + @model_validator(mode="after") + def valid(self): + if self.currency != "GBP" or len({line.line_id for line in self.lines}) != len(self.lines): + raise ValueError("Invalid reservation currency or duplicate line IDs") + return self + + +class ReservationAction(BaseModel): + model_config = ConfigDict(extra="forbid") + reservation_id: UUID + order_id: int = Field(gt=0) + action: str + decline_code: str = "" + + +async def signed_body(request): + body = await request.body() + if len(body) > 256000: + raise HTTPException(413, "Payload too large") + stamp = request.headers.get("x-mf-timestamp", "") + if not stamp.isdigit() or abs(time.time() - int(stamp)) > 300: + raise HTTPException(401, "Expired event signature") + expected = hmac.new(SECRET, stamp.encode() + b"." + body, hashlib.sha256).hexdigest() + if not hmac.compare_digest(expected, request.headers.get("x-mf-signature", "")): + raise HTTPException(401, "Invalid event signature") + return body + + +def signed_response(data): + body = json.dumps(data, separators=(",", ":"), sort_keys=True) + stamp = str(int(time.time())) + signature = hmac.new(SECRET, (stamp + "." + body).encode(), hashlib.sha256).hexdigest() + return Response( + content=body, + media_type="application/json", + headers={"X-MF-Timestamp": stamp, "X-MF-Signature": signature}, + ) + + +async def reservation_order_lock(db, order_id): + await db.execute(text("SELECT pg_advisory_xact_lock(:key)"), {"key": order_id}) + + +def reservation_identity(snapshot): + return ( + snapshot["order_id"], + snapshot["currency"], + snapshot["total_pence"], + sorted( + ( + str(x["product_id"]), + str(x.get("variant_id")), + x["sku"], + x["quantity"], + x["total_pence"], + x["line_id"], + ) + for x in snapshot["lines"] + ), + ) + + +async def locked_reservation_stock(db, lines): + quantities = {} + stock_rows = {} + # Stable parent/variant order serializes overlaps, including duplicate basket lines. + for line in sorted(lines, key=lambda x: (str(x.product_id), str(x.variant_id))): + product = await db.scalar( + select(Product) + .where(Product.id == line.product_id, Product.tenant_id == TENANT) + .with_for_update() + ) + if not product or not product.is_active: + raise HTTPException(409, "Unknown or inactive mapped product") + variant = None + if line.variant_id: + variant = await db.scalar( + select(ProductVariant) + .where( + ProductVariant.id == line.variant_id, + ProductVariant.product_id == product.id, + ProductVariant.tenant_id == TENANT, + ProductVariant.is_active.is_(True), + ) + .with_for_update() + ) + if not variant: + raise HTTPException(409, "Invalid mapped variant") + stock = variant or product + if line.sku != stock.sku: + raise HTTPException(409, "SKU does not match source mapping") + pto = ( + variant.fulfilment_type in ["print_to_order", "made_to_order"] + if variant + else product.print_to_order + ) + if not pto: + key = (str(product.id), str(variant.id) if variant else None) + quantities[key] = quantities.get(key, 0) + line.quantity + stock_rows[key] = stock + return quantities, stock_rows + + +@asynccontextmanager +async def commerce_lifespan(app): + async with engine.begin() as conn: + if os.environ.get("COMMERCE_DATABASE_URL") or not RUNTIME.isolated: + await conn.run_sync(verify_commerce_role) + if RUNTIME.isolated: + await conn.run_sync( + lambda sync: CommerceReservation.__table__.create(sync, checkfirst=True) + ) + await conn.run_sync( + lambda sync: CommerceFulfilmentEvent.__table__.create(sync, checkfirst=True) + ) + else: + required = [ + "commerce_receipts", + "commerce_orders", + "commerce_fulfilment_events", + "commerce_reservations", + ] + present = await conn.run_sync( + lambda sync: all(inspect(sync).has_table(name) for name in required) + ) + if not present: + raise RuntimeError( + "Production commerce migration must be applied before activation" + ) + yield + + +app = FastAPI( + title="Batchivo commerce integration", docs_url=None, redoc_url=None, lifespan=commerce_lifespan +) + + +@app.post("/reservations") +async def reserve(request: Request): + body = await signed_body(request) + try: + data = ReservationRequest.model_validate_json(body) + except ValueError: + raise HTTPException(422, "Invalid reservation") + snapshot = data.model_dump(mode="json") + now = datetime.now(timezone.utc) + async with async_session_maker() as db, db.begin(): + await db.execute( + text("SELECT pg_advisory_xact_lock(hashtextextended(:key,2))"), + {"key": str(data.reservation_id)}, + ) + await reservation_order_lock(db, data.order_id) + prior = await db.scalar( + select(CommerceReservation) + .where(CommerceReservation.reservation_id == str(data.reservation_id)) + .with_for_update() + ) + if prior: + if reservation_identity(prior.snapshot) != reservation_identity(snapshot): + raise HTTPException(409, "Reservation identity cannot be changed") + if prior.state == "held" and prior.expires_at <= now: + raise HTTPException(409, "Reservation expired; use a new attempt") + if prior.state not in ["held", "payment_pending"]: + raise HTTPException(409, "Reservation is terminal") + return { + "reservation_id": prior.reservation_id, + "state": prior.state, + "expires_at": prior.expires_at.isoformat(), + } + existing = await db.scalar( + select(CommerceReservation).where( + CommerceReservation.woo_order_id == data.order_id, + or_( + CommerceReservation.state.in_(["payment_pending", "committed"]), + and_(CommerceReservation.state == "held", CommerceReservation.expires_at > now), + ), + ) + ) + if existing: + raise HTTPException(409, "Order already has an active reservation") + needed, stocks = await locked_reservation_stock(db, data.lines) + active = ( + await db.scalars( + select(CommerceReservation).where( + or_( + CommerceReservation.state == "payment_pending", + and_( + CommerceReservation.state == "held", + CommerceReservation.expires_at > now, + ), + ) + ) + ) + ).all() + reserved = {} + for row in active: + if row.state == "held" and row.expires_at <= now: + continue + for line in row.snapshot["lines"]: + key = (line["product_id"], line.get("variant_id")) + reserved[key] = reserved.get(key, 0) + line["quantity"] + if any( + stocks[key].units_in_stock - reserved.get(key, 0) < qty for key, qty in needed.items() + ): + raise HTTPException(409, "Insufficient available stock") + row = CommerceReservation( + reservation_id=str(data.reservation_id), + woo_order_id=data.order_id, + state="held", + snapshot=snapshot, + expires_at=now + timedelta(minutes=15), + ) + db.add(row) + return { + "reservation_id": row.reservation_id, + "state": row.state, + "expires_at": row.expires_at.isoformat(), + } + + +@app.post("/reservations/action") +async def reservation_action(request: Request): + body = await signed_body(request) + try: + data = ReservationAction.model_validate_json(body) + except ValueError: + raise HTTPException(422, "Invalid reservation action") + if data.action not in ["begin_payment", "release", "payment_declined"]: + raise HTTPException(422, "Unknown reservation action") + async with async_session_maker() as db, db.begin(): + await reservation_order_lock(db, data.order_id) + row = await db.scalar( + select(CommerceReservation) + .where( + CommerceReservation.reservation_id == str(data.reservation_id), + CommerceReservation.woo_order_id == data.order_id, + ) + .with_for_update() + ) + if not row: + raise HTTPException(404, "Reservation not found") + if data.action == "begin_payment": + # Lock the same inventory rows as a competing reservation before validating expiry. + await locked_reservation_stock( + db, ReservationRequest.model_validate(row.snapshot).lines + ) + if row.state == "held" and row.expires_at > datetime.now(timezone.utc): + row.state = "payment_pending" + elif row.state != "payment_pending": + raise HTTPException(409, "Reservation cannot start payment") + elif data.action == "payment_declined": + # Authenticated checkout reports a definitive response for this exact attempt. + if data.decline_code not in [ + "GENERIC_DECLINE", + "CARD_DECLINED", + "VERIFY_CVV_FAILURE", + "VERIFY_AVS_FAILURE", + ]: + raise HTTPException(422, "Not a definitive supported decline") + if row.state == "payment_pending": + row.state = "declined" + elif row.state != "declined": + raise HTTPException(409, "Reservation cannot accept a decline") + else: + if row.state == "held": + row.state = "released" + elif row.state != "released": + raise HTTPException(409, "Payment may be in flight; reconcile before release") + return {"reservation_id": row.reservation_id, "state": row.state} + + +class StockKey(BaseModel): + model_config = ConfigDict(extra="forbid") + product_id: UUID + variant_id: UUID | None = None + + +class StockRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + items: list[StockKey] = Field(min_length=1, max_length=100) + + +@app.post("/stock") +async def stock_snapshot(request: Request): + body = await signed_body(request) + try: + data = StockRequest.model_validate_json(body) + except ValueError: + raise HTTPException(422, "Invalid stock request") + now = datetime.now(timezone.utc) + items = [] + async with async_session_maker() as db: + holds = ( + await db.scalars( + select(CommerceReservation).where( + or_( + CommerceReservation.state == "payment_pending", + and_( + CommerceReservation.state == "held", + CommerceReservation.expires_at > now, + ), + ) + ) + ) + ).all() + reserved = {} + for row in holds: + for line in row.snapshot["lines"]: + key = (line["product_id"], line.get("variant_id")) + reserved[key] = reserved.get(key, 0) + line["quantity"] + for key in data.items: + product = await db.scalar( + select(Product).where(Product.id == key.product_id, Product.tenant_id == TENANT) + ) + if not product: + raise HTTPException(409, "Stock mapping not found") + variant = None + if key.variant_id: + variant = await db.scalar( + select(ProductVariant).where( + ProductVariant.id == key.variant_id, + ProductVariant.product_id == product.id, + ProductVariant.tenant_id == TENANT, + ) + ) + if not variant: + raise HTTPException(409, "Variant mapping not found") + stock = variant or product + pto = ( + variant.fulfilment_type in ["print_to_order", "made_to_order"] + if variant + else product.print_to_order + ) + active = product.is_active and (variant.is_active if variant else True) + quantity = ( + max( + 0, + stock.units_in_stock + - reserved.get( + (str(key.product_id), str(key.variant_id) if key.variant_id else None), 0 + ), + ) + if active + else 0 + ) + items.append( + { + "product_id": str(key.product_id), + "variant_id": str(key.variant_id) if key.variant_id else None, + "available": quantity, + "print_to_order": pto, + } + ) + return {"items": items} + + +@app.post("/fulfilment/dispatch") +async def dispatch(request: Request): + body = await signed_body(request) + try: + command = DispatchCommand.model_validate_json(body) + except ValueError: + raise HTTPException(422, "Invalid dispatch command") + return await record_dispatch(command, hashlib.sha256(body).hexdigest()) + + +async def record_dispatch(command: DispatchCommand, digest: str): + """Single durable dispatch path for signed integration and native operator actions.""" + async with async_session_maker() as db, db.begin(): + await db.execute( + text("SELECT pg_advisory_xact_lock(:key)"), + { + "key": -int.from_bytes( + hashlib.sha256(str(command.event_id).encode()).digest()[:7], "big" + ) + - 1 + }, + ) + await reservation_order_lock(db, command.order_id) + prior = await db.scalar( + select(CommerceFulfilmentEvent).where( + CommerceFulfilmentEvent.event_id == str(command.event_id) + ) + ) + if prior: + if prior.command_sha256 != digest: + raise HTTPException(409, "Dispatch identity reused with different data") + return {"status": "duplicate", "event_id": prior.event_id, "version": prior.version} + mapping = await db.scalar( + select(CommerceOrder) + .where(CommerceOrder.woo_order_id == command.order_id) + .with_for_update() + ) + if not mapping: + raise HTTPException(404, "Mapped paid order not found") + order = await db.scalar( + select(Order) + .where(Order.id == UUID(mapping.batchivo_order_id), Order.tenant_id == TENANT) + .with_for_update() + ) + if not order: + raise HTTPException(409, "Mapped order is unavailable") + previous = await db.scalar( + select(CommerceFulfilmentEvent) + .where(CommerceFulfilmentEvent.woo_order_id == command.order_id) + .order_by(CommerceFulfilmentEvent.version.desc()) + .limit(1) + ) + if command.state == "shipped": + from app.commerce_disposition import remaining_quantity, refund_release_valid + + if order.payment_status not in ["COMPLETED", "PARTIALLY_REFUNDED"] or mapping.state in [ + "cancelled", + "refunded", + ]: + raise HTTPException(409, "Order cannot be dispatched") + if order.status not in ["pending", "processing"] or order.shipped_at: + raise HTTPException(409, "Order has already been dispatched or is terminal") + jobs = ( + await db.scalars( + select(PrintJob) + .where( + PrintJob.tenant_id == TENANT, + PrintJob.reference.like(RUNTIME.order_reference(command.order_id) + ":%"), + ) + .with_for_update() + ) + ).all() + finite = { + (effect["product_id"], effect["variant_id"]) + for effect in mapping.effects.get("stock", []) + } + expected = { + RUNTIME.job_reference(command.order_id, line["line_id"]): line + for line in mapping.snapshot["lines"] + if (line["product_id"], line.get("variant_id")) not in finite + } + if len(jobs) != len(expected) or {job.reference for job in jobs} != set(expected): + raise HTTPException(409, "Manufacturing job mappings require reconciliation") + if any( + job.product_id != UUID(expected[job.reference]["product_id"]) + or ( + remaining_quantity(mapping.effects, expected[job.reference]) + and job.quantity != remaining_quantity(mapping.effects, expected[job.reference]) + ) + for job in jobs + ): + raise HTTPException(409, "Manufacturing quantities require reconciliation") + if any( + job.status + != ( + JobStatus.COMPLETED + if remaining_quantity(mapping.effects, expected[job.reference]) + else JobStatus.CANCELLED + ) + for job in jobs + ): + raise HTTPException(409, "Manufacturing jobs must be completed before dispatch") + try: + payment = await verify_payment(Event.model_validate(mapping.snapshot)) + except httpx.HTTPError: + raise HTTPException(503, "Payment verification unavailable; dispatch not recorded") + if not refund_release_valid(mapping.effects, payment): + raise HTTPException(409, "Refund activity requires review before dispatch") + order.shipped_at = datetime.now(timezone.utc) + order.tracking_number = command.tracking_number or None + order.tracking_url = command.tracking_url or None + # Commerce has already committed stock/payment effects. Never deduct again here. + if not order.fulfilled_at: + order.fulfilled_at = order.shipped_at + else: + # Delivery is a physical fact about an existing shipment. A later + # refund must not erase or block it, or reopen the financial order. + if mapping.state == "cancelled" or order.payment_status not in [ + "COMPLETED", + "PARTIALLY_REFUNDED", + "REFUNDED", + ]: + raise HTTPException(409, "Order requires reconciliation before delivery") + if ( + order.status != "shipped" + or not order.shipped_at + or order.delivered_at + or not previous + or previous.payload["state"] != "shipped" + ): + raise HTTPException(409, "Dispatch must be recorded before delivery") + if command.tracking_number or command.tracking_url: + raise HTTPException(422, "Delivery retains the recorded dispatch tracking") + order.delivered_at = datetime.now(timezone.utc) + order.status = command.state + version = previous.version + 1 if previous else 1 + payload = { + "event_id": str(command.event_id), + "order_id": command.order_id, + "batchivo_order_id": str(order.id), + "payment_id": order.payment_id, + "version": version, + "state": command.state, + "tracking_number": order.tracking_number or "", + "tracking_url": order.tracking_url or "", + "shipped_at": order.shipped_at.isoformat(), + "delivered_at": order.delivered_at.isoformat() if order.delivered_at else None, + } + db.add( + CommerceFulfilmentEvent( + event_id=str(command.event_id), + command_sha256=digest, + woo_order_id=command.order_id, + version=version, + payload=payload, + ) + ) + return {"status": "recorded", "event_id": str(command.event_id), "version": version} + + +class NativeDispatchRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + order_id: int = Field(gt=0) + state: Literal["shipped", "delivered"] + tracking_number: str = Field(default="", max_length=100) + tracking_url: str = Field(default="", max_length=500) + + +@app.post("/fulfilment/operator") +async def operator_dispatch(request: Request): + body = await signed_body(request) + try: + command = NativeDispatchRequest.model_validate_json(body) + except ValueError: + return signed_response({"ok": False, "code": 422, "error": "Invalid dispatch details"}) + from app.api.v1.orders import ship_order, deliver_order, ShipOrderRequest + + try: + if command.state == "delivered" and (command.tracking_number or command.tracking_url): + raise HTTPException(422, "Delivery retains recorded dispatch tracking") + async with async_session_maker() as db: + mapping = await db.scalar( + select(CommerceOrder).where(CommerceOrder.woo_order_id == command.order_id) + ) + if not mapping: + raise HTTPException(404, "Mapped paid order not found") + tenant = await db.get(Tenant, TENANT) + if command.state == "shipped": + result = await ship_order( + UUID(mapping.batchivo_order_id), + ShipOrderRequest( + tracking_number=command.tracking_number, tracking_url=command.tracking_url + ), + db=db, + tenant=tenant, + ) + else: + result = await deliver_order(UUID(mapping.batchivo_order_id), db=db, tenant=tenant) + if not result.get("commerce"): + raise HTTPException(409, "Native commerce dispatch result missing") + return signed_response( + { + "ok": True, + "order_id": command.order_id, + "state": command.state, + "result": result["commerce"], + } + ) + except HTTPException as error: + return signed_response({"ok": False, "code": error.status_code, "error": str(error.detail)}) + + +@app.post("/fulfilment/pending") +async def pending_fulfilment(request: Request): + await signed_body(request) + async with async_session_maker() as db, db.begin(): + events = ( + await db.scalars( + select(CommerceFulfilmentEvent) + .where(CommerceFulfilmentEvent.state == "pending") + .order_by( + CommerceFulfilmentEvent.updated_at, + CommerceFulfilmentEvent.woo_order_id, + CommerceFulfilmentEvent.version, + ) + .limit(100) + .with_for_update(skip_locked=True) + ) + ).all() + for event in events: + event.updated_at = datetime.now(timezone.utc) + return signed_response({"events": [event.payload for event in events]}) + + +@app.post("/fulfilment/ack") +async def acknowledge_fulfilment(request: Request): + body = await signed_body(request) + try: + value = json.loads(body) + event_id = str(UUID(value["event_id"])) + order_id = int(value["order_id"]) + except (ValueError, KeyError, TypeError): + raise HTTPException(422, "Invalid acknowledgement") + async with async_session_maker() as db, db.begin(): + event = await db.scalar( + select(CommerceFulfilmentEvent) + .where( + CommerceFulfilmentEvent.event_id == event_id, + CommerceFulfilmentEvent.woo_order_id == order_id, + ) + .with_for_update() + ) + if not event: + raise HTTPException(404, "Dispatch event not found") + event.state = "delivered" + return signed_response({"status": "acknowledged", "event_id": event_id}) + + +@app.get("/health") +async def health(): + async with engine.connect() as conn: + await conn.execute(text("SELECT 1")) + return {"status": "ok", "environment": RUNTIME.mode} + + +async def verify_payment(event: Event): + async with httpx.AsyncClient(timeout=20) as client: + r = await client.get( + RUNTIME.square_base + "/payments/" + event.payment_id, + headers={ + "Authorization": "Bearer " + os.environ[RUNTIME.token_environment_key], + "Square-Version": "2026-01-22", + }, + ) + if r.status_code != 200: + raise HTTPException(503, "Square payment verification unavailable") + p = r.json()["payment"] + expected_location = os.environ.get(RUNTIME.location_environment_key) + if not expected_location or p.get("location_id") != expected_location: + raise HTTPException(409, "Payment location does not match this store") + if p.get("reference_id") != str(event.order_id): + raise HTTPException(409, "Payment reference does not match this order") + if event.reservation_id and not p.get("note", "").startswith( + "[MF-RESERVATION:" + str(event.reservation_id) + "] " + ): + raise HTTPException(409, "Payment does not match the stock reservation") + if p["status"] != "COMPLETED" or p["amount_money"] != { + "amount": event.total_pence, + "currency": "GBP", + }: + raise HTTPException(409, "Payment status or amount does not match order") + target = ( + event.refunded_pence + if event.refunded_pence is not None + else (event.total_pence if event.state == "refunded" else 0) + ) + verified = [] + if target: + ids = event.refund_ids if event.refunded_pence is not None else p.get("refund_ids", []) + if not ids or not set(ids).issubset(set(p.get("refund_ids", []))): + raise HTTPException( + 503, "Expected refund identities are not yet present on Square payment" + ) + async with httpx.AsyncClient(timeout=20) as client: + for refund_id in ids: + refund = await client.get( + RUNTIME.square_base + "/refunds/" + refund_id, + headers={ + "Authorization": "Bearer " + os.environ[RUNTIME.token_environment_key], + "Square-Version": "2026-01-22", + }, + ) + if refund.status_code != 200: + raise HTTPException(503, "Refund verification unavailable") + value = refund.json()["refund"] + if ( + value.get("payment_id") != event.payment_id + or value.get("amount_money", {}).get("currency") != "GBP" + ): + raise HTTPException(409, "Refund payment identity or currency mismatch") + if value["status"] != "COMPLETED": + raise HTTPException(503, "Refund completion is not yet verified by Square") + verified.append( + { + "id": refund_id, + "amount_pence": value["amount_money"]["amount"], + "status": "COMPLETED", + } + ) + if sum(v["amount_pence"] for v in verified) != target: + raise HTTPException(409, "Verified refund amount does not match snapshot") + elif event.refund_ids: + raise HTTPException(422, "Refund identities require a positive refund amount") + p["_verified_refunds"] = verified + p["_verified_refunded_pence"] = target + return p + + +@app.post("/events") +async def receive(request: Request): + body = await signed_body(request) + try: + event = Event.model_validate_json(body) + except ValueError: + raise HTTPException(422, "Invalid order snapshot") + digest = hashlib.sha256(body).hexdigest() + async with async_session_maker() as db, db.begin(): + # Payment lock prevents one captured payment being assigned to two orders. + await db.execute( + text("SELECT pg_advisory_xact_lock(hashtextextended(:key,1))"), + {"key": event.payment_id}, + ) + reused = await db.scalar( + select(CommerceOrder).where( + CommerceOrder.snapshot["payment_id"].astext == event.payment_id, + CommerceOrder.woo_order_id != event.order_id, + ) + ) + if reused: + raise HTTPException(409, "Payment already belongs to another order") + # Transaction-scoped lock serializes duplicate/new events for one order. + await db.execute(text("SELECT pg_advisory_xact_lock(:key)"), {"key": event.order_id}) + prior = await db.scalar( + select(CommerceReceipt).where(CommerceReceipt.event_id == str(event.event_id)) + ) + if prior: + if prior.body_sha256 != digest: + raise HTTPException(409, "Event ID reused with different data") + recorded = await db.scalar( + select(CommerceOrder).where(CommerceOrder.woo_order_id == event.order_id) + ) + return { + "status": "duplicate", + "event_id": str(event.event_id), + "refund_review": recorded.effects.get("refund_review", "") if recorded else "", + "refunded_pence": recorded.effects.get("refunded_pence", 0) if recorded else 0, + } + mapping = await db.scalar( + select(CommerceOrder) + .where(CommerceOrder.woo_order_id == event.order_id) + .with_for_update() + ) + if mapping and event.version <= mapping.version: + db.add( + CommerceReceipt( + event_id=str(event.event_id), + body_sha256=digest, + woo_order_id=event.order_id, + outcome="stale", + ) + ) + return {"status": "stale", "event_id": str(event.event_id)} + if ( + mapping + and mapping.state in ["cancelled", "refunded"] + and event.state in ["processing", "completed"] + ): + raise HTTPException(409, "Cannot reopen terminal order through delayed payment event") + if mapping: + # Paid quantities and money cannot be rewritten by a later status event. + old = mapping.snapshot + new = event.model_dump(mode="json") + keys = [ + "payment_id", + "currency", + "subtotal_pence", + "shipping_pence", + "discount_pence", + "total_pence", + ] + line_keys = ["line_id", "product_id", "variant_id", "sku", "quantity", "total_pence"] + + def identity(lines): + return sorted([tuple(str(line.get(k)) for k in line_keys) for line in lines]) + + if ( + any(old[k] != new[k] for k in keys) + or old.get("reservation_id") != new.get("reservation_id") + or identity(old["lines"]) != identity(new["lines"]) + ): + raise HTTPException( + 409, "Paid order changes require an explicit adjustment workflow" + ) + payment = await verify_payment(event) + if mapping and payment["_verified_refunded_pence"] < mapping.effects.get( + "refunded_pence", 0 + ): + raise HTTPException(409, "Verified refunds cannot be removed by a later event") + if mapping and not {r["id"] for r in mapping.effects.get("refunds", [])}.issubset( + {r["id"] for r in payment["_verified_refunds"]} + ): + raise HTTPException( + 409, "Verified refund identities cannot be removed by a later event" + ) + reservation = None + if not mapping and event.reservation_id: + reservation = await db.scalar( + select(CommerceReservation) + .where( + CommerceReservation.reservation_id == str(event.reservation_id), + CommerceReservation.woo_order_id == event.order_id, + ) + .with_for_update() + ) + if ( + not reservation + or reservation.state != "payment_pending" + or reservation_identity(reservation.snapshot) + != reservation_identity(event.model_dump(mode="json")) + ): + raise HTTPException(409, "Matching payment-pending reservation required") + if not mapping: + if event.state not in ["processing", "completed"]: + raise HTTPException(409, "Paid order must be reconciled before its terminal event") + order = Order( + tenant_id=TENANT, + sales_channel_id=await commerce_sales_channel( + db, TENANT, os.environ, RUNTIME.isolated + ), + order_number=RUNTIME.order_reference(event.order_id), + status="processing", + customer_email=event.customer.get("email", "test@example.invalid"), + customer_name=event.customer.get("name", "Test customer"), + customer_phone=event.customer.get("phone") or None, + shipping_address_line1=event.shipping.get("address_1", ""), + shipping_address_line2=event.shipping.get("address_2", ""), + shipping_city=event.shipping.get("city", ""), + shipping_postcode=event.shipping.get("postcode", ""), + shipping_country="United Kingdom", + shipping_method=event.shipping.get("method", "Royal Mail"), + shipping_cost=Decimal(event.shipping_pence) / 100, + subtotal=Decimal(event.subtotal_pence) / 100, + total=Decimal(event.total_pence) / 100, + discount_amount=Decimal(event.discount_pence) / 100, + currency="GBP", + payment_provider="square-sandbox" if RUNTIME.isolated else "square", + payment_id=event.payment_id, + payment_status="COMPLETED", + confirmation_email_sent=False, + internal_notes=( + "Isolated WooCommerce acceptance order. Never dispatch to a real customer." + if RUNTIME.isolated + else "WooCommerce owns payment, refund and customer email actions; dispatch through the commerce workflow." + ), + ) + db.add(order) + await db.flush() + effects = {"stock": [], "restored": False, "jobs": []} + for line in sorted(event.lines, key=lambda x: str(x.product_id)): + product = await db.scalar( + select(Product) + .where(Product.id == line.product_id, Product.tenant_id == TENANT) + .with_for_update() + ) + if not product or not product.is_active: + raise HTTPException(409, "Unknown or inactive mapped product") + variant = None + if line.variant_id: + variant = await db.scalar( + select(ProductVariant) + .where( + ProductVariant.id == line.variant_id, + ProductVariant.product_id == product.id, + ProductVariant.tenant_id == TENANT, + ProductVariant.is_active.is_(True), + ) + .with_for_update() + ) + if not variant: + raise HTTPException(409, "Invalid mapped variant") + if line.sku != (variant.sku if variant else product.sku): + raise HTTPException(409, "SKU does not match source mapping") + pto = ( + variant.fulfilment_type in ["print_to_order", "made_to_order"] + if variant + else product.print_to_order + ) + if not pto: + if not reservation: + raise HTTPException(409, "Finite stock requires a pre-payment reservation") + stock = variant or product + if stock.units_in_stock < line.quantity: + raise HTTPException(409, "Insufficient Batchivo stock") + stock.units_in_stock -= line.quantity + effects["stock"].append( + { + "product_id": str(product.id), + "variant_id": str(variant.id) if variant else None, + "quantity": line.quantity, + } + ) + db.add( + OrderItem( + tenant_id=TENANT, + order_id=order.id, + product_id=product.id, + product_sku=line.sku, + product_name=(line.name + " " + line.option).strip()[:255], + quantity=line.quantity, + unit_price=Decimal(line.total_pence) / 100 / line.quantity, + total_price=Decimal(line.total_pence) / 100, + ) + ) + if pto: + job = PrintJob( + id=uuid4(), + tenant_id=TENANT, + product_id=product.id, + quantity=line.quantity, + status=JobStatus.PENDING, + reference=RUNTIME.job_reference(event.order_id, line.line_id), + notes=json.dumps( + { + "source": RUNTIME.source, + "variant_id": str(line.variant_id) if line.variant_id else None, + "sku": line.sku, + "option": line.option, + "payment_id": event.payment_id, + } + ), + ) + db.add(job) + effects.setdefault("jobs", []).append( + {"id": str(job.id), "line_id": line.line_id} + ) + if reservation: + reservation.state = "committed" + mapping = CommerceOrder( + woo_order_id=event.order_id, + batchivo_order_id=str(order.id), + version=event.version, + state=event.state, + snapshot=event.model_dump(mode="json"), + effects=effects, + ) + db.add(mapping) + else: + order = await db.get(Order, UUID(mapping.batchivo_order_id)) + mapping.version = event.version + mapping.state = event.state + mapping.snapshot = event.model_dump(mode="json") + effects = dict(mapping.effects) + if payment["_verified_refunded_pence"]: + effects["refunds"] = payment["_verified_refunds"] + effects["refunded_pence"] = payment["_verified_refunded_pence"] + order.payment_status = ( + "REFUNDED" + if payment["_verified_refunded_pence"] == event.total_pence + else "PARTIALLY_REFUNDED" + ) + if order.payment_status == "PARTIALLY_REFUNDED": + from app.commerce_disposition import refund_release_valid + + if not refund_release_valid(effects, payment): + effects["refund_review"] = ( + "Partial refund requires a fresh fulfilment decision before new manufacturing or dispatch." + ) + if event.state in ["cancelled", "refunded"]: + jobs = ( + await db.scalars( + select(PrintJob) + .where( + PrintJob.tenant_id == TENANT, + PrintJob.reference.like(RUNTIME.order_reference(event.order_id) + ":%"), + ) + .with_for_update() + ) + ).all() + started = any( + j.status not in [JobStatus.PENDING, JobStatus.QUEUED, JobStatus.CANCELLED] + for j in jobs + ) + dispatched = bool(order.shipped_at) + if event.state == "cancelled" and (started or dispatched): + raise HTTPException( + 409, + "Manufacturing/dispatch already started; cancellation requires operator review", + ) + # Refund is a financial fact. Never erase completed/active manufacturing or dispatch. + if event.state == "refunded" and (started or dispatched): + effects["refund_review"] = ( + "Refund after manufacturing or dispatch: review goods/returns; no stock restored." + ) + for job in jobs: + if job.status in [JobStatus.PENDING, JobStatus.QUEUED]: + job.status = JobStatus.CANCELLED + else: + for job in jobs: + job.status = JobStatus.CANCELLED + if not effects.get("restored"): + for effect in effects.get("stock", []): + cls = ProductVariant if effect["variant_id"] else Product + key = UUID(effect["variant_id"] or effect["product_id"]) + stock = await db.scalar( + select(cls) + .where(cls.id == key, cls.tenant_id == TENANT) + .with_for_update() + ) + if not stock: + raise HTTPException(409, "Stock mapping requires operator review") + stock.units_in_stock += effect["quantity"] + effects["restored"] = True + if not dispatched: + order.status = event.state + if event.state == "refunded": + order.payment_status = "REFUNDED" + mapping.effects = effects + await db.flush() + await enrich_job_options(db, event) + db.add( + CommerceReceipt( + event_id=str(event.event_id), + body_sha256=digest, + woo_order_id=event.order_id, + outcome="applied", + ) + ) + return { + "status": "applied", + "event_id": str(event.event_id), + "batchivo_order_id": mapping.batchivo_order_id, + "refund_review": mapping.effects.get("refund_review", ""), + "refunded_pence": mapping.effects.get("refunded_pence", 0), + } + + +async def seed(file): + if not RUNTIME.isolated: + raise RuntimeError("Catalogue seeding is prohibited in production") + d = json.load(open(file)) + if d["_export"]["tenant_id"] != str(TENANT): + raise RuntimeError("Wrong source tenant") + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + await conn.run_sync(lambda sync: payment_identity_index.create(sync, checkfirst=True)) + async with async_session_maker() as db, db.begin(): + if not await db.get(Tenant, TENANT): + db.add( + Tenant( + id=TENANT, + name="Mystmere Forge — isolated commerce test", + slug="mystmere-commerce-test", + settings={}, + ) + ) + await db.flush() + for table in ["designers", "categories", "products", "product_variants"]: + rows = d[table] + for row in rows: + row = dict(row) + if table == "designers": + row["notes"] = None + if table == "products": + row["packaging_consumable_id"] = None + cols = [ + c.name + for c in Base.metadata.tables[table].columns + if c.name in row and not c.computed + ] + names = ",".join('"' + c + '"' for c in cols) + # Only insert missing records; reseeding never resets stock or test orders. + await db.execute( + text( + f"INSERT INTO {table} ({names}) SELECT {names} FROM json_populate_record(NULL::{table},CAST(:row AS json)) ON CONFLICT(id) DO NOTHING" + ), + {"row": json.dumps(row)}, + ) + print( + "Isolated Batchivo schema and catalogue seeded; no production customers, credentials or printers copied." + ) + + +async def reconcile_job_options(): + if not RUNTIME.isolated: + raise RuntimeError("Fixture enrichment is prohibited in production") + async with async_session_maker() as db, db.begin(): + mappings = (await db.scalars(select(CommerceOrder).with_for_update())).all() + changed = 0 + for mapping in mappings: + changed += await enrich_job_options(db, Event.model_validate(mapping.snapshot)) + print( + json.dumps( + { + "option_labels_enriched": changed, + "scope": "Isolated variant labels only; quantities, status, payments and stock unchanged", + } + ) + ) + + +if __name__ != "__main__": + from app.commerce_returns import install_routes as install_return_routes + + install_return_routes(app) + from app.commerce_disposition import install_routes as install_disposition_routes + + install_disposition_routes(app) + +if __name__ == "__main__": + asyncio.run( + reconcile_job_options() if sys.argv[1] == "--reconcile-job-options" else seed(sys.argv[1]) + ) diff --git a/backend/app/commerce_disposition.py b/backend/app/commerce_disposition.py new file mode 100644 index 00000000..78edb987 --- /dev/null +++ b/backend/app/commerce_disposition.py @@ -0,0 +1,280 @@ +"""Audited physical disposition of unshipped partially refunded commerce orders.""" + +import copy +import hashlib +import hmac +from datetime import UTC, datetime +from uuid import UUID + +from fastapi import HTTPException, Request +from pydantic import BaseModel, ConfigDict, Field, StrictInt +from sqlalchemy import select + +from app.commerce_returns import load_order, revision, verify_refunds + + +class Review(BaseModel): + model_config = ConfigDict(extra="forbid") + order_id: int = Field(gt=0, strict=True) + + +class Command(Review): + event_id: UUID + revision: str = Field(pattern=r"^[a-f0-9]{64}$") + cancelled: dict[str, StrictInt] + actor_id: int = Field(gt=0, strict=True) + note: str = Field(min_length=5, max_length=500) + + +def cancelled_quantity(effects, line): + value = effects.get("disposition", {}).get("cancelled", {}).get(str(line["line_id"]), 0) + if type(value) is not int or not 0 <= value <= line["quantity"]: + raise HTTPException(409, "Cancelled quantities require reconciliation") + return value + + +def remaining_quantity(effects, line): + return line["quantity"] - cancelled_quantity(effects, line) + + +def refund_signature(payment): + # Version-independent approval of exact financial facts, never aggregate money alone. + return sorted((r["id"], r["amount_pence"]) for r in payment["_verified_refunds"]) + + +def refund_release_valid(effects, payment): + approval = effects.get("disposition", {}).get("approval") + refunds = payment.get("_verified_refunds", []) + verified_ids = {r["id"] for r in refunds} + if not refunds: + return not payment.get("refund_ids") and not payment.get("refunded_money", {}).get( + "amount", 0 + ) + return bool( + approval + and approval["refunded_pence"] + == payment["_verified_refunded_pence"] + == payment.get("refunded_money", {}).get("amount", 0) + and approval["refunds"] == [list(x) for x in refund_signature(payment)] + and verified_ids == set(payment.get("refund_ids", [])) + ) + + +async def review_in_transaction(db, order_id): + from app.commerce_bridge import RUNTIME, TENANT, PrintJob + + mapping, order = await load_order(db, order_id) + if order.shipped_at or order.status not in ("pending", "processing"): + raise HTTPException(409, "Only unshipped orders can receive a fulfilment decision") + if not 0 < mapping.effects.get("refunded_pence", 0) < mapping.snapshot["total_pence"]: + raise HTTPException(409, "Reconcile a completed partial refund before reviewing fulfilment") + await verify_refunds(mapping, order, require_shipped=False) + jobs = {} + for entry in mapping.effects.get("jobs", []): + key = str(entry["line_id"]) + if key in jobs: + raise HTTPException(409, "Duplicate manufacturing identity") + job = await db.scalar( + select(PrintJob) + .where(PrintJob.id == UUID(entry["id"]), PrintJob.tenant_id == TENANT) + .with_for_update() + ) + if not job: + raise HTTPException(409, "Manufacturing job requires reconciliation") + jobs[key] = job + rows = [] + finite = {(e["product_id"], e.get("variant_id")) for e in mapping.effects.get("stock", [])} + seen = set() + for line in mapping.snapshot["lines"]: + key = str(line["line_id"]) + if key in seen: + raise HTTPException(409, "Duplicate sold line identity") + seen.add(key) + job = jobs.get(key) + is_finite = (line["product_id"], line.get("variant_id")) in finite + remaining = remaining_quantity(mapping.effects, line) + if is_finite == bool(job): + raise HTTPException(409, "Allocation or manufacturing mapping requires reconciliation") + if job and ( + str(job.product_id) != line["product_id"] + or job.reference != RUNTIME.job_reference(order_id, line["line_id"]) + or (remaining and job.quantity != remaining) + or (not remaining and job.status.value != "cancelled") + ): + raise HTTPException(409, "Manufacturing quantities require reconciliation") + rows.append( + { + "line_id": line["line_id"], + "name": line["name"], + "sku": line["sku"], + "sold": line["quantity"], + "cancelled": line["quantity"] - remaining, + "remaining": remaining, + "can_cancel": is_finite or job.status.value in ("pending", "queued"), + "manufacturing_status": job.status.value if job else None, + } + ) + if set(jobs) - seen: + raise HTTPException(409, "Unmapped manufacturing work") + return mapping, order, jobs, rows + + +async def apply_in_transaction(db, command): + from app.commerce_bridge import ( + TENANT, + Event, + JobStatus, + Product, + ProductVariant, + verify_payment, + ) + + mapping, order = await load_order(db, command.order_id) + digest = hashlib.sha256(command.model_dump_json().encode()).hexdigest() + old = mapping.effects.get("disposition", {}) + prior = old.get("receipts", {}).get(str(command.event_id)) + if prior: + if prior["command_sha256"] != digest: + raise HTTPException(409, "Disposition identity reused with changed details") + return { + "status": "duplicate", + "order_id": command.order_id, + "event_id": str(command.event_id), + } + if not hmac.compare_digest(command.revision, revision(mapping, order)): + raise HTTPException(409, "Order changed; review the latest refund and quantities") + if command.note.strip() != command.note or any(ord(c) < 32 for c in command.note): + raise HTTPException(422, "Enter an evidence note without control characters") + mapping, order, jobs, rows = await review_in_transaction(db, command.order_id) + if set(command.cancelled) != {str(r["line_id"]) for r in rows}: + raise HTTPException(422, "Review every sold line exactly once") + if any(not r["cancelled"] <= command.cancelled[str(r["line_id"])] <= r["sold"] for r in rows): + raise HTTPException( + 409, "Cancelled quantities cannot be reversed or exceed sold quantities" + ) + if all(command.cancelled[str(r["line_id"])] == r["sold"] for r in rows): + raise HTTPException(409, "No remaining goods: complete the financial cancellation instead") + effects = copy.deepcopy(mapping.effects) + ledger = effects.setdefault( + "disposition", + { + "cancelled": {}, + "receipts": {}, + "original_stock": copy.deepcopy(effects.get("stock", [])), + }, + ) + if len(ledger["receipts"]) >= 1000: + raise HTTPException(409, "Disposition audit requires operator reconciliation") + deltas = {} + for row in rows: + key = str(row["line_id"]) + delta = command.cancelled[key] - row["cancelled"] + if not delta: + continue + if not row["can_cancel"]: + raise HTTPException( + 409, "Started manufacturing cannot be cancelled as unstarted demand" + ) + line = next(x for x in mapping.snapshot["lines"] if str(x["line_id"]) == key) + if key in jobs: + job = jobs[key] + remaining = row["sold"] - command.cancelled[key] + if remaining: + job.quantity = remaining + else: + job.status = JobStatus.CANCELLED + else: + stock_key = (line["product_id"], line.get("variant_id")) + deltas[stock_key] = deltas.get(stock_key, 0) + delta + for (product_id, variant_id), delta in sorted(deltas.items(), key=lambda x: str(x[0])): + cls = ProductVariant if variant_id else Product + stock = await db.scalar( + select(cls) + .where(cls.id == UUID(variant_id or product_id), cls.tenant_id == TENANT) + .with_for_update() + ) + if not stock or (variant_id and str(stock.product_id) != product_id): + raise HTTPException(409, "Stock identity requires reconciliation") + remaining_delta = delta + for allocation in effects["stock"]: + if (allocation["product_id"], allocation.get("variant_id")) == (product_id, variant_id): + take = min(remaining_delta, allocation["quantity"]) + allocation["quantity"] -= take + remaining_delta -= take + if remaining_delta: + raise HTTPException(409, "Outstanding stock allocation is insufficient") + stock.units_in_stock += delta + # Re-read the provider after constructing the decision, before committing it. + payment = await verify_payment(Event.model_validate(mapping.snapshot)) + expected = {r["id"] for r in effects.get("refunds", [])} + if ( + expected != set(payment.get("refund_ids", [])) + or payment["_verified_refunded_pence"] != effects["refunded_pence"] + or payment.get("refunded_money", {}).get("amount", 0) != effects["refunded_pence"] + ): + raise HTTPException(409, "Refund activity changed; reconcile before release") + ledger["cancelled"] = dict(command.cancelled) + ledger["approval"] = { + "refunds": [list(x) for x in refund_signature(payment)], + "refunded_pence": effects["refunded_pence"], + } + ledger["receipts"][str(command.event_id)] = { + "command_sha256": digest, + "event_id": str(command.event_id), + "cancelled": dict(command.cancelled), + "actor_id": command.actor_id, + "note": command.note, + "at": datetime.now(UTC).isoformat(), + "approval": copy.deepcopy(ledger["approval"]), + } + effects["refund_review"] = ( + "Partial refund reviewed: only remaining quantities may be manufactured and dispatched." + ) + mapping.effects = effects + await db.flush() + return {"status": "recorded", "order_id": command.order_id, "event_id": str(command.event_id)} + + +def install_routes(app): + import httpx + + from app.commerce_bridge import async_session_maker, signed_body, signed_response + + @app.post("/fulfilment/disposition/{action}") + async def handle(action: str, request: Request): + raw = await signed_body(request) + try: + async with async_session_maker() as db, db.begin(): + if action == "review": + command = Review.model_validate_json(raw) + mapping, order, _, rows = await review_in_transaction(db, command.order_id) + result = { + "order_id": command.order_id, + "revision": revision(mapping, order), + "lines": rows, + "refunded_pence": mapping.effects["refunded_pence"], + "history": list( + mapping.effects.get("disposition", {}).get("receipts", {}).values() + ), + } + elif action == "apply": + result = await apply_in_transaction(db, Command.model_validate_json(raw)) + else: + raise HTTPException(404, "Unknown disposition action") + return signed_response({"ok": True, **result}) + except ValueError: + return signed_response( + {"ok": False, "code": 422, "error": "Invalid fulfilment decision"} + ) + except httpx.HTTPError: + return signed_response( + { + "ok": False, + "code": 503, + "error": "Payment verification unavailable; decision held", + } + ) + except HTTPException as error: + return signed_response( + {"ok": False, "code": error.status_code, "error": str(error.detail)} + ) diff --git a/backend/app/commerce_returns.py b/backend/app/commerce_returns.py new file mode 100644 index 00000000..ddb1fd81 --- /dev/null +++ b/backend/app/commerce_returns.py @@ -0,0 +1,300 @@ +"""Physical returns, separate from Square refunds and immutable sold quantities. + +Only a shipped, provider-reconciled paid order can receive returns. Receiving +is separate from inspection and financial refunds; only inspected finite-stock goods increase stock. +Every command shares the order lock with financial and dispatch changes. +""" + +import copy +import hashlib +import hmac +import json +from datetime import UTC, datetime +from typing import Literal +from uuid import UUID + +import httpx +from fastapi import HTTPException, Request +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy import select + + +class ReturnReview(BaseModel): + model_config = ConfigDict(extra="forbid") + order_id: int = Field(gt=0, strict=True) + + +class ReturnCommand(ReturnReview): + event_id: UUID + revision: str = Field(pattern=r"^[a-f0-9]{64}$") + line_id: int = Field(gt=0, strict=True) + action: Literal["receive", "restock", "write_off"] + quantity: int = Field(gt=0, le=1000, strict=True) + actor_id: int = Field(gt=0, strict=True) + note: str = Field(min_length=5, max_length=500) + + +def revision(mapping, order): + return hashlib.sha256( + json.dumps( + { + "version": mapping.version, + "state": mapping.state, + "snapshot": mapping.snapshot, + "effects": mapping.effects, + "order_state": order.status, + "shipped_at": order.shipped_at.isoformat() if order.shipped_at else None, + }, + sort_keys=True, + separators=(",", ":"), + ).encode() + ).hexdigest() + + +async def load_order(db, order_id): + from app.commerce_bridge import TENANT, CommerceOrder, Order, reservation_order_lock + + await reservation_order_lock(db, order_id) + mapping = await db.scalar( + select(CommerceOrder).where(CommerceOrder.woo_order_id == order_id).with_for_update() + ) + if not mapping: + raise HTTPException(404, "Mapped order not found") + order = await db.scalar( + select(Order) + .where(Order.id == UUID(mapping.batchivo_order_id), Order.tenant_id == TENANT) + .with_for_update() + ) + if not order: + raise HTTPException(409, "Native order mapping requires reconciliation") + return mapping, order + + +async def verify_refunds(mapping, order, require_shipped=True): + from app.commerce_bridge import Event, verify_payment + + if require_shipped and not order.shipped_at: + raise HTTPException( + 409, + "Record a physical return only after dispatch; unshipped cancellations use the cancellation workflow", + ) + if mapping.effects.get("restored"): + raise HTTPException( + 409, "Stock was already released; return quantities require reconciliation" + ) + refunded = mapping.effects.get("refunded_pence", 0) + expected_status = ( + "COMPLETED" + if refunded == 0 + else "REFUNDED" + if refunded == mapping.snapshot["total_pence"] + else "PARTIALLY_REFUNDED" + ) + if order.payment_status != expected_status or mapping.state not in ( + "processing", + "completed", + "refunded", + ): + raise HTTPException( + 409, "Reconcile the completed payment and any refunds before recording returns" + ) + try: + payment = await verify_payment(Event.model_validate(mapping.snapshot)) + except (httpx.HTTPError, ValueError, KeyError): + raise HTTPException(503, "Square verification unavailable; return action held") from None + verified = {r["id"] for r in payment["_verified_refunds"]} + if ( + verified != {r["id"] for r in mapping.effects.get("refunds", [])} + or verified != set(payment.get("refund_ids", [])) + or payment["_verified_refunded_pence"] != mapping.effects.get("refunded_pence", 0) + or payment.get("refunded_money", {}).get("amount", 0) + != mapping.effects.get("refunded_pence", 0) + ): + raise HTTPException( + 409, "Square refund activity has changed; reconcile it before recording returns" + ) + + +def lines_for_review(mapping): + from app.commerce_disposition import remaining_quantity + + ledger = mapping.effects.get("returns", {}) + finite = {(s["product_id"], s.get("variant_id")) for s in mapping.effects.get("stock", [])} + rows = [] + seen = set() + for line in mapping.snapshot["lines"]: + key = str(line["line_id"]) + if key in seen: + raise HTTPException(409, "Duplicate sold line identity requires reconciliation") + seen.add(key) + counts = ledger.get("lines", {}).get(key, {}) + received, restocked, written_off = ( + counts.get(k, 0) for k in ("received", "restocked", "written_off") + ) + if ( + not 0 + <= restocked + written_off + <= received + <= remaining_quantity(mapping.effects, line) + ): + raise HTTPException(409, "Return quantities require reconciliation") + rows.append( + { + "line_id": line["line_id"], + "name": line["name"], + "sku": line["sku"], + "option": line.get("option", ""), + "sold": line["quantity"], + "cancelled": line["quantity"] - remaining_quantity(mapping.effects, line), + "shipped_quantity": remaining_quantity(mapping.effects, line), + "received": received, + "restocked": restocked, + "written_off": written_off, + "awaiting_inspection": received - restocked - written_off, + "can_restock": (line["product_id"], line.get("variant_id")) in finite, + } + ) + return rows + + +async def review_returns(order_id): + from app.commerce_bridge import async_session_maker + + async with async_session_maker() as db, db.begin(): + mapping, order = await load_order(db, order_id) + await verify_refunds(mapping, order) + return { + "order_id": order_id, + "revision": revision(mapping, order), + "refunded_pence": mapping.effects.get("refunded_pence", 0), + "lines": lines_for_review(mapping), + "history": list(mapping.effects.get("returns", {}).get("receipts", {}).values()), + } + + +async def apply_return_in_transaction(db, command): + """Caller owns the transaction; used by the HTTP handler and rollback probes.""" + from app.commerce_bridge import TENANT, Product, ProductVariant + + mapping, order = await load_order(db, command.order_id) + digest = hashlib.sha256(command.model_dump_json().encode()).hexdigest() + effects = copy.deepcopy(mapping.effects) + ledger = effects.setdefault("returns", {"lines": {}, "receipts": {}}) + prior = ledger["receipts"].get(str(command.event_id)) + if prior: + if prior["command_sha256"] != digest: + raise HTTPException(409, "Return command identity reused with different details") + return { + "status": "duplicate", + "order_id": command.order_id, + "event_id": str(command.event_id), + "action": command.action, + } + if command.note.strip() != command.note or any(ord(c) < 32 for c in command.note): + raise HTTPException(422, "Enter a short inspection note without control characters") + if not hmac.compare_digest(command.revision, revision(mapping, order)): + raise HTTPException( + 409, "Return or order details changed; review the latest quantities before applying" + ) + await verify_refunds(mapping, order) + lines = {row["line_id"]: row for row in lines_for_review(mapping)} + line = lines.get(command.line_id) + if not line: + raise HTTPException(409, "Sold line does not belong to this order") + if len(ledger["receipts"]) >= 1000: + raise HTTPException(409, "Return audit limit reached; operator reconciliation required") + key = str(command.line_id) + counts = ledger["lines"].setdefault(key, {"received": 0, "restocked": 0, "written_off": 0}) + if command.action == "receive": + if command.quantity > line["shipped_quantity"] - line["received"]: + raise HTTPException(409, "Received quantity exceeds the remaining sold quantity") + counts["received"] += command.quantity + else: + if command.quantity > line["awaiting_inspection"]: + raise HTTPException(409, "Inspect only goods already received and awaiting inspection") + if command.action == "restock": + if not line["can_restock"]: + raise HTTPException( + 409, + "Print-to-order goods require a separate inventory decision; this action only restores finite stock", + ) + sold = next( + row for row in mapping.snapshot["lines"] if row["line_id"] == command.line_id + ) + cls = ProductVariant if sold.get("variant_id") else Product + stock = await db.scalar( + select(cls) + .where( + cls.id == UUID(sold.get("variant_id") or sold["product_id"]), + cls.tenant_id == TENANT, + ) + .with_for_update() + ) + if not stock or ( + sold.get("variant_id") and str(stock.product_id) != sold["product_id"] + ): + raise HTTPException(409, "Stock identity requires reconciliation") + stock.units_in_stock += command.quantity + counts["restocked"] += command.quantity + else: + counts["written_off"] += command.quantity + result = { + "status": "recorded", + "order_id": command.order_id, + "event_id": str(command.event_id), + "action": command.action, + } + ledger["receipts"][str(command.event_id)] = { + "command_sha256": digest, + "event_id": str(command.event_id), + "line_id": command.line_id, + "action": command.action, + "quantity": command.quantity, + "actor_id": command.actor_id, + "note": command.note, + "at": datetime.now(UTC).isoformat(), + "order_version": mapping.version, + "refunded_pence": effects.get("refunded_pence", 0), + } + mapping.effects = effects + await db.flush() + return result + + +async def record_return(command): + from app.commerce_bridge import async_session_maker + + async with async_session_maker() as db, db.begin(): + return await apply_return_in_transaction(db, command) + + +def install_routes(app): + from app.commerce_bridge import signed_body, signed_response + + @app.post("/fulfilment/returns/review") + async def review(request: Request): + body = await signed_body(request) + try: + command = ReturnReview.model_validate_json(body) + return signed_response({"ok": True, **await review_returns(command.order_id)}) + except ValueError: + return signed_response( + {"ok": False, "code": 422, "error": "Invalid return review details"} + ) + except HTTPException as error: + return signed_response( + {"ok": False, "code": error.status_code, "error": str(error.detail)} + ) + + @app.post("/fulfilment/returns/apply") + async def apply(request: Request): + body = await signed_body(request) + try: + command = ReturnCommand.model_validate_json(body) + return signed_response({"ok": True, **await record_return(command)}) + except ValueError: + return signed_response({"ok": False, "code": 422, "error": "Invalid return command"}) + except HTTPException as error: + return signed_response( + {"ok": False, "code": error.status_code, "error": str(error.detail)} + ) diff --git a/backend/app/commerce_runtime.py b/backend/app/commerce_runtime.py new file mode 100644 index 00000000..3773652d --- /dev/null +++ b/backend/app/commerce_runtime.py @@ -0,0 +1,88 @@ +"""Explicit commerce identities; production activation is a separate release action. + +No secrets are retained in this object or included in configuration errors. +""" + +from dataclasses import dataclass +import re +from typing import Mapping +from urllib.parse import unquote, urlsplit + + +@dataclass(frozen=True) +class CommerceRuntime: + mode: str + square_base: str + token_environment_key: str + location_environment_key: str + order_prefix: str + source: str + + @property + def isolated(self): + return self.mode == "isolated-test" + + @classmethod + def from_environment(cls, env: Mapping[str, str]): + mode = env.get("COMMERCE_MODE", "isolated-test") + try: + target = env.get("DATABASE_URL", "") + if env.get("RLS_ENABLED", "false").lower() in {"true", "1", "yes", "on"} and env.get( + "RLS_DATABASE_URL" + ): + target = env["RLS_DATABASE_URL"] + database = urlsplit(target) + name = unquote(database.path.removeprefix("/")) + host = database.hostname + except ValueError: + raise RuntimeError("Invalid commerce database configuration") from None + if ( + database.scheme not in {"postgresql+psycopg", "postgresql+asyncpg", "postgresql"} + or not host + ): + raise RuntimeError("Commerce requires an explicit PostgreSQL target") + if mode == "isolated-test": + if env.get("COMMERCE_ISOLATED") != "true" or name != "batchivo_commerce_test": + raise RuntimeError("Commerce acceptance bridge requires an isolated test database") + return cls( + mode, + "https://connect.squareupsandbox.com/v2", + "SQUARE_SANDBOX_ACCESS_TOKEN", + "SQUARE_SANDBOX_LOCATION_ID", + "WOO-TEST-", + "woocommerce-test", + ) + if mode != "production": + raise RuntimeError("Unknown commerce runtime mode") + if ( + env.get("COMMERCE_ISOLATED") != "false" + or env.get("COMMERCE_PRODUCTION_ENABLED") != "true" + ): + raise RuntimeError("Production commerce release is not activated") + if not re.fullmatch(r"[a-f0-9]{40}|[a-f0-9]{64}", env.get("COMMERCE_RELEASE_ID", "")): + raise RuntimeError("Production commerce requires a versioned release identity") + if name != "batchivo" or host not in { + "postgres.batchivo.svc", + "postgres.batchivo.svc.cluster.local", + }: + raise RuntimeError( + "Production commerce database identity does not match the reviewed target" + ) + if not env.get("SQUARE_PRODUCTION_ACCESS_TOKEN") or not env.get( + "SQUARE_PRODUCTION_LOCATION_ID" + ): + raise RuntimeError("Production commerce payment configuration is incomplete") + return cls( + mode, + "https://connect.squareup.com/v2", + "SQUARE_PRODUCTION_ACCESS_TOKEN", + "SQUARE_PRODUCTION_LOCATION_ID", + "WOO-", + "woocommerce", + ) + + def order_reference(self, order_id): + return f"{self.order_prefix}{int(order_id)}" + + def job_reference(self, order_id, line_id): + return f"{self.order_reference(order_id)}:{int(line_id)}" diff --git a/backend/app/services/commerce_database.py b/backend/app/services/commerce_database.py new file mode 100644 index 00000000..8c34a58b --- /dev/null +++ b/backend/app/services/commerce_database.py @@ -0,0 +1,58 @@ +"""Separate commerce connection ownership from the native application engine.""" + +import re + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import create_async_engine + +from app.commerce_runtime import CommerceRuntime +from app.services.commerce_database_policy import LEDGERS, TENANT_TABLES + + +def commerce_engine(runtime, environment, native_engine): + target = environment.get("COMMERCE_DATABASE_URL") + if not runtime.isolated and not target: + raise RuntimeError("Production commerce requires its dedicated database credential") + if not target: + return native_engine + CommerceRuntime.from_environment( + {**environment, "DATABASE_URL": target, "RLS_ENABLED": "false"} + ) + return create_async_engine(target, echo=False, pool_pre_ping=True, pool_size=5, max_overflow=5) + + +def verify_commerce_role(connection): + role = ( + connection.execute( + text( + "SELECT current_user AS name, rolsuper, rolbypassrls, rolcreatedb, rolcreaterole FROM pg_roles WHERE rolname=current_user" + ) + ) + .mappings() + .one() + ) + if not re.fullmatch(r"mf_commerce_[a-z0-9_]{1,32}", role["name"]) or any( + role[k] for k in ("rolsuper", "rolbypassrls", "rolcreatedb", "rolcreaterole") + ): + raise RuntimeError("Commerce database role has unsafe privileges") + if connection.scalar( + text( + "SELECT count(*) FROM pg_auth_members WHERE member=(SELECT oid FROM pg_roles WHERE rolname=current_user)" + ) + ): + raise RuntimeError("Commerce database role cannot inherit or assume another role") + names = list(TENANT_TABLES) + list(LEDGERS) + rows = ( + connection.execute( + text( + "SELECT relname, relrowsecurity, pg_get_userbyid(relowner)=current_user AS owned FROM pg_class WHERE relnamespace='public'::regnamespace AND relname = ANY(:names)" + ), + {"names": names}, + ) + .mappings() + .all() + ) + if len(rows) != len(names) or any(row["owned"] or not row["relrowsecurity"] for row in rows): + raise RuntimeError("Commerce schema ownership or row security is not ready") + if connection.scalar(text("SELECT has_schema_privilege(current_user,'public','CREATE')")): + raise RuntimeError("Commerce role cannot own schema creation") diff --git a/backend/app/services/commerce_database_policy.py b/backend/app/services/commerce_database_policy.py new file mode 100644 index 00000000..ef85bf41 --- /dev/null +++ b/backend/app/services/commerce_database_policy.py @@ -0,0 +1,59 @@ +"""Reviewed, explicit grants for a dedicated single-store commerce database role. + +Generates SQL only. An operator-controlled migration connection applies it in a +transaction after provisioning a fresh role and the commerce schema. Passwords +never belong in this module. Native application credentials remain separate. +""" + +import re +from uuid import UUID + +TENANT_TABLES = { + "tenants": "id", + "products": "tenant_id", + "product_variants": "tenant_id", + "orders": "tenant_id", + "order_items": "tenant_id", + "print_jobs": "tenant_id", + "sales_channels": "tenant_id", +} +LEDGERS = ( + "commerce_orders", + "commerce_receipts", + "commerce_reservations", + "commerce_fulfilment_events", +) + + +def policy_sql(role: str, tenant_id: UUID) -> list[str]: + if not re.fullmatch(r"mf_commerce_[a-z0-9_]{1,32}", role): + raise ValueError("Dedicated commerce role name required") + tenant = str(UUID(str(tenant_id))) + role_sql = '"' + role + '"' + statements = [f"GRANT USAGE ON SCHEMA public TO {role_sql}"] + context = f"NULLIF(current_setting('app.current_tenant_id', true), '')::uuid = '{tenant}'::uuid" + for table in (*TENANT_TABLES, *LEDGERS): + predicate = context + if table in TENANT_TABLES: + predicate += f" AND {TENANT_TABLES[table]} = '{tenant}'::uuid" + statements.extend( + [ + f"REVOKE ALL ON TABLE public.{table} FROM {role_sql}", + f"ALTER TABLE public.{table} ENABLE ROW LEVEL SECURITY", + f"DROP POLICY IF EXISTS {role}_allow ON public.{table}", + f"DROP POLICY IF EXISTS {role}_fence ON public.{table}", + # A restrictive policy keeps older permissive tenant policies from + # letting this role choose a different tenant through set_config(). + f"CREATE POLICY {role}_allow ON public.{table} AS PERMISSIVE FOR ALL TO {role_sql} USING (true) WITH CHECK (true)", + f"CREATE POLICY {role}_fence ON public.{table} AS RESTRICTIVE FOR ALL TO {role_sql} USING ({predicate}) WITH CHECK ({predicate})", + f"GRANT SELECT ON TABLE public.{table} TO {role_sql}", + ] + ) + for table in ("products", "product_variants"): + statements.append( + f"GRANT UPDATE (units_in_stock, updated_at) ON public.{table} TO {role_sql}" + ) + for table in ("orders", "print_jobs", *LEDGERS): + statements.append(f"GRANT INSERT, UPDATE ON public.{table} TO {role_sql}") + statements.append(f"GRANT INSERT ON public.order_items TO {role_sql}") + return statements diff --git a/backend/app/services/commerce_handover.py b/backend/app/services/commerce_handover.py new file mode 100644 index 00000000..a4020b29 --- /dev/null +++ b/backend/app/services/commerce_handover.py @@ -0,0 +1,201 @@ +"""Drain legacy writers before final sync; old callbacks and reads remain available. + +A session-level shared lock spans the complete request, including intermediate +application commits. A separate bounded pool avoids deadlocks with request DBs. +""" + +import os +from contextlib import asynccontextmanager +from functools import lru_cache +from typing import Annotated +from uuid import UUID + +from fastapi import Depends, HTTPException, Request +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine + +from app.auth.customer_dependencies import CurrentCustomer, decode_customer_token +from app.auth.dependencies import ShopTenant +from app.database import engine as native_engine +from app.database import get_db +from app.schemas.customer import CustomerRefreshToken + +TENANT = UUID("ad62b515-17b3-4830-bcb4-3f4c470c26e2") +LOCK = 0x4D4653484F50 + + +@lru_cache(maxsize=1) +def handover_engine(): + return create_async_engine( + native_engine.url, pool_pre_ping=True, pool_size=2, max_overflow=3, pool_timeout=20 + ) + + +@asynccontextmanager +async def legacy_writer(tenant_id): + if os.environ.get("COMMERCE_LEGACY_HANDOVER_ENABLED") != "true" or tenant_id != TENANT: + yield + return + async with handover_engine().connect() as connection: + locked = False + try: + await connection.execute(text("SELECT pg_advisory_lock_shared(:key)"), {"key": LOCK}) + locked = True + owner = ( + await connection.execute( + text("SELECT owner FROM commerce_handover WHERE tenant_id=:tenant"), + {"tenant": TENANT}, + ) + ).scalar_one_or_none() + await connection.commit() + if owner != "legacy": + message = ( + "The shop is being upgraded. Please reload www.mystmereforge.co.uk to continue. " + "Your account and previous orders are preserved. Check an existing payment before starting another checkout." + ) + raise HTTPException( + 409 if owner in ("fenced", "woocommerce") else 503, + message, + headers={"Retry-After": "5"}, + ) + yield + finally: + if locked: + try: + await connection.execute( + text("SELECT pg_advisory_unlock_shared(:key)"), {"key": LOCK} + ) + await connection.commit() + except BaseException: + # An interrupted cleanup must discard the physical connection, + # never return a session advisory lock to the pool. + await connection.invalidate() + raise + + +async def legacy_shop_write_guard(request: Request, tenant: ShopTenant): + if request.method in ("GET", "HEAD", "OPTIONS"): + yield + return + async with legacy_writer(tenant.id): + yield + + +async def legacy_customer_write_guard(request: Request, customer: CurrentCustomer): + if request.method in ("GET", "HEAD", "OPTIONS"): + yield + return + async with legacy_writer(customer.tenant_id): + yield + + +async def legacy_refresh_write_guard(data: CustomerRefreshToken): + token = decode_customer_token(data.refresh_token) + # The existing endpoint validates token type/account. No unverified tenant + # or caller-supplied hostname can bypass the writer's signed token identity. + async with legacy_writer(token.tenant_id if token else None): + yield + + +async def transition_owner(expected_revision: int, target: str, event_id: str, release_id: str): + """Operator-only CAS transition, draining all legacy requests before a fence. + + No HTTP route exposes this function. The promotion controller must separately + close Woo checkout and reconcile payments before moving ownership back. + """ + import json + import re + from datetime import UTC, datetime + + if target not in {"legacy", "fenced", "woocommerce"} or expected_revision < 0: + raise ValueError("Invalid handover state") + UUID(event_id) + if not re.fullmatch(r"[a-f0-9]{40}|[a-f0-9]{64}", release_id): + raise ValueError("Versioned release identity required") + async with handover_engine().connect() as connection: + locked = False + try: + await connection.execute(text("SET lock_timeout='120s'")) + await connection.execute(text("SELECT pg_advisory_lock(:key)"), {"key": LOCK}) + locked = True + row = ( + ( + await connection.execute( + text( + "SELECT owner,revision,audit FROM commerce_handover WHERE tenant_id=:tenant FOR UPDATE" + ), + {"tenant": TENANT}, + ) + ) + .mappings() + .one() + ) + for entry in row["audit"]: + if entry["event_id"] == event_id: + if ( + entry["to"] != target + or entry["release_id"] != release_id + or entry["revision"] != expected_revision + 1 + ): + raise ValueError("Handover event identity conflict") + return {**entry, "duplicate": True} + if row["revision"] != expected_revision: + raise ValueError("Handover revision changed; re-read state") + if (row["owner"], target) not in { + ("legacy", "fenced"), + ("fenced", "legacy"), + ("fenced", "woocommerce"), + ("woocommerce", "fenced"), + }: + raise ValueError("Ownership must pass through the closed fence") + entry = { + "event_id": event_id, + "from": row["owner"], + "to": target, + "revision": expected_revision + 1, + "release_id": release_id, + "at": datetime.now(UTC).isoformat(), + } + await connection.execute( + text( + "UPDATE commerce_handover SET owner=:owner,revision=:revision,updated_at=now(),audit=audit || CAST(:audit AS jsonb) WHERE tenant_id=:tenant" + ), + { + "owner": target, + "revision": expected_revision + 1, + "audit": json.dumps([entry]), + "tenant": TENANT, + }, + ) + await connection.commit() + return {**entry, "duplicate": False} + finally: + if locked: + try: + await connection.rollback() + await connection.execute(text("SELECT pg_advisory_unlock(:key)"), {"key": LOCK}) + await connection.commit() + except BaseException: + await connection.invalidate() + raise + + +async def legacy_product_write_guard( + request: Request, db: Annotated[AsyncSession, Depends(get_db)] +): + """Review endpoints resolve ownership from the product, not a new header.""" + if os.environ.get("COMMERCE_LEGACY_HANDOVER_ENABLED") != "true": + yield + return + from sqlalchemy import select + + from app.models.product import Product + + try: + product = UUID(request.path_params["product_id"]) + except (KeyError, ValueError): + yield + return + tenant = await db.scalar(select(Product.tenant_id).where(Product.id == product)) + async with legacy_writer(tenant): + yield diff --git a/backend/app/services/commerce_job_ownership.py b/backend/app/services/commerce_job_ownership.py new file mode 100644 index 00000000..73db5dae --- /dev/null +++ b/backend/app/services/commerce_job_ownership.py @@ -0,0 +1,124 @@ +"""Preserve commerce manufacturing identity and check payment before new work. + +Job UUIDs in the durable commerce effects ledger are authoritative. References +only detect missing mappings; removing a reference cannot relinquish ownership. +Already started/completed work remains physical history when a refund arrives. +""" + +import json +import os + +from fastapi import HTTPException +from sqlalchemy import text + +from app.commerce_runtime import CommerceRuntime + + +async def commerce_job_mapping(db, job): + if db.bind.dialect.name == "postgresql": + exists = await db.scalar(text("SELECT to_regclass('public.commerce_orders')")) + if exists: + rows = ( + ( + await db.execute( + text( + "SELECT c.woo_order_id, c.state, c.snapshot, c.effects, o.payment_status " + "FROM commerce_orders c JOIN orders o ON o.id=c.batchivo_order_id::uuid " + "WHERE o.tenant_id=:tenant AND c.effects @> CAST(:identity AS jsonb)" + ), + { + "tenant": job.tenant_id, + "identity": json.dumps({"jobs": [{"id": str(job.id)}]}), + }, + ) + ) + .mappings() + .all() + ) + if len(rows) > 1: + raise HTTPException(409, "Commerce manufacturing ownership requires reconciliation") + if rows: + return dict(rows[0]) + if (job.reference or "").startswith("WOO-"): + raise HTTPException(409, "Commerce manufacturing mapping requires reconciliation") + return None + + +async def protect_commerce_job_identity(db, job, changes=None, deleting=False): + mapping = await commerce_job_mapping(db, job) + if mapping is not None and ( + deleting + or set(changes or {}) & {"reference", "notes", "product_id", "quantity", "model_id"} + ): + raise HTTPException( + 409, + "Commerce job identity and variant details are preserved; reconcile through the commerce workflow", + ) + + +async def verify_commerce_job_work(db, job, starting=False): + mapping = await commerce_job_mapping(db, job) + if mapping is None: + return False + try: + runtime = CommerceRuntime.from_environment(os.environ) + except RuntimeError: + raise HTTPException(409, "Commerce manufacturing requires an activated release") from None + from app.commerce_bridge import TENANT, Event, reservation_order_lock, verify_payment + + if job.tenant_id != TENANT: + raise HTTPException(404, "Job not found") + # Same order lock as refunds/dispatch, then refresh the job under a row lock. + await reservation_order_lock(db, mapping["woo_order_id"]) + await db.refresh(job, with_for_update=True) + mapping = await commerce_job_mapping(db, job) + if mapping is None: + raise HTTPException(409, "Commerce manufacturing mapping changed") + from app.models.print_job import JobStatus + + if mapping["state"] not in ("processing", "completed") or mapping["payment_status"] not in ( + "COMPLETED", + "PARTIALLY_REFUNDED", + ): + raise HTTPException(409, "Commerce payment or refund state prevents new manufacturing") + allowed = ( + (JobStatus.QUEUED,) if starting else (JobStatus.PENDING, JobStatus.QUEUED, JobStatus.FAILED) + ) + if job.status not in allowed: + raise HTTPException(409, "Commerce job cannot be restarted from its current state") + identities = [ + entry for entry in mapping["effects"].get("jobs", []) if entry["id"] == str(job.id) + ] + if len(identities) != 1: + raise HTTPException(409, "Commerce manufacturing identity requires reconciliation") + identity = identities[0] + lines = [ + line + for line in mapping["snapshot"]["lines"] + if str(line["line_id"]) == str(identity["line_id"]) + ] + if len(lines) != 1: + raise HTTPException(409, "Commerce manufacturing line requires reconciliation") + line = lines[0] + from app.commerce_disposition import refund_release_valid, remaining_quantity + + remaining = remaining_quantity(mapping["effects"], line) + if remaining < 1: + raise HTTPException(409, "This manufacturing line was cancelled") + if ( + str(job.product_id) != line["product_id"] + or job.quantity != remaining + or job.reference != runtime.job_reference(mapping["woo_order_id"], line["line_id"]) + ): + raise HTTPException( + 409, "Commerce manufacturing product or quantity requires reconciliation" + ) + import httpx + + try: + payment = await verify_payment(Event.model_validate(mapping["snapshot"])) + except (httpx.HTTPError, ValueError, KeyError): + raise HTTPException(503, "Payment verification unavailable; manufacturing held") from None + if not refund_release_valid(mapping["effects"], payment): + raise HTTPException(409, "Square refund activity requires review before new manufacturing") + return True diff --git a/backend/app/services/commerce_order_ownership.py b/backend/app/services/commerce_order_ownership.py new file mode 100644 index 00000000..d6e2fe4a --- /dev/null +++ b/backend/app/services/commerce_order_ownership.py @@ -0,0 +1,81 @@ +"""Fence WooCommerce-owned effects and route native dispatch through its durable writer. + +The registry is authoritative; order-number prefixes only detect missing mappings. +Legacy orders keep their existing behavior. Production activation remains a separate +release with explicit database, provider and version identity checks. +""" + +import hashlib +import json +import os + +from app.commerce_runtime import CommerceRuntime +from uuid import NAMESPACE_URL, uuid5 + +from fastapi import HTTPException +from sqlalchemy import text + + +async def commerce_order_id(db, order): + # SQLite is used by legacy tests; the commerce integration requires PostgreSQL. + dialect = db.bind.dialect.name + if dialect != "postgresql": + if (order.order_number or "").startswith("WOO-"): + raise HTTPException(409, "WooCommerce order mapping unavailable") + return None + exists = await db.scalar(text("SELECT to_regclass('public.commerce_orders')")) + if exists: + value = await db.scalar( + text("SELECT woo_order_id FROM commerce_orders WHERE batchivo_order_id=:id"), + {"id": str(order.id)}, + ) + if value is not None: + return int(value) + if (order.order_number or "").startswith("WOO-"): + raise HTTPException(409, "WooCommerce order mapping requires reconciliation") + return None + + +async def reject_native_commerce_mutation(db, order): + if await commerce_order_id(db, order) is not None: + raise HTTPException( + 409, + "This WooCommerce order uses the commerce workflow. Manage refunds, " + "cancellations and customer emails in WooCommerce; use Ship/Deliver here " + "for dispatch. Stock must not be deducted or restored again.", + ) + + +async def dispatch_native_commerce(db, order, tenant, state, tracking_number="", tracking_url=""): + woo_id = await commerce_order_id(db, order) + if woo_id is None: + return None + if order.tenant_id != tenant.id: + raise HTTPException(404, "Order not found") + try: + CommerceRuntime.from_environment(os.environ) + except RuntimeError: + raise HTTPException( + 409, "WooCommerce dispatch adapter requires an activated release" + ) from None + from app.commerce_bridge import DispatchCommand, TENANT, record_dispatch + + if tenant.id != TENANT: + raise HTTPException(404, "Order not found") + try: + command = DispatchCommand( + event_id=uuid5( + NAMESPACE_URL, f"mystmere:{tenant.id}:woocommerce:{woo_id}:fulfilment:{state}:v1" + ), + order_id=woo_id, + state=state, + tracking_number=tracking_number or "", + tracking_url=tracking_url or "", + ) + except ValueError: + raise HTTPException(422, "Invalid commerce dispatch details") + raw = json.dumps( + command.model_dump(mode="json"), sort_keys=True, separators=(",", ":") + ).encode() + result = await record_dispatch(command, hashlib.sha256(raw).hexdigest()) + return {"message": f"Order {order.order_number}: {state} recorded", "commerce": result} diff --git a/backend/app/services/commerce_sales_channel.py b/backend/app/services/commerce_sales_channel.py new file mode 100644 index 00000000..d208118d --- /dev/null +++ b/backend/app/services/commerce_sales_channel.py @@ -0,0 +1,29 @@ +"""Explicit storefront reporting channel; never choose an arbitrary active row.""" + +from uuid import UUID + +from fastapi import HTTPException +from sqlalchemy import select + +from app.models.sales_channel import SalesChannel + + +async def commerce_sales_channel(db, tenant_id, environment, isolated): + configured = environment.get("COMMERCE_SALES_CHANNEL_ID") + if not configured and isolated: + return None # Compatibility for historical isolated fixtures only. + try: + channel_id = UUID(configured or "") + except ValueError: + raise HTTPException(503, "Commerce sales channel configuration is required") from None + channel = await db.scalar( + select(SalesChannel).where( + SalesChannel.id == channel_id, + SalesChannel.tenant_id == tenant_id, + SalesChannel.platform_type == "online_shop", + SalesChannel.is_active.is_(True), + ) + ) + if not channel: + raise HTTPException(503, "Commerce sales channel is unavailable") + return channel.id diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index 96703b98..df76d8e9 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -88,7 +88,9 @@ async def _send_email_async( payload["reply_to"] = reply_to async with httpx.AsyncClient(timeout=30.0) as client: - response = await client.post(RESEND_EMAIL_API_URL, json=payload, headers=headers) + response = await client.post( + RESEND_EMAIL_API_URL, json=payload, headers=headers + ) response.raise_for_status() return True except Exception as e: diff --git a/backend/app/services/order_fulfillment.py b/backend/app/services/order_fulfillment.py index 4f1708cb..b4133e39 100644 --- a/backend/app/services/order_fulfillment.py +++ b/backend/app/services/order_fulfillment.py @@ -11,6 +11,7 @@ from app.models.order import Order from app.models.product import Product from app.models.tenant import Tenant +from app.services.commerce_order_ownership import reject_native_commerce_mutation logger = logging.getLogger(__name__) @@ -59,6 +60,8 @@ async def validate_inventory(self, order: Order) -> FulfillmentResult: Returns: FulfillmentResult with validation status """ + await reject_native_commerce_mutation(self.db, order) + items_with_product = [item for item in order.items if item.product_id] product_ids = [item.product_id for item in items_with_product] @@ -128,6 +131,8 @@ async def deduct_inventory(self, order: Order) -> FulfillmentResult: Returns: FulfillmentResult with deduction status """ + await reject_native_commerce_mutation(self.db, order) + insufficient_items = [] deducted_items = [] @@ -227,6 +232,8 @@ async def revert_inventory(self, order: Order) -> FulfillmentResult: Returns: FulfillmentResult with revert status """ + await reject_native_commerce_mutation(self.db, order) + if not order.fulfilled_at: return FulfillmentResult( success=True, diff --git a/backend/app/services/print_queue_service.py b/backend/app/services/print_queue_service.py index e2d4a258..02df8984 100644 --- a/backend/app/services/print_queue_service.py +++ b/backend/app/services/print_queue_service.py @@ -6,7 +6,8 @@ from typing import Optional from uuid import UUID -from sqlalchemy import select, func +from fastapi import HTTPException +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -24,6 +25,10 @@ PrintJobUpdate, QueueOverview, ) +from app.services.commerce_job_ownership import ( + protect_commerce_job_identity, + verify_commerce_job_work, +) logger = logging.getLogger(__name__) @@ -185,6 +190,7 @@ async def update_job( return None update_data = data.model_dump(exclude_unset=True) + await protect_commerce_job_identity(self.db, job, update_data) for field, value in update_data.items(): setattr(job, field, value) @@ -239,6 +245,7 @@ async def delete_job(self, job_id: UUID) -> bool: logger.warning(f"Cannot delete active job {job_id}") return False + await protect_commerce_job_identity(self.db, job, deleting=True) await self.db.delete(job) await self.db.commit() @@ -266,6 +273,8 @@ async def assign_to_printer( if not job: return None + await verify_commerce_job_work(self.db, job) + # Verify printer exists and is active printer = await self._get_printer(printer_id) if not printer or not printer.is_active: @@ -311,6 +320,13 @@ async def auto_assign_jobs(self) -> AutoAssignResult: matching_printer = await self._find_matching_printer(job, idle_printers) if matching_printer: + try: + await verify_commerce_job_work(self.db, job) + except HTTPException: + unassigned_reasons.append( + f"Job {job.id}: Commerce payment/identity review prevents assignment" + ) + continue job.assigned_printer_id = matching_printer.id job.status = JobStatus.QUEUED @@ -476,6 +492,8 @@ async def start_printing(self, job_id: UUID) -> Optional[PrintJob]: if not job or job.status != JobStatus.QUEUED: return None + await verify_commerce_job_work(self.db, job, starting=True) + job.status = JobStatus.PRINTING job.started_at = datetime.now(timezone.utc) diff --git a/backend/app/services/square_webhook_service.py b/backend/app/services/square_webhook_service.py index f2859e29..50005d70 100644 --- a/backend/app/services/square_webhook_service.py +++ b/backend/app/services/square_webhook_service.py @@ -21,6 +21,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.models.order import Order, OrderStatus +from app.services.commerce_order_ownership import commerce_order_id from app.models.payment_log import PaymentLog, PaymentLogOperation, PaymentLogStatus from app.models.webhook_event import ( WebhookDeadLetter, @@ -67,8 +68,8 @@ async def verify_signature( True if signature is valid, False otherwise """ if not webhook_key: - logger.warning("Webhook signature key not configured, skipping validation") - return True + logger.warning("Webhook signature key not configured; rejecting notification") + return False if not signature: logger.warning("Missing webhook signature header") @@ -103,6 +104,9 @@ async def process_webhook( Returns: dict with processing status and details """ + if not signature_valid: + return {"status": "rejected", "message": "Webhook signature validation required"} + event_type = event_data.get("type", "unknown") event_id = event_data.get("event_id") or self._generate_event_id(event_data) @@ -236,7 +240,34 @@ async def _process_event( result = {"event_type": event_type, "actions": []} - # Route to appropriate handler + # Existing Square subscriptions also receive events for new Woo purchases. + # Acknowledge and audit them without becoming a second writable order master. + payment = event_object.get("payment", {}) + refund = event_object.get("refund", {}) + identities = { + value + for value in [payment.get("id"), payment.get("order_id"), refund.get("payment_id")] + if value + } + if identities: + orders = ( + await self.db.scalars(select(Order).where(Order.payment_id.in_(identities))) + ).all() + owned = [(order, await commerce_order_id(self.db, order)) for order in orders] + commerce = [(order, woo_id) for order, woo_id in owned if woo_id is not None] + if commerce: + if len(orders) != 1: + raise RuntimeError("Payment identity has ambiguous order ownership") + order, woo_id = commerce[0] + webhook_event.order_id = order.id + result["order_owner"] = "woocommerce" + result["woo_order_id"] = woo_id + result["actions"].append( + "Recorded provider event; WooCommerce commerce workflow owns business updates" + ) + return result + + # Route legacy-owned events to their existing handlers. if event_type == "payment.created": await self._handle_payment_created(event_object, webhook_event, result) elif event_type == "payment.updated": diff --git a/backend/poetry.lock b/backend/poetry.lock index f9da845d..52c49383 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.3.4 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.4.3 and should not be changed by hand. [[package]] name = "aiosqlite" @@ -861,75 +861,65 @@ toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "cryptography" -version = "46.0.7" +version = "50.0.1" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false -python-versions = "!=3.9.0,!=3.9.1,>=3.8" -groups = ["main"] -files = [ - {file = "cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b"}, - {file = "cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85"}, - {file = "cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e"}, - {file = "cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457"}, - {file = "cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b"}, - {file = "cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2"}, - {file = "cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e"}, - {file = "cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee"}, - {file = "cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298"}, - {file = "cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb"}, - {file = "cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0"}, - {file = "cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85"}, - {file = "cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e"}, - {file = "cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246"}, - {file = "cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3"}, - {file = "cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f"}, - {file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15"}, - {file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455"}, - {file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65"}, - {file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968"}, - {file = "cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4"}, - {file = "cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5"}, -] - -[package.dependencies] -cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9.0\" and platform_python_implementation != \"PyPy\""} - -[package.extras] -docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"] -docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] -nox = ["nox[uv] (>=2024.4.15)"] -pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"] -sdist = ["build (>=1.0.0)"] +python-versions = "!=3.9.0,!=3.9.1,>=3.9" +groups = ["main"] +files = [ + {file = "cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986"}, + {file = "cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f"}, + {file = "cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef"}, + {file = "cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8"}, + {file = "cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45"}, + {file = "cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad"}, + {file = "cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49"}, + {file = "cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f"}, + {file = "cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527"}, + {file = "cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a"}, + {file = "cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959"}, + {file = "cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b"}, + {file = "cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648"}, + {file = "cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f"}, + {file = "cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a"}, + {file = "cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367"}, + {file = "cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5"}, + {file = "cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9"}, + {file = "cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0"}, + {file = "cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc"}, + {file = "cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17"}, + {file = "cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6"}, + {file = "cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3"}, + {file = "cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6"}, + {file = "cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149"}, + {file = "cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf"}, + {file = "cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0"}, + {file = "cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23"}, + {file = "cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733"}, + {file = "cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88"}, + {file = "cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054"}, + {file = "cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5"}, + {file = "cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361"}, + {file = "cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71"}, + {file = "cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80"}, + {file = "cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239"}, + {file = "cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558"}, + {file = "cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e"}, + {file = "cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2"}, + {file = "cryptography-50.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94"}, + {file = "cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f"}, + {file = "cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671"}, + {file = "cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e"}, + {file = "cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6"}, + {file = "cryptography-50.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b"}, + {file = "cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20"}, +] + +[package.dependencies] +cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy\""} + +[package.extras] ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi (>=2024)", "cryptography-vectors (==46.0.7)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] -test-randomorder = ["pytest-randomly"] [[package]] name = "deprecated" @@ -1047,26 +1037,27 @@ valkey = ["valkey (>=6) ; python_version >= \"3.8\""] [[package]] name = "fastapi" -version = "0.124.4" +version = "0.141.1" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "fastapi-0.124.4-py3-none-any.whl", hash = "sha256:6d1e703698443ccb89e50abe4893f3c84d9d6689c0cf1ca4fad6d3c15cf69f15"}, - {file = "fastapi-0.124.4.tar.gz", hash = "sha256:0e9422e8d6b797515f33f500309f6e1c98ee4e85563ba0f2debb282df6343763"}, + {file = "fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3"}, + {file = "fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1"}, ] [package.dependencies] annotated-doc = ">=0.0.2" -pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" -starlette = ">=0.40.0,<0.51.0" +pydantic = ">=2.9.0" +starlette = ">=0.46.0" typing-extensions = ">=4.8.0" +typing-inspection = ">=0.4.2" [package.extras] -all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] -standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] -standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[standard-no-fastapi-cloud-cli] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] +all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.32)", "httpx (>=0.23.0,<1.0.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "uvicorn[standard] (>=0.12.0)"] +standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.32)", "fastar (>=0.9.0)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] +standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[standard-no-fastapi-cloud-cli] (>=0.0.32)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] [[package]] name = "googleapis-common-protos" @@ -1429,14 +1420,14 @@ files = [ [[package]] name = "joserfc" -version = "1.6.5" +version = "1.7.5" description = "The ultimate Python library for JOSE RFCs, including JWS, JWE, JWK, JWA, JWT" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "joserfc-1.6.5-py3-none-any.whl", hash = "sha256:e9878a0f8243fe7b95e11fdda81374ca9f7a689e302751579d3dfdeec559675e"}, - {file = "joserfc-1.6.5.tar.gz", hash = "sha256:1482a7db78fb4602e44ed89e51b599d052e091288c7c532c5b694e20149dec48"}, + {file = "joserfc-1.7.5-py3-none-any.whl", hash = "sha256:add2c2c84e8373b084d526a8b53daba5d7a513a118cd2dcd9fc9f979d0922159"}, + {file = "joserfc-1.7.5.tar.gz", hash = "sha256:d5ff536e658e17664f8c1b1ab60dc4aa62aa973fcef1edd33cc44bda45d6f5ea"}, ] [package.dependencies] @@ -2077,103 +2068,99 @@ tests = ["pytest (>=9)", "typing-extensions (>=4.15)"] [[package]] name = "pillow" -version = "12.2.0" +version = "12.3.0" description = "Python Imaging Library (fork)" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f"}, - {file = "pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97"}, - {file = "pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff"}, - {file = "pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec"}, - {file = "pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136"}, - {file = "pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c"}, - {file = "pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3"}, - {file = "pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa"}, - {file = "pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032"}, - {file = "pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5"}, - {file = "pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024"}, - {file = "pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab"}, - {file = "pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65"}, - {file = "pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7"}, - {file = "pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e"}, - {file = "pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705"}, - {file = "pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176"}, - {file = "pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b"}, - {file = "pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909"}, - {file = "pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808"}, - {file = "pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60"}, - {file = "pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe"}, - {file = "pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5"}, - {file = "pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421"}, - {file = "pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987"}, - {file = "pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76"}, - {file = "pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005"}, - {file = "pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780"}, - {file = "pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5"}, - {file = "pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5"}, - {file = "pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940"}, - {file = "pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5"}, - {file = "pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414"}, - {file = "pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c"}, - {file = "pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2"}, - {file = "pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c"}, - {file = "pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795"}, - {file = "pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f"}, - {file = "pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed"}, - {file = "pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9"}, - {file = "pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed"}, - {file = "pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3"}, - {file = "pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9"}, - {file = "pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795"}, - {file = "pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e"}, - {file = "pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b"}, - {file = "pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06"}, - {file = "pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b"}, - {file = "pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f"}, - {file = "pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612"}, - {file = "pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c"}, - {file = "pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea"}, - {file = "pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4"}, - {file = "pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4"}, - {file = "pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea"}, - {file = "pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24"}, - {file = "pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98"}, - {file = "pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453"}, - {file = "pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8"}, - {file = "pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b"}, - {file = "pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295"}, - {file = "pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed"}, - {file = "pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae"}, - {file = "pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601"}, - {file = "pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be"}, - {file = "pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f"}, - {file = "pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286"}, - {file = "pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50"}, - {file = "pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104"}, - {file = "pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7"}, - {file = "pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150"}, - {file = "pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1"}, - {file = "pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463"}, - {file = "pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3"}, - {file = "pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166"}, - {file = "pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe"}, - {file = "pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd"}, - {file = "pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e"}, - {file = "pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06"}, - {file = "pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43"}, - {file = "pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354"}, - {file = "pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1"}, - {file = "pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb"}, - {file = "pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f"}, - {file = "pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d"}, - {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f"}, - {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e"}, - {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0"}, - {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1"}, - {file = "pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e"}, - {file = "pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5"}, + {file = "pillow-12.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a"}, + {file = "pillow-12.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7"}, + {file = "pillow-12.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f"}, + {file = "pillow-12.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec"}, + {file = "pillow-12.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468"}, + {file = "pillow-12.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed"}, + {file = "pillow-12.3.0-cp310-cp310-win32.whl", hash = "sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1"}, + {file = "pillow-12.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb"}, + {file = "pillow-12.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f"}, + {file = "pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756"}, + {file = "pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6"}, + {file = "pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd"}, + {file = "pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd"}, + {file = "pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c"}, + {file = "pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5"}, + {file = "pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b"}, + {file = "pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a"}, + {file = "pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26"}, + {file = "pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965"}, + {file = "pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7"}, + {file = "pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9"}, + {file = "pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91"}, + {file = "pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c"}, + {file = "pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df"}, + {file = "pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f"}, + {file = "pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09"}, + {file = "pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510"}, + {file = "pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89"}, + {file = "pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace"}, + {file = "pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec"}, + {file = "pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66"}, + {file = "pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35"}, + {file = "pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65"}, + {file = "pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3"}, + {file = "pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a"}, + {file = "pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e"}, + {file = "pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f"}, + {file = "pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8"}, + {file = "pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b"}, + {file = "pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330"}, + {file = "pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217"}, + {file = "pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930"}, + {file = "pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8"}, + {file = "pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0"}, + {file = "pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321"}, + {file = "pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b"}, + {file = "pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198"}, + {file = "pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130"}, + {file = "pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a"}, + {file = "pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d"}, + {file = "pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838"}, + {file = "pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e"}, + {file = "pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17"}, + {file = "pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385"}, + {file = "pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c"}, + {file = "pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d"}, + {file = "pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931"}, + {file = "pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7"}, + {file = "pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c"}, + {file = "pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45"}, + {file = "pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139"}, + {file = "pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402"}, + {file = "pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c"}, + {file = "pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f"}, + {file = "pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701"}, + {file = "pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace"}, + {file = "pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4"}, + {file = "pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39"}, + {file = "pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71"}, + {file = "pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827"}, + {file = "pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5"}, + {file = "pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658"}, + {file = "pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf"}, + {file = "pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64"}, + {file = "pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e"}, + {file = "pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777"}, + {file = "pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1"}, + {file = "pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9"}, + {file = "pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8"}, + {file = "pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418"}, + {file = "pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59"}, + {file = "pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468"}, + {file = "pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94"}, + {file = "pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e"}, + {file = "pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3"}, + {file = "pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a"}, + {file = "pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce"}, ] [package.extras] @@ -2181,7 +2168,7 @@ docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybut fpx = ["olefile"] mic = ["olefile"] test-arrow = ["arro3-compute", "arro3-core", "nanoarrow", "pyarrow"] -tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma (>=5)", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"] +tests = ["coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "setuptools", "trove-classifiers (>=2024.10.12)"] xmp = ["defusedxml"] [[package]] @@ -2607,21 +2594,18 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pyjwt" -version = "2.12.1" +version = "2.13.0" description = "JSON Web Token implementation in Python" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c"}, - {file = "pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b"}, + {file = "pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728"}, + {file = "pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423"}, ] [package.extras] crypto = ["cryptography (>=3.4.0)"] -dev = ["coverage[toml] (==7.10.7)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=8.4.2,<9.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] -docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] -tests = ["coverage[toml] (==7.10.7)", "pytest (>=8.4.2,<9.0.0)"] [[package]] name = "pypng" @@ -2770,14 +2754,14 @@ cli = ["click (>=5.0)"] [[package]] name = "python-multipart" -version = "0.0.29" +version = "0.0.32" description = "A streaming multipart parser for Python" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "python_multipart-0.0.29-py3-none-any.whl", hash = "sha256:2ddcc971cef266225f54f552d8fa10bcfbb1f14446caec199060daac59ff2d69"}, - {file = "python_multipart-0.0.29.tar.gz", hash = "sha256:643e93849196645e2dbdd81a0f8829a23123ad7f797a84a364c6fb3563f18904"}, + {file = "python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23"}, + {file = "python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e"}, ] [[package]] @@ -3320,14 +3304,14 @@ typing_extensions = ">=4.0.0" [[package]] name = "starlette" -version = "0.49.3" +version = "1.6.0" description = "The little ASGI library that shines." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "starlette-0.49.3-py3-none-any.whl", hash = "sha256:b579b99715fdc2980cf88c8ec96d3bf1ce16f5a8051a7c2b84ef9b1cdecaea2f"}, - {file = "starlette-0.49.3.tar.gz", hash = "sha256:1c14546f299b5901a1ea0e34410575bc33bbd741377a10484a54445588d00284"}, + {file = "starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c"}, + {file = "starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b"}, ] [package.dependencies] @@ -3335,7 +3319,7 @@ anyio = ">=3.6.2,<5" typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""} [package.extras] -full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] +full = ["httpx (>=0.27.0,<0.29.0)", "httpx2 (>=2.0.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] [[package]] name = "typing-extensions" @@ -3861,4 +3845,4 @@ dev = ["black", "fakeredis", "httpx", "moto", "mypy", "pygments", "pytest", "pyt [metadata] lock-version = "2.1" python-versions = ">=3.12,<4.0" -content-hash = "ef0aa676d9c44feca5433e15094abb55db22791c1ff4036e0b14018350fc3194" +content-hash = "7d81cbc08fb9f97e0f0d790375a842821e78117cc97bc8bf4e3801d4ed80ec30" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 124d2f2e..0d326231 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -35,7 +35,7 @@ dependencies = [ "pyyaml>=6.0.0", "squareup>=42.0.0", "slowapi>=0.1.9", - "starlette (==0.49.3)", + "starlette>=1.3.1,<2", "boto3>=1.35.0", "paho-mqtt>=2.1.0", "dnspython>=2.7.0", @@ -80,6 +80,8 @@ line-length = 100 target-version = "py311" [tool.ruff.lint] +# Keep the established rule set explicit across Ruff default changes. +select = ["E4", "E7", "E9", "F"] # F821: Undefined name - SQLAlchemy uses string annotations for relationships # E402: Module level import not at top - intentional for conditional imports ignore = ["F821", "E402"] diff --git a/backend/scripts/backfill_commerce_job_identities.py b/backend/scripts/backfill_commerce_job_identities.py new file mode 100644 index 00000000..c7e7ef88 --- /dev/null +++ b/backend/scripts/backfill_commerce_job_identities.py @@ -0,0 +1,94 @@ +"""Validate and attach stable job UUIDs to existing isolated commerce effects. +No stock/order/job status changes. One transaction; dry run rolls back. +""" + +import asyncio +import json +import sys +from uuid import UUID + +from sqlalchemy import select +from app.commerce_bridge import ( + CommerceOrder, + PrintJob, + RUNTIME, + TENANT, + async_session_maker, + reservation_order_lock, +) + + +async def main(): + if not RUNTIME.isolated: + raise RuntimeError("Only isolated test mappings may be backfilled by this entry point") + counts = {"orders": 0, "jobs": 0, "changed": 0, "applied": "--apply" in sys.argv} + async with async_session_maker() as db: + async with db.begin(): + ids = ( + await db.scalars( + select(CommerceOrder.woo_order_id).order_by(CommerceOrder.woo_order_id) + ) + ).all() + for order_id in ids: + await reservation_order_lock(db, order_id) + mapping = await db.scalar( + select(CommerceOrder) + .where(CommerceOrder.woo_order_id == order_id) + .with_for_update() + ) + finite = { + (s["product_id"], s["variant_id"]) for s in mapping.effects.get("stock", []) + } + identities = [] + for line in mapping.snapshot["lines"]: + if (line["product_id"], line.get("variant_id")) in finite: + continue + jobs = ( + await db.scalars( + select(PrintJob) + .where( + PrintJob.tenant_id == TENANT, + PrintJob.reference + == RUNTIME.job_reference(order_id, line["line_id"]), + ) + .with_for_update() + ) + ).all() + if len(jobs) != 1: + raise RuntimeError( + "Expected exactly one commerce job for order " + str(order_id) + ) + job = jobs[0] + if ( + job.product_id != UUID(line["product_id"]) + or job.quantity != line["quantity"] + ): + raise RuntimeError( + "Job product/quantity mismatch for order " + str(order_id) + ) + notes = json.loads(job.notes or "{}") + if ( + notes.get("variant_id") != line.get("variant_id") + or notes.get("sku") != line["sku"] + or notes.get("option") != line["option"] + ): + raise RuntimeError( + "Job option identity mismatch for order " + str(order_id) + ) + identities.append({"id": str(job.id), "line_id": line["line_id"]}) + identities.sort(key=lambda x: str(x["line_id"])) + previous = sorted(mapping.effects.get("jobs", []), key=lambda x: str(x["line_id"])) + if previous and previous != identities: + raise RuntimeError("Existing UUID mapping differs for order " + str(order_id)) + counts["orders"] += 1 + counts["jobs"] += len(identities) + if "jobs" not in mapping.effects: + mapping.effects = {**mapping.effects, "jobs": identities} + counts["changed"] += 1 + if "--apply" not in sys.argv: + await db.rollback() + print(json.dumps(counts)) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/scripts/test_commerce_manufacturing.py b/backend/scripts/test_commerce_manufacturing.py new file mode 100644 index 00000000..5f099013 --- /dev/null +++ b/backend/scripts/test_commerce_manufacturing.py @@ -0,0 +1,203 @@ +"""Actual isolated Postgres/native service and Square readback acceptance. +All temporary stale-state/queued-state fixtures roll back. No physical printing. +""" + +import asyncio +import hashlib +import json +from uuid import UUID, uuid4 +from unittest.mock import AsyncMock, patch +from types import SimpleNamespace + +import httpx +from fastapi import HTTPException +from sqlalchemy import select + +from app.commerce_bridge import CommerceOrder, Order, PrintJob, RUNTIME, TENANT, async_session_maker +from app.models.print_job import JobStatus +from app.models.tenant import Tenant +from app.services.commerce_job_ownership import ( + commerce_job_mapping, + protect_commerce_job_identity, + verify_commerce_job_work, +) +from app.services.print_queue_service import PrintQueueService + +checks = [] + + +def check(name, condition): + checks.append({"check": name, "pass": bool(condition)}) + if not condition: + raise AssertionError(name) + + +async def state(): + async with async_session_maker() as db: + m = await db.scalar(select(CommerceOrder).where(CommerceOrder.woo_order_id == 580)) + order = await db.get(Order, UUID(m.batchivo_order_id)) + jobs = ( + await db.scalars( + select(PrintJob).where( + PrintJob.tenant_id == TENANT, PrintJob.reference.like("WOO-TEST-580:%") + ) + ) + ).all() + return hashlib.sha256( + json.dumps( + { + "snapshot": m.snapshot, + "effects": m.effects, + "state": m.state, + "order_state": order.status, + "payment_state": order.payment_status, + "jobs": [ + ( + str(j.id), + j.status.value, + j.reference, + j.notes, + str(j.assigned_printer_id), + str(j.started_at), + ) + for j in jobs + ], + }, + sort_keys=True, + ).encode() + ).hexdigest() + + +async def expect_rejection(name, callback, code, contains): + try: + await callback() + except HTTPException as e: + check(name, e.status_code == code and contains in str(e.detail)) + return + raise AssertionError(name + " was allowed") + + +async def main(): + if not RUNTIME.isolated: + raise RuntimeError("Only isolated acceptance") + baseline = await state() + for case in [ + "refunded-start", + "refunded-assign", + "delete", + "rename", + "provider-refund", + "provider-outage", + "legacy", + ]: + async with async_session_maker() as db: + await db.begin() + try: + m = await db.scalar(select(CommerceOrder).where(CommerceOrder.woo_order_id == 580)) + order = await db.get(Order, UUID(m.batchivo_order_id)) + check( + case + " exact synthetic fixture", + order.customer_email == "telegram-notification-20260909@example.invalid" + and order.tenant_id == TENANT, + ) + job = await db.get(PrintJob, UUID(m.effects["jobs"][0]["id"])) + tenant = await db.get(Tenant, TENANT) + service = PrintQueueService(db, tenant) + # Any unexpected native commit is only a flush in this acceptance + # transaction, so the finally rollback preserves the fixture. + db.commit = db.flush + if case == "refunded-start": + job.status = JobStatus.QUEUED + await db.flush() + await expect_rejection( + "native start blocks local refund", + lambda: service.start_printing(job.id), + 409, + "payment or refund", + ) + elif case == "refunded-assign": + await expect_rejection( + "native assignment blocks refunded job", + lambda: service.assign_to_printer(job.id, uuid4()), + 409, + "payment or refund", + ) + elif case == "delete": + await expect_rejection( + "native delete preserves commerce history", + lambda: service.delete_job(job.id), + 409, + "identity", + ) + elif case == "rename": + job.reference = "renamed-inside-rollback" + await db.flush() + check( + "UUID retains ownership after reference changes", + (await commerce_job_mapping(db, job))["woo_order_id"] == 580, + ) + await expect_rejection( + "mapped identity edits rejected", + lambda: protect_commerce_job_identity(db, job, {"reference": "changed"}), + 409, + "identity", + ) + elif case in ["provider-refund", "provider-outage"]: + # Emulate delayed Woo/Batchivo notification processing. Square + # remains authoritative and has a real completed sandbox refund. + m.state = "processing" + m.snapshot = { + **m.snapshot, + "state": "processing", + "refunded_pence": 0, + "refund_ids": [], + } + order.payment_status = "COMPLETED" + job.status = JobStatus.QUEUED + await db.flush() + if case == "provider-refund": + await expect_rejection( + "real Square refund blocks start despite stale local state", + lambda: service.start_printing(job.id), + 409, + "Square refund activity", + ) + else: + with patch( + "app.commerce_bridge.verify_payment", + AsyncMock(side_effect=httpx.ConnectError("controlled outage")), + ): + await expect_rejection( + "provider transport outage holds native start", + lambda: service.start_printing(job.id), + 503, + "manufacturing held", + ) + check( + case + " job remained queued", + job.status == JobStatus.QUEUED and job.started_at is None, + ) + else: + legacy = SimpleNamespace( + id=uuid4(), tenant_id=TENANT, reference="MANUAL-ACCEPTANCE" + ) + check( + "unmapped manual jobs retain native ownership", + await verify_commerce_job_work(db, legacy) is False, + ) + finally: + await db.rollback() + check("fixture state unchanged after every rollback", baseline == await state()) + print( + json.dumps( + { + "checks": checks, + "failed": 0, + "scope": "Actual deployed native service, PostgreSQL ownership mappings and real Square sandbox refunded payment580. Delayed local state and provider transport exception are controlled rollback fixtures. No real manufacturing or customer effects.", + } + ) + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/scripts/test_commerce_paid_manufacturing.py b/backend/scripts/test_commerce_paid_manufacturing.py new file mode 100644 index 00000000..89a9769b --- /dev/null +++ b/backend/scripts/test_commerce_paid_manufacturing.py @@ -0,0 +1,68 @@ +"""Real paid sandbox582/native start, entirely inside a rolled-back transaction.""" + +import asyncio +import json +from uuid import UUID +from sqlalchemy import select +from app.commerce_bridge import CommerceOrder, Order, PrintJob, RUNTIME, TENANT, async_session_maker +from app.models.print_job import JobStatus +from app.models.tenant import Tenant +from app.services.commerce_job_ownership import verify_commerce_job_work +from app.services.print_queue_service import PrintQueueService + + +async def main(): + if not RUNTIME.isolated: + raise RuntimeError("Isolated test only") + async with async_session_maker() as db: + await db.begin() + try: + mapping = await db.scalar( + select(CommerceOrder).where(CommerceOrder.woo_order_id == 582) + ) + order = await db.get(Order, UUID(mapping.batchivo_order_id)) + assert mapping.state == "processing" and order.payment_status == "COMPLETED" + assert ( + order.customer_email == "external-refund-20260909@example.invalid" + and order.tenant_id == TENANT + ) + assert len(mapping.effects["jobs"]) == 1 + job = await db.get(PrintJob, UUID(mapping.effects["jobs"][0]["id"])) + assert ( + job.status == JobStatus.PENDING + and job.started_at is None + and job.assigned_printer_id is None + ) + assert await verify_commerce_job_work(db, job) is True + job.status = JobStatus.QUEUED + await db.flush() + db.commit = db.flush + service = PrintQueueService(db, await db.get(Tenant, TENANT)) + started = await service.start_printing(job.id) + assert started.status == JobStatus.PRINTING and started.started_at is not None + job_id = job.id + finally: + await db.rollback() + async with async_session_maker() as db: + job = await db.get(PrintJob, job_id) + assert ( + job.status == JobStatus.PENDING + and job.started_at is None + and job.assigned_printer_id is None + ) + print( + json.dumps( + { + "order_id": 582, + "new_job_uuid_mapping_present": True, + "real_square_paid_verification": True, + "native_start_allowed_inside_rollback": True, + "persistent_job_remained_pending": True, + "scope": "Real deployed service, Postgres and Square sandbox GET; only temporary transactional queue/start state, rolled back before other sessions could observe it. No printer assigned or physical manufacturing triggered.", + } + ) + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/scripts/test_commerce_restricted_paid.py b/backend/scripts/test_commerce_restricted_paid.py new file mode 100644 index 00000000..478fe6f3 --- /dev/null +++ b/backend/scripts/test_commerce_restricted_paid.py @@ -0,0 +1,68 @@ +"""Real paid sandbox601/native start, entirely inside a rolled-back transaction.""" + +import asyncio +import json +from uuid import UUID +from sqlalchemy import select +from app.commerce_bridge import CommerceOrder, Order, PrintJob, RUNTIME, TENANT, async_session_maker +from app.models.print_job import JobStatus +from app.models.tenant import Tenant +from app.services.commerce_job_ownership import verify_commerce_job_work +from app.services.print_queue_service import PrintQueueService + + +async def main(): + if not RUNTIME.isolated: + raise RuntimeError("Isolated test only") + async with async_session_maker() as db: + await db.begin() + try: + mapping = await db.scalar( + select(CommerceOrder).where(CommerceOrder.woo_order_id == 601) + ) + order = await db.get(Order, UUID(mapping.batchivo_order_id)) + assert mapping.state == "processing" and order.payment_status == "COMPLETED" + assert ( + order.customer_email == "restricted-role-20260909@example.invalid" + and order.tenant_id == TENANT + ) + assert len(mapping.effects["jobs"]) == 1 + job = await db.get(PrintJob, UUID(mapping.effects["jobs"][0]["id"])) + assert ( + job.status == JobStatus.PENDING + and job.started_at is None + and job.assigned_printer_id is None + ) + assert await verify_commerce_job_work(db, job) is True + job.status = JobStatus.QUEUED + await db.flush() + db.commit = db.flush + service = PrintQueueService(db, await db.get(Tenant, TENANT)) + started = await service.start_printing(job.id) + assert started.status == JobStatus.PRINTING and started.started_at is not None + job_id = job.id + finally: + await db.rollback() + async with async_session_maker() as db: + job = await db.get(PrintJob, job_id) + assert ( + job.status == JobStatus.PENDING + and job.started_at is None + and job.assigned_printer_id is None + ) + print( + json.dumps( + { + "order_id": 601, + "new_job_uuid_mapping_present": True, + "real_square_paid_verification": True, + "native_start_allowed_inside_rollback": True, + "persistent_job_remained_pending": True, + "scope": "Real deployed service, Postgres and Square sandbox GET; only temporary transactional queue/start state, rolled back before other sessions could observe it. No printer assigned or physical manufacturing triggered.", + } + ) + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/scripts/test_commerce_role_runtime.py b/backend/scripts/test_commerce_role_runtime.py new file mode 100644 index 00000000..ed695043 --- /dev/null +++ b/backend/scripts/test_commerce_role_runtime.py @@ -0,0 +1,69 @@ +"""Read-only live credential separation and reporting-channel acceptance.""" + +import asyncio +import json +import os +from uuid import UUID +from fastapi import HTTPException +from sqlalchemy import text +from app.commerce_bridge import async_session_maker, native_engine, TENANT +from app.services.commerce_database import verify_commerce_role +from app.services.commerce_sales_channel import commerce_sales_channel + + +async def main(): + checks = [] + + def check(name, value): + checks.append({"check": name, "pass": bool(value)}) + if not value: + raise AssertionError(name) + + async with async_session_maker() as db: + identity = (await db.execute(text("SELECT current_user,session_user"))).one() + check( + "actual commerce connection authenticates as dedicated role", + tuple(identity) == ("mf_commerce_test", "mf_commerce_test"), + ) + channel = await commerce_sales_channel(db, TENANT, os.environ, True) + check( + "new paid order assigned to configured storefront channel", + await db.scalar( + text("SELECT sales_channel_id FROM orders WHERE order_number='WOO-TEST-601'") + ) + == channel, + ) + for name, config, tenant in [ + ("missing production setting", {}, TENANT), + ("invalid identifier", {"COMMERCE_SALES_CHANNEL_ID": "invalid"}, TENANT), + ("other tenant", os.environ, UUID("00000000-0000-0000-0000-000000000001")), + ]: + rejected = False + try: + await commerce_sales_channel(db, tenant, config, False) + except HTTPException as error: + rejected = error.status_code == 503 + check(name + " rejected", rejected) + async with native_engine.connect() as connection: + rejected = False + try: + await connection.run_sync(verify_commerce_role) + except RuntimeError: + rejected = True + check("native administrative credential rejected as commerce role", rejected) + check( + "native engine retains separate original identity", + await connection.scalar(text("SELECT current_user")) == "commerce", + ) + print( + json.dumps( + { + "checks": checks, + "failed": 0, + "scope": "Read-only actual deployed PostgreSQL identities and order601 channel. No production or financial writes.", + } + ) + ) + + +asyncio.run(main()) diff --git a/backend/tests/api/test_shop_checkout.py b/backend/tests/api/test_shop_checkout.py index 0946a952..e976230c 100644 --- a/backend/tests/api/test_shop_checkout.py +++ b/backend/tests/api/test_shop_checkout.py @@ -634,9 +634,9 @@ async def test_complete_checkout_requires_valid_session( class TestOrderLookup: """Tests for order status lookup.""" - async def test_get_order_not_found(self, client: AsyncClient): + async def test_get_order_not_found(self, shop_client: AsyncClient): """Test looking up non-existent order.""" - response = await client.get( + response = await shop_client.get( "/api/v1/shop/orders/MF-99999999-999", params={"email": "test@example.com"}, ) @@ -644,7 +644,7 @@ async def test_get_order_not_found(self, client: AsyncClient): async def test_get_order_wrong_email( self, - client: AsyncClient, + shop_client: AsyncClient, db_session: AsyncSession, test_tenant: Tenant, sales_channel: SalesChannel, @@ -673,7 +673,7 @@ async def test_get_order_wrong_email( db_session.add(order) await db_session.commit() - response = await client.get( + response = await shop_client.get( "/api/v1/shop/orders/MF-20240101-001", params={"email": "wrong@example.com"}, ) diff --git a/backend/tests/api/test_shop_order_lookup.py b/backend/tests/api/test_shop_order_lookup.py new file mode 100644 index 00000000..c5438e8a --- /dev/null +++ b/backend/tests/api/test_shop_order_lookup.py @@ -0,0 +1,94 @@ +"""Real ORM/HTTP storefront order boundaries, using disposable test data.""" + +from decimal import Decimal +from uuid import uuid4 + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.auth.dependencies import get_shop_sales_channel +from app.database import get_db +from app.main import app +from app.models.order import Order +from app.models.sales_channel import SalesChannel +from app.models.tenant import Tenant + + +@pytest.mark.anyio +@pytest.mark.parametrize( + "case", + ["own", "email_case", "wrong_email", "other_tenant", "other_channel", "no_channel", "missing"], +) +async def test_order_lookup_boundary(db_session, test_tenant, case): + other = Tenant(id=uuid4(), name="Other shop", slug="other-" + uuid4().hex) + channel = SalesChannel( + id=uuid4(), + tenant_id=test_tenant.id, + name="Shop", + platform_type="online_shop", + is_active=True, + ) + foreign = SalesChannel( + id=uuid4(), tenant_id=other.id, name="Other", platform_type="online_shop", is_active=True + ) + second = SalesChannel( + id=uuid4(), tenant_id=test_tenant.id, name="Fair", platform_type="fair", is_active=True + ) + db_session.add(other) + await db_session.flush() + db_session.add_all([channel, foreign, second]) + await db_session.flush() + order = Order( + tenant_id=other.id if case == "other_tenant" else test_tenant.id, + sales_channel_id=foreign.id + if case == "other_tenant" + else second.id + if case == "other_channel" + else None + if case == "no_channel" + else channel.id, + order_number="LOOKUP-" + uuid4().hex, + status="shipped", + customer_name="Synthetic customer", + customer_email="fixture@example.invalid", + shipping_address_line1="Test", + shipping_city="Test", + shipping_postcode="TEST", + shipping_method="Standard", + shipping_cost=Decimal("4.99"), + subtotal=Decimal("9.99"), + total=Decimal("14.98"), + ) + db_session.add(order) + await db_session.commit() + + async def context(): + return test_tenant, channel + + async def session(): + yield db_session + + app.dependency_overrides[get_shop_sales_channel] = context + app.dependency_overrides[get_db] = session + try: + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + result = await client.get( + "/api/v1/shop/orders/" + ("missing" if case == "missing" else order.order_number), + params={ + "email": "wrong@example.invalid" + if case == "wrong_email" + else " FIXTURE@EXAMPLE.INVALID " + if case == "email_case" + else order.customer_email + }, + ) + if case in ["own", "email_case"]: + assert result.status_code == 200, result.text + assert result.json()["data"]["status"] == "shipped" + assert result.headers["cache-control"] == "private, no-store" + assert "shipping_address_line1" not in result.json()["data"] + else: + assert result.status_code == 404, result.text + assert "Synthetic customer" not in result.text + finally: + app.dependency_overrides.clear() diff --git a/backend/tests/commerce/disposition_dispatch_live.py b/backend/tests/commerce/disposition_dispatch_live.py new file mode 100644 index 00000000..ca69c429 --- /dev/null +++ b/backend/tests/commerce/disposition_dispatch_live.py @@ -0,0 +1,142 @@ +"""Real PostgreSQL dispatch transactions, nested under an outer rollback.""" + +import asyncio +import hashlib +import json +from uuid import UUID, uuid4 + +from fastapi import HTTPException +from sqlalchemy import select, text +from sqlalchemy.ext.asyncio import async_sessionmaker + +import app.commerce_bridge as bridge + + +async def main(): + checks = [] + + def check(name, passed): + checks.append({"check": name, "pass": bool(passed)}) + if not passed: + raise AssertionError(name) + + tables = [ + "products", + "product_variants", + "orders", + "order_items", + "print_jobs", + "commerce_orders", + "commerce_receipts", + "commerce_fulfilment_events", + ] + + async def fingerprint(db): + await db.execute( + text("SELECT set_config('app.current_tenant_id', :tenant, true)"), + {"tenant": str(bridge.TENANT)}, + ) + return [ + await db.scalar( + text(f"SELECT md5(string_agg(row_to_json(t)::text,'' ORDER BY id)) FROM {table} t") + ) + for table in tables + ] + + original = bridge.async_session_maker + async with bridge.engine.connect() as connection: + before = await fingerprint(connection) + await connection.rollback() + outer = await connection.begin() + bridge.async_session_maker = async_sessionmaker( + connection, + expire_on_commit=False, + join_transaction_mode="create_savepoint", + sync_session_class=bridge.CommerceSession, + ) + try: + async with bridge.async_session_maker() as db: + mapping = await db.scalar( + select(bridge.CommerceOrder).where(bridge.CommerceOrder.woo_order_id == 605) + ) + order_id = UUID(mapping.batchivo_order_id) + order = await db.get(bridge.Order, order_id) + from app.commerce_disposition import review_in_transaction + from app.services.commerce_job_ownership import verify_commerce_job_work + + mapping, order, jobs, _rows = await review_in_transaction(db, 605) + job = next(iter(jobs.values())) + check( + "Browser decision retained one variant", + job.quantity == 1 and job.status == bridge.JobStatus.PENDING, + ) + check( + "Native manufacturing assignment accepts exact reviewed refund", + await verify_commerce_job_work(db, job), + ) + job.status = bridge.JobStatus.QUEUED + await db.flush() + check( + "Native manufacturing start accepts remaining quantity", + await verify_commerce_job_work(db, job, starting=True), + ) + await db.commit() + command = bridge.DispatchCommand( + event_id=uuid4(), + order_id=605, + state="shipped", + tracking_number="SIMULATED-ROLLBACK-605", + ) + + async def apply(cmd): + raw = json.dumps(cmd.model_dump(mode="json"), sort_keys=True).encode() + return await bridge.record_dispatch(cmd, hashlib.sha256(raw).hexdigest()) + + try: + await apply(command) + except HTTPException as error: + check( + "Unfinished remaining manufacturing blocks dispatch", error.status_code == 409 + ) + else: + check("Unfinished remaining manufacturing blocks dispatch", False) + async with bridge.async_session_maker() as db: + job = await db.get(bridge.PrintJob, job.id) + job.status = bridge.JobStatus.COMPLETED + await db.commit() + check( + "Completed remaining goods can dispatch after partial refund", + (await apply(command))["status"] == "recorded", + ) + check( + "Dispatch exact replay does not duplicate shipment", + (await apply(command))["status"] == "duplicate", + ) + async with bridge.async_session_maker() as db: + mapping = await db.scalar( + select(bridge.CommerceOrder).where(bridge.CommerceOrder.woo_order_id == 605) + ) + order = await db.get(bridge.Order, UUID(mapping.batchivo_order_id)) + check( + "Shipment retains original sale and partial-refund money", + order.payment_status == "PARTIALLY_REFUNDED" + and mapping.snapshot["total_pence"] == 3397 + and mapping.effects["refunded_pence"] == 999 + and order.status == "shipped", + ) + finally: + bridge.async_session_maker = original + await outer.rollback() + check("Outer rollback restores all eight tables", before == await fingerprint(connection)) + print( + json.dumps( + { + "checks": checks, + "failed": sum(not c["pass"] for c in checks), + "scope": "Actual restricted PostgreSQL transactions/savepoints with outer rollback. Actual Square sandbox read-only payment/refund verification; no payment writes or customer messages. Manufacturing completion is simulated only inside rollback; persisted browser decision is real.", + } + ) + ) + + +asyncio.run(main()) diff --git a/backend/tests/commerce/disposition_live.py b/backend/tests/commerce/disposition_live.py new file mode 100644 index 00000000..328b59c0 --- /dev/null +++ b/backend/tests/commerce/disposition_live.py @@ -0,0 +1,202 @@ +"""Real sandbox refund and restricted PostgreSQL decision checks, rolled back.""" + +import asyncio +import copy +import json +from uuid import uuid4 + +from fastapi import HTTPException +from sqlalchemy import text + +from app import commerce_bridge as bridge +from app import commerce_disposition as module +from app.commerce_returns import lines_for_review + + +async def main(): + checks = [] + + def check(name, passed): + checks.append({"check": name, "pass": bool(passed)}) + if not passed: + raise AssertionError(name) + + async def fingerprint(db): + return [ + await db.scalar( + text(f"SELECT md5(string_agg(row_to_json(t)::text,'' ORDER BY id)) FROM {table} t") + ) + for table in [ + "products", + "product_variants", + "orders", + "order_items", + "print_jobs", + "commerce_orders", + "commerce_receipts", + "commerce_fulfilment_events", + ] + ] + + async with bridge.async_session_maker() as db: + before = await fingerprint(db) + try: + mapping, order, jobs, rows = await module.review_in_transaction(db, 605) + check( + "Actual mixed browser order and completed Square partial refund", + mapping.snapshot["total_pence"] == 3397 + and mapping.effects["refunded_pence"] == 999 + and len(rows) == 2 + and len(jobs) == 1, + ) + snapshot = copy.deepcopy(mapping.snapshot) + base = copy.deepcopy(mapping.effects) + job = next(iter(jobs.values())) + pto = next(r for r in rows if r["manufacturing_status"]) + finite = next(r for r in rows if not r["manufacturing_status"]) + product = await db.get( + bridge.Product, + __import__("uuid").UUID( + next( + line for line in snapshot["lines"] if line["line_id"] == finite["line_id"] + )["product_id"] + ), + ) + stock = product.units_in_stock + + def command(cancelled=None): + return module.Command( + order_id=605, + event_id=uuid4(), + revision=module.revision(mapping, order), + cancelled=cancelled or {str(r["line_id"]): 0 for r in rows}, + actor_id=1, + note="Sandbox rollback fulfilment decision evidence", + ) + + async def deny(name, cmd): + try: + await module.apply_in_transaction(db, cmd) + except HTTPException as e: + check(name, e.status_code in (409, 422, 503)) + else: + check(name, False) + + await deny( + "Every sold line must be explicitly reviewed", command({str(pto["line_id"]): 1}) + ) + await deny( + "Cannot cancel more than sold", + command({str(pto["line_id"]): 3, str(finite["line_id"]): 0}), + ) + await deny( + "Cannot cancel all goods through partial financial release", + command({str(pto["line_id"]): 2, str(finite["line_id"]): 1}), + ) + job.status = bridge.JobStatus.PRINTING + await deny( + "Started manufacturing cannot be reduced", + command({str(pto["line_id"]): 1, str(finite["line_id"]): 0}), + ) + job.status = bridge.JobStatus.PENDING + old_reference = job.reference + job.reference = "CHANGED" + await deny("Changed manufacturing identity fails closed", command()) + job.reference = old_reference + payment = await bridge.verify_payment(bridge.Event.model_validate(snapshot)) + check( + "Unreviewed partial refund holds fulfilment", + not module.refund_release_valid(mapping.effects, payment), + ) + cmd = command({str(pto["line_id"]): 1, str(finite["line_id"]): 0}) + result = await module.apply_in_transaction(db, cmd) + check( + "Partial cancellation reduces pending manufacturing from two to one", + result["status"] == "recorded" + and job.quantity == 1 + and job.status == bridge.JobStatus.PENDING, + ) + check( + "Reviewed exact refund releases remaining goods", + module.refund_release_valid(mapping.effects, payment), + ) + check( + "Original sold snapshot and finite stock preserved", + mapping.snapshot == snapshot and product.units_in_stock == stock, + ) + check( + "Exact retry is a durable duplicate", + (await module.apply_in_transaction(db, cmd))["status"] == "duplicate" + and job.quantity == 1, + ) + await deny( + "Same command identity cannot change details", + cmd.model_copy(update={"note": "Different details"}), + ) + await deny( + "Another tab must refresh stale revision", + cmd.model_copy(update={"event_id": uuid4()}), + ) + await deny("Cancelled quantities cannot be reversed", command()) + changed = copy.deepcopy(payment) + changed["refund_ids"].append("synthetic-later-refund") + check( + "Later pending refund identity invalidates approval", + not module.refund_release_valid(mapping.effects, changed), + ) + changed = copy.deepcopy(payment) + changed["refunded_money"]["amount"] += 1 + check( + "Later changed money invalidates approval", + not module.refund_release_valid(mapping.effects, changed), + ) + # Independent finite-stock decision, within the same outer rollback. + cmd2 = command({str(pto["line_id"]): 1, str(finite["line_id"]): 1}) + await module.apply_in_transaction(db, cmd2) + check( + "Cancelled finite allocation returns stock once", + product.units_in_stock == stock + 1 + and sum(x["quantity"] for x in mapping.effects["stock"]) == 0, + ) + await module.apply_in_transaction(db, cmd2) + check( + "Finite cancellation replay cannot return stock twice", + product.units_in_stock == stock + 1, + ) + review = lines_for_review(mapping) + check( + "Physical returns exclude cancelled quantities", + all( + r["shipped_quantity"] == (0 if r["line_id"] == finite["line_id"] else 1) + for r in review + ), + ) + check( + "Original stock allocation retained for audit", + mapping.effects["disposition"]["original_stock"] == base["stock"], + ) + check( + "All decisions retain operator and evidence", + len(mapping.effects["disposition"]["receipts"]) == 2 + and all( + r["actor_id"] == 1 for r in mapping.effects["disposition"]["receipts"].values() + ), + ) + finally: + await db.rollback() + check( + "Rollback preserves all eight persisted business tables", + before == await fingerprint(db), + ) + print( + json.dumps( + { + "checks": checks, + "failed": sum(not x["pass"] for x in checks), + "scope": "Actual completed Square sandbox refund GETs and restricted PostgreSQL row locks/transactions. Mutated refund copies only test approval invalidation. All business mutations rolled back; browser decision follows separately.", + } + ) + ) + + +asyncio.run(main()) diff --git a/backend/tests/commerce/fulfilment_live.py b/backend/tests/commerce/fulfilment_live.py new file mode 100644 index 00000000..fc53b123 --- /dev/null +++ b/backend/tests/commerce/fulfilment_live.py @@ -0,0 +1,272 @@ +"""Live isolated fulfilment acceptance on retained sandbox fixture 431, never order 468. +Runs native queue service transitions against a simulated printer without network settings. +""" + +import asyncio +import hashlib +import hmac +import json +import logging +import sys +import time +from uuid import uuid5, NAMESPACE_URL +import httpx +from sqlalchemy import select, text +from app.commerce_bridge import ( + CommerceOrder, + CommerceFulfilmentEvent, + PrintJob, + JobStatus, + Tenant, + TENANT, + SECRET, + async_session_maker, +) +from app.models.printer import Printer +from app.services.print_queue_service import PrintQueueService + +logging.disable(logging.CRITICAL) +ORDER = 431 +SHIP = str(uuid5(NAMESPACE_URL, "mystmere-test-431-dispatch-v1")) +DELIVER = str(uuid5(NAMESPACE_URL, "mystmere-test-431-delivery-v2")) + + +async def main(): + checks = [] + error = None + + def check(name, ok): + checks.append({"check": name, "pass": bool(ok)}) + if not ok: + raise AssertionError(name) + + async def protected(db): + return [ + await db.scalar(text(q)) + for q in [ + "SELECT md5(string_agg(row_to_json(t)::text,'' ORDER BY id)) FROM products t", + "SELECT md5(string_agg(row_to_json(t)::text,'' ORDER BY id)) FROM product_variants t", + "SELECT md5(string_agg(row_to_json(t)::text,'' ORDER BY id)) FROM commerce_orders t WHERE woo_order_id=468", + "SELECT md5(string_agg(row_to_json(t)::text,'' ORDER BY id)) FROM orders t WHERE id=(SELECT batchivo_order_id::uuid FROM commerce_orders WHERE woo_order_id=468)", + "SELECT md5(string_agg(row_to_json(t)::text,'' ORDER BY id)) FROM print_jobs t WHERE reference NOT LIKE 'WOO-TEST-431:%'", + ] + ] + + async with async_session_maker() as db: + before = await protected(db) + mode = sys.argv[1] if len(sys.argv) > 1 else "ship" + command = { + "event_id": SHIP, + "order_id": ORDER, + "state": "shipped", + "tracking_number": "TEST-431-NOT-A-PARCEL", + "tracking_url": "https://test.mystmereforge.co.uk/my-account/orders/", + } + async with httpx.AsyncClient(base_url="http://127.0.0.1:8000", timeout=30) as client: + + async def post(path, data, bad=False): + raw = json.dumps(data, separators=(",", ":")).encode() + stamp = str(int(time.time())) + sig = hmac.new(SECRET, stamp.encode() + b"." + raw, hashlib.sha256).hexdigest() + return await client.post( + "/fulfilment/" + path, + content=raw, + headers={ + "Content-Type": "application/json", + "X-MF-Timestamp": stamp, + "X-MF-Signature": "0" * 64 if bad else sig, + }, + ) + + try: + check( + "unsigned dispatch rejected", + (await post("dispatch", command, True)).status_code == 401, + ) + check( + "non-HTTPS tracking rejected", + ( + await post("dispatch", {**command, "tracking_url": "javascript:alert(1)"}) + ).status_code + == 422, + ) + check( + "embedded tracking credentials rejected", + ( + await post( + "dispatch", {**command, "tracking_url": "https://user:pass@example.com/"} + ) + ).status_code + == 422, + ) + check( + "unknown order rejected", + ( + await post( + "dispatch", + { + **command, + "event_id": str(uuid5(NAMESPACE_URL, "unknown-order-test")), + "order_id": 99999999, + }, + ) + ).status_code + == 404, + ) + async with async_session_maker() as db: + mapping = await db.scalar( + select(CommerceOrder).where(CommerceOrder.woo_order_id == ORDER) + ) + check( + "fixture is retained paid sandbox mapping", + mapping is not None + and mapping.snapshot["payment_id"] + and mapping.state == "processing", + ) + jobs = ( + await db.scalars( + select(PrintJob).where( + PrintJob.tenant_id == TENANT, PrintJob.reference.like("WOO-TEST-431:%") + ) + ) + ).all() + check( + "two distinct fixture jobs retained", + len(jobs) == 2 + and {j.reference for j in jobs} == {"WOO-TEST-431:4", "WOO-TEST-431:5"} + and all(j.quantity == 1 for j in jobs), + ) + already = await db.scalar( + select(CommerceFulfilmentEvent).where(CommerceFulfilmentEvent.event_id == SHIP) + ) + if mode == "ship": + if not already: + check( + "delivery before dispatch rejected", + ( + await post( + "dispatch", + {"event_id": DELIVER, "order_id": ORDER, "state": "delivered"}, + ) + ).status_code + == 409, + ) + if any(j.status != JobStatus.COMPLETED for j in jobs): + check( + "pending manufacturing blocks dispatch", + (await post("dispatch", command)).status_code == 409, + ) + async with async_session_maker() as db: + tenant = await db.get(Tenant, TENANT) + service = PrintQueueService(db, tenant) + printer = await db.scalar( + select(Printer).where( + Printer.tenant_id == TENANT, + Printer.name == "Commerce simulated printer 431", + ) + ) + if not printer: + printer = Printer( + tenant_id=TENANT, + name="Commerce simulated printer 431", + current_status="idle", + is_active=True, + ) + db.add(printer) + await db.commit() + await db.refresh(printer) + for job in jobs: + current = await service.get_job(job.id) + if current.status == JobStatus.PENDING: + check( + job.reference + " native assign", + (await service.assign_to_printer(job.id, printer.id)).status + == JobStatus.QUEUED, + ) + current = await service.get_job(job.id) + if current.status == JobStatus.QUEUED: + check( + job.reference + " native start", + (await service.start_printing(job.id)).status + == JobStatus.PRINTING, + ) + current = await service.get_job(job.id) + if current.status == JobStatus.PRINTING: + check( + job.reference + " native complete", + (await service.complete_job(job.id)).status + == JobStatus.COMPLETED, + ) + printer.is_active = False + await db.commit() + response = await post("dispatch", command) + check( + "real dispatch verifies Square sandbox and records once; HTTP " + + str(response.status_code), + response.status_code == 200 and response.json()["version"] == 1, + ) + check( + "identical command retry returns duplicate", + (await post("dispatch", command)).json().get("status") == "duplicate", + ) + check( + "reused command cannot change tracking", + (await post("dispatch", {**command, "tracking_number": "changed"})).status_code + == 409, + ) + elif mode == "deliver": + response = await post( + "dispatch", {"event_id": DELIVER, "order_id": ORDER, "state": "delivered"} + ) + check( + "delivery recorded after dispatch", + response.status_code == 200 and response.json()["version"] == 2, + ) + check( + "delivery command replay idempotent", + ( + await post( + "dispatch", + {"event_id": DELIVER, "order_id": ORDER, "state": "delivered"}, + ) + ) + .json() + .get("status") + == "duplicate", + ) + else: + raise ValueError("Unknown fixture stage") + feed = await post("pending", []) + stamp = feed.headers.get("x-mf-timestamp", "") + sig = hmac.new(SECRET, stamp.encode() + b"." + feed.content, hashlib.sha256).hexdigest() + check( + "pending feed authenticates exact response bytes", + feed.status_code == 200 + and hmac.compare_digest(sig, feed.headers.get("x-mf-signature", "")), + ) + except Exception as exc: + error = ( + type(exc).__name__ + ": " + str(exc) + if isinstance(exc, AssertionError) + else type(exc).__name__ + ) + async with async_session_maker() as db: + check( + "all product and variant rows, operator468 order and all other jobs unchanged", + before == await protected(db), + ) + print( + json.dumps( + { + "fixture_order": ORDER, + "stage": mode, + "checks": checks, + "failed": sum(not x["pass"] for x in checks), + "error": error, + "scope": "Live HTTP/SQL and native Batchivo queue service; simulated printer without hardware connection. Separate browser acceptance required.", + } + ) + ) + + +asyncio.run(main()) diff --git a/backend/tests/commerce/late_delivery_live.py b/backend/tests/commerce/late_delivery_live.py new file mode 100644 index 00000000..6de9df97 --- /dev/null +++ b/backend/tests/commerce/late_delivery_live.py @@ -0,0 +1,157 @@ +"""Real PostgreSQL dispatch transactions, nested under an outer rollback.""" + +import asyncio +import copy +import hashlib +import json +from uuid import UUID, uuid4 + +from fastapi import HTTPException +from sqlalchemy import select, text +from sqlalchemy.ext.asyncio import async_sessionmaker + +import app.commerce_bridge as bridge + + +async def main(): + checks = [] + + def check(name, passed): + checks.append({"check": name, "pass": bool(passed)}) + if not passed: + raise AssertionError(name) + + tables = [ + "products", + "product_variants", + "orders", + "order_items", + "print_jobs", + "commerce_orders", + "commerce_receipts", + "commerce_fulfilment_events", + ] + + async def fingerprint(db): + await db.execute( + text("SELECT set_config('app.current_tenant_id', :tenant, true)"), + {"tenant": str(bridge.TENANT)}, + ) + return [ + await db.scalar( + text(f"SELECT md5(string_agg(row_to_json(t)::text,'' ORDER BY id)) FROM {table} t") + ) + for table in tables + ] + + original = bridge.async_session_maker + async with bridge.engine.connect() as connection: + before = await fingerprint(connection) + await connection.rollback() + outer = await connection.begin() + bridge.async_session_maker = async_sessionmaker( + connection, + expire_on_commit=False, + join_transaction_mode="create_savepoint", + sync_session_class=bridge.CommerceSession, + ) + try: + async with bridge.async_session_maker() as db: + mapping = await db.scalar( + select(bridge.CommerceOrder).where(bridge.CommerceOrder.woo_order_id == 603) + ) + order_id = UUID(mapping.batchivo_order_id) + order = await db.get(bridge.Order, order_id) + check( + "Recorded refunded shipment fixture", + order.status == "shipped" + and order.payment_status == "REFUNDED" + and bool(order.shipped_at), + ) + snapshot, effects = copy.deepcopy(mapping.snapshot), copy.deepcopy(mapping.effects) + shipped, tracking = order.shipped_at, order.tracking_number + command = bridge.DispatchCommand(event_id=uuid4(), order_id=603, state="delivered") + + async def apply(cmd): + raw = json.dumps(cmd.model_dump(mode="json"), sort_keys=True).encode() + return await bridge.record_dispatch(cmd, hashlib.sha256(raw).hexdigest()) + + async def deny(name, cmd): + try: + await apply(cmd) + except HTTPException as error: + check(name, error.status_code in [409, 422]) + else: + check(name, False) + + await deny( + "New dispatch remains forbidden after refund", + command.model_copy(update={"event_id": uuid4(), "state": "shipped"}), + ) + await deny( + "Delivery cannot replace tracking", + command.model_copy(update={"tracking_number": "REPLACEMENT"}), + ) + await deny( + "Unshipped refund cannot acquire delivery", + command.model_copy(update={"order_id": 601}), + ) + result = await apply(command) + check("Late delivery recorded", result["status"] == "recorded") + replay = await apply(command) + check( + "Exact retry returns same durable version", + replay["status"] == "duplicate" and replay["version"] == result["version"], + ) + await deny( + "Reused command cannot change details", + command.model_copy(update={"tracking_number": "CHANGED"}), + ) + await deny( + "Different event cannot deliver twice", + command.model_copy(update={"event_id": uuid4()}), + ) + async with bridge.async_session_maker() as db: + mapping = await db.scalar( + select(bridge.CommerceOrder).where(bridge.CommerceOrder.woo_order_id == 603) + ) + order = await db.get(bridge.Order, order_id) + check( + "Financial and returns records preserved", + mapping.snapshot == snapshot + and mapping.effects == effects + and mapping.state == "refunded" + and order.payment_status == "REFUNDED", + ) + check( + "Physical state and timestamps retained", + order.status == "delivered" + and bool(order.delivered_at) + and order.shipped_at == shipped + and order.tracking_number == tracking, + ) + check( + "Restricted role remains in use", + await db.scalar(text("SELECT current_user")) == "mf_commerce_test", + ) + after = await fingerprint(connection) + check( + "Products variants items jobs and commerce financial records unchanged", + all(before[i] == after[i] for i in [0, 1, 3, 4, 5, 6]), + ) + finally: + bridge.async_session_maker = original + await outer.rollback() + check("Outer rollback restores all eight tables", before == await fingerprint(connection)) + print( + json.dumps( + { + "checks": checks, + "failed": sum(not c["pass"] for c in checks), + "scope": "Actual restricted PostgreSQL transactions/savepoints with outer rollback. No payment calls or customer messages. Deployed browser/operator acceptance is separate.", + } + ) + ) + + +asyncio.run(main()) diff --git a/backend/tests/commerce/legacy_webhook_ownership_live.py b/backend/tests/commerce/legacy_webhook_ownership_live.py new file mode 100644 index 00000000..0680aebb --- /dev/null +++ b/backend/tests/commerce/legacy_webhook_ownership_live.py @@ -0,0 +1,208 @@ +"""Real isolated PostgreSQL callback processing, including commits, in an outer rollback. + +Events are constructed signed fixtures; no event is claimed as delivered by Square. +Actual provider/payment evidence is retained separately in runtime/browser reports. +""" + +import asyncio +import base64 +import hashlib +import hmac +import json +import logging +from decimal import Decimal +from uuid import uuid4 +from sqlalchemy import select, text +from sqlalchemy.ext.asyncio import AsyncSession +from app.commerce_bridge import RUNTIME, engine, TENANT, CommerceOrder +from app.models.order import Order +from app.models.webhook_event import WebhookEvent, WebhookDeadLetter +from app.models.payment_log import PaymentLog +from app.services.square_webhook_service import SquareWebhookService + +logging.getLogger("app.services.square_webhook_service").setLevel(logging.CRITICAL) + + +async def main(): + if not RUNTIME.isolated: + raise RuntimeError("Offline ownership fixtures require isolated database") + checks = [] + + def check(name, value): + checks.append({"check": name, "pass": bool(value)}) + if not value: + raise RuntimeError(name) + + tables = [ + "orders", + "order_items", + "products", + "product_variants", + "print_jobs", + "commerce_orders", + "commerce_receipts", + WebhookEvent.__tablename__, + WebhookDeadLetter.__tablename__, + PaymentLog.__tablename__, + ] + + async def fingerprints(conn): + return { + table: await conn.scalar( + text(f"SELECT md5(string_agg(row_to_json(t)::text,'' ORDER BY id)) FROM {table} t") + ) + for table in tables + } + + async with engine.connect() as connection: + before = await fingerprints(connection) + await connection.rollback() + outer = await connection.begin() + try: + async with AsyncSession( + bind=connection, expire_on_commit=False, join_transaction_mode="create_savepoint" + ) as db: + service = SquareWebhookService(db) + key = uuid4().hex + url = "https://callback-rehearsal.invalid/square" + body = b'{"type":"payment.updated"}' + signature = base64.b64encode( + hmac.new(key.encode(), url.encode() + body, hashlib.sha256).digest() + ).decode() + check( + "actual signature verifier accepts correct URL and bytes", + await service.verify_signature(body, signature, key, url), + ) + check( + "missing signature configuration fails closed", + not await service.verify_signature(body, signature, "", url), + ) + check( + "changed notification URL rejected", + not await service.verify_signature(body, signature, key, url + "/changed"), + ) + count = await db.scalar(text("SELECT count(*) FROM webhook_events")) + rejected = await service.process_webhook( + {"event_id": str(uuid4()), "type": "payment.updated"}, signature_valid=False + ) + check( + "failed authentication has no event or business writes", + rejected["status"] == "rejected" + and await db.scalar(text("SELECT count(*) FROM webhook_events")) == count, + ) + for oid in [468, 571, 574]: + mapping = await db.scalar( + select(CommerceOrder).where(CommerceOrder.woo_order_id == oid) + ) + payment_id = mapping.snapshot["payment_id"] + native_id = mapping.batchivo_order_id + original = await db.scalar( + text( + "SELECT md5(row_to_json(o)::text) FROM orders o WHERE id=CAST(:id AS uuid)" + ), + {"id": native_id}, + ) + for event_type in [ + "payment.created", + "payment.updated", + "payment.failed", + "refund.created", + "refund.updated", + ]: + payment = { + "id": payment_id, + "status": "COMPLETED", + "amount_money": { + "amount": mapping.snapshot["total_pence"], + "currency": "GBP", + }, + } + refund = { + "id": "fixture-" + uuid4().hex, + "payment_id": payment_id, + "status": "COMPLETED", + "amount_money": {"amount": 1, "currency": "GBP"}, + } + event = { + "event_id": str(uuid4()), + "type": event_type, + "data": { + "object": {"payment": payment} + if event_type.startswith("payment.") + else {"refund": refund} + }, + } + raw = json.dumps(event).encode() + signed = base64.b64encode( + hmac.new(key.encode(), url.encode() + raw, hashlib.sha256).digest() + ).decode() + verified = await service.verify_signature(raw, signed, key, url) + result = await service.process_webhook(event, signature_valid=verified) + check( + f"{oid} {event_type} acknowledged with Woo ownership", + result["status"] == "processed" + and result["result"].get("order_owner") == "woocommerce" + and result["result"].get("woo_order_id") == oid, + ) + duplicate = await service.process_webhook(event, signature_valid=verified) + check( + f"{oid} {event_type} exact duplicate acknowledged", + duplicate["status"] == "duplicate", + ) + actual = await db.scalar( + text( + "SELECT md5(row_to_json(o)::text) FROM orders o WHERE id=CAST(:id AS uuid)" + ), + {"id": native_id}, + ) + check( + f"{oid} late payment and partial refund events preserve entire order", + actual == original, + ) + legacy = Order( + tenant_id=TENANT, + order_number="LEGACY-WEBHOOK-" + uuid4().hex, + customer_email="legacy-webhook@example.invalid", + customer_name="Isolated fixture", + shipping_address_line1="1 Fixture Lane", + shipping_city="Test", + shipping_postcode="SS1 1AA", + shipping_country="United Kingdom", + shipping_method="Fixture delivery", + subtotal=Decimal("10"), + total=Decimal("10"), + payment_id="fixture-" + uuid4().hex, + payment_provider="square", + payment_status="PENDING", + ) + db.add(legacy) + await db.commit() + event = { + "event_id": str(uuid4()), + "type": "payment.updated", + "data": { + "object": {"payment": {"id": legacy.payment_id, "status": "COMPLETED"}} + }, + } + result = await service.process_webhook(event) + await db.refresh(legacy) + check( + "ordinary legacy callback still updates its legacy order", + result["status"] == "processed" + and legacy.payment_status == "COMPLETED" + and "order_owner" not in result["result"], + ) + finally: + await outer.rollback() + after = await fingerprints(connection) + for table in tables: + check(table + " unchanged after transactional rehearsal", before[table] == after[table]) + report = { + "checks": checks, + "failed": sum(not c["pass"] for c in checks), + "scope": "Constructed HMAC-signed late/duplicate/partial-refund events through actual deployed legacy Square service and real PostgreSQL savepoint commits, all rolled back. Actual legacy normal behavior exercised. No external provider request or production mutation.", + } + print(json.dumps(report)) + + +asyncio.run(main()) diff --git a/backend/tests/commerce/native_fulfilment_live.py b/backend/tests/commerce/native_fulfilment_live.py new file mode 100644 index 00000000..7fd791be --- /dev/null +++ b/backend/tests/commerce/native_fulfilment_live.py @@ -0,0 +1,235 @@ +"""Native manufacturing and reconciliation for the real browser sandbox order557.""" + +import asyncio +import hashlib +import hmac +import json +import logging +import sys +import time +from uuid import UUID +import httpx +from sqlalchemy import select, text +from app.commerce_bridge import ( + CommerceOrder, + CommerceFulfilmentEvent, + Event, + Order, + PrintJob, + JobStatus, + Tenant, + TENANT, + SECRET, + async_session_maker, + verify_payment, +) +from app.models.printer import Printer +from app.models.printer_connection import PrinterConnection +from app.services.print_queue_service import PrintQueueService + +logging.disable(logging.CRITICAL) +ORDER = 557 + + +async def main(): + checks = [] + stage = sys.argv[1] + + def check(name, value): + checks.append({"check": name, "pass": bool(value)}) + if not value: + raise AssertionError(name) + + async def protected(db): + return [ + await db.scalar(text(q)) + for q in [ + "SELECT md5(string_agg(row_to_json(t)::text,'' ORDER BY id)) FROM products t", + "SELECT md5(string_agg(row_to_json(t)::text,'' ORDER BY id)) FROM product_variants t", + "SELECT md5(string_agg(row_to_json(t)::text,'' ORDER BY id)) FROM commerce_orders t WHERE woo_order_id<>557", + "SELECT md5(string_agg(row_to_json(t)::text,'' ORDER BY id)) FROM orders t WHERE id<>(SELECT batchivo_order_id::uuid FROM commerce_orders WHERE woo_order_id=557)", + "SELECT md5(string_agg(row_to_json(t)::text,'' ORDER BY id)) FROM print_jobs t WHERE reference NOT LIKE 'WOO-TEST-557:%'", + ] + ] + + async with async_session_maker() as db: + before = await protected(db) + mapping = await db.scalar(select(CommerceOrder).where(CommerceOrder.woo_order_id == ORDER)) + check( + "exact browser fixture with synthetic recipient", + mapping + and mapping.snapshot["customer"]["email"] + == "native-fulfilment-20260908@example.invalid" + and mapping.snapshot["total_pence"] == 2997, + ) + check( + "two distinct paid size variants", + len(mapping.snapshot["lines"]) == 2 + and {v["option"] for v in mapping.snapshot["lines"]} == {"15cm", "25cm"} + and all(v["quantity"] == 1 for v in mapping.snapshot["lines"]), + ) + check("print to order has no finite stock deductions", not mapping.effects["stock"]) + payment = await verify_payment(Event.model_validate(mapping.snapshot)) + check( + "independent Square sandbox captured £29.97", + payment["status"] == "COMPLETED" + and payment["amount_money"]["amount"] == 2997 + and not payment.get("refund_ids"), + ) + jobs = ( + await db.scalars( + select(PrintJob).where( + PrintJob.tenant_id == TENANT, PrintJob.reference.like("WOO-TEST-557:%") + ) + ) + ).all() + check( + "two mapped manufacturing jobs retain option and payment", + len(jobs) == 2 + and {json.loads(j.notes)["option"] for j in jobs} == {"15cm", "25cm"} + and all( + j.quantity == 1 + and json.loads(j.notes)["payment_id"] == mapping.snapshot["payment_id"] + for j in jobs + ), + ) + if stage == "manufacture": + tenant = await db.get(Tenant, TENANT) + service = PrintQueueService(db, tenant) + printer = await db.scalar( + select(Printer).where( + Printer.tenant_id == TENANT, Printer.name == "Commerce simulated printer 557" + ) + ) + if not printer: + printer = Printer( + tenant_id=TENANT, + name="Commerce simulated printer 557", + current_status="idle", + is_active=True, + notes="Isolated acceptance only. No physical printer connection.", + ) + db.add(printer) + await db.commit() + await db.refresh(printer) + check( + "simulated printer has no hardware connection", + await db.scalar( + select(PrinterConnection).where(PrinterConnection.printer_id == printer.id) + ) + is None, + ) + for job in jobs: + current = await service.get_job(job.id) + if current.status == JobStatus.PENDING: + check( + job.reference + " native queue", + (await service.assign_to_printer(job.id, printer.id)).status + == JobStatus.QUEUED, + ) + current = await service.get_job(job.id) + if current.status == JobStatus.QUEUED: + check( + job.reference + " native start", + (await service.start_printing(job.id)).status == JobStatus.PRINTING, + ) + current = await service.get_job(job.id) + if current.status == JobStatus.PRINTING: + check( + job.reference + " native complete", + (await service.complete_job(job.id)).status == JobStatus.COMPLETED, + ) + printer.is_active = False + await db.commit() + check("simulated printer deactivated", not printer.is_active) + elif stage in ["shipped", "delivered"]: + order = await db.get(Order, UUID(mapping.batchivo_order_id)) + events = ( + await db.scalars( + select(CommerceFulfilmentEvent) + .where(CommerceFulfilmentEvent.woo_order_id == ORDER) + .order_by(CommerceFulfilmentEvent.version) + ) + ).all() + check("native order state matches browser action", order.status == stage) + check( + "manufacturing completion retained", + all(j.status == JobStatus.COMPLETED for j in jobs), + ) + check( + "one immutable event per transition", + len(events) == (1 if stage == "shipped" else 2) + and [e.version for e in events] == list(range(1, len(events) + 1)), + ) + check( + "tracking retained correctly", + order.tracking_number == "TEST-557-NOT-A-PARCEL" + and order.tracking_url == "https://test.mystmereforge.co.uk/order-lookup/", + ) + check( + "stock effects are not repeated at dispatch", + not mapping.effects["stock"] and order.fulfilled_at is not None, + ) + command = { + "order_id": ORDER, + "state": stage, + "tracking_number": order.tracking_number if stage == "shipped" else "", + "tracking_url": order.tracking_url if stage == "shipped" else "", + } + async with httpx.AsyncClient(base_url="http://127.0.0.1:8000", timeout=25) as client: + + async def send(payload, bad=False): + raw = json.dumps(payload, separators=(",", ":")).encode() + stamp = str(int(time.time())) + sig = hmac.new(SECRET, stamp.encode() + b"." + raw, hashlib.sha256).hexdigest() + return await client.post( + "/fulfilment/operator", + content=raw, + headers={ + "Content-Type": "application/json", + "X-MF-Timestamp": stamp, + "X-MF-Signature": "0" * 64 if bad else sig, + }, + ) + + check( + "unauthenticated operator request denied", + (await send(command, True)).status_code == 401, + ) + r = await send(command) + stamp = r.headers.get("x-mf-timestamp", "") + signature = hmac.new( + SECRET, stamp.encode() + b"." + r.content, hashlib.sha256 + ).hexdigest() + check( + "operator response signature verified", + r.status_code == 200 + and hmac.compare_digest(signature, r.headers.get("x-mf-signature", "")), + ) + check( + "identical native action replays durable receipt", + r.json().get("ok") and r.json()["result"]["status"] == "duplicate", + ) + if stage == "shipped": + r = await send({**command, "tracking_number": "CHANGED"}) + check( + "duplicate cannot rewrite tracking", + r.json().get("ok") is False and r.json().get("code") == 409, + ) + else: + raise ValueError("Unsupported stage") + check("all stock other orders and other jobs unchanged", before == await protected(db)) + print( + json.dumps( + { + "order_id": ORDER, + "stage": stage, + "checks": checks, + "failed": sum(not c["pass"] for c in checks), + "scope": "Real browser sandbox order; actual Square read, native queue, PostgreSQL and signed HTTP; simulated printer only", + } + ) + ) + + +asyncio.run(main()) diff --git a/backend/tests/commerce/native_ownership_live.py b/backend/tests/commerce/native_ownership_live.py new file mode 100644 index 00000000..ba840e9e --- /dev/null +++ b/backend/tests/commerce/native_ownership_live.py @@ -0,0 +1,268 @@ +"""Read/rollback-only native order fences against actual isolated PostgreSQL. + +Uses the fully refunded retained fixture 431. It never submits a provider request, +changes stock, dispatches another order, or sends an email. +""" + +import asyncio +import json +import hashlib +import hmac +import time +from decimal import Decimal +import httpx +import logging +from types import SimpleNamespace +from uuid import UUID, uuid4 + +from fastapi import HTTPException +from sqlalchemy import select, text +from sqlalchemy.orm import selectinload +from app.commerce_bridge import ( + TENANT, + SECRET, + CommerceOrder, + CommerceFulfilmentEvent, + Order, + Tenant, + async_session_maker, +) +from app.models import Product, OrderItem +from app.database import async_session_maker as native_session_maker +from app.api.v1 import orders as api +from app.services.commerce_order_ownership import commerce_order_id +from app.services.order_fulfillment import OrderFulfillmentService + +logging.disable(logging.CRITICAL) + + +async def main(): + checks = [] + + async def protected(db): + return [ + await db.scalar( + text(f"SELECT md5(string_agg(row_to_json(t)::text,'' ORDER BY id)) FROM {table} t") + ) + for table in [ + "products", + "product_variants", + "orders", + "order_items", + "print_jobs", + "commerce_orders", + "commerce_fulfilment_events", + ] + ] + + def check(name, ok): + checks.append({"check": name, "pass": bool(ok)}) + if not ok: + raise AssertionError(name) + + async def denied(name, operation): + try: + await operation() + check(name, False) + except HTTPException as error: + check(name, error.status_code == 409) + + async with native_session_maker() as db: + before = await protected(db) + check( + "native API regression uses its separate database connection", + await db.scalar(text("SELECT current_user")) == "commerce", + ) + tenant = await db.get(Tenant, TENANT) + mapping = await db.scalar(select(CommerceOrder).where(CommerceOrder.woo_order_id == 431)) + order = await db.scalar( + select(Order) + .where(Order.id == UUID(mapping.batchivo_order_id)) + .options(selectinload(Order.items)) + ) + check( + "fixture431 is fully refunded and physically delivered", + order.payment_status == "REFUNDED" + and order.delivered_at is not None + and mapping.effects["refunded_pence"] == 2997, + ) + check("actual mapping identifies Woo order", await commerce_order_id(db, order) == 431) + original = order.order_number + order.order_number = "A different display number" + check( + "mapping remains authoritative after display rename", + await commerce_order_id(db, order) == 431, + ) + order.order_number = original + check( + "legacy order without commerce mapping remains native", + await commerce_order_id(db, SimpleNamespace(id=uuid4(), order_number="LEGACY-FIXTURE")) + is None, + ) + await denied( + "missing reserved Woo mapping fails closed", + lambda: commerce_order_id( + db, SimpleNamespace(id=uuid4(), order_number="WOO-TEST-MISSING") + ), + ) + service = OrderFulfillmentService(db, tenant) + await denied( + "direct inventory validation cannot bypass ownership", + lambda: service.validate_inventory(order), + ) + await denied( + "direct deduction cannot consume stock twice", lambda: service.deduct_inventory(order) + ) + await denied( + "direct reversal cannot restore dispatched stock", + lambda: service.revert_inventory(order), + ) + await denied( + "native manual fulfil rejects competing stock writer", + lambda: api.fulfill_order(order.id, db=db, tenant=tenant), + ) + await denied( + "native cancellation rejects competing terminal writer", + lambda: api.cancel_order(order.id, api.CancelOrderRequest(), db=db, tenant=tenant), + ) + await denied( + "native refund requires Woo payment workflow", + lambda: api.refund_order( + order.id, api.RefundOrderRequest(), tenant=tenant, _=None, db=db + ), + ) + await denied( + "native resend does not send a duplicate customer message", + lambda: api.resend_order_email( + order.id, api.ResendEmailRequest(email_type="confirmation"), db=db, tenant=tenant + ), + ) + for field, value in [ + ("status", "processing"), + ("tracking_number", "CHANGED"), + ("tracking_url", "https://example.invalid/"), + ]: + await denied( + "generic update cannot bypass " + field + " ownership", + lambda f=field, v=value: api.update_order( + order.id, api.UpdateOrderRequest(**{f: v}), db=db, tenant=tenant + ), + ) + await denied( + "native ship uses durable refund guard", + lambda: api.ship_order(order.id, api.ShipOrderRequest(), db=db, tenant=tenant), + ) + await denied( + "native delivery uses durable refund guard", + lambda: api.deliver_order(order.id, db=db, tenant=tenant), + ) + # Exercise the unchanged legacy stock path inside a transaction rolled back below. + product = Product( + id=uuid4(), + tenant_id=TENANT, + sku="OWNERSHIP-" + uuid4().hex, + name="Temporary native ownership fixture", + units_in_stock=5, + is_active=True, + ) + legacy = Order( + id=uuid4(), + tenant_id=TENANT, + order_number="NATIVE-FIXTURE-" + uuid4().hex, + status="processing", + customer_email="fixture@example.invalid", + customer_name="Fixture", + shipping_address_line1="Fixture", + shipping_city="Fixture", + shipping_postcode="TE1 1ST", + shipping_country="United Kingdom", + shipping_method="Test", + shipping_cost=Decimal("0"), + subtotal=Decimal("2"), + total=Decimal("2"), + currency="GBP", + payment_provider="test", + payment_status="completed", + ) + legacy.items = [ + OrderItem( + id=uuid4(), + tenant_id=TENANT, + product_id=product.id, + product_sku=product.sku, + product_name=product.name, + quantity=2, + unit_price=Decimal("1"), + total_price=Decimal("2"), + ) + ] + db.add_all([product, legacy]) + await db.flush() + check( + "legacy native inventory validation still works", + (await service.validate_inventory(legacy)).success, + ) + check( + "legacy native deduction still works", + (await service.deduct_inventory(legacy)).success + and product.units_in_stock == 3 + and legacy.fulfilled_at is not None, + ) + check( + "legacy native reversal still works", + (await service.revert_inventory(legacy)).success + and product.units_in_stock == 5 + and legacy.fulfilled_at is None, + ) + await db.rollback() + check( + "all products variants orders items jobs mappings events unchanged", + before == await protected(db), + ) + async with async_session_maker() as db: + event = await db.scalar( + select(CommerceFulfilmentEvent).where( + CommerceFulfilmentEvent.woo_order_id == 431, CommerceFulfilmentEvent.version == 1 + ) + ) + command = { + "event_id": event.event_id, + "order_id": 431, + "state": "shipped", + "tracking_number": event.payload["tracking_number"], + "tracking_url": event.payload["tracking_url"], + } + raw = json.dumps(command, separators=(",", ":")).encode() + check( + "retained signed dispatch command identity matches original", + hashlib.sha256(raw).hexdigest() == event.command_sha256, + ) + async with httpx.AsyncClient(base_url="http://127.0.0.1:8000", timeout=20) as client: + stamp = str(int(time.time())) + signature = hmac.new(SECRET, stamp.encode() + b"." + raw, hashlib.sha256).hexdigest() + response = await client.post( + "/fulfilment/dispatch", + content=raw, + headers={ + "Content-Type": "application/json", + "X-MF-Timestamp": stamp, + "X-MF-Signature": signature, + }, + ) + check( + "signed HTTP dispatch still replays retained receipt after refactor", + response.status_code == 200 and response.json().get("status") == "duplicate", + ) + check("HTTP replay leaves all protected tables unchanged", before == await protected(db)) + print( + json.dumps( + { + "scope": "Actual native API/service calls and isolated PostgreSQL; refunded fixture431, no mocked persistence or payment calls", + "checks": checks, + "failed": sum(not c["pass"] for c in checks), + } + ) + ) + + +asyncio.run(main()) diff --git a/backend/tests/commerce/refunds_live.py b/backend/tests/commerce/refunds_live.py new file mode 100644 index 00000000..cb61fde7 --- /dev/null +++ b/backend/tests/commerce/refunds_live.py @@ -0,0 +1,113 @@ +"""Readback and rejected-event tests on real sandbox refunds for retained fixture431.""" + +import asyncio +import hashlib +import hmac +import json +import time +from uuid import uuid4 +import httpx +from sqlalchemy import select, text +from app.commerce_bridge import ( + CommerceOrder, + Order, + PrintJob, + TENANT, + SECRET, + async_session_maker, + JobStatus, +) + + +async def main(): + checks = [] + + def check(name, ok): + checks.append({"check": name, "pass": bool(ok)}) + + async with async_session_maker() as db: + mapping = await db.scalar(select(CommerceOrder).where(CommerceOrder.woo_order_id == 431)) + order = await db.scalar(select(Order).where(Order.id == mapping.batchivo_order_id)) + before = await db.scalar( + text("SELECT md5(row_to_json(t)::text) FROM commerce_orders t WHERE woo_order_id=431") + ) + jobs = ( + await db.scalars( + select(PrintJob).where( + PrintJob.reference.like("WOO-TEST-431:%"), PrintJob.tenant_id == TENANT + ) + ) + ).all() + target = mapping.effects.get("refunded_pence", 0) + check("verified cumulative refund is partial or full", target in [100, 2997]) + check( + "dispatch/delivery preserved", + order.status == "delivered" + and order.shipped_at is not None + and order.delivered_at is not None, + ) + check( + "both completed manufacturing jobs preserved", + len(jobs) == 2 and all(j.status == JobStatus.COMPLETED for j in jobs), + ) + check("no automatic post-dispatch stock restoration", not mapping.effects.get("restored")) + check( + "payment status reflects verified refund", + order.payment_status == ("REFUNDED" if target == 2997 else "PARTIALLY_REFUNDED"), + ) + check( + "provider refund ledger totals reconcile", + sum(r["amount_pence"] for r in mapping.effects.get("refunds", [])) == target + and all(r["status"] == "COMPLETED" for r in mapping.effects.get("refunds", [])), + ) + if target == 2997: + check("return/goods review recorded", bool(mapping.effects.get("refund_review"))) + async with httpx.AsyncClient(base_url="http://127.0.0.1:8000", timeout=30) as client: + + async def post(data): + raw = json.dumps(data, separators=(",", ":")).encode() + stamp = str(int(time.time())) + sig = hmac.new(SECRET, stamp.encode() + b"." + raw, hashlib.sha256).hexdigest() + return await client.post( + "/events", + content=raw, + headers={ + "Content-Type": "application/json", + "X-MF-Timestamp": stamp, + "X-MF-Signature": sig, + }, + ) + + base = {**mapping.snapshot, "version": mapping.version + 1, "event_id": str(uuid4())} + regression = { + **base, + "state": "refunded" if target == 2997 else "completed", + "refunded_pence": 0, + "refund_ids": [], + } + response = await post(regression) + check("refund amount regression rejected", response.status_code in [409, 422]) + altered = {**base, "refunded_pence": target + 1} + response = await post(altered) + check("unverified extra penny rejected", response.status_code in [409, 422]) + duplicate = {**base, "refund_ids": mapping.snapshot.get("refund_ids", []) * 2} + response = await post(duplicate) + check("duplicate refund identities rejected", response.status_code == 422) + async with async_session_maker() as db: + after = await db.scalar( + text("SELECT md5(row_to_json(t)::text) FROM commerce_orders t WHERE woo_order_id=431") + ) + check("rejected events leave mapping unchanged", before == after) + print( + json.dumps( + { + "order_id": 431, + "verified_refunded_pence": target, + "checks": checks, + "failed": sum(not c["pass"] for c in checks), + } + ) + ) + + +asyncio.run(main()) diff --git a/backend/tests/commerce/reservations_live.py b/backend/tests/commerce/reservations_live.py new file mode 100644 index 00000000..20483cd3 --- /dev/null +++ b/backend/tests/commerce/reservations_live.py @@ -0,0 +1,301 @@ +"""Explicit live isolated-DB/API acceptance; never collected as a mocked unit test.""" + +import asyncio +import hashlib +import hmac +import json +import os +import time +from datetime import datetime, timedelta, timezone +from uuid import uuid4 +import httpx +from sqlalchemy import select, text, delete +from app.commerce_bridge import ( + CommerceReservation, + Product, + async_session_maker, + TENANT, +) + + +async def main(): + if os.environ.get("COMMERCE_ISOLATED") != "true": + raise RuntimeError("Isolated database only") + checks = [] + owned = [] + + def check(name, passed): + checks.append({"check": name, "pass": bool(passed)}) + if not passed: + raise AssertionError(name) + + async with async_session_maker() as db: + product = await db.scalar( + select(Product) + .where( + Product.tenant_id == TENANT, + Product.is_active.is_(True), + Product.print_to_order.is_(False), + Product.units_in_stock > 0, + Product.units_in_stock <= 500, + ) + .order_by(Product.units_in_stock) + .limit(1) + ) + pto = await db.scalar( + select(Product) + .where( + Product.tenant_id == TENANT, + Product.is_active.is_(True), + Product.print_to_order.is_(True), + ) + .limit(1) + ) + if not product or not pto: + raise RuntimeError("Finite and print-to-order catalogue fixtures required") + before = await db.scalar( + text("SELECT md5(string_agg(row_to_json(t)::text,'' ORDER BY id)) FROM products t") + ) + order_count = await db.scalar(text("SELECT count(*) FROM orders")) + job_count = await db.scalar(text("SELECT count(*) FROM print_jobs")) + + def payload(p, quantity): + rid = str(uuid4()) + owned.append(rid) + return { + "reservation_id": rid, + "order_id": 900000000 + int(uuid4().hex[:7], 16), + "total_pence": 100, + "currency": "GBP", + "lines": [ + { + "line_id": 1, + "product_id": str(p.id), + "variant_id": None, + "sku": p.sku, + "name": p.name, + "quantity": quantity, + "total_pence": 100, + } + ], + } + + async with httpx.AsyncClient(base_url="http://127.0.0.1:8000", timeout=25) as client: + + async def post(path, data, bad=False): + body = json.dumps(data, separators=(",", ":")).encode() + stamp = str(int(time.time())) + signature = hmac.new( + os.environ["COMMERCE_BRIDGE_SECRET"].encode(), + stamp.encode() + b"." + body, + hashlib.sha256, + ).hexdigest() + return await client.post( + path, + content=body, + headers={ + "Content-Type": "application/json", + "X-MF-Timestamp": stamp, + "X-MF-Signature": "0" * 64 if bad else signature, + }, + ) + + async def action(data, kind, order=None): + return await post( + "/reservations/action", + { + "reservation_id": data["reservation_id"], + "order_id": order or data["order_id"], + "action": kind, + }, + ) + + try: + a = payload(product, product.units_in_stock) + b = payload(product, product.units_in_stock) + check( + "unsigned reservation denied", + (await post("/reservations", a, True)).status_code == 401, + ) + replies = await asyncio.gather(post("/reservations", a), post("/reservations", b)) + check( + "two simultaneous requests for all finite stock yield exactly one winner", + sorted(r.status_code for r in replies) == [200, 409], + ) + winner = a if replies[0].status_code == 200 else b + loser = b if winner is a else a + stock = await post( + "/stock", {"items": [{"product_id": str(product.id), "variant_id": None}]} + ) + check( + "availability excludes the held units", + stock.status_code == 200 and stock.json()["items"][0]["available"] == 0, + ) + check( + "identical reservation retry is idempotent", + (await post("/reservations", winner)).status_code == 200, + ) + changed = json.loads(json.dumps(winner)) + changed["total_pence"] += 1 + check( + "reservation identity cannot be rewritten", + (await post("/reservations", changed)).status_code == 409, + ) + check( + "foreign order cannot release hold", + (await action(winner, "release", winner["order_id"] + 1)).status_code == 404, + ) + check( + "release before payment succeeds", + (await action(winner, "release")).status_code == 200, + ) + stock = await post( + "/stock", {"items": [{"product_id": str(product.id), "variant_id": None}]} + ) + check( + "released units return to availability", + stock.status_code == 200 + and stock.json()["items"][0]["available"] == product.units_in_stock, + ) + check( + "release retry is idempotent", (await action(winner, "release")).status_code == 200 + ) + check( + "released reservation cannot start payment", + (await action(winner, "begin_payment")).status_code == 409, + ) + check( + "released units are available to waiting order", + (await post("/reservations", loser)).status_code == 200, + ) + check( + "begin payment marks durable pending hold", + (await action(loser, "begin_payment")).json().get("state") == "payment_pending", + ) + check( + "begin payment retry is idempotent", + (await action(loser, "begin_payment")).status_code == 200, + ) + async with async_session_maker() as db, db.begin(): + row = await db.scalar( + select(CommerceReservation).where( + CommerceReservation.reservation_id == loser["reservation_id"] + ) + ) + row.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1) + c = payload(product, product.units_in_stock) + check( + "in-flight payment does not expire and oversell", + (await post("/reservations", c)).status_code == 409, + ) + check( + "in-flight payment cannot release without reconciliation", + (await action(loser, "release")).status_code == 409, + ) + # Test did not invoke Square: remove only this owned synthetic pending hold. + async with async_session_maker() as db, db.begin(): + await db.execute( + delete(CommerceReservation).where( + CommerceReservation.reservation_id == loser["reservation_id"] + ) + ) + check( + "next reservation succeeds once fixture hold removed", + (await post("/reservations", c)).status_code == 200, + ) + async with async_session_maker() as db, db.begin(): + row = await db.scalar( + select(CommerceReservation).where( + CommerceReservation.reservation_id == c["reservation_id"] + ) + ) + row.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1) + check( + "expired pre-payment hold cannot begin payment", + (await action(c, "begin_payment")).status_code == 409, + ) + check( + "expired ID cannot be renewed silently", + (await post("/reservations", c)).status_code == 409, + ) + d = payload(product, product.units_in_stock) + check( + "expired pre-payment hold frees capacity", + (await post("/reservations", d)).status_code == 200, + ) + await action(d, "release") + duplicate = payload(product, product.units_in_stock) + duplicate["lines"].append({**duplicate["lines"][0], "line_id": 2}) + check( + "multiple basket lines cannot exceed shared stock", + (await post("/reservations", duplicate)).status_code == 409, + ) + e = payload(pto, 1000) + f = payload(pto, 1000) + replies = await asyncio.gather(post("/reservations", e), post("/reservations", f)) + check( + "print-to-order demand is not limited by physical stock", + all(r.status_code == 200 for r in replies), + ) + replacement = payload(pto, 1000) + replacement["order_id"] = e["order_id"] + check( + "one order cannot hold two active attempts", + (await post("/reservations", replacement)).status_code == 409, + ) + async with async_session_maker() as db, db.begin(): + row = await db.scalar( + select(CommerceReservation).where( + CommerceReservation.reservation_id == e["reservation_id"] + ) + ) + row.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1) + check( + "same order can replace expired pre-payment attempt", + (await post("/reservations", replacement)).status_code == 200, + ) + third = payload(pto, 1000) + third["order_id"] = e["order_id"] + check( + "expired history cannot hide a newer active attempt", + (await post("/reservations", third)).status_code == 409, + ) + await action(replacement, "release") + await action(e, "release") + await action(f, "release") + async with async_session_maker() as db: + check( + "reservation tests do not decrement physical stock", + before + == await db.scalar( + text( + "SELECT md5(string_agg(row_to_json(t)::text,'' ORDER BY id)) FROM products t" + ) + ), + ) + check( + "no commerce orders created", + order_count == await db.scalar(text("SELECT count(*) FROM orders")), + ) + check( + "no manufacturing triggered", + job_count == await db.scalar(text("SELECT count(*) FROM print_jobs")), + ) + finally: + async with async_session_maker() as db, db.begin(): + await db.execute( + delete(CommerceReservation).where(CommerceReservation.reservation_id.in_(owned)) + ) + print( + json.dumps( + { + "scope": "Live signed HTTP to deployed isolated service and real PostgreSQL; no Square request. Explicit synthetic test holds removed. Checkout/payment commit integration not yet accepted.", + "checks": checks, + "failed": sum(not c["pass"] for c in checks), + } + ) + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/tests/commerce/returns_http_live.py b/backend/tests/commerce/returns_http_live.py new file mode 100644 index 00000000..55fb8c7f --- /dev/null +++ b/backend/tests/commerce/returns_http_live.py @@ -0,0 +1,164 @@ +"""Read-only deployed HTTP/security and final physical-return reconciliation.""" + +import asyncio +import hashlib +import hmac +import json +import time +from uuid import UUID, uuid4 + +import httpx +from sqlalchemy import select, text + +from app.commerce_bridge import SECRET, CommerceOrder, Order, Product, async_session_maker + + +async def main(): + checks = [] + + def check(name, value): + checks.append({"check": name, "pass": bool(value)}) + if not value: + raise AssertionError(name) + + async with async_session_maker() as db: + + async def fingerprint(): + return [ + await db.scalar( + text( + f"SELECT md5(string_agg(row_to_json(t)::text,'' ORDER BY id)) FROM {table} t" + ) + ) + for table in [ + "products", + "product_variants", + "orders", + "order_items", + "print_jobs", + "commerce_orders", + "commerce_receipts", + "commerce_fulfilment_events", + ] + ] + + before = await fingerprint() + mapping = await db.scalar(select(CommerceOrder).where(CommerceOrder.woo_order_id == 603)) + order = await db.get(Order, UUID(mapping.batchivo_order_id)) + product = await db.get(Product, UUID("65cf3926-cda4-438f-8dd5-c0851072b727")) + check( + "Native sold order remains shipped and fully refunded", + order.status == "shipped" + and order.payment_status == "REFUNDED" + and mapping.snapshot["total_pence"] == 1898 + and mapping.effects["refunded_pence"] == 1898, + ) + check( + "One sold unit remains immutable", + len(mapping.snapshot["lines"]) == 1 and mapping.snapshot["lines"][0]["quantity"] == 1, + ) + ledger = mapping.effects["returns"] + counts = ledger["lines"][str(mapping.snapshot["lines"][0]["line_id"])] + check( + "Received and inspected counts reconcile", + counts == {"received": 1, "restocked": 1, "written_off": 0}, + ) + check( + "One receive and one restock receipt only", + len(ledger["receipts"]) == 2 + and sorted(r["action"] for r in ledger["receipts"].values()) == ["receive", "restock"], + ) + check( + "Original finite allocation preserved without automatic release", + mapping.effects["stock"][0]["quantity"] == 1 and not mapping.effects["restored"], + ) + check("Original isolated stock level restored exactly once", product.units_in_stock == 1) + async with httpx.AsyncClient(base_url="http://127.0.0.1:8000", timeout=30) as client: + + async def post(path, payload, signed=True): + raw = json.dumps(payload, separators=(",", ":")).encode() + stamp = str(int(time.time())) + headers = {"Content-Type": "application/json"} + if signed: + headers.update( + { + "X-MF-Timestamp": stamp, + "X-MF-Signature": hmac.new( + SECRET, stamp.encode() + b"." + raw, hashlib.sha256 + ).hexdigest(), + } + ) + return await client.post( + "/fulfilment/returns/" + path, content=raw, headers=headers + ) + + r = await post("review", {"order_id": 603}, False) + check("Unsigned returns review refused", r.status_code in (401, 403)) + r = await post("apply", {"order_id": 603}, False) + check("Unsigned return write refused", r.status_code in (401, 403)) + r = await post("review", {"order_id": 603}) + review = r.json() + check( + "Actual signed review verifies Square and saved quantities", + r.status_code == 200 and review["ok"] and review["lines"][0]["restocked"] == 1, + ) + stamp = r.headers.get("x-mf-timestamp", "") + check( + "HTTP response signature verifies", + hmac.compare_digest( + r.headers.get("x-mf-signature", ""), + hmac.new(SECRET, stamp.encode() + b"." + r.content, hashlib.sha256).hexdigest(), + ), + ) + before_refund = await post("review", {"order_id": 557}) + check( + "Signed review supports goods arriving before a refund", + before_refund.status_code == 200 + and before_refund.json()["ok"] + and before_refund.json()["refunded_pence"] == 0, + ) + command = { + "order_id": 603, + "event_id": str(uuid4()), + "revision": review["revision"], + "line_id": review["lines"][0]["line_id"], + "action": "restock", + "quantity": 1, + "actor_id": 1, + "note": "Rejected HTTP acceptance probe", + } + r = await post("apply", command) + check( + "Already inspected goods cannot restock again", + r.status_code == 200 and not r.json()["ok"] and r.json()["code"] == 409, + ) + r = await post("apply", {**command, "quantity": True}) + check( + "Boolean cannot masquerade as quantity", + r.status_code == 200 and not r.json()["ok"] and r.json()["code"] == 422, + ) + r = await post("apply", {**command, "quantity": 0}) + check("Zero quantity refused", not r.json()["ok"] and r.json()["code"] == 422) + r = await post("apply", {**command, "revision": "0" * 64}) + check("Stale return revision held", not r.json()["ok"] and r.json()["code"] == 409) + r = await post("review", {"order_id": 601}) + check( + "Unshipped refunded order cannot enter returns flow", + not r.json()["ok"] and r.json()["code"] == 409, + ) + check( + "All rejected and read-only probes leave eight tables unchanged", + before == await fingerprint(), + ) + print( + json.dumps( + { + "checks": checks, + "failed": sum(not c["pass"] for c in checks), + "scope": "Actual signed HTTP, Square sandbox verification and restricted PostgreSQL. No new payment or refund submitted.", + } + ) + ) + + +asyncio.run(main()) diff --git a/backend/tests/commerce/returns_live.py b/backend/tests/commerce/returns_live.py new file mode 100644 index 00000000..47b6a6cd --- /dev/null +++ b/backend/tests/commerce/returns_live.py @@ -0,0 +1,223 @@ +"""Rollback checks against browser fixture603 and real Square payment/refund GETs. + +The return ledger is reset only inside the rolled-back transaction. A temporary +allocation classification exercises the print-to-order guard. All persisted +stock, existing return receipts and financial history must remain unchanged. +""" + +import asyncio +import copy +import importlib.util +import json +import logging +from uuid import UUID, uuid4 + +from fastapi import HTTPException +from sqlalchemy import text + +from app.commerce_bridge import Product, async_session_maker + +spec = importlib.util.spec_from_file_location("returns_under_test", "/tmp/mf-commerce-returns.py") +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +logging.disable(logging.CRITICAL) + + +async def main(): + checks = [] + + def check(name, passed): + checks.append({"check": name, "pass": bool(passed)}) + if not passed: + raise AssertionError(name) + + async def deny(name, command): + try: + await module.apply_return_in_transaction(db, command) + except HTTPException as error: + check(name, error.status_code in (409, 422, 503)) + return + check(name, False) + + async def fingerprint(db): + return [ + await db.scalar( + text(f"SELECT md5(string_agg(row_to_json(t)::text,'' ORDER BY id)) FROM {table} t") + ) + for table in [ + "products", + "product_variants", + "orders", + "order_items", + "print_jobs", + "commerce_orders", + "commerce_fulfilment_events", + "commerce_receipts", + ] + ] + + async with async_session_maker() as db: + before = await fingerprint(db) + try: + mapping, order = await module.load_order(db, 603) + check( + "Fresh refunded finite-stock fixture was dispatched", + bool(order.shipped_at) and order.payment_status == "REFUNDED", + ) + await module.verify_refunds(mapping, order) + check("Actual Square payment and completed refunds reconcile", True) + original_snapshot = copy.deepcopy(mapping.snapshot) + original_effects = copy.deepcopy(mapping.effects) + original_effects.pop("returns", None) + mapping.effects = copy.deepcopy(original_effects) + line = mapping.snapshot["lines"][0] + temporary = copy.deepcopy(mapping.effects) + temporary["stock"] = [] + mapping.effects = temporary + await db.flush() + + def command(action="receive", quantity=1, **kwargs): + return module.ReturnCommand( + order_id=603, + event_id=uuid4(), + revision=module.revision(mapping, order), + line_id=line["line_id"], + action=action, + quantity=quantity, + actor_id=1, + note="Synthetic rollback inspection only", + **kwargs, + ) + + await deny("Cannot inspect goods before receiving them", command("write_off")) + await deny("Cannot receive more than sold", command(quantity=line["quantity"] + 1)) + receive = command() + result = await module.apply_return_in_transaction(db, receive) + check( + "Received goods remain awaiting inspection", + result["status"] == "recorded" + and module.lines_for_review(mapping)[0]["awaiting_inspection"] == 1, + ) + check( + "Exact replay returns duplicate", + (await module.apply_return_in_transaction(db, receive))["status"] == "duplicate", + ) + await deny( + "Reused command cannot change quantity", receive.model_copy(update={"quantity": 2}) + ) + await deny( + "Another tab cannot reuse old revision", + receive.model_copy(update={"event_id": uuid4()}), + ) + await deny("Print-to-order goods cannot inflate finite stock", command("restock")) + await module.apply_return_in_transaction(db, command("write_off")) + check( + "Inspected write-off does not erase financial snapshot", + module.lines_for_review(mapping)[0]["written_off"] == 1 + and mapping.snapshot == original_snapshot, + ) + await deny("Inspected goods cannot be inspected twice", command("write_off")) + check( + "Audit records actor and evidence note", + all( + r["actor_id"] == 1 and r["note"] == "Synthetic rollback inspection only" + for r in mapping.effects["returns"]["receipts"].values() + ), + ) + # A temporary allocation exercises the finite branch without inventing + # a persisted shipment/payment or changing the retained fixture. + mapping.effects = copy.deepcopy(original_effects) + effects = copy.deepcopy(mapping.effects) + effects["stock"] = [ + { + "product_id": line["product_id"], + "variant_id": line.get("variant_id"), + "quantity": line["quantity"], + } + ] + mapping.effects = effects + await db.flush() + from app.commerce_bridge import ProductVariant + + cls = ProductVariant if line.get("variant_id") else Product + stock = await db.get(cls, UUID(line.get("variant_id") or line["product_id"])) + stock_before = stock.units_in_stock + await module.apply_return_in_transaction(db, command()) + inspect = command("restock") + await module.apply_return_in_transaction(db, inspect) + check( + "Finite stock increases by inspected quantity only", + stock.units_in_stock == stock_before + 1, + ) + check( + "Restock replay never increments twice", + (await module.apply_return_in_transaction(db, inspect))["status"] == "duplicate" + and stock.units_in_stock == stock_before + 1, + ) + await deny("Restock cannot exceed received quantity", command("restock")) + check( + "Sold quantities and captured money remain unchanged", + mapping.snapshot == original_snapshot, + ) + saved = order.shipped_at + order.shipped_at = None + await deny("Unshipped refunds cannot be treated as received returns", command()) + order.shipped_at = saved + effects = copy.deepcopy(mapping.effects) + effects["restored"] = True + mapping.effects = effects + await deny("Automatically released stock cannot be restored again", command()) + check( + "Database writes use restricted commerce role", + await db.scalar(text("SELECT current_user")) == "mf_commerce_test", + ) + finally: + await db.rollback() + check("Rollback restores all eight protected tables", await fingerprint(db) == before) + async with async_session_maker() as db: + before = await fingerprint(db) + try: + mapping, order = await module.load_order(db, 557) + await module.verify_refunds(mapping, order) + check( + "Shipped paid goods can be received before a refund", + order.payment_status == "COMPLETED" and not mapping.effects.get("refunded_pence"), + ) + original = copy.deepcopy(mapping.snapshot) + effects = copy.deepcopy(mapping.effects) + effects.pop("returns", None) + mapping.effects = effects + cmd = module.ReturnCommand( + order_id=557, + event_id=uuid4(), + revision=module.revision(mapping, order), + line_id=mapping.snapshot["lines"][0]["line_id"], + action="receive", + quantity=1, + actor_id=1, + note="Synthetic pre-refund receipt; rollback only", + ) + await module.apply_return_in_transaction(db, cmd) + check( + "Pre-refund receipt leaves payment and sold snapshot unchanged", + mapping.snapshot == original and order.payment_status == "COMPLETED", + ) + check( + "Pre-refund goods remain awaiting inspection", + module.lines_for_review(mapping)[0]["awaiting_inspection"] == 1, + ) + finally: + await db.rollback() + check("Pre-refund probe restores all eight tables", await fingerprint(db) == before) + print( + json.dumps( + { + "checks": checks, + "failed": sum(not c["pass"] for c in checks), + "scope": "Actual restricted-role PostgreSQL and Square sandbox reads, temporary print-to-order classification branch rolled back. No committed return, new transaction, customer email or physical manufacturing. Fresh finite browser payment is independently reconciled.", + } + ) + ) + + +asyncio.run(main()) diff --git a/backend/tests/commerce/runtime_profiles_live.py b/backend/tests/commerce/runtime_profiles_live.py new file mode 100644 index 00000000..6a6e4d28 --- /dev/null +++ b/backend/tests/commerce/runtime_profiles_live.py @@ -0,0 +1,115 @@ +"""Read-only real sandbox verification after the runtime-profile rollout.""" + +import asyncio +import json +import os +from fastapi import HTTPException +from sqlalchemy import select, text +from app.commerce_bridge import ( + RUNTIME, + CommerceOrder, + Event, + PrintJob, + TENANT, + async_session_maker, + verify_payment, + CommerceSession, +) + + +async def main(): + checks = [] + + def check(name, value): + checks.append({"check": name, "pass": bool(value)}) + + check("running process remains isolated", RUNTIME.isolated) + async with async_session_maker() as db: + check( + "bridge transaction receives fixed tenant context", + await db.scalar(text("SELECT current_setting('app.current_tenant_id',true)")) + == str(TENANT), + ) + for oid in [468, 557, 571]: + mapping = await db.scalar( + select(CommerceOrder).where(CommerceOrder.woo_order_id == oid) + ) + event = Event.model_validate(mapping.snapshot) + payment = await verify_payment(event) + check( + f"{oid} actual Square sandbox payment and location verified", + payment["status"] == "COMPLETED" + and payment["amount_money"]["amount"] == event.total_pence, + ) + check( + f"{oid} provider order reference matches Woo identity", + payment.get("reference_id") == str(oid), + ) + check( + f"{oid} provider reservation note matches immutable hold", + not event.reservation_id + or payment.get("note", "").startswith( + "[MF-RESERVATION:" + str(event.reservation_id) + "] " + ), + ) + refs = ( + await db.scalars( + select(PrintJob.reference).where( + PrintJob.tenant_id == TENANT, + PrintJob.reference.like(RUNTIME.order_reference(oid) + ":%"), + ) + ) + ).all() + check( + f"{oid} existing test manufacturing references retained", + bool(refs) + and set(refs) == {RUNTIME.job_reference(oid, line.line_id) for line in event.lines}, + ) + from uuid import uuid4 + + for name, updates in [ + ("order reference", {"order_id": event.order_id + 100000}), + ("reservation identity", {"reservation_id": uuid4()}), + ]: + rejected = False + try: + await verify_payment(event.model_copy(update=updates)) + except HTTPException as e: + rejected = e.status_code == 409 + check("real provider payment rejected for mismatched " + name, rejected) + original = os.environ[RUNTIME.location_environment_key] + try: + os.environ[RUNTIME.location_environment_key] = "WRONG-ISOLATED-FIXTURE-LOCATION" + rejected = False + try: + await verify_payment(event) + except HTTPException as e: + rejected = e.status_code == 409 + check("real provider payment rejected for a different configured location", rejected) + finally: + os.environ[RUNTIME.location_environment_key] = original + from app.database import async_session_maker as native_sessions + + async with native_sessions() as native: + check( + "native sessions retain independent session class", + not isinstance(native.sync_session, CommerceSession), + ) + check( + "bridge tenant does not leak into native transactions", + not await native.scalar(text("SELECT current_setting('app.current_tenant_id',true)")), + ) + print( + json.dumps( + { + "checks": checks, + "failed": sum(not c["pass"] for c in checks), + "scope": "Read-only actual Square sandbox GET verification and native reference lookup; no charges, refunds, orders, stock or configuration changes.", + } + ) + ) + if any(not c["pass"] for c in checks): + raise SystemExit(1) + + +asyncio.run(main()) diff --git a/backend/tests/commerce/schema_rehearsal_live.py b/backend/tests/commerce/schema_rehearsal_live.py new file mode 100644 index 00000000..2f62dd2a --- /dev/null +++ b/backend/tests/commerce/schema_rehearsal_live.py @@ -0,0 +1,122 @@ +"""Rehearse additive DDL on real isolated PostgreSQL, rolling back every change.""" + +import asyncio +import importlib.util +import json +import sys +from uuid import uuid4 +from alembic.migration import MigrationContext +from alembic.operations import Operations +from sqlalchemy import inspect, text +from app.commerce_bridge import ( + RUNTIME, + engine, + CommerceReceipt, + CommerceOrder, + CommerceFulfilmentEvent, + CommerceReservation, +) + + +async def main(): + if not RUNTIME.isolated: + raise RuntimeError("Schema rehearsal requires isolated PostgreSQL") + spec = importlib.util.spec_from_file_location("commerce_schema", sys.argv[1]) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + schema = "commerce_rehearsal_" + uuid4().hex + checks = [] + + def check(name, value): + checks.append({"check": name, "pass": bool(value)}) + + async with engine.connect() as connection: + transaction = await connection.begin() + try: + await connection.execute(text(f"CREATE SCHEMA {schema}")) + await connection.execute(text(f"SET LOCAL search_path TO {schema}")) + + def apply(sync): + with Operations.context(MigrationContext.configure(sync)): + module.upgrade() + inspector = inspect(sync) + models = [ + CommerceReceipt, + CommerceOrder, + CommerceFulfilmentEvent, + CommerceReservation, + ] + check( + "exactly four additive commerce tables", + set(inspector.get_table_names(schema=schema)) + == {m.__tablename__ for m in models}, + ) + for model in models: + table = model.__table__ + actual = inspector.get_columns(table.name, schema=schema) + expected = { + c.name: (str(c.type.compile(dialect=sync.dialect)), c.nullable) + for c in table.columns + } + found = { + c["name"]: (str(c["type"].compile(dialect=sync.dialect)), c["nullable"]) + for c in actual + } + check(table.name + " column types and nullability match ORM", found == expected) + check( + table.name + " primary key matches ORM", + inspector.get_pk_constraint(table.name, schema=schema)[ + "constrained_columns" + ] + == ["id"], + ) + expected_unique = { + tuple(c.name for c in constraint.columns) + for constraint in table.constraints + if constraint.__class__.__name__ == "UniqueConstraint" + } + check( + table.name + " uniqueness constraints match ORM", + { + tuple(c["column_names"]) + for c in inspector.get_unique_constraints(table.name, schema=schema) + } + == expected_unique, + ) + found_indexes = { + i["name"]: bool(i["unique"]) + for i in inspector.get_indexes(table.name, schema=schema) + if not i.get("duplicates_constraint") + } + check( + table.name + " indexes match ORM", + found_indexes == {i.name: bool(i.unique) for i in table.indexes}, + ) + + await connection.run_sync(apply) + refused = False + try: + module.downgrade() + except RuntimeError: + refused = True + check("destructive ledger downgrade refused", refused) + finally: + await transaction.rollback() + check( + "temporary schema fully rolled back", + await connection.scalar( + text("SELECT NOT EXISTS(SELECT 1 FROM pg_namespace WHERE nspname=:schema)"), + {"schema": schema}, + ), + ) + report = { + "checks": checks, + "failed": sum(not c["pass"] for c in checks), + "scope": "Actual additive Alembic DDL on isolated PostgreSQL in a temporary schema; transaction rolled back. Production untouched.", + } + print(json.dumps(report)) + if report["failed"]: + raise SystemExit(1) + + +asyncio.run(main()) diff --git a/backend/tests/commerce/test_runtime_profiles.py b/backend/tests/commerce/test_runtime_profiles.py new file mode 100644 index 00000000..b3f825e0 --- /dev/null +++ b/backend/tests/commerce/test_runtime_profiles.py @@ -0,0 +1,90 @@ +"""No database or provider calls: fail-closed configuration boundary tests.""" + +import unittest +from app.commerce_runtime import CommerceRuntime + + +class RuntimeProfiles(unittest.TestCase): + def test_isolated_references_remain_compatible(self): + p = CommerceRuntime.from_environment( + { + "DATABASE_URL": "postgresql+psycopg://u:password@batchivo-db/batchivo_commerce_test", + "COMMERCE_ISOLATED": "true", + } + ) + self.assertTrue(p.isolated) + self.assertEqual(p.job_reference(468, 23), "WOO-TEST-468:23") + self.assertEqual(p.square_base, "https://connect.squareupsandbox.com/v2") + self.assertEqual(p.token_environment_key, "SQUARE_SANDBOX_ACCESS_TOKEN") + + def test_isolation_is_exact_not_substring(self): + for url in [ + "postgresql://u:batchivo_commerce_test@postgres/batchivo", + "postgresql://db/batchivo_commerce_test_other", + "postgresql://db/batchivo_commerce_test/other", + "sqlite:///batchivo_commerce_test", + ]: + with self.subTest(url=url), self.assertRaises(RuntimeError): + CommerceRuntime.from_environment({"DATABASE_URL": url, "COMMERCE_ISOLATED": "true"}) + + def test_effective_rls_target_cannot_bypass_isolation(self): + env = { + "DATABASE_URL": "postgresql://db/batchivo_commerce_test", + "COMMERCE_ISOLATED": "true", + "RLS_ENABLED": "true", + "RLS_DATABASE_URL": "postgresql://postgres.batchivo.svc/batchivo", + } + with self.assertRaises(RuntimeError): + CommerceRuntime.from_environment(env) + + def production(self): + return { + "COMMERCE_MODE": "production", + "COMMERCE_ISOLATED": "false", + "COMMERCE_PRODUCTION_ENABLED": "true", + "COMMERCE_RELEASE_ID": "a" * 40, + "DATABASE_URL": "postgresql+psycopg://u:secret@postgres.batchivo.svc.cluster.local/batchivo", + "SQUARE_PRODUCTION_ACCESS_TOKEN": "fixture", + "SQUARE_PRODUCTION_LOCATION_ID": "fixture-location", + } + + def test_production_requires_each_activation_field(self): + for key in self.production(): + env = self.production() + env.pop(key) + with self.subTest(key=key), self.assertRaises(RuntimeError): + CommerceRuntime.from_environment(env) + + def test_production_rejects_test_and_ambiguous_targets(self): + for key, value in [ + ("COMMERCE_ISOLATED", "true"), + ("COMMERCE_MODE", "staging"), + ("COMMERCE_RELEASE_ID", "latest"), + ("DATABASE_URL", "postgresql://db/batchivo_commerce_test"), + ("DATABASE_URL", "postgresql://postgres/batchivo"), + ("DATABASE_URL", "postgresql://postgres.batchivo.svc/another"), + ]: + env = self.production() + env[key] = value + with self.subTest(key=key, value=value), self.assertRaises(RuntimeError): + CommerceRuntime.from_environment(env) + + def test_activated_production_has_distinct_credentials_and_references(self): + p = CommerceRuntime.from_environment(self.production()) + self.assertFalse(p.isolated) + self.assertEqual(p.order_reference(468), "WOO-468") + self.assertEqual(p.job_reference(468, 23), "WOO-468:23") + self.assertEqual(p.square_base, "https://connect.squareup.com/v2") + self.assertEqual(p.token_environment_key, "SQUARE_PRODUCTION_ACCESS_TOKEN") + self.assertEqual(p.source, "woocommerce") + + def test_configuration_error_does_not_echo_credentials(self): + env = self.production() + env["DATABASE_URL"] = "postgresql://u:secret@unreviewed/batchivo" + with self.assertRaises(RuntimeError) as ctx: + CommerceRuntime.from_environment(env) + self.assertNotIn("secret", str(ctx.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/integration/commerce_handover_http.py b/backend/tests/integration/commerce_handover_http.py new file mode 100644 index 00000000..894f0b17 --- /dev/null +++ b/backend/tests/integration/commerce_handover_http.py @@ -0,0 +1,139 @@ +"""Actual native route dependencies and signed callback against disposable PostgreSQL.""" + +import base64 +import hashlib +import hmac +import json +from types import SimpleNamespace +from uuid import uuid4 + +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient +from sqlalchemy import text +from sqlalchemy.ext.asyncio import async_sessionmaker + +from app.api.v1 import customer_account, customer_auth, payments, shop +from app.auth.customer_dependencies import create_customer_refresh_token, get_current_customer +from app.auth.dependencies import get_shop_sales_channel, get_shop_tenant +from app.database import get_db +from app.models.webhook_event import WebhookEvent +from app.services import commerce_handover as handover + + +async def probe_http(engine, check): + app = FastAPI() + for prefix, module in [ + ("/payments", payments), + ("/shop", shop), + ("/auth", customer_auth), + ("/account", customer_account), + ]: + app.include_router(module.router, prefix=prefix) + tenant = SimpleNamespace(id=handover.TENANT) + customer = SimpleNamespace(id=uuid4(), tenant_id=handover.TENANT) + sessions = async_sessionmaker(engine, expire_on_commit=False) + + async def database(): + async with sessions() as session: + yield session + + # Authentication/context fixtures only. Ownership, locks, callback processing, + # signature verification, persistence and route dependency wiring are real. + app.dependency_overrides[get_db] = database + app.dependency_overrides[get_shop_tenant] = lambda: tenant + app.dependency_overrides[get_shop_sales_channel] = lambda: (tenant, None) + app.dependency_overrides[get_current_customer] = lambda: customer + product = uuid4() + async with engine.begin() as conn: + await conn.execute( + text("CREATE TABLE products (id uuid PRIMARY KEY, tenant_id uuid NOT NULL)") + ) + await conn.execute( + text("INSERT INTO products VALUES (:id,:tenant)"), + {"id": product, "tenant": handover.TENANT}, + ) + await conn.execute(text("CREATE TABLE orders (id uuid PRIMARY KEY)")) + await conn.run_sync(WebhookEvent.__table__.create) + refresh = create_customer_refresh_token( + customer.id, handover.TENANT, "handover@example.invalid" + ) + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://handover.test" + ) as client: + routes = [ + ("POST", "/payments/hosted-checkout", {}), + ("POST", "/payments/process", {}), + ("POST", "/shop/checkout/create-payment", {}), + ("POST", "/shop/checkout/complete", {}), + ("POST", "/auth/register", {}), + ("POST", "/auth/login", {}), + ("POST", "/auth/forgot-password", {}), + ("POST", "/auth/reset-password", {}), + ("POST", "/auth/verify-email", {}), + ("POST", "/auth/resend-verification", {}), + ("POST", "/auth/change-password", {}), + ("POST", "/auth/refresh", {"refresh_token": refresh}), + ("PUT", "/account/profile", {}), + ("POST", f"/shop/products/{product}/reviews", {}), + ("POST", f"/shop/products/{product}/reviews/{uuid4()}/helpful", {}), + ] + for method, path, body in routes: + response = await client.request(method, path, json=body) + check( + "Native HTTP writer fenced: " + path.replace(str(product), "product"), + response.status_code == 409, + ) + response = await client.get("/payments/config") + check( + "Read-only payment configuration remains available during ownership fence", + response.status_code == 200, + ) + previous = payments.settings.square_webhook_signature_key + key = "isolated-handover-signature-key" + payments.settings.square_webhook_signature_key = key + try: + body = json.dumps( + {"type": "handover.acceptance", "event_id": str(uuid4()), "data": {}} + ).encode() + signature = base64.b64encode( + hmac.new( + key.encode(), + b"http://handover.test/payments/webhooks/square" + body, + hashlib.sha256, + ).digest() + ).decode() + headers = {"x-square-hmacsha256-signature": signature} + first = await client.post("/payments/webhooks/square", content=body, headers=headers) + check( + "Signed legacy callback persists successfully while legacy writers are fenced", + first.status_code == 200 and first.json()["status"] == "processed", + ) + duplicate = await client.post( + "/payments/webhooks/square", content=body, headers=headers + ) + check( + "Actual PostgreSQL callback receipt prevents duplicate processing", + duplicate.status_code == 200 and duplicate.json()["status"] == "duplicate", + ) + async with engine.begin() as conn: + await conn.execute( + text("ALTER TABLE webhook_events RENAME TO webhook_events_unavailable") + ) + failed = await client.post("/payments/webhooks/square", content=body, headers=headers) + check( + "Actual unavailable receipt table returns retryable HTTP 503", + failed.status_code == 503, + ) + async with engine.begin() as conn: + await conn.execute( + text("ALTER TABLE webhook_events_unavailable RENAME TO webhook_events") + ) + recovered = await client.post( + "/payments/webhooks/square", content=body, headers=headers + ) + check( + "Legacy callback recovers without a duplicate after database availability returns", + recovered.status_code == 200 and recovered.json()["status"] == "duplicate", + ) + finally: + payments.settings.square_webhook_signature_key = previous diff --git a/backend/tests/integration/commerce_handover_probe.py b/backend/tests/integration/commerce_handover_probe.py new file mode 100644 index 00000000..bc2b497a --- /dev/null +++ b/backend/tests/integration/commerce_handover_probe.py @@ -0,0 +1,153 @@ +"""Executable probe against a disposable real PostgreSQL database, not mocks.""" + +import asyncio +import importlib.util +import json +import os +from pathlib import Path +from uuid import uuid4 + +from alembic.migration import MigrationContext +from alembic.operations import Operations +from fastapi import HTTPException +from sqlalchemy import text + +from app.services import commerce_handover as handover + +checks = [] + + +def check(name, passed): + checks.append({"check": name, "pass": bool(passed)}) + if not passed: + raise RuntimeError(name) + + +async def probe(): + engine = handover.handover_engine() + migration_path = ( + Path(__file__).resolve().parents[2] / "alembic/versions/20260909_commerce_handover.py" + ) + spec = importlib.util.spec_from_file_location("handover_migration", migration_path) + migration = importlib.util.module_from_spec(spec) + spec.loader.exec_module(migration) + async with engine.begin() as c: + + def migrate(conn): + with Operations.context(MigrationContext.configure(conn)): + migration.upgrade() + + await c.run_sync(migrate) + check("Actual Alembic migration creates legacy ownership without changing it", True) + os.environ["COMMERCE_LEGACY_HANDOVER_ENABLED"] = "true" + release = "a" * 40 + eid = str(uuid4()) + entered = asyncio.Event() + finish = asyncio.Event() + + async def existing_writer(): + async with handover.legacy_writer(handover.TENANT): + # Real intermediate transaction/commit on an independent connection. + async with engine.begin() as transaction: + await transaction.execute(text("SELECT 1")) + entered.set() + await finish.wait() + + old = asyncio.create_task(existing_writer()) + await entered.wait() + async with engine.connect() as c: + acquired = ( + await c.execute(text("SELECT pg_try_advisory_lock(:key)"), {"key": handover.LOCK}) + ).scalar_one() + check("Shared request lock survives an intermediate application commit", not acquired) + if acquired: + await c.execute(text("SELECT pg_advisory_unlock(:key)"), {"key": handover.LOCK}) + transition = asyncio.create_task(handover.transition_owner(0, "fenced", eid, release)) + # Give the real server time to enqueue the exclusive lock request. + await asyncio.sleep(0.15) + check("Final fence waits for an already-running legacy writer", not transition.done()) + finish.set() + await old + result = await transition + check( + "Fence commits only after the existing writer exits", + result["revision"] == 1 and result["to"] == "fenced", + ) + try: + async with handover.legacy_writer(handover.TENANT): + raise AssertionError("Unexpected legacy write") + except HTTPException as e: + check("New legacy writes are rejected after the drain", e.status_code == 409) + duplicate = await handover.transition_owner(0, "fenced", eid, release) + check( + "Same transition receipt replays without another revision", + duplicate["duplicate"] and duplicate["revision"] == 1, + ) + for expected, target, event in [(0, "legacy", str(uuid4())), (0, "woocommerce", eid)]: + try: + await handover.transition_owner(expected, target, event, release) + raise AssertionError("Conflict accepted") + except ValueError: + check("Stale revision or reused event body is refused", True) + await handover.transition_owner(1, "woocommerce", str(uuid4()), release) + try: + async with handover.legacy_writer(handover.TENANT): + raise AssertionError("Unexpected legacy write") + except HTTPException as e: + check("Woo ownership keeps legacy writers closed", e.status_code == 409) + async with handover.legacy_writer(uuid4()): + check("Another tenant retains its existing behavior", True) + try: + await handover.transition_owner(2, "legacy", str(uuid4()), release) + raise AssertionError("Direct ownership jump allowed") + except ValueError: + check("Rollback cannot jump directly across the fence", True) + from commerce_handover_http import probe_http + + await probe_http(engine, check) + await handover.transition_owner(2, "fenced", str(uuid4()), release) + await handover.transition_owner(3, "legacy", str(uuid4()), release) + entered.clear() + old = asyncio.create_task(existing_writer()) + # Reset the completion gate for a genuinely held/cancelled request. + finish.clear() + await entered.wait() + old.cancel() + try: + await old + except asyncio.CancelledError: + pass + async with engine.connect() as c: + acquired = ( + await c.execute(text("SELECT pg_try_advisory_lock(:key)"), {"key": handover.LOCK}) + ).scalar_one() + check("Cancelled request releases its physical-session lock", acquired) + if acquired: + await c.execute(text("SELECT pg_advisory_unlock(:key)"), {"key": handover.LOCK}) + async with engine.begin() as c: + await c.execute( + text("DELETE FROM commerce_handover WHERE tenant_id=:tenant"), + {"tenant": handover.TENANT}, + ) + try: + async with handover.legacy_writer(handover.TENANT): + raise AssertionError("Missing ownership opened writes") + except HTTPException as e: + check("Missing ownership record fails closed", e.status_code == 503) + os.environ["COMMERCE_LEGACY_HANDOVER_ENABLED"] = "false" + async with handover.legacy_writer(handover.TENANT): + check("Disabled deployment feature preserves earlier native behavior", True) + await engine.dispose() + print( + json.dumps( + { + "checks": checks, + "failed": sum(not x["pass"] for x in checks), + "scope": "Actual PostgreSQL advisory locks, Alembic migration, concurrent drain, intermediate commits, cancellation, CAS/audit replay and rollback state machine in a disposable database. No production fence applied.", + } + ) + ) + + +if __name__ == "__main__": + asyncio.run(probe()) diff --git a/backend/tests/unit/test_payments_webhook.py b/backend/tests/unit/test_payments_webhook.py index cc8a3963..2796ecc9 100644 --- a/backend/tests/unit/test_payments_webhook.py +++ b/backend/tests/unit/test_payments_webhook.py @@ -356,7 +356,8 @@ async def test_unhandled_event_type_returns_200(self, app, mock_db): assert response.status_code == 200 # Unhandled events are logged and processed gracefully - assert response.json()["status"] in ("received", "processed") + assert response.json()["status"] == "rejected" + mock_db.execute.assert_not_awaited() @pytest.mark.asyncio async def test_event_without_type_returns_200(self, app, mock_db): @@ -655,33 +656,68 @@ class TestWebhookErrorHandling: """Tests for error handling during webhook processing.""" @pytest.mark.asyncio - async def test_handler_exception_does_not_fail_webhook(self, app, mock_db): - """Test that handler exceptions don't cause webhook to fail.""" - mock_settings = MagicMock() - mock_settings.square_webhook_signature_key = None - - # Make the db query raise an exception - mock_db.execute.side_effect = Exception("Database error") - - event = create_payment_updated_event() - body = json.dumps(event).encode() + async def test_database_exception_requests_provider_retry(self, app, mock_db, mock_settings): + """A signed event must not be acknowledged if no receipt can be stored.""" + mock_db.execute.side_effect = Exception("Database error containing private data") + body = json.dumps(create_payment_updated_event()).encode() + signature = generate_signature( + body, + mock_settings.square_webhook_signature_key, + "http://testserver/payments/webhooks/square", + ) + from app.database import get_db - with patch("app.api.v1.payments.get_settings", return_value=mock_settings): + app.dependency_overrides[get_db] = lambda: mock_db + with patch("app.api.v1.payments.settings", mock_settings): async with AsyncClient( transport=ASGITransport(app=app), base_url="http://testserver" ) as client: - from app.database import get_db - - app.dependency_overrides[get_db] = lambda: mock_db - response = await client.post( "/payments/webhooks/square", content=body, + headers={"x-square-hmacsha256-signature": signature}, ) + assert response.status_code == 503 + assert response.headers["retry-after"] == "60" + assert "private data" not in response.text + mock_db.execute.assert_awaited_once() - # Should still return 200 even if handler fails - assert response.status_code == 200 - assert response.json()["status"] == "received" + @pytest.mark.parametrize( + "status,expected", + [("failed", 503), ("processing", 503), ("processed", 200), ("duplicate", 200)], + ) + @pytest.mark.asyncio + async def test_processing_result_controls_acknowledgement( + self, app, mock_db, mock_settings, status, expected + ): + body = json.dumps( + {"type": "some.unhandled.event", "event_id": "test-event", "data": {}} + ).encode() + signature = generate_signature( + body, + mock_settings.square_webhook_signature_key, + "http://testserver/payments/webhooks/square", + ) + from app.database import get_db + + app.dependency_overrides[get_db] = lambda: mock_db + with ( + patch("app.api.v1.payments.settings", mock_settings), + patch( + "app.services.square_webhook_service.SquareWebhookService.process_webhook", + new=AsyncMock(return_value={"status": status, "event_id": "test-event"}), + ) as process, + ): + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://testserver" + ) as client: + response = await client.post( + "/payments/webhooks/square", + content=body, + headers={"x-square-hmacsha256-signature": signature}, + ) + assert response.status_code == expected + assert process.call_args.kwargs["signature_valid"] is True # ============================================ @@ -712,8 +748,8 @@ class TestWebhookIntegration: """Integration-style tests for webhook endpoint.""" @pytest.mark.asyncio - async def test_full_payment_created_flow(self, app, mock_db): - """Test complete flow for payment.created event.""" + async def test_unsigned_payment_never_reaches_database(self, app, mock_db): + """Unsigned payment events cannot mutate orders.""" mock_settings = MagicMock() mock_settings.square_webhook_signature_key = None @@ -736,7 +772,8 @@ async def test_full_payment_created_flow(self, app, mock_db): assert response.status_code == 200 # Response now includes additional fields from the robust webhook service data = response.json() - assert data["status"] in ["received", "processed", "duplicate"] + assert data["status"] == "rejected" + mock_db.execute.assert_not_awaited() @pytest.mark.asyncio async def test_full_refund_flow_updates_order(self, app, mock_db, mock_order): diff --git a/backend/tests/unit/test_square_webhook_service.py b/backend/tests/unit/test_square_webhook_service.py index 64fa44f9..fbdd2574 100644 --- a/backend/tests/unit/test_square_webhook_service.py +++ b/backend/tests/unit/test_square_webhook_service.py @@ -45,6 +45,10 @@ def mock_db(): """Create mock async database session.""" db = AsyncMock() db.execute = AsyncMock() + # SQLAlchemy scalars() is awaited; ScalarResult.all() is synchronous. + scalar_result = MagicMock() + scalar_result.all.return_value = [] + db.scalars = AsyncMock(return_value=scalar_result) db.commit = AsyncMock() db.flush = AsyncMock() db.refresh = AsyncMock() @@ -234,24 +238,24 @@ async def test_invalid_signature_returns_false(self, webhook_service): assert result is False @pytest.mark.asyncio - async def test_empty_key_skips_validation(self, webhook_service): - """Test that missing key skips validation.""" + async def test_empty_key_rejects_signature(self, webhook_service): + """Test that missing key rejects validation.""" body = b'{"type": "payment.created"}' url = "https://example.com/webhooks/square" result = await webhook_service.verify_signature(body, "any", "", url) - assert result is True + assert result is False @pytest.mark.asyncio - async def test_none_key_skips_validation(self, webhook_service): - """Test that None key skips validation.""" + async def test_none_key_rejects_signature(self, webhook_service): + """Test that None key rejects validation.""" body = b'{"type": "payment.created"}' url = "https://example.com/webhooks/square" result = await webhook_service.verify_signature(body, "any", None, url) - assert result is True + assert result is False @pytest.mark.asyncio async def test_missing_signature_returns_false(self, webhook_service): diff --git a/docs/operations/SINGLE-OWNER-RELEASES.md b/docs/operations/SINGLE-OWNER-RELEASES.md new file mode 100644 index 00000000..5495ef1e --- /dev/null +++ b/docs/operations/SINGLE-OWNER-RELEASES.md @@ -0,0 +1,78 @@ +# Batchivo single-owner releases + +## Handover sequence + +PR #202 introduces real Woodpecker PR validation and includes the native commerce +source already deployed in production. Keep the Argo commit pin until the PR has +passed its required checks, merged normally, and its Kubernetes tree has been +verified against the running release. The PR and the shop operations handover +report record the current migration status. Do not bypass validation. + +## Validation and publishing + +`test-fast` runs backend lint/format, unit tests with ephemeral PostgreSQL, +frontend lint/tests/TypeScript build, and backend/frontend dependency scans. +`test-integration` runs the remaining backend tests against its own ephemeral +PostgreSQL databases. Both run on PRs targeting main, pushes to main, and explicit +`build-release` deployment events. Neither receives publishing credentials or +production database credentials. + +GitHub reports the actual PR contexts as `ci/woodpecker/pr/test-fast` and +`ci/woodpecker/pr/test-integration`. Require both with an up-to-date branch before +merging. Single-owner policy removes the impossible second-person/code-owner +approval requirement; it does not waive tests or security checks. + +Publishing is a separate owner-triggered action on a successful main pipeline: + +```sh +woodpecker-cli pipeline deploy techize/batchivo MAIN_PIPELINE_NUMBER build-release +``` + +The event reruns validation first. `build` depends on both validation workflows, +publishes commit-tagged backend/frontend images, then scans those images. A failed +scan means the images are not eligible for promotion. Publishing does not edit +staging or production manifests. PR validation never publishes images. + +## Promotion + +1. Record the successful release run, exact main commit and image digests. +2. Rehearse migrations and verify the release against isolated commerce data. +3. Create a separate manifest PR pinning the approved digests (including migration + init containers), with a rollback reference and migration compatibility notes. +4. Run required PR checks and inspect the rendered manifest diff. Preserve commerce + role credentials, order ownership and legacy payment callback configuration. +5. Merge the exact validated PR. Allow Argo to synchronize only after checking its + target revision and ensuring the intended manifest is the only release change. +6. Verify readiness, order/stock processing, callbacks and monitoring. Do not make + real payment charges as a deployment smoke test. + +Rollback uses the previously recorded manifest/image revision. Database changes +must remain backwards compatible; do not restore a stale database over live orders. +The existing shop cutover runbook retains the customer/payment reconciliation steps. + +## Lint compatibility + +Ruff 0.16 expanded its default rule set. The project explicitly selects its +established `E4`, `E7`, `E9`, `F` rules to prevent dependency upgrades silently +changing lint policy. Existing SQLAlchemy relationship/conditional-import ignores +are retained. Source was formatted and genuine selected-rule failures fixed. +See https://astral.sh/blog/ruff-v0.16.0. + +## CI host log capture + +The k3s nodes need sufficient inotify capacity for Kubernetes log streams. The +shop operations repository owns `commerce/scripts/configure-ci-log-limits.py` +and its node preimage report. It persists a minimum of 1,024 instances and +524,288 watches in `/etc/sysctl.d/90-woodpecker-inotify.conf`, without reducing +higher existing limits. It does not restart services. Scratch CI databases use +`fsync=off` and `synchronous_commit=off`; production database settings are untouched. + +## Validation scope + +The legacy integration suite includes pre-existing skipped cases (rate-limiter +isolation, SQLite-era return fixtures and unfinished historical endpoints). A +green CI run does not mean those skipped scenarios were exercised. Preserve their +counts in release evidence and use the commerce sandbox/browser acceptance suite +for changes to payments, orders, fulfilment or stock. Dependency lock updates here +do not patch the already-running image; a new image needs its own isolated release +acceptance before promotion. diff --git a/frontend/.gitignore b/frontend/.gitignore index f6f7f336..737b3568 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -32,3 +32,6 @@ dist-ssr *storybook.log storybook-static + +# CI-generated JUnit evidence +test-results.xml diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fdef0842..00c4f5f6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -41,11 +41,11 @@ "@tailwindcss/typography": "^0.5.19", "@tanstack/react-query": "^5.90.5", "@tanstack/react-router": "^1.141.1", - "@tiptap/extension-link": "^3.14.0", - "@tiptap/extension-placeholder": "^3.14.0", - "@tiptap/react": "^3.14.0", - "@tiptap/starter-kit": "^3.20.4", - "axios": "^1.15.2", + "@tiptap/extension-link": "^3.31.3", + "@tiptap/extension-placeholder": "^3.31.3", + "@tiptap/react": "^3.31.3", + "@tiptap/starter-kit": "^3.31.3", + "axios": "^1.20.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -93,7 +93,7 @@ "eslint-plugin-storybook": "^10.1.11", "globals": "^16.4.0", "jsdom": "^27.3.0", - "postcss": "^8.5.10", + "postcss": "^8.5.28", "storybook": "^10.1.11", "tailwindcss": "^3.4.18", "typescript": "~5.9.3", @@ -185,13 +185,13 @@ "license": "MIT" }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -200,9 +200,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz", - "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -210,21 +210,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -241,14 +241,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -258,14 +258,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -275,9 +275,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -285,29 +285,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -327,9 +327,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -337,9 +337,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -347,9 +347,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -357,27 +357,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -429,33 +429,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -463,14 +463,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -695,6 +695,40 @@ "react": ">=16.8.0" } }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -1363,29 +1397,43 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "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.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "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.1", + "@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", @@ -1433,18 +1481,18 @@ } }, "node_modules/@joshwooding/vite-plugin-react-docgen-typescript": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/@joshwooding/vite-plugin-react-docgen-typescript/-/vite-plugin-react-docgen-typescript-0.6.3.tgz", - "integrity": "sha512-9TGZuAX+liGkNKkwuo3FYJu7gHWT0vkBcf7GkOe7s7fmC19XwH/4u5u7sDIFrMooe558ORcmuBvBz7Ur5PlbHw==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@joshwooding/vite-plugin-react-docgen-typescript/-/vite-plugin-react-docgen-typescript-0.7.0.tgz", + "integrity": "sha512-qvsTEwEFefhdirGOPnu9Wp6ChfIwy2dBCRuETU3uE+4cC+PFoxMSiiEhxk4lOluA34eARHA0OxqsEUYDqRMgeQ==", "dev": true, "license": "MIT", "dependencies": { - "glob": "^11.1.0", + "glob": "^13.0.1", "react-docgen-typescript": "^2.2.2" }, "peerDependencies": { "typescript": ">= 4.3.x", - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "typescript": { @@ -1483,9 +1531,9 @@ } }, "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==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -1516,6 +1564,28 @@ "react": ">=16" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz", + "integrity": "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" + } + }, "node_modules/@neoconfetti/react": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@neoconfetti/react/-/react-1.0.0.tgz", @@ -1818,12 +1888,12 @@ } }, "node_modules/@opentelemetry/resources": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz", - "integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.11.0.tgz", + "integrity": "sha512-Ie7+8q8MDF4FAEQCKVMTx3ReUvxiIAgIiiW3c9JdmP8+HMcDy20puT+AHjexnExgnbvBxjQ9fjkFDWrikJ2jQA==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.7.1", + "@opentelemetry/core": "2.11.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -1834,9 +1904,9 @@ } }, "node_modules/@opentelemetry/resources/node_modules/@opentelemetry/core": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz", - "integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.11.0.tgz", + "integrity": "sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" @@ -2010,15 +2080,720 @@ "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.39.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.39.0.tgz", - "integrity": "sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.39.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.39.0.tgz", + "integrity": "sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@oxc-parser/binding-android-arm-eabi": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.127.0.tgz", + "integrity": "sha512-0LC7ye4hvqbIKxAzThzvswgHLFu2AURKzYLeSVvLdu2TBOYWQDmHnTqPLeA597BcUCxiLqLsS4CJ5uoI5WYWCQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-android-arm64": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.127.0.tgz", + "integrity": "sha512-b5jtVTH6AU5CJXHNdj7Jj9IEiR9yVjjnwHzPJhGyHGPdcsZSzBCkS9GBbV33niRMvKthDwQRFRJfI4a+k4PvYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-arm64": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.127.0.tgz", + "integrity": "sha512-obCE8B7ISKkJidjlhv9xRGJPOSDG2Yu6PRga9Ruaz35uintHxbp1Ki/Yc71wx4rj3Edrm0a1kzG1TAwit0wFpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-x64": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.127.0.tgz", + "integrity": "sha512-JL6Xb5IwPQT8rUzlpsX7E+AgfcdNklXNPFp8pjCQQ5MQOQo5rtEB2ui+3Hgg9Sn7Y9Egj6YOLLiHhLpdAe12Aw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-freebsd-x64": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.127.0.tgz", + "integrity": "sha512-SDQ/3MQFw58fqQz3Z1PhSKFF3JoCF4gmlNjziDm8X02tTahCw0qJbd7FGPDKw1i4VTBZene9JPyC3mHtSvi+wA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.127.0.tgz", + "integrity": "sha512-Av+D1MIqzV0YMGPT9we2SIZaMKD7Cxs4CvXSx/yxaWHewZjYEjScpOf5igc8IILASViw4WTnjlwUdI1KzVtDHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-musleabihf": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.127.0.tgz", + "integrity": "sha512-Cs2fdJ8cPpFdeebj6p4dag8A4+56hPvZ0AhQQzlaLswGz1tz7bXt1nETLeorrM9+AMcWFFkqxcXwDGfTVidY8g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-gnu": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.127.0.tgz", + "integrity": "sha512-qdOfTcT6SY8gsJrrV92uyEUyjqMGPpIB5JZUG6QN5dukYd+7/j0kX6MwK1DgQj39jtUYixxPiaRUiEN1+0CXgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-musl": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.127.0.tgz", + "integrity": "sha512-EoTCZneNFU/P2qrpEM+RHmQwt+CvDkyGESG6qhr7KaegXLZwePfbrkCDfAk8/rhxbDUVGsZILX+2tqPzFtoFWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-ppc64-gnu": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.127.0.tgz", + "integrity": "sha512-zALjmZYgxFLHjXeudcDF0xFGNydTAtkAeXAr2EuC17ywCyFxcmQra4w0BMde0Yi/re4Bi4iwEoEXtYN7l6eBLQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-gnu": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.127.0.tgz", + "integrity": "sha512-fPP8M6zQLS7Jz7o9d5ArUSuAuSK3e+WCYVrCpdzeCOejidtZExJ9tjhDrAd3HEPqARBCPmdpqxESPFqy44vkBQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-musl": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.127.0.tgz", + "integrity": "sha512-7IcC4Ao02oGpfnjt+X/oF4U2mllo2qoSkw5xxiXNKL9MCTsTiAC6616beOuehdxGcnz1bRoPC1RQ2f1GQDdN+g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-s390x-gnu": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.127.0.tgz", + "integrity": "sha512-pbXIhiNFHoqWeqDNLiJ9JkpHz1IM9k4DXa66x+1GTWMG7iLxtkXgE53iiuKSXwmk3zIYmaPVfBvgcAhS583K4Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-gnu": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.127.0.tgz", + "integrity": "sha512-MYCguB9RvBvlSd6gbuNI7QwiLoCCAlGnlRJFPrzLI6U1/9wkC/WK6LtBAUln55H1Ctqw45PWmqrobKoMhsYQzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-musl": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.127.0.tgz", + "integrity": "sha512-5eY0B/bxf1xIUxb4NOTvOI3KWtBQfPWYyKAzgcrCt0mDibSZygVpO1Pz8bkeiSZ5Jj9+M09dkggG3H8I5d0Uyg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-openharmony-arm64": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.127.0.tgz", + "integrity": "sha512-Gld0ajrFTUXNtdw20fVBuTQx66FA75nIVg+//pPfR3sXkuABB4mTBhl3r9JNzrJpgW//qiwxf0nWXUWGJSL3UQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-wasm32-wasi": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.127.0.tgz", + "integrity": "sha512-T6KVD7rhLzFlwGRXMnxUFfkCZD8FHnb968wVXW1mXzgRFc5RNXOBY2mPPDZ77x5Ln76ltLMgtPg0cOkU1NSrEQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-arm64-msvc": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.127.0.tgz", + "integrity": "sha512-Ujvw4X+LD1CCGULcsQcvb4YNVoBGqt+JHgNNzGGaCImELiZLk477ifUH53gIbE7EKd933NdTi25JWEr9K2HwXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-ia32-msvc": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.127.0.tgz", + "integrity": "sha512-0cwxKO7KHQQQfo4Uf4B2SQrhgm+cJaP9OvFFhx52Tkg4bezsacu83GB2/In5bC415Ueeym+kXdnge/57rbSfTw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-x64-msvc": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.127.0.tgz", + "integrity": "sha512-rOrnSQSCbhI2kowr9XxE7m9a8oQXnBHjnS6j95LxxAnEZ0+Fz20WlRXG4ondQb+ejjt2KOsa65sE6++L6kUd+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxc-resolver/binding-android-arm-eabi": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.21.2.tgz", + "integrity": "sha512-xQoCRv+gKax9KTdwdaQNnAFOai8neay7g3jExDIORzhbrejwGSJaZNTdOJHR5ziLg2joMxOCMOFMo4zuxza2uQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oxc-resolver/binding-android-arm64": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.21.2.tgz", + "integrity": "sha512-HF5oiE2L05yInPYCFD/4uxSrEZW4SuIfn99Y6L1xnJnzl066JR+MJs2rIdstw8A2MPlAKH+13dpFPNycjqzvGg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oxc-resolver/binding-darwin-arm64": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.21.2.tgz", + "integrity": "sha512-UX4u49CVCAD8QZNELaW8eMGgMAGwFWYEPbvNsh+3r/gs4NX3KfpiACMVwRQT0EuH3uat9hM5Zl+Ppm9pJD8tgg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-darwin-x64": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.21.2.tgz", + "integrity": "sha512-J9xPx7YBkrRmJ+xl561ztnMWEc1aOyjEIxBiGX1dVb3u7bGSnfObfcZk+Pd+uM0HZAPNsQ1xvD8j52A/uOSqNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-freebsd-x64": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.21.2.tgz", + "integrity": "sha512-fRlt7OvSaQkWj6+EDTVxawVxOlqJB2QSnBfkeCyK4RTvsGctbw3BiH2Tb7DzMs7bikc4BRBpvWP5zF9K8b54Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.21.2.tgz", + "integrity": "sha512-gK+vPUcPQITkGwBKpZGrcDHSlU6eDGl7AQacxS2CEKAZIBHWkOVFeJwLZ4tYnA1acJqRM5lt7yYwPCVqGHIJ7A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-musleabihf": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.21.2.tgz", + "integrity": "sha512-L/Rgas7SrOKy/z7IH+HxSFRqVO4PuLDKLEGKvnhoCBBq3UJ0YzGBou3qMzaAGgkJwsIvX2pBF+7ojzfUhLiZxQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-gnu": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.21.2.tgz", + "integrity": "sha512-CUEYvlX1Fk7E9kUMzuswru1J9HLxMwnpDeQGjQuI4ZH+iNCoa2X9T+pvyzrbsgl7WnIeTFTlNlHsRfVdf5g9/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-musl": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.21.2.tgz", + "integrity": "sha512-ViN1ZibQyxwC67GpoP33oo+S9UyUnkog13vzQb9+v9bCNvrVzJuk0MRdacjtv/9xfRMVF/eqHFyl8YOeykI10Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-ppc64-gnu": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.21.2.tgz", + "integrity": "sha512-CU1sCqWnhGqYD5I1HedHk5pujrn7ssDkNB/AEQd/pd3D/EojVSgJUlpbafwIbyhia3PgIfkvdFpRPWSALzVumA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-gnu": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.21.2.tgz", + "integrity": "sha512-jWVyZtIHca4Gb96x7dag+y69vlei7ffjrsveLkmf2ZhqEAz6ZSBnY1GWvgZUaZlwZP62A3xD092BW97Q5VGc+g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-musl": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.21.2.tgz", + "integrity": "sha512-LF29obFqNgBUgDX7rmUK7M4D0JQG5LxhYzn3xXmECcHU9aQAdWG7NiY052qybtesEdwHQXKNTWYQ7mTsybNvWg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-s390x-gnu": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.21.2.tgz", + "integrity": "sha512-+NYcm+cCHBbtdQQ3A4phQTSuVRYnNHz7wrl9XRAPEovcdoqi0mb1K5ZOl+jN54ZD+q1zz3V0vltbFJmzecJKmw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-x64-gnu": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.21.2.tgz", + "integrity": "sha512-UQqZDdG2r2HhAOsZEgufkIWHPQ886IUyuJQkoZByvzhW8j51R4UNzGBJFkTiTnLhQnggwJRdJgFWK4uY6ZVIMw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-x64-musl": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.21.2.tgz", + "integrity": "sha512-3Q9PMRjalWkT6NZ4jfujuqTCFwoWErg3y3BnOgb544B8IMw4PktiwWOigMfOHNRLMghZeJ7hpfpZf4CP7rV7Og==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-openharmony-arm64": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.21.2.tgz", + "integrity": "sha512-Eljeq3ndtyKhM+Es8LITi4Zl2htzuRZcrPMF3kMCsrILztvU6AjZ3FuEhHHKobPUB9rMpzLpJP5bDqXI7r+iog==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.21.2.tgz", + "integrity": "sha512-HGDbNsIywqc4LxU38+CTJNnB/6BF7rheWOJ259b4eE0aEnelYblC8x+1tEd63bp31fYne63RQz9Jb4yVnC7Yig==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.0", + "@emnapi/runtime": "1.11.0", + "@napi-rs/wasm-runtime": "^1.1.5" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.0.tgz", + "integrity": "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz", + "integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" } }, + "node_modules/@oxc-resolver/binding-win32-arm64-msvc": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.21.2.tgz", + "integrity": "sha512-iWx25CBEgH49iE9q5coEGI/jb1jl5kkCY9z6U5Og67xCkQ/WFMDc2J5U78+AE91SUxM2NqSLhJC8/PLfWnImww==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxc-resolver/binding-win32-x64-msvc": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.21.2.tgz", + "integrity": "sha512-VPoCAhKvCQTlG7vxqaBXcmuvbh77BfnGXj8g0pbvVXpm1F/R8rDVwqIWcEbMzrI1JvJlm8v7T9uMVQb6UctMRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2072,19 +2847,18 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", "license": "BSD-3-Clause", "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" + "@protobufjs/aspromise": "^1.1.1" } }, "node_modules/@protobufjs/float": { @@ -2093,12 +2867,6 @@ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", "license": "BSD-3-Clause" }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz", - "integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==", - "license": "BSD-3-Clause" - }, "node_modules/@protobufjs/path": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", @@ -4194,14 +4962,12 @@ } }, "node_modules/@storybook/builder-vite": { - "version": "10.1.11", - "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.1.11.tgz", - "integrity": "sha512-MMD09Ap7FyzDfWG961pkIMv/w684XXe1bBEi+wCEpHxvrgAd3j3A9w/Rqp9Am2uRDPCEdi1QgSzS3SGW3aGThQ==", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.6.0.tgz", + "integrity": "sha512-7zt8tYB6dnSO8fbjUz91MHexGDpf9HrLlA3FAmlVb+2ZL3eWUs499L3I6BDUymcWu7oW5WCBJlB8ERj8ZLLzig==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/csf-plugin": "10.1.11", - "@vitest/mocker": "3.2.4", "ts-dedent": "^2.0.0" }, "funding": { @@ -4209,8 +4975,8 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^10.1.11", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" + "storybook": "^10.6.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "node_modules/@storybook/csf-plugin": { @@ -4256,38 +5022,46 @@ "license": "MIT" }, "node_modules/@storybook/icons": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@storybook/icons/-/icons-2.0.1.tgz", - "integrity": "sha512-/smVjw88yK3CKsiuR71vNgWQ9+NuY2L+e8X7IMrFjexjm6ZR8ULrV2DRkTA61aV6ryefslzHEGDInGpnNeIocg==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@storybook/icons/-/icons-2.1.0.tgz", + "integrity": "sha512-Fxh9vYpX9bQqFeHRiY8h2ApeRGDzRSMLwJwNZ/AIRqnyOKHxRKL+yFe+ctEkVJmuptRE9u1Hrn8ZZNHyfDKKNg==", "dev": true, "license": "MIT", "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/@storybook/react": { - "version": "10.1.11", - "resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.1.11.tgz", - "integrity": "sha512-rmMGmEwBaM2YpB8oDk2moM0MNjNMqtwyoPPZxjyruY9WVhYca8EDPGKEdRzUlb4qZJsTgLi7VU4eqg6LD/mL3Q==", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.6.0.tgz", + "integrity": "sha512-0xfYYPRHcr5ta1hmXJCSezH4JGuUHJ+xVF9f1j+Y/MWEMOQE4wnaKWKsk/HLrDjZkT5hKfvmogt9b0yduP6zEQ==", "dev": true, "license": "MIT", "dependencies": { "@storybook/global": "^5.0.0", - "@storybook/react-dom-shim": "10.1.11", - "react-docgen": "^8.0.2" + "@storybook/react-dom-shim": "10.6.0", + "react-docgen": "^8.0.2", + "react-docgen-typescript": "^2.2.2" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/storybook" }, "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.1.11", + "storybook": "^10.6.0", "typescript": ">= 4.9.x" }, "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + }, "typescript": { "optional": true } @@ -4310,19 +5084,19 @@ } }, "node_modules/@storybook/react-vite": { - "version": "10.1.11", - "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.1.11.tgz", - "integrity": "sha512-qh1BCD25nIoiDfqwha+qBkl7pcG4WuzM+c8tsE63YEm8AFIbNKg5K8lVUoclF+4CpFz7IwBpWe61YUTDfp+91w==", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.6.0.tgz", + "integrity": "sha512-nc1dqnmjGOcDJvPCkAn4Hmu1gi7dNHHoibNmlBp0d1adosejELZxuNjHBkPl/KxZjbzP3jljQHnHB/guxd8e2g==", "dev": true, "license": "MIT", "dependencies": { - "@joshwooding/vite-plugin-react-docgen-typescript": "^0.6.3", + "@joshwooding/vite-plugin-react-docgen-typescript": "^0.7.0", "@rollup/pluginutils": "^5.0.2", - "@storybook/builder-vite": "10.1.11", - "@storybook/react": "10.1.11", + "@storybook/builder-vite": "10.6.0", + "@storybook/react": "10.6.0", "empathic": "^2.0.0", - "magic-string": "^0.30.0", - "react-docgen": "^8.0.0", + "magic-string": "^1.1.0", + "react-docgen": "^8.0.2", "resolve": "^1.22.8", "tsconfig-paths": "^4.2.0" }, @@ -4333,8 +5107,50 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.1.11", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" + "storybook": "^10.6.0", + "typescript": ">= 4.9.x", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@storybook/react-vite/node_modules/magic-string": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.3.1.tgz", + "integrity": "sha512-rm91zr2Ou+XueDTohjQQjdQEcYM6zVi8KVUCG8Ec3vHwUEKrhSdCNyfuIywkA6hcCAteIn0ZOtAHA6eGpiX+Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/@storybook/react/node_modules/@storybook/react-dom-shim": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.6.0.tgz", + "integrity": "sha512-ZE0l4xL6d00rKFeJhRDuh0t3r+/2ffhx47lIyaSWrTD5kuM3nDjKDxCIWwX4E3gLV+oNX8E1prXRMuEWJUJwJg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "storybook": "^10.6.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, "node_modules/@tailwindcss/typography": { @@ -4469,7 +5285,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -4540,9 +5355,9 @@ } }, "node_modules/@testing-library/user-event": { - "version": "14.6.1", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", - "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "version": "14.6.7", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.7.tgz", + "integrity": "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==", "dev": true, "license": "MIT", "engines": { @@ -4554,48 +5369,49 @@ } }, "node_modules/@tiptap/core": { - "version": "3.22.5", - "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.22.5.tgz", - "integrity": "sha512-L1lhWz6ujGny8LduTJ7MBWYhzigwOvfUJUrJ7IzOJSuy3+OAzisdGDD1GV7LEO/hU0Hr2Mkm1wajRIHExvS9HQ==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.31.3.tgz", + "integrity": "sha512-Cz50pvciQrxdSxgTkHOVz0uD0Yl/8Xt0QatGD6ILm47jW8EzyHR9RkUGs/D5IqzXKuVPntfw1ttaT926vXfiRg==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/pm": "3.22.5" + "@tiptap/pm": "3.31.3" } }, "node_modules/@tiptap/extension-blockquote": { - "version": "3.22.5", - "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.22.5.tgz", - "integrity": "sha512-ajyP5W8fG5Hrru47T/eF3xMKOpNvWofgNJqBTeNuGl02sYxsy9a4EunyFxudsaZP9WW3VOD4SaIWr5+MqpbnOQ==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.31.3.tgz", + "integrity": "sha512-fyY2XMbyDDDfOTQ1Qdrnqa1qwC9DWE4n7AfE0EKQI0G8MfLV8RaDlLDcOZDJ9JbMPY7/Gx7EjyK4NKxSW9n2hQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "3.22.5" + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" } }, "node_modules/@tiptap/extension-bold": { - "version": "3.22.5", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.22.5.tgz", - "integrity": "sha512-l/uDtpJISiFFyfctvnODNWBN/XPZI1jVZRacTRDDnSn8+x6KQ7G2qgFYueU7KvVJGDFVT39Iio56mcFRG/Pozg==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.31.3.tgz", + "integrity": "sha512-dIuYhKk8TitKU/FeDpoTeWZhU42YgDN5npgWNjAmMmRktPdoxnH3/wGSiwXlqZgJWjehN7kPWDePWpJeAImGpQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "3.22.5" + "@tiptap/core": "3.31.3" } }, "node_modules/@tiptap/extension-bubble-menu": { - "version": "3.15.3", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.15.3.tgz", - "integrity": "sha512-e88DG1bTy6hKxrt7iPVQhJnH5/EOrnKpIyp09dfRDgWrrW88fE0Qjys7a/eT8W+sXyXM3z10Ye7zpERWsrLZDg==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.31.3.tgz", + "integrity": "sha512-EV6ZnwKc++2OM/OcD54n8s1B7C9LP7GKAtdEPwu0t3BYJf3sG8Ueoinf7SgGPO9oMPg96GneOkDNm1urMV167g==", "license": "MIT", "optional": true, "dependencies": { @@ -4606,80 +5422,80 @@ "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.15.3", - "@tiptap/pm": "^3.15.3" + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" } }, "node_modules/@tiptap/extension-bullet-list": { - "version": "3.22.5", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.22.5.tgz", - "integrity": "sha512-cf54fG9AybU8NgPMv1TOcoqAkELeRc/VpnSCt/rIJZphWQx9nsFmrtkrlCatrIcCaGtNZYwlHlMnC5LVVMu0uA==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.31.3.tgz", + "integrity": "sha512-qEyyoPapPef4LO8XKaN83bxtaNzkJ4kFn/IxLnEKd4BJ3Mvi4MH2yJYlyDqwFnMoev4pMA1zBHRAt/C0THRmZw==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extension-list": "3.22.5" + "@tiptap/extension-list": "3.31.3" } }, "node_modules/@tiptap/extension-code": { - "version": "3.22.5", - "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.22.5.tgz", - "integrity": "sha512-mwDNOJC9rYbDu/JcqrN4dbUQRklJU8Fuk2raxD/IvFw9qUIcPCmxQ2XT9UTKmZz/Ju7Kdy72fss6XpgWv6gLAQ==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.31.3.tgz", + "integrity": "sha512-SzxOqchrD2AcN3uT67PjKmRFEMOU3vNNiwNaamZJUbZrI6Hmy+bvdHJrc3jrIddyyCrAtsnAfRI0cmorW1jGfg==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "3.22.5" + "@tiptap/core": "3.31.3" } }, "node_modules/@tiptap/extension-code-block": { - "version": "3.22.5", - "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.22.5.tgz", - "integrity": "sha512-d123kCfLdJTi4fue1m0+TNFztDkmIRSZGZmGu6H9KqwG5Q7IzjT9o8lzRsz+pXxYqHvqgYmXoEpM6srbzXx/Ag==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.31.3.tgz", + "integrity": "sha512-nvknt4FhyJQjYcvxptmeUlFsIAc8ibua3E5BN4Pim374/9RWepH4cdE9X0/qUTtguHElLx0iKtSIY3rW2qGTFA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "3.22.5", - "@tiptap/pm": "3.22.5" + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" } }, "node_modules/@tiptap/extension-document": { - "version": "3.22.5", - "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.22.5.tgz", - "integrity": "sha512-8NJERd+pCtvSuEP4C4WMGYmRRCV12ePZL7bC+QUdFlbdXg+kNZS0zZ7hh879tYA0Kidbi8rWWD1Tx+H2ezkmMw==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.31.3.tgz", + "integrity": "sha512-EexgmqnyDNyGlISxo7SMrp5MygpJYmqD+0cY5jB6L1U6L4CpKKRWUt8OO1sWzEHDE1+TTvwt+WIFoIWAziOtEA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "3.22.5" + "@tiptap/core": "3.31.3" } }, "node_modules/@tiptap/extension-dropcursor": { - "version": "3.22.5", - "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.22.5.tgz", - "integrity": "sha512-Mp40DaFrY3sEUVtFqmxrR0BmU4G3k8GCYYNGqNa9OqWv7BrcFDC03V2n3okESDKt4MKkzhQQmypq+ouLy8dLfA==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.31.3.tgz", + "integrity": "sha512-NWomSfu5CSC7VacnMSDzKT8qm66SzMfZwVPEtwY5bPpRTJgTiT1rNK0neDrrzfMN27MfylGyKWWf7Q5Qf8w/fg==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extensions": "3.22.5" + "@tiptap/extensions": "3.31.3" } }, "node_modules/@tiptap/extension-floating-menu": { - "version": "3.15.3", - "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.15.3.tgz", - "integrity": "sha512-+3DVBleKKffadEJEdLYxmYAJOjHjLSqtiSFUE3RABT4V2ka1ODy2NIpyKX0o1SvQ5N1jViYT9Q+yUbNa6zCcDw==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.31.3.tgz", + "integrity": "sha512-rd4VJ9PGSP9Eop8ZTEwaLZcMzMXLuJKe3hUNf58rq+zANpwM+9fI+vB7g9MAc3eXwUNDxNDVACFIL2oeBqpQ3A==", "license": "MIT", "optional": true, "funding": { @@ -4688,243 +5504,244 @@ }, "peerDependencies": { "@floating-ui/dom": "^1.0.0", - "@tiptap/core": "^3.15.3", - "@tiptap/pm": "^3.15.3" + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" } }, "node_modules/@tiptap/extension-gapcursor": { - "version": "3.22.5", - "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.22.5.tgz", - "integrity": "sha512-4WkMu7qqjbsm8hCQS+8X+la1wjriN0SKoRdvpfKH33qM50MB34tYJuGLAO+y7TTh4MMMco3AZCKPBL5JVMqNIg==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.31.3.tgz", + "integrity": "sha512-EBXKb1FrVStsNYCcRGtd9jmzveCvR+eqgg1rVqoONrqFK6U7bga6LN+1dMKroP1kliDVgveYkP3vRYxqw+rFqg==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extensions": "3.22.5" + "@tiptap/extensions": "3.31.3" } }, "node_modules/@tiptap/extension-hard-break": { - "version": "3.22.5", - "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.22.5.tgz", - "integrity": "sha512-n0R2mUVYZU2AVbJhg/WcY9+zx690wVwvsItHJf0DrYbf1tCYHx+PRHUt/AoXk6u8BSmnkb8/FDziS8m3mjfpSg==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.31.3.tgz", + "integrity": "sha512-QAdCvNO4+yW9ATwsrej11NTkDYFqPLIEQr3ARNrKOK1qaiS7A0fia2SEukb/hrkP3A6mbozhoQt2r2RGUf/DpQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "3.22.5" + "@tiptap/core": "3.31.3" } }, "node_modules/@tiptap/extension-heading": { - "version": "3.22.5", - "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.22.5.tgz", - "integrity": "sha512-hjyEG4947PAhMBfP1G6B0QAh6+y9mp2C5BQmNjprA05/lQzDAT7KFZzNh8ZVp3ol6aICKq/N1gFOW9Dc/9FUOw==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.31.3.tgz", + "integrity": "sha512-rk5VHMAeQcg06SLauN6EGdD2jc0O2qY8QkZYPd0LxNvLblw2BxBx+lxUQSYwLAT9Ie5914gKIK2YbRyO2Ts3ig==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "3.22.5" + "@tiptap/core": "3.31.3" } }, "node_modules/@tiptap/extension-horizontal-rule": { - "version": "3.22.5", - "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.22.5.tgz", - "integrity": "sha512-vUV0/ugIbXOc8SJib0h8UMhgcqZXWu/dkEhlswZN4VVven1o5enkfxEiDw+OyIJHi5rUkrdhsQ/KTxG/Xb7X8A==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.31.3.tgz", + "integrity": "sha512-YnHGy2KShRwvCseAmmxl9VP7R0qaj8QMp3DA6DJWZqp7r5gLGvDkAqhedxqqefqsE4Y43hjkBdjtB9Ce78LkIw==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "3.22.5", - "@tiptap/pm": "3.22.5" + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" } }, "node_modules/@tiptap/extension-italic": { - "version": "3.22.5", - "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.22.5.tgz", - "integrity": "sha512-4T8baSiLkeIymTgEwirxDFt5YgYofkP3m1+MGYdGy2HKcOK+1vpvlPhEO1X5qtZngtJW5S4+njKjinRg52A4PA==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.31.3.tgz", + "integrity": "sha512-ibGvdvAPyfxBMUVNRI43eb9h2/Jka1MRG5GtnGqbcCX/2+Y/y0EOfFrPQPkuYphiWQYyu9+FzPKMB/pjuaLuKQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "3.22.5" + "@tiptap/core": "3.31.3" } }, "node_modules/@tiptap/extension-link": { - "version": "3.22.5", - "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.22.5.tgz", - "integrity": "sha512-d671MvF3GPKoS2OVxjIlQ7hIE7MS3hREdR+d4cvnnoiLLD+ZJ6KgDnxmWqF0a1s4qxLWK2KxKRSOIfYGE31QWQ==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.31.3.tgz", + "integrity": "sha512-986wOQzTL9Zr5lf84LCLpm+YOms8A0K39/8DVoqRfebqcOe0/eq4bnztmAlfabOd+kJY92g3AgZERFUx/w+dcw==", "license": "MIT", "dependencies": { - "linkifyjs": "^4.3.2" + "linkifyjs": "^4.3.3" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "3.22.5", - "@tiptap/pm": "3.22.5" + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" } }, "node_modules/@tiptap/extension-list": { - "version": "3.22.5", - "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.22.5.tgz", - "integrity": "sha512-cVO3ZHCgxAWZ4zrFSs81FO2nyCk1wb2EHkpLpW98FzbJLkN9rDkazhW99P3HRWy/CvUldOT+8ecI1YrQtBojMg==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.31.3.tgz", + "integrity": "sha512-LoveGnC0FVdCV4jUNBaG1ZA+KWE07+adzV3kGy6uUYFcJEjbVUHTnPDrBOob3IwOSO3sCwIkvQb6EVYeXn/4yg==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "3.22.5", - "@tiptap/pm": "3.22.5" + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" } }, "node_modules/@tiptap/extension-list-item": { - "version": "3.22.5", - "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.22.5.tgz", - "integrity": "sha512-W7uTmyKLhlsvuTPLv+8WwnsY+mlikBFIoLSvVcBaFt4MwpsZ+DeB6KQg02Y7tbtaAnG7rXu9Fvw2QORh2P728A==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.31.3.tgz", + "integrity": "sha512-4QlKOriJJMvg95QJTEsy9BUYPBQ6UvJyb8WURRwdUtQUkkqb8h32lg/eyQUv2FzW9IT1AdUyNZfVaxH64kRfQA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extension-list": "3.22.5" + "@tiptap/extension-list": "3.31.3" } }, "node_modules/@tiptap/extension-list-keymap": { - "version": "3.22.5", - "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.22.5.tgz", - "integrity": "sha512-cGUnxJ0y515e1bVHNjUmbx7oWHoEon59w6BA5N2KwV9iW2mZZchlTX4yxJSOX+ixeVRChsa7YwC3Z1jUZ6AMEg==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.31.3.tgz", + "integrity": "sha512-If8UOEdDZbPJU6iYTvLtH6DOp2KBy6BKxg9UELL1AevVetGHEF/7lW8hP50Gn1tHMVpPRqmhSzVrJRpEJJgb/Q==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extension-list": "3.22.5" + "@tiptap/extension-list": "3.31.3" } }, "node_modules/@tiptap/extension-ordered-list": { - "version": "3.22.5", - "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.22.5.tgz", - "integrity": "sha512-OXdh4k4CNrukwiSdWdEQ49uvgnqvR0Z9aNSP4HI5/kZQ/Te1NtRtYCpUrzWyO/7CtjcCisXHti0o9C/TV8YMbQ==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.31.3.tgz", + "integrity": "sha512-mp3g11NgA/PYu8rj7J7Ez3l4qBy6WfTSmHIG4PZvEGG5w2oUAIkgb9DU7nPPzjmeme27oazFYZw+AtZA0+u4tw==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extension-list": "3.22.5" + "@tiptap/extension-list": "3.31.3" } }, "node_modules/@tiptap/extension-paragraph": { - "version": "3.22.5", - "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.22.5.tgz", - "integrity": "sha512-52KCto4+XKpnBWpIufspWLyq4UWxAWC72ANPdGuIhbi72NRTabiTbTVN40uwGSPkyakeESG0/vKdWJCVvB4f0g==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.31.3.tgz", + "integrity": "sha512-+iPku7wJfy5hbNDNLX8dveFtYVsZMmh7vztjuq8hT3mipSC4IByDehDbU8fzUCjfXiEzmI7mQn7c8LGmHULuzA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "3.22.5" + "@tiptap/core": "3.31.3" } }, "node_modules/@tiptap/extension-placeholder": { - "version": "3.15.3", - "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.15.3.tgz", - "integrity": "sha512-XcHHnojT186hKIoOgcPBesXk89+caNGVUdMtc171Vcr/5s0dpnr4q5LfE+YRC+S85CpCxCRRnh84Ou+XRtOqrw==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.31.3.tgz", + "integrity": "sha512-9jYtR8ELEw7GVaruyrm4oFkPcjig9Q+crc+dpmarhBNXUmxagCdlhVzNwCJ2WJRzvBAtx59sEYqNTU38Wx8S3A==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extensions": "^3.15.3" + "@tiptap/extensions": "3.31.3" } }, "node_modules/@tiptap/extension-strike": { - "version": "3.22.5", - "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.22.5.tgz", - "integrity": "sha512-42WrrFK5gOom/0znH85x12Mw5IQ/6O6DWdyUWoRIrNA/qJpuHtU8oVU+bIgU2tuomMGHruRjIzgBQv5sBjEtww==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.31.3.tgz", + "integrity": "sha512-G29bhKttYwcKHT+BI6emWVFol3RO/gUXxQVcmr/iT8LXXy7j8J6HFUnsKM+Kg5YlP1rxMRgsa65dbrQVahZi0A==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "3.22.5" + "@tiptap/core": "3.31.3" } }, "node_modules/@tiptap/extension-text": { - "version": "3.22.5", - "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.22.5.tgz", - "integrity": "sha512-bzpDOdAEo1JeoVZDIyV0oY0jGXkEG+AzF70SzHoRSjOvFDtKWunyXf9eO1OnOr2/fmMcckT2qwUBNBMQplWBzw==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.31.3.tgz", + "integrity": "sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "3.22.5" + "@tiptap/core": "3.31.3" } }, "node_modules/@tiptap/extension-underline": { - "version": "3.22.5", - "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.22.5.tgz", - "integrity": "sha512-9ut09rJD0iEbS6sk7yd2j6IwuFDLTNmDEGTDLodvqAfi+bq7ddsTDv0YviXoZaA9sdHAdTEVr2ITy2m6WK5jpA==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.31.3.tgz", + "integrity": "sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "3.22.5" + "@tiptap/core": "3.31.3" } }, "node_modules/@tiptap/extensions": { - "version": "3.22.5", - "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.22.5.tgz", - "integrity": "sha512-Ifg4MzKCj3uRqe3ieTwYnomu2y4p7EXr2avVSKZYfh12i2dyWe2Gkn1KuZDREANVE+gHqFlQjJRYzhJFwzSCrg==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.31.3.tgz", + "integrity": "sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "3.22.5", - "@tiptap/pm": "3.22.5" + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" } }, "node_modules/@tiptap/pm": { - "version": "3.22.5", - "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.22.5.tgz", - "integrity": "sha512-Cr9Mv4igxvI2tKMiahw48sZxva3PfDzypErH8IB82N+9qa9n9ygVMt0BOaDg53hLKxEEVeYr2S/wCcJIVFgBTw==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.31.3.tgz", + "integrity": "sha512-sZime0SWsz/k62W2WvHx5Ig7G2h7kVhrrmnqy+wEgIHfDwEfOlelRjaWCiBCFlF7dxGUntJusCh9FxlLhni0Ag==", "license": "MIT", "dependencies": { - "prosemirror-changeset": "^2.3.0", - "prosemirror-commands": "^1.6.2", - "prosemirror-dropcursor": "^1.8.1", - "prosemirror-gapcursor": "^1.3.2", - "prosemirror-history": "^1.4.1", - "prosemirror-keymap": "^1.2.2", - "prosemirror-model": "^1.24.1", - "prosemirror-schema-list": "^1.5.0", - "prosemirror-state": "^1.4.3", - "prosemirror-tables": "^1.6.4", - "prosemirror-transform": "^1.10.2", - "prosemirror-view": "^1.38.1" + "prosemirror-changeset": "^2.4.1", + "prosemirror-commands": "^1.7.1", + "prosemirror-dropcursor": "^1.8.2", + "prosemirror-gapcursor": "^1.4.1", + "prosemirror-history": "^1.5.0", + "prosemirror-inputrules": "^1.5.1", + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.11", + "prosemirror-schema-list": "^1.5.1", + "prosemirror-state": "^1.4.4", + "prosemirror-tables": "^1.8.5", + "prosemirror-transform": "^1.12.0", + "prosemirror-view": "^1.42.3" }, "funding": { "type": "github", @@ -4932,9 +5749,9 @@ } }, "node_modules/@tiptap/react": { - "version": "3.15.3", - "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.15.3.tgz", - "integrity": "sha512-XvouB+Hrqw8yFmZLPEh+HWlMeRSjZfHSfWfWuw5d8LSwnxnPeu3Bg/rjHrRrdwb+7FumtzOnNWMorpb/PSOttQ==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.31.3.tgz", + "integrity": "sha512-QiwQqvaLFLm5EMFu5tg7nAgXJxCUiUTLD8EsK+TqVV5P4bqOoMOCM39khbhXTJyahCuYpiFWx5YOSDtC/JiPtg==", "license": "MIT", "dependencies": { "@types/use-sync-external-store": "^0.0.6", @@ -4946,12 +5763,12 @@ "url": "https://github.com/sponsors/ueberdosis" }, "optionalDependencies": { - "@tiptap/extension-bubble-menu": "^3.15.3", - "@tiptap/extension-floating-menu": "^3.15.3" + "@tiptap/extension-bubble-menu": "^3.31.3", + "@tiptap/extension-floating-menu": "^3.31.3" }, "peerDependencies": { - "@tiptap/core": "^3.15.3", - "@tiptap/pm": "^3.15.3", + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", @@ -4959,48 +5776,58 @@ } }, "node_modules/@tiptap/starter-kit": { - "version": "3.22.5", - "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.22.5.tgz", - "integrity": "sha512-LZ/LYbwH6rnDi5DnRyagkuNsYAVyhM+yJvvz+ZuYA0JkPiTXJV86J5PWSKew8M0gVfMHcNVtKjfQCvViFCeIgw==", - "license": "MIT", - "dependencies": { - "@tiptap/core": "^3.22.5", - "@tiptap/extension-blockquote": "^3.22.5", - "@tiptap/extension-bold": "^3.22.5", - "@tiptap/extension-bullet-list": "^3.22.5", - "@tiptap/extension-code": "^3.22.5", - "@tiptap/extension-code-block": "^3.22.5", - "@tiptap/extension-document": "^3.22.5", - "@tiptap/extension-dropcursor": "^3.22.5", - "@tiptap/extension-gapcursor": "^3.22.5", - "@tiptap/extension-hard-break": "^3.22.5", - "@tiptap/extension-heading": "^3.22.5", - "@tiptap/extension-horizontal-rule": "^3.22.5", - "@tiptap/extension-italic": "^3.22.5", - "@tiptap/extension-link": "^3.22.5", - "@tiptap/extension-list": "^3.22.5", - "@tiptap/extension-list-item": "^3.22.5", - "@tiptap/extension-list-keymap": "^3.22.5", - "@tiptap/extension-ordered-list": "^3.22.5", - "@tiptap/extension-paragraph": "^3.22.5", - "@tiptap/extension-strike": "^3.22.5", - "@tiptap/extension-text": "^3.22.5", - "@tiptap/extension-underline": "^3.22.5", - "@tiptap/extensions": "^3.22.5", - "@tiptap/pm": "^3.22.5" + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.31.3.tgz", + "integrity": "sha512-WKof9RewdmGHvWJ1wn0/HVNG2mV+HOgVRyJkKekuM9fgr6BZAAH/xZsWE1eon+94JnQ+KtK2ThydXQM/qc6b2A==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/extension-blockquote": "3.31.3", + "@tiptap/extension-bold": "3.31.3", + "@tiptap/extension-bullet-list": "3.31.3", + "@tiptap/extension-code": "3.31.3", + "@tiptap/extension-code-block": "3.31.3", + "@tiptap/extension-document": "3.31.3", + "@tiptap/extension-dropcursor": "3.31.3", + "@tiptap/extension-gapcursor": "3.31.3", + "@tiptap/extension-hard-break": "3.31.3", + "@tiptap/extension-heading": "3.31.3", + "@tiptap/extension-horizontal-rule": "3.31.3", + "@tiptap/extension-italic": "3.31.3", + "@tiptap/extension-link": "3.31.3", + "@tiptap/extension-list": "3.31.3", + "@tiptap/extension-list-item": "3.31.3", + "@tiptap/extension-list-keymap": "3.31.3", + "@tiptap/extension-ordered-list": "3.31.3", + "@tiptap/extension-paragraph": "3.31.3", + "@tiptap/extension-strike": "3.31.3", + "@tiptap/extension-text": "3.31.3", + "@tiptap/extension-underline": "3.31.3", + "@tiptap/extensions": "3.31.3", + "@tiptap/pm": "3.31.3" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" } }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -5547,29 +6374,29 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.17.tgz", - "integrity": "sha512-/6zU2FLGg0jsd+ePZcwHRy3+WpNTBBhDY56P4JTRqUN/Dp6CvOEa9HrikcQ4KfV2b2kAHUFB4dl1SuocWXSFEw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.0.17", - "ast-v8-to-istanbul": "^0.3.10", + "@vitest/utils": "4.1.10", + "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", - "magicast": "^0.5.1", + "magicast": "^0.5.2", "obug": "^2.1.1", - "std-env": "^3.10.0", - "tinyrainbow": "^3.0.3" + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.0.17", - "vitest": "4.0.17" + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -5633,22 +6460,22 @@ } }, "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.4", + "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" + "magic-string": "^0.30.21" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -5659,6 +6486,16 @@ } } }, + "node_modules/@vitest/mocker/node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vitest/mocker/node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -5670,26 +6507,26 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.17.tgz", - "integrity": "sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.17.tgz", - "integrity": "sha512-JmuQyf8aMWoo/LmNFppdpkfRVHJcsgzkbCA+/Bk7VfNH7RE6Ut2qxegeyx2j3ojtJtKIbIGy3h+KxGfYfk28YQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.17", + "@vitest/utils": "4.1.10", "pathe": "^2.0.3" }, "funding": { @@ -5697,13 +6534,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.17.tgz", - "integrity": "sha512-npPelD7oyL+YQM2gbIYvlavlMVWUfNNGZPcu0aEUQXt7FXTuqhmgiYupPnAanhKvyP6Srs2pIbWo30K0RbDtRQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.17", + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -5725,36 +6563,37 @@ } }, "node_modules/@vitest/ui": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.0.17.tgz", - "integrity": "sha512-hRDjg6dlDz7JlZAvjbiCdAJ3SDG+NH8tjZe21vjxfvT2ssYAn72SRXMge3dKKABm3bIJ3C+3wdunIdur8PHEAw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.10.tgz", + "integrity": "sha512-EOUqfXHTXtpSHsyLHH40ts3Ue+hRhSGwzwzMlK0dTEOLSDYyOXLyr5JDGmHQWhN2DYI30gw6dVx3cdgM9FZl+Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.17", + "@vitest/utils": "4.1.10", "fflate": "^0.8.2", - "flatted": "^3.3.3", + "flatted": "^3.4.2", "pathe": "^2.0.3", "sirv": "^3.0.2", "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "4.0.17" + "vitest": "4.1.10" } }, "node_modules/@vitest/utils": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.17.tgz", - "integrity": "sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.17", - "tinyrainbow": "^3.0.3" + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -5929,15 +6768,15 @@ } }, "node_modules/ast-v8-to-istanbul": { - "version": "0.3.10", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.10.tgz", - "integrity": "sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.6.tgz", + "integrity": "sha512-fvpl29helSO2w/z7utIbrkNXILdrLwDwAMH2I/zPKlGf5244+gf+B4cyS1sANcrPY2h+hWCGSgC8N61s/+AF9A==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", - "js-tokens": "^9.0.1" + "js-tokens": "^10.0.0" } }, "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": { @@ -5951,9 +6790,9 @@ } }, "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", "dev": true, "license": "MIT" }, @@ -6011,16 +6850,42 @@ } }, "node_modules/axios": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", - "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", - "form-data": "^4.0.5", + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, + "node_modules/axios/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axios/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/axios/node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -6048,13 +6913,16 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.15", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.15.tgz", - "integrity": "sha512-kX8h7K2srmDyYnXRIppo4AH/wYgzWVCs+eKr3RusRSQ5PvRYoEFmR/I0PbdTjKFAoKqp5+kbxnNTFO9jOfSVJg==", + "version": "2.11.22", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.22.tgz", + "integrity": "sha512-pWc4w51fBFd7mav43/zKRC+RI6f4yfzQoVlfvE8dECePyfkn1bzLp01Fj0QACcyCZyFhiEMyD2qScfKRWgWibA==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/bidi-js": { @@ -6080,9 +6948,9 @@ } }, "node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -6102,9 +6970,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", "dev": true, "funding": [ { @@ -6122,11 +6990,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" }, "bin": { "browserslist": "cli.js" @@ -6184,9 +7052,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001765", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001765.tgz", - "integrity": "sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "dev": true, "funding": [ { @@ -6860,13 +7728,12 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dompurify": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz", - "integrity": "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==", + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz", + "integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -6907,9 +7774,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.267", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", - "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "version": "1.5.427", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.427.tgz", + "integrity": "sha512-n14zb3FdsChZ2BNobqNHAJMcP3ifFv4paox2LvCrfVAQcqGiSURgbJl+PfMpHVCNFkStnNc+RRVtPBTVW5PDgw==", "dev": true, "license": "ISC" }, @@ -6962,16 +7829,16 @@ } }, "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==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", "dev": true, "license": "MIT" }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -7406,9 +8273,9 @@ } }, "node_modules/fflate": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", - "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", "dev": true, "license": "MIT" }, @@ -7523,16 +8390,16 @@ } }, "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==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "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" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -7785,9 +8652,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -8294,10 +9161,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -8393,6 +9270,13 @@ "node": ">=6" } }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jsonfile": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", @@ -8449,9 +9333,9 @@ "license": "MIT" }, "node_modules/linkifyjs": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz", - "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz", + "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==", "license": "MIT" }, "node_modules/locate-path": { @@ -8525,7 +9409,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -8541,14 +9424,14 @@ } }, "node_modules/magicast": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.1.tgz", - "integrity": "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==", + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.5.tgz", + "integrity": "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "source-map-js": "^1.2.1" } }, @@ -9587,9 +10470,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", "funding": [ { "type": "github", @@ -9658,11 +10541,14 @@ } }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.55", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz", + "integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/normalize-path": { "version": "3.0.0", @@ -9745,6 +10631,75 @@ "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", "license": "MIT" }, + "node_modules/oxc-parser": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.127.0.tgz", + "integrity": "sha512-bkgD4qHlN7WxLdX8bLXdaU54TtQtAIg/ZBAfm0aje/mo3MRDo3P0hZSgr4U7O3xfX+fQmR5AP04JS/TGcZLcFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "^0.127.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-parser/binding-android-arm-eabi": "0.127.0", + "@oxc-parser/binding-android-arm64": "0.127.0", + "@oxc-parser/binding-darwin-arm64": "0.127.0", + "@oxc-parser/binding-darwin-x64": "0.127.0", + "@oxc-parser/binding-freebsd-x64": "0.127.0", + "@oxc-parser/binding-linux-arm-gnueabihf": "0.127.0", + "@oxc-parser/binding-linux-arm-musleabihf": "0.127.0", + "@oxc-parser/binding-linux-arm64-gnu": "0.127.0", + "@oxc-parser/binding-linux-arm64-musl": "0.127.0", + "@oxc-parser/binding-linux-ppc64-gnu": "0.127.0", + "@oxc-parser/binding-linux-riscv64-gnu": "0.127.0", + "@oxc-parser/binding-linux-riscv64-musl": "0.127.0", + "@oxc-parser/binding-linux-s390x-gnu": "0.127.0", + "@oxc-parser/binding-linux-x64-gnu": "0.127.0", + "@oxc-parser/binding-linux-x64-musl": "0.127.0", + "@oxc-parser/binding-openharmony-arm64": "0.127.0", + "@oxc-parser/binding-wasm32-wasi": "0.127.0", + "@oxc-parser/binding-win32-arm64-msvc": "0.127.0", + "@oxc-parser/binding-win32-ia32-msvc": "0.127.0", + "@oxc-parser/binding-win32-x64-msvc": "0.127.0" + } + }, + "node_modules/oxc-resolver": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.21.2.tgz", + "integrity": "sha512-w5tLwYN3Zo24w5EeWJjJWZOwhYqTtC8PS2B1tIt7BZUuqTIcU07sQValbDw+rq7+AuAGzOHklgK+ifsy4lpXfw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-resolver/binding-android-arm-eabi": "11.21.2", + "@oxc-resolver/binding-android-arm64": "11.21.2", + "@oxc-resolver/binding-darwin-arm64": "11.21.2", + "@oxc-resolver/binding-darwin-x64": "11.21.2", + "@oxc-resolver/binding-freebsd-x64": "11.21.2", + "@oxc-resolver/binding-linux-arm-gnueabihf": "11.21.2", + "@oxc-resolver/binding-linux-arm-musleabihf": "11.21.2", + "@oxc-resolver/binding-linux-arm64-gnu": "11.21.2", + "@oxc-resolver/binding-linux-arm64-musl": "11.21.2", + "@oxc-resolver/binding-linux-ppc64-gnu": "11.21.2", + "@oxc-resolver/binding-linux-riscv64-gnu": "11.21.2", + "@oxc-resolver/binding-linux-riscv64-musl": "11.21.2", + "@oxc-resolver/binding-linux-s390x-gnu": "11.21.2", + "@oxc-resolver/binding-linux-x64-gnu": "11.21.2", + "@oxc-resolver/binding-linux-x64-musl": "11.21.2", + "@oxc-resolver/binding-openharmony-arm64": "11.21.2", + "@oxc-resolver/binding-wasm32-wasi": "11.21.2", + "@oxc-resolver/binding-win32-arm64-msvc": "11.21.2", + "@oxc-resolver/binding-win32-x64-msvc": "11.21.2" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -9971,9 +10926,9 @@ } }, "node_modules/postcss": { - "version": "8.5.13", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", - "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==", + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "funding": [ { "type": "opencollective", @@ -9990,7 +10945,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -10108,9 +11063,9 @@ } }, "node_modules/postcss-nested/node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -10155,7 +11110,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -10171,7 +11125,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -10184,8 +11137,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/progress": { "version": "2.0.3", @@ -10208,9 +11160,9 @@ } }, "node_modules/prosemirror-changeset": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.3.1.tgz", - "integrity": "sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.2.tgz", + "integrity": "sha512-ViYrjMSg3YFiXwIhKaluu+/mi3Yrxt6AR8ri14ulTaGcZtXO1CThl7A2gv79qx5fQnOw8woKwyBU2u+9PVCm3w==", "license": "MIT", "dependencies": { "prosemirror-transform": "^1.0.0" @@ -10239,9 +11191,9 @@ } }, "node_modules/prosemirror-gapcursor": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.0.tgz", - "integrity": "sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", "license": "MIT", "dependencies": { "prosemirror-keymap": "^1.0.0", @@ -10262,6 +11214,16 @@ "rope-sequence": "^1.3.0" } }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, "node_modules/prosemirror-keymap": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", @@ -10273,9 +11235,9 @@ } }, "node_modules/prosemirror-model": { - "version": "1.25.4", - "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", - "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", + "version": "1.25.11", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.11.tgz", + "integrity": "sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==", "license": "MIT", "dependencies": { "orderedmap": "^2.0.0" @@ -10317,44 +11279,43 @@ } }, "node_modules/prosemirror-transform": { - "version": "1.10.5", - "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.5.tgz", - "integrity": "sha512-RPDQCxIDhIBb1o36xxwsaeAvivO8VLJcgBtzmOwQ64bMtsVFh5SSuJ6dWSxO1UsHTiTXPCgQm3PDJt7p6IOLbw==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.1.tgz", + "integrity": "sha512-t4F5615FycnCqsX7ShTUs8+jfnwcf46kuFRvSl/3qFx2QTZ4DjgowesLHQXcFP7G//TjesqZG3WtgL7vdyVJyA==", "license": "MIT", "dependencies": { "prosemirror-model": "^1.21.0" } }, "node_modules/prosemirror-view": { - "version": "1.41.5", - "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.5.tgz", - "integrity": "sha512-UDQbIPnDrjE8tqUBbPmCOZgtd75htE6W3r0JCmY9bL6W1iemDM37MZEKC49d+tdQ0v/CKx4gjxLoLsfkD2NiZA==", + "version": "1.42.3", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.3.tgz", + "integrity": "sha512-oTN7EtH+CpwxU9NrwEYWd0UZ4JUx7l048l5A2Xppm4p/60isZYLnth9QVQmC3VRIvdrIWCxwZSd+Uz791G31/w==", "license": "MIT", "dependencies": { - "prosemirror-model": "^1.20.0", + "prosemirror-model": "^1.25.8", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0" } }, "node_modules/protobufjs": { - "version": "7.5.6", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.6.tgz", - "integrity": "sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg==", + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.1", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", - "long": "^5.0.0" + "long": "^5.3.2" }, "engines": { "node": ">=12.0.0" @@ -10416,9 +11377,9 @@ } }, "node_modules/react-docgen": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-8.0.2.tgz", - "integrity": "sha512-+NRMYs2DyTP4/tqWz371Oo50JqmWltR1h2gcdgUMAWZJIAvrd0/SqlCfx7tpzpl/s36rzw6qH2MjoNrxtRNYhA==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-8.0.3.tgz", + "integrity": "sha512-aEZ9qP+/M+58x2qgfSFEWH1BxLyHe5+qkLNJOZQb5iGS017jpbRnoKhNRrXPeA6RfBrZO5wZrT9DMC1UqE1f1w==", "dev": true, "license": "MIT", "dependencies": { @@ -10982,9 +11943,9 @@ } }, "node_modules/seroval": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.4.2.tgz", - "integrity": "sha512-N3HEHRCZYn3cQbsC4B5ldj9j+tHdf4JZoYPlcI4rRYu0Xy4qN8MQf1Z08EibzB0WpgRG5BGK08FTrmM66eSzKQ==", + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.6.7.tgz", + "integrity": "sha512-AeDcLh0yO2SFm9W71essgnSzLV9DI8ZH0x0knXn2DMnUZj728mpLbxjlbB6IqKCmqh8JA3cEqRyGoNkt584JcQ==", "license": "MIT", "engines": { "node": ">=10" @@ -11097,32 +12058,36 @@ "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==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", "dev": true, "license": "MIT" }, "node_modules/storybook": { - "version": "10.3.6", - "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.3.6.tgz", - "integrity": "sha512-vbSz7g/1rGMC1uAULqMZjALkIuLu2QABqfhRYhyr/11kzyesi+vAmwyJLukZP1FfecxGOgMwOh6GS0YsGpHAvQ==", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.6.0.tgz", + "integrity": "sha512-H48X6AURAFTpfN2zEux6vNesee5Oq8Gxj1IanYmblPZ218sfKSeyyerF1NC7GD7BrruoC8L4/cU0zII4y+d6zw==", "dev": true, "license": "MIT", "dependencies": { "@storybook/global": "^5.0.0", - "@storybook/icons": "^2.0.1", - "@testing-library/jest-dom": "^6.9.1", - "@testing-library/user-event": "^14.6.1", + "@storybook/icons": "^2.0.2", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "6.9.1", + "@testing-library/user-event": "^14.6.3", "@vitest/expect": "3.2.4", "@vitest/spy": "3.2.4", "@webcontainer/env": "^1.1.1", - "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0", + "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0 || ^0.28.0", + "jsonc-parser": "^3.3.1", "open": "^10.2.0", + "oxc-parser": "^0.127.0", + "oxc-resolver": "11.21.2", "recast": "^0.23.5", "semver": "^7.7.3", "use-sync-external-store": "^1.5.0", - "ws": "^8.18.0" + "ws": "^8.21.1" }, "bin": { "storybook": "dist/bin/dispatcher.js" @@ -11132,10 +12097,14 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "prettier": "^2 || ^3", - "vite-plus": "^0.1.15" + "vite-plus": "^0.1.15 || ^0.2.0" }, "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, "prettier": { "optional": true }, @@ -11424,9 +12393,9 @@ } }, "node_modules/tailwindcss/node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -11503,9 +12472,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", "dev": true, "license": "MIT", "engines": { @@ -11852,9 +12821,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", "dev": true, "funding": [ { @@ -12001,13 +12970,13 @@ } }, "node_modules/vite": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", - "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", + "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", @@ -12111,31 +13080,31 @@ } }, "node_modules/vitest": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.17.tgz", - "integrity": "sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.0.17", - "@vitest/mocker": "4.0.17", - "@vitest/pretty-format": "4.0.17", - "@vitest/runner": "4.0.17", - "@vitest/snapshot": "4.0.17", - "@vitest/spy": "4.0.17", - "@vitest/utils": "4.0.17", - "es-module-lexer": "^1.7.0", - "expect-type": "^1.2.2", + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", - "std-env": "^3.10.0", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { @@ -12151,12 +13120,15 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.17", - "@vitest/browser-preview": "4.0.17", - "@vitest/browser-webdriverio": "4.0.17", - "@vitest/ui": "4.0.17", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -12177,6 +13149,12 @@ "@vitest/browser-webdriverio": { "optional": true }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, "@vitest/ui": { "optional": true }, @@ -12185,58 +13163,34 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, "node_modules/vitest/node_modules/@vitest/expect": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.17.tgz", - "integrity": "sha512-mEoqP3RqhKlbmUmntNDDCJeTDavDR+fVYkSOw8qRwJFaW/0/5zA9zFeTrHqNtcmwh6j26yMmwx2PqUDPzt5ZAQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", "dev": true, "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.0.0", + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.17", - "@vitest/utils": "4.0.17", - "chai": "^6.2.1", - "tinyrainbow": "^3.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vitest/node_modules/@vitest/mocker": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.17.tgz", - "integrity": "sha512-+ZtQhLA3lDh1tI2wxe3yMsGzbp7uuJSWBM1iTIKCbppWTSBN09PUC+L+fyNlQApQoR+Ps8twt2pbSSXg2fQVEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.0.17", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } } }, "node_modules/vitest/node_modules/@vitest/spy": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.17.tgz", - "integrity": "sha512-I1bQo8QaP6tZlTomQNWKJE6ym4SHf3oLS7ceNjozxxgzavRAgZDc06T7kD8gb9bXKEgcLNt00Z+kZO6KaJ62Ew==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", "dev": true, "license": "MIT", "funding": { @@ -12253,16 +13207,6 @@ "node": ">=18" } }, - "node_modules/vitest/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/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", @@ -12458,9 +13402,9 @@ } }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", "dev": true, "license": "MIT", "engines": { diff --git a/frontend/package.json b/frontend/package.json index fce9e241..926bd9f0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,7 +8,7 @@ "build": "vite build", "build:check": "tsc -b && vite build", "lint": "eslint .", - "typecheck": "tsc --noEmit", + "typecheck": "tsc -b", "preview": "vite preview", "test": "vitest", "test:ui": "vitest --ui", @@ -57,11 +57,11 @@ "@tailwindcss/typography": "^0.5.19", "@tanstack/react-query": "^5.90.5", "@tanstack/react-router": "^1.141.1", - "@tiptap/extension-link": "^3.14.0", - "@tiptap/extension-placeholder": "^3.14.0", - "@tiptap/react": "^3.14.0", - "@tiptap/starter-kit": "^3.20.4", - "axios": "^1.15.2", + "@tiptap/extension-link": "^3.31.3", + "@tiptap/extension-placeholder": "^3.31.3", + "@tiptap/react": "^3.31.3", + "@tiptap/starter-kit": "^3.31.3", + "axios": "^1.20.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -136,7 +136,7 @@ "eslint-plugin-storybook": "^10.1.11", "globals": "^16.4.0", "jsdom": "^27.3.0", - "postcss": "^8.5.10", + "postcss": "^8.5.28", "storybook": "^10.1.11", "tailwindcss": "^3.4.18", "typescript": "~5.9.3", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 5c7bd08a..eb5d7420 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -4,25 +4,6 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false -overrides: - minimatch@>=3.0.0 <3.1.3: ^3.1.3 - minimatch@>=8.0.0 <8.0.6: ^8.0.6 - minimatch@>=9.0.0 <9.0.6: ^9.0.6 - minimatch@>=10.0.0 <10.2.3: ^10.2.3 - rollup@>=4.0.0 <4.59.0: ^4.59.0 - flatted@<3.4.2: ^3.4.2 - ajv@>=6.0.0 <6.14.0: ^6.14.0 - picomatch@<2.3.2: ^2.3.2 - picomatch@>=4.0.0 <4.0.4: ^4.0.4 - brace-expansion@>=1.0.0 <1.1.13: ^1.1.13 - brace-expansion@>=2.0.0 <2.0.3: ^2.0.3 - brace-expansion@>=4.0.0 <5.0.6: ^5.0.6 - protobufjs@<=7.5.5: ^7.5.6 - '@protobufjs/utf8@<=1.1.0': ^1.1.1 - follow-redirects@<=1.15.11: ^1.16.0 - postcss@<8.5.10: ^8.5.10 - ws@>=8.0.0 <8.20.1: ^8.20.1 - importers: .: @@ -50,13 +31,13 @@ importers: version: 0.208.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': specifier: ^0.214.0 - version: 0.214.0(@opentelemetry/api@1.9.0) + version: 0.214.0(@opentelemetry/api@1.9.0)(supports-color@7.2.0) '@opentelemetry/instrumentation-fetch': specifier: ^0.214.0 - version: 0.214.0(@opentelemetry/api@1.9.0) + version: 0.214.0(@opentelemetry/api@1.9.0)(supports-color@7.2.0) '@opentelemetry/instrumentation-user-interaction': specifier: ^0.53.0 - version: 0.53.0(@opentelemetry/api@1.9.0)(zone.js@0.16.0) + version: 0.53.0(@opentelemetry/api@1.9.0)(supports-color@7.2.0)(zone.js@0.15.1) '@opentelemetry/resources': specifier: ^2.6.1 version: 2.6.1(@opentelemetry/api@1.9.0) @@ -127,20 +108,20 @@ importers: specifier: ^1.141.1 version: 1.150.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tiptap/extension-link': - specifier: ^3.14.0 - version: 3.15.3(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4) + specifier: ^3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/extension-placeholder': - specifier: ^3.14.0 - version: 3.15.3(@tiptap/extensions@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)) + specifier: ^3.31.3 + version: 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) '@tiptap/react': - specifier: ^3.14.0 - version: 3.15.3(@floating-ui/dom@1.7.4)(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^3.31.3 + version: 3.31.3(@floating-ui/dom@1.7.4)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tiptap/starter-kit': - specifier: ^3.20.4 - version: 3.20.4 + specifier: ^3.31.3 + version: 3.31.3 axios: - specifier: ^1.15.2 - version: 1.16.1 + specifier: ^1.20.0 + version: 1.20.0(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -182,7 +163,7 @@ importers: version: 7.71.1(react@19.2.3) react-markdown: specifier: ^10.1.0 - version: 10.1.0(@types/react@19.2.8)(react@19.2.3) + version: 10.1.0(@types/react@19.2.8)(react@19.2.3)(supports-color@7.2.0) react-swipeable: specifier: ^7.0.2 version: 7.0.2(react@19.2.3) @@ -191,7 +172,7 @@ importers: version: 3.6.0(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react-is@19.2.3)(react@19.2.3)(redux@5.0.1) remark-gfm: specifier: ^4.0.1 - version: 4.0.1 + version: 4.0.1(supports-color@7.2.0) tailwind-merge: specifier: ^3.3.1 version: 3.4.0 @@ -213,7 +194,7 @@ importers: version: 1.60.0 '@sentry/vite-plugin': specifier: ^5.2.0 - version: 5.2.0(rollup@4.60.1) + version: 5.2.0(rollup@4.60.1)(supports-color@7.2.0) '@storybook/addon-a11y': specifier: ^10.1.11 version: 10.2.10(storybook@10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)) @@ -225,7 +206,7 @@ importers: version: 10.2.10(storybook@10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)) '@storybook/react-vite': specifier: ^10.1.11 - version: 10.2.10(esbuild@0.27.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rollup@4.60.1)(storybook@10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3)(vite@7.3.3(@types/node@24.10.9)(jiti@1.21.7)) + version: 10.2.10(esbuild@0.27.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rollup@4.60.1)(storybook@10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(supports-color@7.2.0)(typescript@5.9.3)(vite@7.3.3(@types/node@24.10.9)(jiti@1.21.7)) '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 @@ -249,7 +230,7 @@ importers: version: 19.2.3(@types/react@19.2.8) '@vitejs/plugin-react': specifier: ^5.1.4 - version: 5.1.4(vite@7.3.3(@types/node@24.10.9)(jiti@1.21.7)) + version: 5.1.4(supports-color@7.2.0)(vite@7.3.3(@types/node@24.10.9)(jiti@1.21.7)) '@vitest/coverage-v8': specifier: ^4.0.15 version: 4.0.17(vitest@4.0.17) @@ -258,28 +239,28 @@ importers: version: 4.0.17(vitest@4.0.17) autoprefixer: specifier: ^10.4.21 - version: 10.4.23(postcss@8.5.15) + version: 10.4.23(postcss@8.5.28) eslint: specifier: ^9.36.0 - version: 9.39.2(jiti@1.21.7) + version: 9.39.2(jiti@1.21.7)(supports-color@7.2.0) eslint-plugin-react-hooks: specifier: ^7.0.1 - version: 7.0.1(eslint@9.39.2(jiti@1.21.7)) + version: 7.0.1(eslint@9.39.2(jiti@1.21.7)(supports-color@7.2.0))(supports-color@7.2.0) eslint-plugin-react-refresh: specifier: ^0.4.22 - version: 0.4.26(eslint@9.39.2(jiti@1.21.7)) + version: 0.4.26(eslint@9.39.2(jiti@1.21.7)(supports-color@7.2.0)) eslint-plugin-storybook: specifier: ^10.1.11 - version: 10.2.10(eslint@9.39.2(jiti@1.21.7))(storybook@10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3) + version: 10.2.10(eslint@9.39.2(jiti@1.21.7)(supports-color@7.2.0))(storybook@10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(supports-color@7.2.0)(typescript@5.9.3) globals: specifier: ^16.4.0 version: 16.5.0 jsdom: specifier: ^27.3.0 - version: 27.4.0 + version: 27.4.0(supports-color@7.2.0) postcss: - specifier: ^8.5.10 - version: 8.5.15 + specifier: ^8.5.28 + version: 8.5.28 storybook: specifier: ^10.1.11 version: 10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -291,16 +272,16 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.45.0 - version: 8.53.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + version: 8.53.0(eslint@9.39.2(jiti@1.21.7)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) vite: specifier: ^7.3.2 version: 7.3.3(@types/node@24.10.9)(jiti@1.21.7) vite-tsconfig-paths: specifier: ^5.1.4 - version: 5.1.4(typescript@5.9.3)(vite@7.3.3(@types/node@24.10.9)(jiti@1.21.7)) + version: 5.1.4(supports-color@7.2.0)(typescript@5.9.3)(vite@7.3.3(@types/node@24.10.9)(jiti@1.21.7)) vitest: specifier: ^4.0.15 - version: 4.0.17(@opentelemetry/api@1.9.0)(@types/node@24.10.9)(@vitest/ui@4.0.17)(jiti@1.21.7)(jsdom@27.4.0) + version: 4.0.17(@opentelemetry/api@1.9.0)(@types/node@24.10.9)(@vitest/ui@4.0.17)(jiti@1.21.7)(jsdom@27.4.0(supports-color@7.2.0)) packages: @@ -953,8 +934,8 @@ packages: '@protobufjs/codegen@2.0.5': resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} - '@protobufjs/eventemitter@1.1.0': - resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} '@protobufjs/fetch@1.1.1': resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} @@ -962,9 +943,6 @@ packages: '@protobufjs/float@1.0.2': resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} - '@protobufjs/inquire@1.1.2': - resolution: {integrity: sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==} - '@protobufjs/path@1.1.2': resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} @@ -1489,9 +1467,6 @@ packages: react-redux: optional: true - '@remirror/core-constants@3.0.0': - resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==} - '@rolldown/pluginutils@1.0.0-rc.3': resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} @@ -1499,7 +1474,7 @@ packages: resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} engines: {node: '>=14.0.0'} peerDependencies: - rollup: ^4.59.0 + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 peerDependenciesMeta: rollup: optional: true @@ -1538,66 +1513,79 @@ packages: resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.1': resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.1': resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.1': resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.1': resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.1': resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.1': resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.1': resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.1': resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.1': resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.1': resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.1': resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.1': resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.60.1': resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} @@ -1723,7 +1711,7 @@ packages: resolution: {integrity: sha512-a8LfpvcYMFtFSroro5MpCcOoS528LeLfUHzxWURnpofOnY+Aso9Si4y4dFlna+RKqxCXjmFbn6CLnfI+YrHysQ==} engines: {node: '>= 18'} peerDependencies: - rollup: ^4.59.0 + rollup: '>=3.2.0' peerDependenciesMeta: rollup: optional: true @@ -1763,7 +1751,7 @@ packages: resolution: {integrity: sha512-aFvgaNDAnKMjuyhPK5ialT22pPqMN0XfPBNPeeNVPYztngkdKBa8WFqF/umDd47HxAjebq+vn6uId1xHyOHH3g==} peerDependencies: esbuild: '*' - rollup: ^4.59.0 + rollup: '*' storybook: ^10.2.10 vite: '*' webpack: '*' @@ -1878,165 +1866,160 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' - '@tiptap/core@3.20.4': - resolution: {integrity: sha512-3i/DG89TFY/b34T5P+j35UcjYuB5d3+9K8u6qID+iUqNPiza015HPIZLuPfE5elNwVdV3EXIoPo0LLeBLgXXAg==} + '@tiptap/core@3.31.3': + resolution: {integrity: sha512-Cz50pvciQrxdSxgTkHOVz0uD0Yl/8Xt0QatGD6ILm47jW8EzyHR9RkUGs/D5IqzXKuVPntfw1ttaT926vXfiRg==} peerDependencies: - '@tiptap/pm': ^3.20.4 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-blockquote@3.20.4': - resolution: {integrity: sha512-9sskyyhYj2oKat//lyZVXCp9YrPt4oJAZnGHYWXS0xlskjsLElrfKKlM4vpbhGss3VrhQRoEGqWLnIaJYPF1zw==} + '@tiptap/extension-blockquote@3.31.3': + resolution: {integrity: sha512-fyY2XMbyDDDfOTQ1Qdrnqa1qwC9DWE4n7AfE0EKQI0G8MfLV8RaDlLDcOZDJ9JbMPY7/Gx7EjyK4NKxSW9n2hQ==} peerDependencies: - '@tiptap/core': ^3.20.4 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-bold@3.20.4': - resolution: {integrity: sha512-Md7/mNAeJCY+VLJc8JRGI+8XkVPKiOGB1NgqQPdh3aYtxXQDChQOZoJEQl6TuudDxZ85bLZB67NjZlx3jo8/0g==} + '@tiptap/extension-bold@3.31.3': + resolution: {integrity: sha512-dIuYhKk8TitKU/FeDpoTeWZhU42YgDN5npgWNjAmMmRktPdoxnH3/wGSiwXlqZgJWjehN7kPWDePWpJeAImGpQ==} peerDependencies: - '@tiptap/core': ^3.20.4 + '@tiptap/core': 3.31.3 - '@tiptap/extension-bubble-menu@3.15.3': - resolution: {integrity: sha512-e88DG1bTy6hKxrt7iPVQhJnH5/EOrnKpIyp09dfRDgWrrW88fE0Qjys7a/eT8W+sXyXM3z10Ye7zpERWsrLZDg==} + '@tiptap/extension-bubble-menu@3.31.3': + resolution: {integrity: sha512-EV6ZnwKc++2OM/OcD54n8s1B7C9LP7GKAtdEPwu0t3BYJf3sG8Ueoinf7SgGPO9oMPg96GneOkDNm1urMV167g==} peerDependencies: - '@tiptap/core': ^3.15.3 - '@tiptap/pm': ^3.15.3 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-bullet-list@3.20.4': - resolution: {integrity: sha512-1RTGrur1EKoxfnLZ3M6xeNj8GITAz74jH2DHGcjLsd2Xr7Q7BozGaIq6GkkvKguMwbI1zCOxTHFCpUETXAIQQA==} + '@tiptap/extension-bullet-list@3.31.3': + resolution: {integrity: sha512-qEyyoPapPef4LO8XKaN83bxtaNzkJ4kFn/IxLnEKd4BJ3Mvi4MH2yJYlyDqwFnMoev4pMA1zBHRAt/C0THRmZw==} peerDependencies: - '@tiptap/extension-list': ^3.20.4 + '@tiptap/extension-list': 3.31.3 - '@tiptap/extension-code-block@3.20.4': - resolution: {integrity: sha512-Zlw3FrXTy01+o1yISeX/LC+iJeHA+ym602bMXGmtA6lyl7QSOSO7WExweJ6xeJGhbCjldwT5al6fkRAs8iGJZg==} + '@tiptap/extension-code-block@3.31.3': + resolution: {integrity: sha512-nvknt4FhyJQjYcvxptmeUlFsIAc8ibua3E5BN4Pim374/9RWepH4cdE9X0/qUTtguHElLx0iKtSIY3rW2qGTFA==} peerDependencies: - '@tiptap/core': ^3.20.4 - '@tiptap/pm': ^3.20.4 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-code@3.20.4': - resolution: {integrity: sha512-7j8Hi964bH1SZ9oLdZC1fkqWz27mliSDV7M8lmL/M14+Qw42D/VOAKS4Aw9OCFtHMlTsjLR6qsoVxL8Lpkt6NA==} + '@tiptap/extension-code@3.31.3': + resolution: {integrity: sha512-SzxOqchrD2AcN3uT67PjKmRFEMOU3vNNiwNaamZJUbZrI6Hmy+bvdHJrc3jrIddyyCrAtsnAfRI0cmorW1jGfg==} peerDependencies: - '@tiptap/core': ^3.20.4 + '@tiptap/core': 3.31.3 - '@tiptap/extension-document@3.20.4': - resolution: {integrity: sha512-zF1CIFVLt8MfSpWWnPwtGyxPOsT0xYM2qJKcXf2yZcTG37wDKmUi6heG53vGigIavbQlLaAFvs+1mNdOu2x/0A==} + '@tiptap/extension-document@3.31.3': + resolution: {integrity: sha512-EexgmqnyDNyGlISxo7SMrp5MygpJYmqD+0cY5jB6L1U6L4CpKKRWUt8OO1sWzEHDE1+TTvwt+WIFoIWAziOtEA==} peerDependencies: - '@tiptap/core': ^3.20.4 + '@tiptap/core': 3.31.3 - '@tiptap/extension-dropcursor@3.20.4': - resolution: {integrity: sha512-TgMwvZ8myXYdmd6bUV7qkpZXv7ZUiSmX/8eo+iPEzYo2CnDLAGvDKgC50nfq/g87SDvfBgPuAiBfFvsMQQWaTw==} + '@tiptap/extension-dropcursor@3.31.3': + resolution: {integrity: sha512-NWomSfu5CSC7VacnMSDzKT8qm66SzMfZwVPEtwY5bPpRTJgTiT1rNK0neDrrzfMN27MfylGyKWWf7Q5Qf8w/fg==} peerDependencies: - '@tiptap/extensions': ^3.20.4 + '@tiptap/extensions': 3.31.3 - '@tiptap/extension-floating-menu@3.15.3': - resolution: {integrity: sha512-+3DVBleKKffadEJEdLYxmYAJOjHjLSqtiSFUE3RABT4V2ka1ODy2NIpyKX0o1SvQ5N1jViYT9Q+yUbNa6zCcDw==} + '@tiptap/extension-floating-menu@3.31.3': + resolution: {integrity: sha512-rd4VJ9PGSP9Eop8ZTEwaLZcMzMXLuJKe3hUNf58rq+zANpwM+9fI+vB7g9MAc3eXwUNDxNDVACFIL2oeBqpQ3A==} peerDependencies: '@floating-ui/dom': ^1.0.0 - '@tiptap/core': ^3.15.3 - '@tiptap/pm': ^3.15.3 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-gapcursor@3.20.4': - resolution: {integrity: sha512-JJ6f1iQ1e0s4kISgq55U3UYGwWV/N9f0PYMtB6e3L+SBQjXnywaLK0g6vfN6IvTCC2vdIuqeSOX8VlSO97sJLw==} + '@tiptap/extension-gapcursor@3.31.3': + resolution: {integrity: sha512-EBXKb1FrVStsNYCcRGtd9jmzveCvR+eqgg1rVqoONrqFK6U7bga6LN+1dMKroP1kliDVgveYkP3vRYxqw+rFqg==} peerDependencies: - '@tiptap/extensions': ^3.20.4 + '@tiptap/extensions': 3.31.3 - '@tiptap/extension-hard-break@3.20.4': - resolution: {integrity: sha512-gJbq58d8zB1gzyqVEopowej5CpW4/Fpg6oGJvlZxaCukqd0gJRWGC89K+jE62YA1Td4sfcKrekKvN7jm2y/ZUg==} + '@tiptap/extension-hard-break@3.31.3': + resolution: {integrity: sha512-QAdCvNO4+yW9ATwsrej11NTkDYFqPLIEQr3ARNrKOK1qaiS7A0fia2SEukb/hrkP3A6mbozhoQt2r2RGUf/DpQ==} peerDependencies: - '@tiptap/core': ^3.20.4 + '@tiptap/core': 3.31.3 - '@tiptap/extension-heading@3.20.4': - resolution: {integrity: sha512-xsnkmTGggJc5P2iCwS1lv8KFG31xC/GNPJKoi/3UH67j/lKDhA3AdtshsLeyv2FKtTtYDb8oV0IqzHB1MM6a7w==} + '@tiptap/extension-heading@3.31.3': + resolution: {integrity: sha512-rk5VHMAeQcg06SLauN6EGdD2jc0O2qY8QkZYPd0LxNvLblw2BxBx+lxUQSYwLAT9Ie5914gKIK2YbRyO2Ts3ig==} peerDependencies: - '@tiptap/core': ^3.20.4 + '@tiptap/core': 3.31.3 - '@tiptap/extension-horizontal-rule@3.20.4': - resolution: {integrity: sha512-y6joCi49haAA0bo3EGUY+dWUMHH1GPUc84hxrBY/0pMs+Bn+kQ1+DQJErZDTWGJrlHPWU/yekBZT72SNdp0DNA==} + '@tiptap/extension-horizontal-rule@3.31.3': + resolution: {integrity: sha512-YnHGy2KShRwvCseAmmxl9VP7R0qaj8QMp3DA6DJWZqp7r5gLGvDkAqhedxqqefqsE4Y43hjkBdjtB9Ce78LkIw==} peerDependencies: - '@tiptap/core': ^3.20.4 - '@tiptap/pm': ^3.20.4 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-italic@3.20.4': - resolution: {integrity: sha512-4ZqiWr7cmqPFux8tj1ZLiYytyWf343IvQemNX6AvVWvscrJcrfj3YX4Le2BA0RW3A3M6RpLQXXozuF8vxYFDeQ==} + '@tiptap/extension-italic@3.31.3': + resolution: {integrity: sha512-ibGvdvAPyfxBMUVNRI43eb9h2/Jka1MRG5GtnGqbcCX/2+Y/y0EOfFrPQPkuYphiWQYyu9+FzPKMB/pjuaLuKQ==} peerDependencies: - '@tiptap/core': ^3.20.4 + '@tiptap/core': 3.31.3 - '@tiptap/extension-link@3.15.3': - resolution: {integrity: sha512-PdDXyBF9Wco9U1x6e+b7tKBWG+kqBDXDmaYXHkFm/gYuQCQafVJ5mdrDdKgkHDWVnJzMWZXBcZjT9r57qtlLWg==} + '@tiptap/extension-link@3.31.3': + resolution: {integrity: sha512-986wOQzTL9Zr5lf84LCLpm+YOms8A0K39/8DVoqRfebqcOe0/eq4bnztmAlfabOd+kJY92g3AgZERFUx/w+dcw==} peerDependencies: - '@tiptap/core': ^3.15.3 - '@tiptap/pm': ^3.15.3 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-link@3.20.4': - resolution: {integrity: sha512-JNDSkWrVdb8NSvbQXwHWvK5tCMbTWwOHFOweknQZ1JPK4dei9FJVofYQaHyW4bJBdcCjds3NZSnXE8DM9iAWmg==} + '@tiptap/extension-list-item@3.31.3': + resolution: {integrity: sha512-4QlKOriJJMvg95QJTEsy9BUYPBQ6UvJyb8WURRwdUtQUkkqb8h32lg/eyQUv2FzW9IT1AdUyNZfVaxH64kRfQA==} peerDependencies: - '@tiptap/core': ^3.20.4 - '@tiptap/pm': ^3.20.4 + '@tiptap/extension-list': 3.31.3 - '@tiptap/extension-list-item@3.20.4': - resolution: {integrity: sha512-QoTc5RACXaZF+vIIBBxjGO7D0oWFUDgBKJCpvUZ0CoGGKosnfe4a9I5THFyLj4201cf0oUqgf1oZhTqETGxlVw==} + '@tiptap/extension-list-keymap@3.31.3': + resolution: {integrity: sha512-If8UOEdDZbPJU6iYTvLtH6DOp2KBy6BKxg9UELL1AevVetGHEF/7lW8hP50Gn1tHMVpPRqmhSzVrJRpEJJgb/Q==} peerDependencies: - '@tiptap/extension-list': ^3.20.4 + '@tiptap/extension-list': 3.31.3 - '@tiptap/extension-list-keymap@3.20.4': - resolution: {integrity: sha512-RIqXM649+8IP7p/KVfaGlJiwjCylm1m6OPlaoM3K8O7oEOGRQzNeexexECCD2jsXRxew4E+vBNMD2orXqJmu8A==} + '@tiptap/extension-list@3.31.3': + resolution: {integrity: sha512-LoveGnC0FVdCV4jUNBaG1ZA+KWE07+adzV3kGy6uUYFcJEjbVUHTnPDrBOob3IwOSO3sCwIkvQb6EVYeXn/4yg==} peerDependencies: - '@tiptap/extension-list': ^3.20.4 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-list@3.20.4': - resolution: {integrity: sha512-X+5plTKhOioNcQ4KsAFJJSb/3+zR8Xhdpow4HzXtoV1KcbdDey1fhZdpsfkbrzCL0s6/wAgwZuAchCK7HujurQ==} + '@tiptap/extension-ordered-list@3.31.3': + resolution: {integrity: sha512-mp3g11NgA/PYu8rj7J7Ez3l4qBy6WfTSmHIG4PZvEGG5w2oUAIkgb9DU7nPPzjmeme27oazFYZw+AtZA0+u4tw==} peerDependencies: - '@tiptap/core': ^3.20.4 - '@tiptap/pm': ^3.20.4 + '@tiptap/extension-list': 3.31.3 - '@tiptap/extension-ordered-list@3.20.4': - resolution: {integrity: sha512-3budNL8BgBon3TcXZ4hjT0YpFvx1Ka3uSIECKDxHgES+OQcR+6cagxSb60gFEccf3Dr0PIwcVTY6g14lC1qKRQ==} + '@tiptap/extension-paragraph@3.31.3': + resolution: {integrity: sha512-+iPku7wJfy5hbNDNLX8dveFtYVsZMmh7vztjuq8hT3mipSC4IByDehDbU8fzUCjfXiEzmI7mQn7c8LGmHULuzA==} peerDependencies: - '@tiptap/extension-list': ^3.20.4 + '@tiptap/core': 3.31.3 - '@tiptap/extension-paragraph@3.20.4': - resolution: {integrity: sha512-lm6fOScWuZAF/Sfp97igUwFd3L1QHIVLAWP5NVdh0DTLrEIt4rMBmsww+yOpMQRhvz2uTgMbMXynrimhzi/QVw==} + '@tiptap/extension-placeholder@3.31.3': + resolution: {integrity: sha512-9jYtR8ELEw7GVaruyrm4oFkPcjig9Q+crc+dpmarhBNXUmxagCdlhVzNwCJ2WJRzvBAtx59sEYqNTU38Wx8S3A==} peerDependencies: - '@tiptap/core': ^3.20.4 + '@tiptap/extensions': 3.31.3 - '@tiptap/extension-placeholder@3.15.3': - resolution: {integrity: sha512-XcHHnojT186hKIoOgcPBesXk89+caNGVUdMtc171Vcr/5s0dpnr4q5LfE+YRC+S85CpCxCRRnh84Ou+XRtOqrw==} + '@tiptap/extension-strike@3.31.3': + resolution: {integrity: sha512-G29bhKttYwcKHT+BI6emWVFol3RO/gUXxQVcmr/iT8LXXy7j8J6HFUnsKM+Kg5YlP1rxMRgsa65dbrQVahZi0A==} peerDependencies: - '@tiptap/extensions': ^3.15.3 + '@tiptap/core': 3.31.3 - '@tiptap/extension-strike@3.20.4': - resolution: {integrity: sha512-It1Px9uDGTsVqyyg6cy7DigLoenljpQwqdI0jssM7QclZrHnsrye9fZxBBiiuCzzV1305MxKgHvratkHwqmVNA==} + '@tiptap/extension-text@3.31.3': + resolution: {integrity: sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg==} peerDependencies: - '@tiptap/core': ^3.20.4 + '@tiptap/core': 3.31.3 - '@tiptap/extension-text@3.20.4': - resolution: {integrity: sha512-jchJcBZixDEO2J66Zx5dchsI2mA6IYsROqF8P1poxL4ienH7RVQRCTsBNnSfIeOtREKKWeOU/tEs5fcpvvGwIQ==} + '@tiptap/extension-underline@3.31.3': + resolution: {integrity: sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw==} peerDependencies: - '@tiptap/core': ^3.20.4 + '@tiptap/core': 3.31.3 - '@tiptap/extension-underline@3.20.4': - resolution: {integrity: sha512-0OjMc3FDujX16G+jhvqcY/mLot8SrNtDu8ggUwNLAfiI/QIvMVgk7giFD71DATC/4Nb8i/iwAEegTD8MxBIXCg==} + '@tiptap/extensions@3.31.3': + resolution: {integrity: sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA==} peerDependencies: - '@tiptap/core': ^3.20.4 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extensions@3.20.4': - resolution: {integrity: sha512-8p6hVT65DjuQjtEdlH6ewX9SOJHlVQAOee3sWIJQmeJNRnZNvqPIBLleebUqDiljNTpxBv6s6QWkSTKgf3btwg==} - peerDependencies: - '@tiptap/core': ^3.20.4 - '@tiptap/pm': ^3.20.4 + '@tiptap/pm@3.31.3': + resolution: {integrity: sha512-sZime0SWsz/k62W2WvHx5Ig7G2h7kVhrrmnqy+wEgIHfDwEfOlelRjaWCiBCFlF7dxGUntJusCh9FxlLhni0Ag==} - '@tiptap/pm@3.20.4': - resolution: {integrity: sha512-rCHYSBToilBEuI6PtjziHDdRkABH/XqwJ7dG4Amn/SD3yGiZKYCiEApQlTUS2zZeo8DsLeuqqqB4vEOeD4OEPg==} - - '@tiptap/react@3.15.3': - resolution: {integrity: sha512-XvouB+Hrqw8yFmZLPEh+HWlMeRSjZfHSfWfWuw5d8LSwnxnPeu3Bg/rjHrRrdwb+7FumtzOnNWMorpb/PSOttQ==} + '@tiptap/react@3.31.3': + resolution: {integrity: sha512-QiwQqvaLFLm5EMFu5tg7nAgXJxCUiUTLD8EsK+TqVV5P4bqOoMOCM39khbhXTJyahCuYpiFWx5YOSDtC/JiPtg==} peerDependencies: - '@tiptap/core': ^3.15.3 - '@tiptap/pm': ^3.15.3 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 '@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tiptap/starter-kit@3.20.4': - resolution: {integrity: sha512-WcyK6hsTl8eBsQhQ+d9Sq8fYZKOYdL+D45MyH3hz583elXqJlW3h3JPFYb0o87gddGxn8Mm57OA/gA1zEdeDMw==} + '@tiptap/starter-kit@3.31.3': + resolution: {integrity: sha512-WKof9RewdmGHvWJ1wn0/HVNG2mV+HOgVRyJkKekuM9fgr6BZAAH/xZsWE1eon+94JnQ+KtK2ThydXQM/qc6b2A==} '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -2108,18 +2091,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/linkify-it@5.0.0': - resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} - - '@types/markdown-it@14.1.2': - resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} - '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} - '@types/mdurl@2.0.0': - resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} - '@types/mdx@2.0.13': resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==} @@ -2213,6 +2187,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@vitejs/plugin-react@5.1.4': resolution: {integrity: sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==} @@ -2365,14 +2340,14 @@ packages: engines: {node: ^10 || ^12 || >=14} hasBin: true peerDependencies: - postcss: ^8.5.10 + postcss: ^8.1.0 axe-core@4.11.1: resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==} engines: {node: '>=4'} - axios@1.16.1: - resolution: {integrity: sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==} + axios@1.20.0: + resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==} bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -2523,9 +2498,6 @@ packages: cookie-es@2.0.0: resolution: {integrity: sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==} - crelt@1.0.6: - resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} - cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2685,10 +2657,6 @@ packages: resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} engines: {node: '>=14'} - entities@4.5.0: - resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} - engines: {node: '>=0.12'} - entities@6.0.1: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} @@ -2764,6 +2732,7 @@ packages: eslint@9.39.2: resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -2839,7 +2808,7 @@ packages: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} peerDependencies: - picomatch: ^4.0.4 + picomatch: ^3 || ^4 peerDependenciesMeta: picomatch: optional: true @@ -2879,8 +2848,8 @@ packages: debug: optional: true - form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} fraction.js@5.3.4: @@ -2975,6 +2944,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} @@ -3189,11 +3162,8 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - linkify-it@5.0.0: - resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} - - linkifyjs@4.3.2: - resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==} + linkifyjs@4.3.3: + resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==} locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} @@ -3241,10 +3211,6 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} - markdown-it@14.1.1: - resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} - hasBin: true - markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} @@ -3300,9 +3266,6 @@ packages: mdn-data@2.12.2: resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} - mdurl@2.0.0: - resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} - merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -3444,8 +3407,8 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - nanoid@3.3.12: - resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -3563,20 +3526,20 @@ packages: resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} engines: {node: '>=14.0.0'} peerDependencies: - postcss: ^8.5.10 + postcss: ^8.0.0 postcss-js@4.1.0: resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} engines: {node: ^12 || ^14 || >= 16} peerDependencies: - postcss: ^8.5.10 + postcss: ^8.4.21 postcss-load-config@6.0.1: resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} engines: {node: '>= 18'} peerDependencies: jiti: '>=1.21.0' - postcss: ^8.5.10 + postcss: '>=8.0.9' tsx: ^4.8.1 yaml: ^2.4.2 peerDependenciesMeta: @@ -3593,7 +3556,7 @@ packages: resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} engines: {node: '>=12.0'} peerDependencies: - postcss: ^8.5.10 + postcss: ^8.2.14 postcss-selector-parser@6.0.10: resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} @@ -3606,8 +3569,8 @@ packages: postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - postcss@8.5.15: - resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} prelude-ls@1.2.1: @@ -3625,11 +3588,8 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} - prosemirror-changeset@2.4.0: - resolution: {integrity: sha512-LvqH2v7Q2SF6yxatuPP2e8vSUKS/L+xAU7dPDC4RMyHMhZoGDfBC74mYuyYF4gLqOEG758wajtyhNnsTkuhvng==} - - prosemirror-collab@1.3.1: - resolution: {integrity: sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==} + prosemirror-changeset@2.4.2: + resolution: {integrity: sha512-ViYrjMSg3YFiXwIhKaluu+/mi3Yrxt6AR8ri14ulTaGcZtXO1CThl7A2gv79qx5fQnOw8woKwyBU2u+9PVCm3w==} prosemirror-commands@1.7.1: resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==} @@ -3649,18 +3609,12 @@ packages: prosemirror-keymap@1.2.3: resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==} - prosemirror-markdown@1.13.4: - resolution: {integrity: sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==} - - prosemirror-menu@1.3.0: - resolution: {integrity: sha512-TImyPXCHPcDsSka2/lwJ6WjTASr4re/qWq1yoTTuLOqfXucwF6VcRa2LWCkM/EyTD1UO3CUwiH8qURJoWJRxwg==} + prosemirror-model@1.25.11: + resolution: {integrity: sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==} prosemirror-model@1.25.4: resolution: {integrity: sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==} - prosemirror-schema-basic@1.2.4: - resolution: {integrity: sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==} - prosemirror-schema-list@1.5.1: resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==} @@ -3670,21 +3624,20 @@ packages: prosemirror-tables@1.8.5: resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==} - prosemirror-trailing-node@3.0.0: - resolution: {integrity: sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==} - peerDependencies: - prosemirror-model: ^1.22.1 - prosemirror-state: ^1.4.2 - prosemirror-view: ^1.33.8 - prosemirror-transform@1.11.0: resolution: {integrity: sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==} + prosemirror-transform@1.12.1: + resolution: {integrity: sha512-t4F5615FycnCqsX7ShTUs8+jfnwcf46kuFRvSl/3qFx2QTZ4DjgowesLHQXcFP7G//TjesqZG3WtgL7vdyVJyA==} + prosemirror-view@1.41.7: resolution: {integrity: sha512-jUwKNCEIGiqdvhlS91/2QAg21e4dfU5bH2iwmSDQeosXJgKF7smG0YSplOWK0cjSNgIqXe7VXqo7EIfUFJdt3w==} - protobufjs@7.6.0: - resolution: {integrity: sha512-LtESOsMPTZgyYtwxhvdgdjGL0HmXEaRA/hVD6sol4zA60hVXXXP/SGmxnqDbgGE8gy7pYex7cym+5vYPcmaXBQ==} + prosemirror-view@1.42.3: + resolution: {integrity: sha512-oTN7EtH+CpwxU9NrwEYWd0UZ4JUx7l048l5A2Xppm4p/60isZYLnth9QVQmC3VRIvdrIWCxwZSd+Uz791G31/w==} + + protobufjs@7.6.6: + resolution: {integrity: sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==} engines: {node: '>=12.0.0'} proxy-from-env@1.1.0: @@ -3694,10 +3647,6 @@ packages: resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} engines: {node: '>=10'} - punycode.js@2.3.1: - resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} - engines: {node: '>=6'} - punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -3904,8 +3853,8 @@ packages: peerDependencies: seroval: ^1.0 - seroval@1.4.2: - resolution: {integrity: sha512-N3HEHRCZYn3cQbsC4B5ldj9j+tHdf4JZoYPlcI4rRYu0Xy4qN8MQf1Z08EibzB0WpgRG5BGK08FTrmM66eSzKQ==} + seroval@1.6.7: + resolution: {integrity: sha512-AeDcLh0yO2SFm9W71essgnSzLV9DI8ZH0x0knXn2DMnUZj728mpLbxjlbB6IqKCmqh8JA3cEqRyGoNkt584JcQ==} engines: {node: '>=10'} shebang-command@2.0.0: @@ -4086,6 +4035,7 @@ packages: tsconfck@3.1.6: resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} engines: {node: ^18 || >=20} + deprecated: unmaintained hasBin: true peerDependencies: typescript: ^5.0.0 @@ -4116,9 +4066,6 @@ packages: engines: {node: '>=14.17'} hasBin: true - uc.micro@2.1.0: - resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} - undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -4360,6 +4307,9 @@ packages: zod@4.3.5: resolution: {integrity: sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==} + zone.js@0.15.1: + resolution: {integrity: sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==} + zone.js@0.16.0: resolution: {integrity: sha512-LqLPpIQANebrlxY6jKcYKdgN5DTXyyHAKnnWWjE5pPfEQ4n7j5zn7mOEEpwNZVKGqx3kKKmvplEmoBrvpgROTA==} @@ -4406,40 +4356,40 @@ snapshots: '@babel/compat-data@7.29.0': {} - '@babel/core@7.28.6': + '@babel/core@7.28.6(supports-color@7.2.0)': dependencies: '@babel/code-frame': 7.28.6 '@babel/generator': 7.28.6 '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.28.6) + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.28.6(supports-color@7.2.0))(supports-color@7.2.0) '@babel/helpers': 7.28.6 '@babel/parser': 7.28.6 '@babel/template': 7.28.6 - '@babel/traverse': 7.28.6 + '@babel/traverse': 7.28.6(supports-color@7.2.0) '@babel/types': 7.28.6 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/core@7.29.0': + '@babel/core@7.29.0(supports-color@7.2.0)': dependencies: '@babel/code-frame': 7.29.0 '@babel/generator': 7.29.1 '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0) '@babel/helpers': 7.28.6 '@babel/parser': 7.29.0 '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@7.2.0) '@babel/types': 7.29.0 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -4472,28 +4422,28 @@ snapshots: '@babel/helper-globals@7.28.0': {} - '@babel/helper-module-imports@7.28.6': + '@babel/helper-module-imports@7.28.6(supports-color@7.2.0)': dependencies: - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@7.2.0) '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.6(@babel/core@7.28.6)': + '@babel/helper-module-transforms@7.28.6(@babel/core@7.28.6(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: - '@babel/core': 7.28.6 - '@babel/helper-module-imports': 7.28.6 + '@babel/core': 7.28.6(supports-color@7.2.0) + '@babel/helper-module-imports': 7.28.6(supports-color@7.2.0) '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@7.2.0) transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-imports': 7.28.6 + '@babel/core': 7.29.0(supports-color@7.2.0) + '@babel/helper-module-imports': 7.28.6(supports-color@7.2.0) '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -4518,14 +4468,14 @@ snapshots: dependencies: '@babel/types': 7.29.0 - '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0(supports-color@7.2.0))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0(supports-color@7.2.0))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.28.6 '@babel/runtime@7.28.6': {} @@ -4538,7 +4488,7 @@ snapshots: '@babel/parser': 7.29.0 '@babel/types': 7.29.0 - '@babel/traverse@7.28.6': + '@babel/traverse@7.28.6(supports-color@7.2.0)': dependencies: '@babel/code-frame': 7.29.0 '@babel/generator': 7.29.1 @@ -4546,11 +4496,11 @@ snapshots: '@babel/parser': 7.29.0 '@babel/template': 7.28.6 '@babel/types': 7.29.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color - '@babel/traverse@7.29.0': + '@babel/traverse@7.29.0(supports-color@7.2.0)': dependencies: '@babel/code-frame': 7.29.0 '@babel/generator': 7.29.1 @@ -4558,7 +4508,7 @@ snapshots: '@babel/parser': 7.29.0 '@babel/template': 7.28.6 '@babel/types': 7.29.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -4711,17 +4661,17 @@ snapshots: '@esbuild/win32-x64@0.27.2': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@1.21.7))': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@1.21.7)(supports-color@7.2.0))': dependencies: - eslint: 9.39.2(jiti@1.21.7) + eslint: 9.39.2(jiti@1.21.7)(supports-color@7.2.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.21.1': + '@eslint/config-array@0.21.1(supports-color@7.2.0)': dependencies: '@eslint/object-schema': 2.1.7 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -4734,10 +4684,10 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.3': + '@eslint/eslintrc@3.3.3(supports-color@7.2.0)': dependencies: ajv: 6.14.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -4885,41 +4835,41 @@ snapshots: '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-fetch@0.214.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-fetch@0.214.0(@opentelemetry/api@1.9.0)(supports-color@7.2.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.0)(supports-color@7.2.0) '@opentelemetry/sdk-trace-web': 2.6.1(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.38.0 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-user-interaction@0.53.0(@opentelemetry/api@1.9.0)(zone.js@0.16.0)': + '@opentelemetry/instrumentation-user-interaction@0.53.0(@opentelemetry/api@1.9.0)(supports-color@7.2.0)(zone.js@0.15.1)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 2.4.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0)(supports-color@7.2.0) '@opentelemetry/sdk-trace-web': 2.4.0(@opentelemetry/api@1.9.0) - zone.js: 0.16.0 + zone.js: 0.15.1 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0)(supports-color@7.2.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/api-logs': 0.208.0 import-in-the-middle: 2.0.4 - require-in-the-middle: 8.0.1 + require-in-the-middle: 8.0.1(supports-color@7.2.0) transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation@0.214.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation@0.214.0(@opentelemetry/api@1.9.0)(supports-color@7.2.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/api-logs': 0.214.0 import-in-the-middle: 3.0.1 - require-in-the-middle: 8.0.1 + require-in-the-middle: 8.0.1(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -4938,7 +4888,7 @@ snapshots: '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-metrics': 2.2.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0) - protobufjs: 7.6.0 + protobufjs: 7.6.6 '@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0)': dependencies: @@ -5018,7 +4968,7 @@ snapshots: '@protobufjs/codegen@2.0.5': {} - '@protobufjs/eventemitter@1.1.0': {} + '@protobufjs/eventemitter@1.1.1': {} '@protobufjs/fetch@1.1.1': dependencies: @@ -5026,8 +4976,6 @@ snapshots: '@protobufjs/float@1.0.2': {} - '@protobufjs/inquire@1.1.2': {} - '@protobufjs/path@1.1.2': {} '@protobufjs/pool@1.1.0': {} @@ -5559,8 +5507,6 @@ snapshots: react: 19.2.3 react-redux: 9.2.0(@types/react@19.2.8)(react@19.2.3)(redux@5.0.1) - '@remirror/core-constants@3.0.0': {} - '@rolldown/pluginutils@1.0.0-rc.3': {} '@rollup/pluginutils@5.3.0(rollup@4.60.1)': @@ -5674,11 +5620,11 @@ snapshots: '@sentry-internal/replay-canvas': 8.55.0 '@sentry/core': 8.55.0 - '@sentry/bundler-plugin-core@5.2.0': + '@sentry/bundler-plugin-core@5.2.0(supports-color@7.2.0)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@7.2.0) '@sentry/babel-plugin-component-annotate': 5.2.0 - '@sentry/cli': 2.58.5 + '@sentry/cli': 2.58.5(supports-color@7.2.0) dotenv: 16.6.1 find-up: 5.0.0 glob: 13.0.6 @@ -5711,9 +5657,9 @@ snapshots: '@sentry/cli-win32-x64@2.58.5': optional: true - '@sentry/cli@2.58.5': + '@sentry/cli@2.58.5(supports-color@7.2.0)': dependencies: - https-proxy-agent: 5.0.1 + https-proxy-agent: 5.0.1(supports-color@7.2.0) node-fetch: 2.7.0 progress: 2.0.3 proxy-from-env: 1.1.0 @@ -5740,9 +5686,9 @@ snapshots: hoist-non-react-statics: 3.3.2 react: 19.2.3 - '@sentry/rollup-plugin@5.2.0(rollup@4.60.1)': + '@sentry/rollup-plugin@5.2.0(rollup@4.60.1)(supports-color@7.2.0)': dependencies: - '@sentry/bundler-plugin-core': 5.2.0 + '@sentry/bundler-plugin-core': 5.2.0(supports-color@7.2.0) magic-string: 0.30.21 optionalDependencies: rollup: 4.60.1 @@ -5750,10 +5696,10 @@ snapshots: - encoding - supports-color - '@sentry/vite-plugin@5.2.0(rollup@4.60.1)': + '@sentry/vite-plugin@5.2.0(rollup@4.60.1)(supports-color@7.2.0)': dependencies: - '@sentry/bundler-plugin-core': 5.2.0 - '@sentry/rollup-plugin': 5.2.0(rollup@4.60.1) + '@sentry/bundler-plugin-core': 5.2.0(supports-color@7.2.0) + '@sentry/rollup-plugin': 5.2.0(rollup@4.60.1)(supports-color@7.2.0) transitivePeerDependencies: - encoding - rollup @@ -5823,16 +5769,16 @@ snapshots: react-dom: 19.2.3(react@19.2.3) storybook: 10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@storybook/react-vite@10.2.10(esbuild@0.27.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rollup@4.60.1)(storybook@10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3)(vite@7.3.3(@types/node@24.10.9)(jiti@1.21.7))': + '@storybook/react-vite@10.2.10(esbuild@0.27.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rollup@4.60.1)(storybook@10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(supports-color@7.2.0)(typescript@5.9.3)(vite@7.3.3(@types/node@24.10.9)(jiti@1.21.7))': dependencies: '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.4(typescript@5.9.3)(vite@7.3.3(@types/node@24.10.9)(jiti@1.21.7)) '@rollup/pluginutils': 5.3.0(rollup@4.60.1) '@storybook/builder-vite': 10.2.10(esbuild@0.27.2)(rollup@4.60.1)(storybook@10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.3(@types/node@24.10.9)(jiti@1.21.7)) - '@storybook/react': 10.2.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3) + '@storybook/react': 10.2.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(supports-color@7.2.0)(typescript@5.9.3) empathic: 2.0.0 magic-string: 0.30.21 react: 19.2.3 - react-docgen: 8.0.2 + react-docgen: 8.0.2(supports-color@7.2.0) react-dom: 19.2.3(react@19.2.3) resolve: 1.22.11 storybook: 10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -5845,12 +5791,12 @@ snapshots: - typescript - webpack - '@storybook/react@10.2.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3)': + '@storybook/react@10.2.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(supports-color@7.2.0)(typescript@5.9.3)': dependencies: '@storybook/global': 5.0.0 '@storybook/react-dom-shim': 10.2.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)) react: 19.2.3 - react-docgen: 8.0.2 + react-docgen: 8.0.2(supports-color@7.2.0) react-dom: 19.2.3(react@19.2.3) storybook: 10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) optionalDependencies: @@ -5895,8 +5841,8 @@ snapshots: '@tanstack/history': 1.145.7 '@tanstack/store': 0.8.0 cookie-es: 2.0.0 - seroval: 1.4.2 - seroval-plugins: 1.4.2(seroval@1.4.2) + seroval: 1.6.7 + seroval-plugins: 1.4.2(seroval@1.6.7) tiny-invariant: 1.3.3 tiny-warning: 1.0.3 @@ -5936,153 +5882,143 @@ snapshots: dependencies: '@testing-library/dom': 10.4.1 - '@tiptap/core@3.20.4(@tiptap/pm@3.20.4)': + '@tiptap/core@3.31.3(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/pm': 3.20.4 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-blockquote@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))': + '@tiptap/extension-blockquote@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/extension-bold@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))': + '@tiptap/extension-bold@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-bubble-menu@3.15.3(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)': + '@tiptap/extension-bubble-menu@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: '@floating-ui/dom': 1.7.4 - '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4) - '@tiptap/pm': 3.20.4 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 optional: true - '@tiptap/extension-bullet-list@3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))': + '@tiptap/extension-bullet-list@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extension-list': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-code-block@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)': + '@tiptap/extension-code-block@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4) - '@tiptap/pm': 3.20.4 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/extension-code@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))': + '@tiptap/extension-code@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-document@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))': + '@tiptap/extension-document@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-dropcursor@3.20.4(@tiptap/extensions@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))': + '@tiptap/extension-dropcursor@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extensions': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4) + '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-floating-menu@3.15.3(@floating-ui/dom@1.7.4)(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)': + '@tiptap/extension-floating-menu@3.31.3(@floating-ui/dom@1.7.4)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: '@floating-ui/dom': 1.7.4 - '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4) - '@tiptap/pm': 3.20.4 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 optional: true - '@tiptap/extension-gapcursor@3.20.4(@tiptap/extensions@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))': - dependencies: - '@tiptap/extensions': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4) - - '@tiptap/extension-hard-break@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))': + '@tiptap/extension-gapcursor@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4) + '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-heading@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))': + '@tiptap/extension-hard-break@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-horizontal-rule@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)': + '@tiptap/extension-heading@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4) - '@tiptap/pm': 3.20.4 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-italic@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))': + '@tiptap/extension-horizontal-rule@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/extension-link@3.15.3(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)': + '@tiptap/extension-italic@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4) - '@tiptap/pm': 3.20.4 - linkifyjs: 4.3.2 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-link@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)': + '@tiptap/extension-link@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4) - '@tiptap/pm': 3.20.4 - linkifyjs: 4.3.2 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + linkifyjs: 4.3.3 - '@tiptap/extension-list-item@3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))': + '@tiptap/extension-list-item@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extension-list': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-list-keymap@3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))': + '@tiptap/extension-list-keymap@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extension-list': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)': + '@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4) - '@tiptap/pm': 3.20.4 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/extension-ordered-list@3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))': + '@tiptap/extension-ordered-list@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extension-list': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-paragraph@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))': + '@tiptap/extension-paragraph@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-placeholder@3.15.3(@tiptap/extensions@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))': + '@tiptap/extension-placeholder@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extensions': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4) + '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-strike@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))': + '@tiptap/extension-strike@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-text@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))': + '@tiptap/extension-text@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-underline@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))': + '@tiptap/extension-underline@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extensions@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)': + '@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4) - '@tiptap/pm': 3.20.4 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/pm@3.20.4': + '@tiptap/pm@3.31.3': dependencies: - prosemirror-changeset: 2.4.0 - prosemirror-collab: 1.3.1 + prosemirror-changeset: 2.4.2 prosemirror-commands: 1.7.1 prosemirror-dropcursor: 1.8.2 prosemirror-gapcursor: 1.4.1 prosemirror-history: 1.5.0 prosemirror-inputrules: 1.5.1 prosemirror-keymap: 1.2.3 - prosemirror-markdown: 1.13.4 - prosemirror-menu: 1.3.0 - prosemirror-model: 1.25.4 - prosemirror-schema-basic: 1.2.4 + prosemirror-model: 1.25.11 prosemirror-schema-list: 1.5.1 prosemirror-state: 1.4.4 prosemirror-tables: 1.8.5 - prosemirror-trailing-node: 3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.7) - prosemirror-transform: 1.11.0 - prosemirror-view: 1.41.7 + prosemirror-transform: 1.12.1 + prosemirror-view: 1.42.3 - '@tiptap/react@3.15.3(@floating-ui/dom@1.7.4)(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@tiptap/react@3.31.3(@floating-ui/dom@1.7.4)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4) - '@tiptap/pm': 3.20.4 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 '@types/react': 19.2.8 '@types/react-dom': 19.2.3(@types/react@19.2.8) '@types/use-sync-external-store': 0.0.6 @@ -6091,37 +6027,37 @@ snapshots: react-dom: 19.2.3(react@19.2.3) use-sync-external-store: 1.6.0(react@19.2.3) optionalDependencies: - '@tiptap/extension-bubble-menu': 3.15.3(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4) - '@tiptap/extension-floating-menu': 3.15.3(@floating-ui/dom@1.7.4)(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4) + '@tiptap/extension-bubble-menu': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-floating-menu': 3.31.3(@floating-ui/dom@1.7.4)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) transitivePeerDependencies: - '@floating-ui/dom' - '@tiptap/starter-kit@3.20.4': - dependencies: - '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4) - '@tiptap/extension-blockquote': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4)) - '@tiptap/extension-bold': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4)) - '@tiptap/extension-bullet-list': 3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)) - '@tiptap/extension-code': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4)) - '@tiptap/extension-code-block': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4) - '@tiptap/extension-document': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4)) - '@tiptap/extension-dropcursor': 3.20.4(@tiptap/extensions@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)) - '@tiptap/extension-gapcursor': 3.20.4(@tiptap/extensions@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)) - '@tiptap/extension-hard-break': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4)) - '@tiptap/extension-heading': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4)) - '@tiptap/extension-horizontal-rule': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4) - '@tiptap/extension-italic': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4)) - '@tiptap/extension-link': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4) - '@tiptap/extension-list': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4) - '@tiptap/extension-list-item': 3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)) - '@tiptap/extension-list-keymap': 3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)) - '@tiptap/extension-ordered-list': 3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)) - '@tiptap/extension-paragraph': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4)) - '@tiptap/extension-strike': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4)) - '@tiptap/extension-text': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4)) - '@tiptap/extension-underline': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4)) - '@tiptap/extensions': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4) - '@tiptap/pm': 3.20.4 + '@tiptap/starter-kit@3.31.3': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/extension-blockquote': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-bold': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-bullet-list': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-code': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-code-block': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-document': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-dropcursor': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-gapcursor': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-hard-break': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-heading': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-horizontal-rule': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-italic': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-link': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-list-item': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-list-keymap': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-ordered-list': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-paragraph': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-strike': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-text': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-underline': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 '@types/aria-query@5.0.4': {} @@ -6199,19 +6135,10 @@ snapshots: '@types/json-schema@7.0.15': {} - '@types/linkify-it@5.0.0': {} - - '@types/markdown-it@14.1.2': - dependencies: - '@types/linkify-it': 5.0.0 - '@types/mdurl': 2.0.0 - '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 - '@types/mdurl@2.0.0': {} - '@types/mdx@2.0.13': {} '@types/ms@2.1.0': {} @@ -6239,15 +6166,15 @@ snapshots: '@types/use-sync-external-store@0.0.6': {} - '@typescript-eslint/eslint-plugin@8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@1.21.7)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.53.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.53.0(eslint@9.39.2(jiti@1.21.7)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.53.0 - '@typescript-eslint/type-utils': 8.53.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/utils': 8.53.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.53.0(eslint@9.39.2(jiti@1.21.7)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + '@typescript-eslint/utils': 8.53.0(eslint@9.39.2(jiti@1.21.7)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.53.0 - eslint: 9.39.2(jiti@1.21.7) + eslint: 9.39.2(jiti@1.21.7)(supports-color@7.2.0) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.4.0(typescript@5.9.3) @@ -6255,23 +6182,23 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@1.21.7)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.53.0 '@typescript-eslint/types': 8.53.0 - '@typescript-eslint/typescript-estree': 8.53.0(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.53.0(supports-color@7.2.0)(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.53.0 - debug: 4.4.3 - eslint: 9.39.2(jiti@1.21.7) + debug: 4.4.3(supports-color@7.2.0) + eslint: 9.39.2(jiti@1.21.7)(supports-color@7.2.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.53.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.53.0(supports-color@7.2.0)(typescript@5.9.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.53.0(typescript@5.9.3) '@typescript-eslint/types': 8.53.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -6285,13 +6212,13 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.53.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.53.0(eslint@9.39.2(jiti@1.21.7)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.53.0 - '@typescript-eslint/typescript-estree': 8.53.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.53.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - debug: 4.4.3 - eslint: 9.39.2(jiti@1.21.7) + '@typescript-eslint/typescript-estree': 8.53.0(supports-color@7.2.0)(typescript@5.9.3) + '@typescript-eslint/utils': 8.53.0(eslint@9.39.2(jiti@1.21.7)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + debug: 4.4.3(supports-color@7.2.0) + eslint: 9.39.2(jiti@1.21.7)(supports-color@7.2.0) ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -6299,13 +6226,13 @@ snapshots: '@typescript-eslint/types@8.53.0': {} - '@typescript-eslint/typescript-estree@8.53.0(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.53.0(supports-color@7.2.0)(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.53.0(typescript@5.9.3) + '@typescript-eslint/project-service': 8.53.0(supports-color@7.2.0)(typescript@5.9.3) '@typescript-eslint/tsconfig-utils': 8.53.0(typescript@5.9.3) '@typescript-eslint/types': 8.53.0 '@typescript-eslint/visitor-keys': 8.53.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) minimatch: 9.0.9 semver: 7.7.3 tinyglobby: 0.2.15 @@ -6314,13 +6241,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.53.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/utils@8.53.0(eslint@9.39.2(jiti@1.21.7)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7)(supports-color@7.2.0)) '@typescript-eslint/scope-manager': 8.53.0 '@typescript-eslint/types': 8.53.0 - '@typescript-eslint/typescript-estree': 8.53.0(typescript@5.9.3) - eslint: 9.39.2(jiti@1.21.7) + '@typescript-eslint/typescript-estree': 8.53.0(supports-color@7.2.0)(typescript@5.9.3) + eslint: 9.39.2(jiti@1.21.7)(supports-color@7.2.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -6332,11 +6259,11 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-react@5.1.4(vite@7.3.3(@types/node@24.10.9)(jiti@1.21.7))': + '@vitejs/plugin-react@5.1.4(supports-color@7.2.0)(vite@7.3.3(@types/node@24.10.9)(jiti@1.21.7))': dependencies: - '@babel/core': 7.29.0 - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@babel/core': 7.29.0(supports-color@7.2.0) + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0(supports-color@7.2.0)) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0(supports-color@7.2.0)) '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 @@ -6356,7 +6283,7 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.17(@opentelemetry/api@1.9.0)(@types/node@24.10.9)(@vitest/ui@4.0.17)(jiti@1.21.7)(jsdom@27.4.0) + vitest: 4.0.17(@opentelemetry/api@1.9.0)(@types/node@24.10.9)(@vitest/ui@4.0.17)(jiti@1.21.7)(jsdom@27.4.0(supports-color@7.2.0)) '@vitest/expect@3.2.4': dependencies: @@ -6417,7 +6344,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vitest: 4.0.17(@opentelemetry/api@1.9.0)(@types/node@24.10.9)(@vitest/ui@4.0.17)(jiti@1.21.7)(jsdom@27.4.0) + vitest: 4.0.17(@opentelemetry/api@1.9.0)(@types/node@24.10.9)(@vitest/ui@4.0.17)(jiti@1.21.7)(jsdom@27.4.0(supports-color@7.2.0)) '@vitest/utils@3.2.4': dependencies: @@ -6446,9 +6373,9 @@ snapshots: acorn@8.16.0: {} - agent-base@6.0.2: + agent-base@6.0.2(supports-color@7.2.0): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -6506,22 +6433,22 @@ snapshots: asynckit@0.4.0: {} - autoprefixer@10.4.23(postcss@8.5.15): + autoprefixer@10.4.23(postcss@8.5.28): dependencies: browserslist: 4.28.1 caniuse-lite: 1.0.30001764 fraction.js: 5.3.4 picocolors: 1.1.1 - postcss: 8.5.15 + postcss: 8.5.28 postcss-value-parser: 4.2.0 axe-core@4.11.1: {} - axios@1.16.1: + axios@1.20.0(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0): dependencies: - follow-redirects: 1.16.0 - form-data: 4.0.5 - https-proxy-agent: 5.0.1 + follow-redirects: 1.16.0(debug@4.4.3(supports-color@7.2.0)) + form-data: 4.0.6 + https-proxy-agent: 5.0.1(supports-color@7.2.0) proxy-from-env: 2.1.0 transitivePeerDependencies: - debug @@ -6662,8 +6589,6 @@ snapshots: cookie-es@2.0.0: {} - crelt@1.0.6: {} - cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -6733,9 +6658,11 @@ snapshots: date-fns@4.1.0: {} - debug@4.4.3: + debug@4.4.3(supports-color@7.2.0): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 7.2.0 decimal.js-light@2.5.1: {} @@ -6796,8 +6723,6 @@ snapshots: empathic@2.0.0: {} - entities@4.5.0: {} - entities@6.0.1: {} es-define-property@1.0.1: {} @@ -6854,25 +6779,25 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-plugin-react-hooks@7.0.1(eslint@9.39.2(jiti@1.21.7)): + eslint-plugin-react-hooks@7.0.1(eslint@9.39.2(jiti@1.21.7)(supports-color@7.2.0))(supports-color@7.2.0): dependencies: - '@babel/core': 7.28.6 + '@babel/core': 7.28.6(supports-color@7.2.0) '@babel/parser': 7.28.6 - eslint: 9.39.2(jiti@1.21.7) + eslint: 9.39.2(jiti@1.21.7)(supports-color@7.2.0) hermes-parser: 0.25.1 zod: 4.3.5 zod-validation-error: 4.0.2(zod@4.3.5) transitivePeerDependencies: - supports-color - eslint-plugin-react-refresh@0.4.26(eslint@9.39.2(jiti@1.21.7)): + eslint-plugin-react-refresh@0.4.26(eslint@9.39.2(jiti@1.21.7)(supports-color@7.2.0)): dependencies: - eslint: 9.39.2(jiti@1.21.7) + eslint: 9.39.2(jiti@1.21.7)(supports-color@7.2.0) - eslint-plugin-storybook@10.2.10(eslint@9.39.2(jiti@1.21.7))(storybook@10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3): + eslint-plugin-storybook@10.2.10(eslint@9.39.2(jiti@1.21.7)(supports-color@7.2.0))(storybook@10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(supports-color@7.2.0)(typescript@5.9.3): dependencies: - '@typescript-eslint/utils': 8.53.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - eslint: 9.39.2(jiti@1.21.7) + '@typescript-eslint/utils': 8.53.0(eslint@9.39.2(jiti@1.21.7)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + eslint: 9.39.2(jiti@1.21.7)(supports-color@7.2.0) storybook: 10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) transitivePeerDependencies: - supports-color @@ -6887,14 +6812,14 @@ snapshots: eslint-visitor-keys@4.2.1: {} - eslint@9.39.2(jiti@1.21.7): + eslint@9.39.2(jiti@1.21.7)(supports-color@7.2.0): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7)(supports-color@7.2.0)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.1 + '@eslint/config-array': 0.21.1(supports-color@7.2.0) '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.3 + '@eslint/eslintrc': 3.3.3(supports-color@7.2.0) '@eslint/js': 9.39.2 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.7 @@ -6904,7 +6829,7 @@ snapshots: ajv: 6.14.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -7010,14 +6935,16 @@ snapshots: flatted@3.4.2: {} - follow-redirects@1.16.0: {} + follow-redirects@1.16.0(debug@4.4.3(supports-color@7.2.0)): + optionalDependencies: + debug: 4.4.3(supports-color@7.2.0) - form-data@4.0.5: + form-data@4.0.6: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 es-set-tostringtag: 2.1.0 - hasown: 2.0.2 + hasown: 2.0.4 mime-types: 2.1.35 fraction.js@5.3.4: {} @@ -7098,7 +7025,11 @@ snapshots: dependencies: function-bind: 1.1.2 - hast-util-to-jsx-runtime@2.3.6: + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hast-util-to-jsx-runtime@2.3.6(supports-color@7.2.0): dependencies: '@types/estree': 1.0.8 '@types/hast': 3.0.4 @@ -7107,9 +7038,9 @@ snapshots: devlop: 1.1.0 estree-util-is-identifier-name: 3.0.0 hast-util-whitespace: 3.0.0 - mdast-util-mdx-expression: 2.0.1 - mdast-util-mdx-jsx: 3.2.0 - mdast-util-mdxjs-esm: 2.0.1 + mdast-util-mdx-expression: 2.0.1(supports-color@7.2.0) + mdast-util-mdx-jsx: 3.2.0(supports-color@7.2.0) + mdast-util-mdxjs-esm: 2.0.1(supports-color@7.2.0) property-information: 7.1.0 space-separated-tokens: 2.0.2 style-to-js: 1.1.21 @@ -7144,24 +7075,24 @@ snapshots: html5-qrcode@2.3.8: {} - http-proxy-agent@7.0.2: + http-proxy-agent@7.0.2(supports-color@7.2.0): dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color - https-proxy-agent@5.0.1: + https-proxy-agent@5.0.1(supports-color@7.2.0): dependencies: - agent-base: 6.0.2 - debug: 4.4.3 + agent-base: 6.0.2(supports-color@7.2.0) + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color - https-proxy-agent@7.0.6: + https-proxy-agent@7.0.6(supports-color@7.2.0): dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -7270,7 +7201,7 @@ snapshots: dependencies: argparse: 2.0.1 - jsdom@27.4.0: + jsdom@27.4.0(supports-color@7.2.0): dependencies: '@acemir/cssom': 0.9.31 '@asamuzakjp/dom-selector': 6.7.6 @@ -7279,8 +7210,8 @@ snapshots: data-urls: 6.0.0 decimal.js: 10.6.0 html-encoding-sniffer: 6.0.0 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + http-proxy-agent: 7.0.2(supports-color@7.2.0) + https-proxy-agent: 7.0.6(supports-color@7.2.0) is-potential-custom-element-name: 1.0.1 parse5: 8.0.0 saxes: 6.0.0 @@ -7327,11 +7258,7 @@ snapshots: lines-and-columns@1.2.4: {} - linkify-it@5.0.0: - dependencies: - uc.micro: 2.1.0 - - linkifyjs@4.3.2: {} + linkifyjs@4.3.3: {} locate-path@6.0.0: dependencies: @@ -7373,15 +7300,6 @@ snapshots: dependencies: semver: 7.7.3 - markdown-it@14.1.1: - dependencies: - argparse: 2.0.1 - entities: 4.5.0 - linkify-it: 5.0.0 - mdurl: 2.0.0 - punycode.js: 2.3.1 - uc.micro: 2.1.0 - markdown-table@3.0.4: {} math-intrinsics@1.1.0: {} @@ -7393,14 +7311,14 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 - mdast-util-from-markdown@2.0.2: + mdast-util-from-markdown@2.0.2(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 '@types/unist': 3.0.3 decode-named-character-reference: 1.2.0 devlop: 1.1.0 mdast-util-to-string: 4.0.0 - micromark: 4.0.2 + micromark: 4.0.2(supports-color@7.2.0) micromark-util-decode-numeric-character-reference: 2.0.2 micromark-util-decode-string: 2.0.1 micromark-util-normalize-identifier: 2.0.1 @@ -7418,67 +7336,67 @@ snapshots: mdast-util-find-and-replace: 3.0.2 micromark-util-character: 2.1.1 - mdast-util-gfm-footnote@2.1.0: + mdast-util-gfm-footnote@2.1.0(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.2(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 micromark-util-normalize-identifier: 2.0.1 transitivePeerDependencies: - supports-color - mdast-util-gfm-strikethrough@2.0.0: + mdast-util-gfm-strikethrough@2.0.0(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.2(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm-table@2.0.0: + mdast-util-gfm-table@2.0.0(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 markdown-table: 3.0.4 - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.2(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm-task-list-item@2.0.0: + mdast-util-gfm-task-list-item@2.0.0(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.2(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm@3.1.0: + mdast-util-gfm@3.1.0(supports-color@7.2.0): dependencies: - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.2(supports-color@7.2.0) mdast-util-gfm-autolink-literal: 2.0.1 - mdast-util-gfm-footnote: 2.1.0 - mdast-util-gfm-strikethrough: 2.0.0 - mdast-util-gfm-table: 2.0.0 - mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-gfm-footnote: 2.1.0(supports-color@7.2.0) + mdast-util-gfm-strikethrough: 2.0.0(supports-color@7.2.0) + mdast-util-gfm-table: 2.0.0(supports-color@7.2.0) + mdast-util-gfm-task-list-item: 2.0.0(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-mdx-expression@2.0.1: + mdast-util-mdx-expression@2.0.1(supports-color@7.2.0): dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.4 '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.2(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-mdx-jsx@3.2.0: + mdast-util-mdx-jsx@3.2.0(supports-color@7.2.0): dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.4 @@ -7486,7 +7404,7 @@ snapshots: '@types/unist': 3.0.3 ccount: 2.0.1 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.2(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 parse-entities: 4.0.2 stringify-entities: 4.0.4 @@ -7495,13 +7413,13 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-mdxjs-esm@2.0.1: + mdast-util-mdxjs-esm@2.0.1(supports-color@7.2.0): dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.4 '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.2(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color @@ -7541,8 +7459,6 @@ snapshots: mdn-data@2.12.2: {} - mdurl@2.0.0: {} - merge2@1.4.1: {} micromark-core-commonmark@2.0.3: @@ -7714,10 +7630,10 @@ snapshots: micromark-util-types@2.0.2: {} - micromark@4.0.2: + micromark@4.0.2(supports-color@7.2.0): dependencies: '@types/debug': 4.1.12 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) decode-named-character-reference: 1.2.0 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 @@ -7783,7 +7699,7 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 - nanoid@3.3.12: {} + nanoid@3.3.18: {} natural-compare@1.4.0: {} @@ -7878,28 +7794,28 @@ snapshots: optionalDependencies: fsevents: 2.3.2 - postcss-import@15.1.0(postcss@8.5.15): + postcss-import@15.1.0(postcss@8.5.28): dependencies: - postcss: 8.5.15 + postcss: 8.5.28 postcss-value-parser: 4.2.0 read-cache: 1.0.0 resolve: 1.22.11 - postcss-js@4.1.0(postcss@8.5.15): + postcss-js@4.1.0(postcss@8.5.28): dependencies: camelcase-css: 2.0.1 - postcss: 8.5.15 + postcss: 8.5.28 - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.15): + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.28): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 1.21.7 - postcss: 8.5.15 + postcss: 8.5.28 - postcss-nested@6.2.0(postcss@8.5.15): + postcss-nested@6.2.0(postcss@8.5.28): dependencies: - postcss: 8.5.15 + postcss: 8.5.28 postcss-selector-parser: 6.1.2 postcss-selector-parser@6.0.10: @@ -7914,9 +7830,9 @@ snapshots: postcss-value-parser@4.2.0: {} - postcss@8.5.15: + postcss@8.5.28: dependencies: - nanoid: 3.3.12 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -7932,14 +7848,10 @@ snapshots: property-information@7.1.0: {} - prosemirror-changeset@2.4.0: + prosemirror-changeset@2.4.2: dependencies: prosemirror-transform: 1.11.0 - prosemirror-collab@1.3.1: - dependencies: - prosemirror-state: 1.4.4 - prosemirror-commands@1.7.1: dependencies: prosemirror-model: 1.25.4 @@ -7976,27 +7888,14 @@ snapshots: prosemirror-state: 1.4.4 w3c-keyname: 2.2.8 - prosemirror-markdown@1.13.4: - dependencies: - '@types/markdown-it': 14.1.2 - markdown-it: 14.1.1 - prosemirror-model: 1.25.4 - - prosemirror-menu@1.3.0: + prosemirror-model@1.25.11: dependencies: - crelt: 1.0.6 - prosemirror-commands: 1.7.1 - prosemirror-history: 1.5.0 - prosemirror-state: 1.4.4 + orderedmap: 2.1.1 prosemirror-model@1.25.4: dependencies: orderedmap: 2.1.1 - prosemirror-schema-basic@1.2.4: - dependencies: - prosemirror-model: 1.25.4 - prosemirror-schema-list@1.5.1: dependencies: prosemirror-model: 1.25.4 @@ -8017,15 +7916,11 @@ snapshots: prosemirror-transform: 1.11.0 prosemirror-view: 1.41.7 - prosemirror-trailing-node@3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.7): + prosemirror-transform@1.11.0: dependencies: - '@remirror/core-constants': 3.0.0 - escape-string-regexp: 4.0.0 prosemirror-model: 1.25.4 - prosemirror-state: 1.4.4 - prosemirror-view: 1.41.7 - prosemirror-transform@1.11.0: + prosemirror-transform@1.12.1: dependencies: prosemirror-model: 1.25.4 @@ -8035,15 +7930,20 @@ snapshots: prosemirror-state: 1.4.4 prosemirror-transform: 1.11.0 - protobufjs@7.6.0: + prosemirror-view@1.42.3: + dependencies: + prosemirror-model: 1.25.11 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.11.0 + + protobufjs@7.6.6: dependencies: '@protobufjs/aspromise': 1.1.2 '@protobufjs/base64': 1.1.2 '@protobufjs/codegen': 2.0.5 - '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/eventemitter': 1.1.1 '@protobufjs/fetch': 1.1.1 '@protobufjs/float': 1.0.2 - '@protobufjs/inquire': 1.1.2 '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.1 @@ -8054,8 +7954,6 @@ snapshots: proxy-from-env@2.1.0: {} - punycode.js@2.3.1: {} - punycode@2.3.1: {} qrcode.react@4.2.0(react@19.2.3): @@ -8068,10 +7966,10 @@ snapshots: dependencies: typescript: 5.9.3 - react-docgen@8.0.2: + react-docgen@8.0.2(supports-color@7.2.0): dependencies: - '@babel/core': 7.29.0 - '@babel/traverse': 7.28.6 + '@babel/core': 7.29.0(supports-color@7.2.0) + '@babel/traverse': 7.28.6(supports-color@7.2.0) '@babel/types': 7.28.6 '@types/babel__core': 7.20.5 '@types/babel__traverse': 7.28.0 @@ -8098,17 +7996,17 @@ snapshots: react-is@19.2.3: {} - react-markdown@10.1.0(@types/react@19.2.8)(react@19.2.3): + react-markdown@10.1.0(@types/react@19.2.8)(react@19.2.3)(supports-color@7.2.0): dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 '@types/react': 19.2.8 devlop: 1.1.0 - hast-util-to-jsx-runtime: 2.3.6 + hast-util-to-jsx-runtime: 2.3.6(supports-color@7.2.0) html-url-attributes: 3.0.1 mdast-util-to-hast: 13.2.1 react: 19.2.3 - remark-parse: 11.0.0 + remark-parse: 11.0.0(supports-color@7.2.0) remark-rehype: 11.1.2 unified: 11.0.5 unist-util-visit: 5.0.0 @@ -8207,21 +8105,21 @@ snapshots: redux@5.0.1: {} - remark-gfm@4.0.1: + remark-gfm@4.0.1(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 - mdast-util-gfm: 3.1.0 + mdast-util-gfm: 3.1.0(supports-color@7.2.0) micromark-extension-gfm: 3.0.0 - remark-parse: 11.0.0 + remark-parse: 11.0.0(supports-color@7.2.0) remark-stringify: 11.0.0 unified: 11.0.5 transitivePeerDependencies: - supports-color - remark-parse@11.0.0: + remark-parse@11.0.0(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.2(supports-color@7.2.0) micromark-util-types: 2.0.2 unified: 11.0.5 transitivePeerDependencies: @@ -8243,9 +8141,9 @@ snapshots: require-from-string@2.0.2: {} - require-in-the-middle@8.0.1: + require-in-the-middle@8.0.1(supports-color@7.2.0): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) module-details-from-path: 1.0.4 transitivePeerDependencies: - supports-color @@ -8311,11 +8209,11 @@ snapshots: semver@7.7.3: {} - seroval-plugins@1.4.2(seroval@1.4.2): + seroval-plugins@1.4.2(seroval@1.6.7): dependencies: - seroval: 1.4.2 + seroval: 1.6.7 - seroval@1.4.2: {} + seroval@1.6.7: {} shebang-command@2.0.0: dependencies: @@ -8425,11 +8323,11 @@ snapshots: normalize-path: 3.0.0 object-hash: 3.0.0 picocolors: 1.1.1 - postcss: 8.5.15 - postcss-import: 15.1.0(postcss@8.5.15) - postcss-js: 4.1.0(postcss@8.5.15) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.15) - postcss-nested: 6.2.0(postcss@8.5.15) + postcss: 8.5.28 + postcss-import: 15.1.0(postcss@8.5.28) + postcss-js: 4.1.0(postcss@8.5.28) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.28) + postcss-nested: 6.2.0(postcss@8.5.28) postcss-selector-parser: 6.1.2 resolve: 1.22.11 sucrase: 3.35.1 @@ -8514,21 +8412,19 @@ snapshots: dependencies: prelude-ls: 1.2.1 - typescript-eslint@8.53.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3): + typescript-eslint@8.53.0(eslint@9.39.2(jiti@1.21.7)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/parser': 8.53.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.53.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.53.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - eslint: 9.39.2(jiti@1.21.7) + '@typescript-eslint/eslint-plugin': 8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@1.21.7)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + '@typescript-eslint/parser': 8.53.0(eslint@9.39.2(jiti@1.21.7)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.53.0(supports-color@7.2.0)(typescript@5.9.3) + '@typescript-eslint/utils': 8.53.0(eslint@9.39.2(jiti@1.21.7)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + eslint: 9.39.2(jiti@1.21.7)(supports-color@7.2.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color typescript@5.9.3: {} - uc.micro@2.1.0: {} - undici-types@7.16.0: {} unified@11.0.5: @@ -8631,9 +8527,9 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.3.3(@types/node@24.10.9)(jiti@1.21.7)): + vite-tsconfig-paths@5.1.4(supports-color@7.2.0)(typescript@5.9.3)(vite@7.3.3(@types/node@24.10.9)(jiti@1.21.7)): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) optionalDependencies: @@ -8647,7 +8543,7 @@ snapshots: esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - postcss: 8.5.15 + postcss: 8.5.28 rollup: 4.60.1 tinyglobby: 0.2.15 optionalDependencies: @@ -8655,7 +8551,7 @@ snapshots: fsevents: 2.3.3 jiti: 1.21.7 - vitest@4.0.17(@opentelemetry/api@1.9.0)(@types/node@24.10.9)(@vitest/ui@4.0.17)(jiti@1.21.7)(jsdom@27.4.0): + vitest@4.0.17(@opentelemetry/api@1.9.0)(@types/node@24.10.9)(@vitest/ui@4.0.17)(jiti@1.21.7)(jsdom@27.4.0(supports-color@7.2.0)): dependencies: '@vitest/expect': 4.0.17 '@vitest/mocker': 4.0.17(vite@7.3.3(@types/node@24.10.9)(jiti@1.21.7)) @@ -8681,7 +8577,7 @@ snapshots: '@opentelemetry/api': 1.9.0 '@types/node': 24.10.9 '@vitest/ui': 4.0.17(vitest@4.0.17) - jsdom: 27.4.0 + jsdom: 27.4.0(supports-color@7.2.0) transitivePeerDependencies: - jiti - less @@ -8752,6 +8648,8 @@ snapshots: zod@4.3.5: {} + zone.js@0.15.1: {} + zone.js@0.16.0: {} zwitch@2.0.4: {} diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 285d1dbc..968f623b 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -14,6 +14,7 @@ describe('App routing — inventory redirect', () => { // (b) its component is a redirect (renders Navigate pointing at /filaments) const rootRoute = new RootRoute({ component: () => null }) + rootRoute.init({ originalIndex: 0 }) // Recreate the same route configuration as inventoryRoute in App.tsx const inventoryRoute = new Route({ @@ -22,17 +23,19 @@ describe('App routing — inventory redirect', () => { component: () => , }) - expect(inventoryRoute.options.path).toBe('/inventory') + inventoryRoute.init({ originalIndex: 0 }) + expect(inventoryRoute.fullPath).toBe('/inventory') // The component renders a Navigate element targeting /filaments // We verify this by calling the component function and checking the JSX it returns - const InventoryComponent = inventoryRoute.options.component as () => React.ReactElement + const InventoryComponent = inventoryRoute.options.component as () => React.ReactElement<{ to: string }> const element = InventoryComponent() expect(element.props.to).toBe('/filaments') }) it('filamentsRoute renders FilamentLibrary component', () => { const rootRoute = new RootRoute({ component: () => null }) + rootRoute.init({ originalIndex: 0 }) const filamentsRoute = new Route({ getParentRoute: () => rootRoute, @@ -41,7 +44,8 @@ describe('App routing — inventory redirect', () => { }) // The filaments route is registered at /filaments — the canonical path - expect(filamentsRoute.options.path).toBe('/filaments') + filamentsRoute.init({ originalIndex: 1 }) + expect(filamentsRoute.fullPath).toBe('/filaments') // Inventory and filaments are separate paths (redirect + destination) const inventoryRoute = new Route({ @@ -49,6 +53,7 @@ describe('App routing — inventory redirect', () => { path: '/inventory', component: () => , }) - expect(inventoryRoute.options.path).not.toBe(filamentsRoute.options.path) + inventoryRoute.init({ originalIndex: 0 }) + expect(inventoryRoute.fullPath).not.toBe(filamentsRoute.fullPath) }) }) diff --git a/frontend/src/components/categories/CategoryList.tsx b/frontend/src/components/categories/CategoryList.tsx index f960ceb3..bd97522e 100644 --- a/frontend/src/components/categories/CategoryList.tsx +++ b/frontend/src/components/categories/CategoryList.tsx @@ -501,7 +501,7 @@ export function CategoryList() {