From 55554f15b0893377fec34d770929b05a269971b0 Mon Sep 17 00:00:00 2001 From: Jonathan Gill Date: Mon, 7 Sep 2026 12:36:49 +0100 Subject: [PATCH 01/30] Add isolated commerce bridge with verified sandbox payment delivery --- .beads/interactions.jsonl | 1 + .beads/issues.jsonl | 1 + backend/app/commerce_bridge.py | 209 +++++++++++++++++++++++++++++++++ issues.jsonl | 3 +- 4 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 backend/app/commerce_bridge.py 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..ec08007c 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.","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-07T11:35:32Z","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/backend/app/commerce_bridge.py b/backend/app/commerce_bridge.py new file mode 100644 index 00000000..e509e3fa --- /dev/null +++ b/backend/app/commerce_bridge.py @@ -0,0 +1,209 @@ +"""Isolated WooCommerce acceptance bridge using real Batchivo order/print-job models. + +This service deliberately refuses production databases. Promotion requires a reviewed +production adapter release, not removal of this guard during testing. +""" +import asyncio +from contextlib import asynccontextmanager +from datetime import datetime, timezone +from decimal import Decimal +import hashlib +import hmac +import json +import os +import sys +import time +from uuid import UUID, uuid4 + +import httpx +from fastapi import FastAPI, HTTPException, Request +from pydantic import BaseModel, ConfigDict, Field, model_validator +from sqlalchemy import Index, Integer, String, Text, select, text +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +if os.environ.get('COMMERCE_ISOLATED') != 'true' or 'batchivo_commerce_test' not in os.environ.get('DATABASE_URL',''): + raise RuntimeError('Commerce acceptance bridge requires an isolated test database') +from app.database import Base, engine, async_session_maker +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() + +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) + +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) + 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 sum(x.total_pence for x in self.lines)+self.shipping_pence!=self.total_pence: + raise ValueError('Line/shipping totals do not reconcile') + return self + +app=FastAPI(title='Batchivo isolated commerce acceptance',docs_url=None,redoc_url=None) + +@app.get('/health') +async def health(): + async with engine.connect() as conn:await conn.execute(text('SELECT 1')) + return {'status':'ok','environment':'isolated-test'} + +async def verify_payment(event: Event): + async with httpx.AsyncClient(timeout=20) as client: + r=await client.get('https://connect.squareupsandbox.com/v2/payments/'+event.payment_id, + headers={'Authorization':'Bearer '+os.environ['SQUARE_SANDBOX_ACCESS_TOKEN'],'Square-Version':'2026-01-22'}) + if r.status_code!=200:raise HTTPException(503,'Square sandbox payment verification unavailable') + p=r.json()['payment'] + 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') + if event.state=='refunded' and p.get('refunded_money',{}).get('amount',0)!=event.total_pence: + raise HTTPException(409,'Full refund is not yet verified by Square') + +@app.post('/events') +async def receive(request: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') + 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') + return {'status':'duplicate','event_id':str(event.event_id)} + 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 identity(old['lines'])!=identity(new['lines']): + raise HTTPException(409,'Paid order changes require an explicit adjustment workflow') + await verify_payment(event) + 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,order_number=f'WOO-TEST-{event.order_id}',status='processing', + customer_email=event.customer.get('email','test@example.invalid'),customer_name=event.customer.get('name','Test customer'), + 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',payment_id=event.payment_id,payment_status='COMPLETED', + confirmation_email_sent=False,internal_notes='Isolated WooCommerce acceptance order. Never dispatch to a real customer.') + db.add(order);await db.flush();effects={'stock':[],'restored':False} + 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=='print_to_order' if variant else product.print_to_order + if not pto: + stock=variant or product + if stock.units_in_stock Date: Mon, 7 Sep 2026 14:18:22 +0100 Subject: [PATCH 02/30] Enrich missing manufacturing labels from validated variants --- .beads/issues.jsonl | 2 +- backend/app/commerce_bridge.py | 34 +++++++++++++++++++++++++++++++++- issues.jsonl | 2 +- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index ec08007c..10fbc4d5 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -19,7 +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.","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-07T11:35:32Z","started_at":"2026-09-07T11:35:32Z","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.","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-07T13:18:14Z","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/backend/app/commerce_bridge.py b/backend/app/commerce_bridge.py index e509e3fa..cd39d999 100644 --- a/backend/app/commerce_bridge.py +++ b/backend/app/commerce_bridge.py @@ -83,6 +83,27 @@ def valid(self): raise ValueError('Line/shipping totals do not reconcile') 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==f'WOO-TEST-{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 + app=FastAPI(title='Batchivo isolated commerce acceptance',docs_url=None,redoc_url=None) @app.get('/health') @@ -183,6 +204,8 @@ def identity(lines):return sorted([tuple(str(line.get(k)) for k in line_keys) fo effects['restored']=True;mapping.effects=effects order.status=event.state if event.state=='refunded':order.payment_status='REFUNDED' + 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} @@ -206,4 +229,13 @@ async def seed(file): # 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.') -if __name__=='__main__':asyncio.run(seed(sys.argv[1])) +async def reconcile_job_options(): + 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__': + asyncio.run(reconcile_job_options() if sys.argv[1]=='--reconcile-job-options' else seed(sys.argv[1])) diff --git a/issues.jsonl b/issues.jsonl index ec08007c..10fbc4d5 100644 --- a/issues.jsonl +++ b/issues.jsonl @@ -19,7 +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.","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-07T11:35:32Z","started_at":"2026-09-07T11:35:32Z","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.","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-07T13:18:14Z","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} From 8c1524373986a1e598a050d8f368f1a300bab4d7 Mon Sep 17 00:00:00 2001 From: Jonathan Gill Date: Mon, 7 Sep 2026 16:41:17 +0100 Subject: [PATCH 03/30] Add isolated atomic stock reservation service and live contention tests --- .beads/issues.jsonl | 2 +- backend/app/commerce_bridge.py | 131 ++++++++++++++++++-- backend/tests/commerce/reservations_live.py | 88 +++++++++++++ issues.jsonl | 2 +- 4 files changed, 212 insertions(+), 11 deletions(-) create mode 100644 backend/tests/commerce/reservations_live.py diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 10fbc4d5..43d227b7 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -19,7 +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.","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-07T13:18:14Z","started_at":"2026-09-07T11:35:32Z","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":"Isolated CommerceReservation table and authenticated reserve/begin/release endpoints implemented.25 live signedHTTP/PostgreSQL assertions pass via backend/tests/commerce/reservations_live.py, including atomic last-stock contention, expiry, pending-payment holds, duplicate item capacity. No stock/order/job effects; test holds deleted. Checkout and paid-event atomic consume plus provider-safe release are next; API alone is not finite-stock purchase acceptance. Production untouched.","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-07T15:41:01Z","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/backend/app/commerce_bridge.py b/backend/app/commerce_bridge.py index cd39d999..604336c3 100644 --- a/backend/app/commerce_bridge.py +++ b/backend/app/commerce_bridge.py @@ -5,7 +5,7 @@ """ import asyncio from contextlib import asynccontextmanager -from datetime import datetime, timezone +from datetime import datetime, timezone, timedelta from decimal import Decimal import hashlib import hmac @@ -18,7 +18,7 @@ import httpx from fastapi import FastAPI, HTTPException, Request from pydantic import BaseModel, ConfigDict, Field, model_validator -from sqlalchemy import Index, Integer, String, Text, select, text +from sqlalchemy import DateTime, Index, Integer, String, Text, and_, or_, select, text from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column @@ -104,7 +104,125 @@ async def enrich_job_options(db, event: Event): changed+=1 return changed -app=FastAPI(title='Batchivo isolated commerce acceptance',docs_url=None,redoc_url=None) +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 + +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 + +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: + await conn.run_sync(lambda sync:CommerceReservation.__table__.create(sync,checkfirst=True)) + yield + +app=FastAPI(title='Batchivo isolated commerce acceptance',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)datetime.now(timezone.utc):row.state='payment_pending' + elif row.state!='payment_pending':raise HTTPException(409,'Reservation cannot start payment') + 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} @app.get('/health') async def health(): @@ -124,12 +242,7 @@ async def verify_payment(event: Event): @app.post('/events') async def receive(request: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') + 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() diff --git a/backend/tests/commerce/reservations_live.py b/backend/tests/commerce/reservations_live.py new file mode 100644 index 00000000..ee0ae207 --- /dev/null +++ b/backend/tests/commerce/reservations_live.py @@ -0,0 +1,88 @@ +"""Explicit live isolated-DB/API acceptance; never collected as a mocked unit test.""" +import asyncio, hashlib, hmac, json, os, 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, PrintJob, Order, 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 + 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) + 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/issues.jsonl b/issues.jsonl index 10fbc4d5..43d227b7 100644 --- a/issues.jsonl +++ b/issues.jsonl @@ -19,7 +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.","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-07T13:18:14Z","started_at":"2026-09-07T11:35:32Z","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":"Isolated CommerceReservation table and authenticated reserve/begin/release endpoints implemented.25 live signedHTTP/PostgreSQL assertions pass via backend/tests/commerce/reservations_live.py, including atomic last-stock contention, expiry, pending-payment holds, duplicate item capacity. No stock/order/job effects; test holds deleted. Checkout and paid-event atomic consume plus provider-safe release are next; API alone is not finite-stock purchase acceptance. Production untouched.","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-07T15:41:01Z","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} From f9115e4e1ff4f637d790351312f6c9d5d37a301e Mon Sep 17 00:00:00 2001 From: Jonathan Gill Date: Mon, 7 Sep 2026 16:59:52 +0100 Subject: [PATCH 04/30] Consume reserved stock with verified payments and completed refunds --- .beads/issues.jsonl | 2 +- backend/app/commerce_bridge.py | 66 +++++++++++++++++++-- backend/tests/commerce/reservations_live.py | 4 ++ issues.jsonl | 2 +- 4 files changed, 67 insertions(+), 7 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 43d227b7..1c0b4461 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -19,7 +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":"Isolated CommerceReservation table and authenticated reserve/begin/release endpoints implemented.25 live signedHTTP/PostgreSQL assertions pass via backend/tests/commerce/reservations_live.py, including atomic last-stock contention, expiry, pending-payment holds, duplicate item capacity. No stock/order/job effects; test holds deleted. Checkout and paid-event atomic consume plus provider-safe release are next; API alone is not finite-stock purchase acceptance. Production untouched.","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-07T15:41:01Z","started_at":"2026-09-07T11:35:32Z","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":"Paid receiver consumes matching pending reservation atomically; finite orders requirehold, stock snapshots signed, definitive checkout-reported declines releasehold, fullrefund waits for individual Square refundsCOMPLETED. Gateway API evidence469/471/473 completed+fullyrefunded, guard474 nopayment, finalstock1 zeroactiveholds zerojobs.27HTTP/Postgres checks. Browsercurrenthooks, uncertaincapture reconciliation, partialrefunds/fulfilment/prod adapter remain open. See shop STOCK-RESERVATIONS.md.","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-07T15:59:27Z","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/backend/app/commerce_bridge.py b/backend/app/commerce_bridge.py index 604336c3..a46e5185 100644 --- a/backend/app/commerce_bridge.py +++ b/backend/app/commerce_bridge.py @@ -67,6 +67,7 @@ class Event(BaseModel): version: int=Field(gt=0) state: str payment_id: str=Field(min_length=1,max_length=192) + reservation_id: UUID|None=None currency: str='GBP' subtotal_pence: int=Field(ge=0) shipping_pence: int=Field(ge=0) @@ -130,6 +131,7 @@ class ReservationAction(BaseModel): reservation_id: UUID order_id: int=Field(gt=0) action: str + decline_code: str='' async def signed_body(request): body=await request.body() @@ -209,7 +211,7 @@ 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']:raise HTTPException(422,'Unknown 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()) @@ -219,11 +221,50 @@ async def reservation_action(request:Request): 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.get('/health') async def health(): async with engine.connect() as conn:await conn.execute(text('SELECT 1')) @@ -237,8 +278,16 @@ async def verify_payment(event: Event): p=r.json()['payment'] 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') - if event.state=='refunded' and p.get('refunded_money',{}).get('amount',0)!=event.total_pence: - raise HTTPException(409,'Full refund is not yet verified by Square') + if event.state=='refunded': + completed=0 + async with httpx.AsyncClient(timeout=20) as client: + for refund_id in p.get('refund_ids',[]): + refund=await client.get('https://connect.squareupsandbox.com/v2/refunds/'+refund_id, + headers={'Authorization':'Bearer '+os.environ['SQUARE_SANDBOX_ACCESS_TOKEN'],'Square-Version':'2026-01-22'}) + if refund.status_code!=200:raise HTTPException(503,'Refund verification unavailable') + value=refund.json()['refund'] + if value['status']=='COMPLETED' and value['amount_money']['currency']=='GBP':completed+=value['amount_money']['amount'] + if completed!=event.total_pence:raise HTTPException(503,'Full refund completion is not yet verified by Square') @app.post('/events') async def receive(request:Request): @@ -269,9 +318,14 @@ async def receive(request:Request): 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 identity(old['lines'])!=identity(new['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') await verify_payment(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,order_number=f'WOO-TEST-{event.order_id}',status='processing', @@ -291,14 +345,16 @@ def identity(lines):return sorted([tuple(str(line.get(k)) for k in line_keys) fo 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=='print_to_order' if variant else product.print_to_order + 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 Date: Mon, 7 Sep 2026 19:29:31 +0100 Subject: [PATCH 05/30] Add isolated Batchivo dispatch and delivery event workflow --- .beads/issues.jsonl | 2 +- backend/app/commerce_bridge.py | 112 +++++++++++++++++++++- backend/tests/commerce/fulfilment_live.py | 73 ++++++++++++++ issues.jsonl | 2 +- 4 files changed, 186 insertions(+), 3 deletions(-) create mode 100644 backend/tests/commerce/fulfilment_live.py diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 1c0b4461..d1503c98 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -19,7 +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":"Paid receiver consumes matching pending reservation atomically; finite orders requirehold, stock snapshots signed, definitive checkout-reported declines releasehold, fullrefund waits for individual Square refundsCOMPLETED. Gateway API evidence469/471/473 completed+fullyrefunded, guard474 nopayment, finalstock1 zeroactiveholds zerojobs.27HTTP/Postgres checks. Browsercurrenthooks, uncertaincapture reconciliation, partialrefunds/fulfilment/prod adapter remain open. See shop STOCK-RESERVATIONS.md.","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-07T15:59:27Z","started_at":"2026-09-07T11:35:32Z","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":"Fulfilment checkpoint: isolated transactional dispatch/delivery events, signed response feed/ack, immutable IDs, rotation and fresh Square verification. Fixture431 native queue service transitions with disconnected simulated printer passed19 ship+10 delivery checks; product/variant rows and operator468 unchanged. Woo receipts/tracking/email/browser verified in companion shop. Production ship UI/inventory fencing, post-dispatch refund/returns, email crash recovery, queue load/outage and fresh restore remain open; full goal active.","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-07T18:29:10Z","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/backend/app/commerce_bridge.py b/backend/app/commerce_bridge.py index a46e5185..08425994 100644 --- a/backend/app/commerce_bridge.py +++ b/backend/app/commerce_bridge.py @@ -14,11 +14,14 @@ 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 DateTime, Index, Integer, String, Text, and_, or_, select, text +from sqlalchemy import DateTime, Index, Integer, String, Text, UniqueConstraint, and_, or_, select, text from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column @@ -47,6 +50,33 @@ class CommerceOrder(Base, UUIDMixin, TimestampMixin): 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): @@ -142,6 +172,12 @@ async def signed_body(request): 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}) @@ -171,6 +207,7 @@ async def locked_reservation_stock(db,lines): async def commerce_lifespan(app): async with engine.begin() as conn: await conn.run_sync(lambda sync:CommerceReservation.__table__.create(sync,checkfirst=True)) + await conn.run_sync(lambda sync:CommerceFulfilmentEvent.__table__.create(sync,checkfirst=True)) yield app=FastAPI(title='Batchivo isolated commerce acceptance',docs_url=None,redoc_url=None,lifespan=commerce_lifespan) @@ -265,6 +302,78 @@ async def stock_snapshot(request:Request): 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') + digest=hashlib.sha256(body).hexdigest() + 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 or order.payment_status!='COMPLETED' or mapping.state in ['cancelled','refunded']: + raise HTTPException(409,'Order cannot be dispatched') + 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': + 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(f'WOO-TEST-{command.order_id}:%')).with_for_update())).all() + finite={(effect['product_id'],effect['variant_id']) for effect in mapping.effects.get('stock',[])} + expected={f'WOO-TEST-{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 job.quantity!=expected[job.reference]['quantity'] for job in jobs): + raise HTTPException(409,'Manufacturing quantities require reconciliation') + if any(job.status!=JobStatus.COMPLETED 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 payment.get('refund_ids') or payment.get('refunded_money',{}).get('amount',0): + 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: + if order.status!='shipped' 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} + +@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')) @@ -288,6 +397,7 @@ async def verify_payment(event: Event): value=refund.json()['refund'] if value['status']=='COMPLETED' and value['amount_money']['currency']=='GBP':completed+=value['amount_money']['amount'] if completed!=event.total_pence:raise HTTPException(503,'Full refund completion is not yet verified by Square') + return p @app.post('/events') async def receive(request:Request): diff --git a/backend/tests/commerce/fulfilment_live.py b/backend/tests/commerce/fulfilment_live.py new file mode 100644 index 00000000..0e5bec9e --- /dev/null +++ b/backend/tests/commerce/fulfilment_live.py @@ -0,0 +1,73 @@ +"""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,hashlib,hmac,json,logging,os,sys,time +from uuid import UUID,uuid5,NAMESPACE_URL +import httpx +from sqlalchemy import select,text +from app.commerce_bridge import CommerceOrder,CommerceFulfilmentEvent,Order,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/issues.jsonl b/issues.jsonl index 1c0b4461..d1503c98 100644 --- a/issues.jsonl +++ b/issues.jsonl @@ -19,7 +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":"Paid receiver consumes matching pending reservation atomically; finite orders requirehold, stock snapshots signed, definitive checkout-reported declines releasehold, fullrefund waits for individual Square refundsCOMPLETED. Gateway API evidence469/471/473 completed+fullyrefunded, guard474 nopayment, finalstock1 zeroactiveholds zerojobs.27HTTP/Postgres checks. Browsercurrenthooks, uncertaincapture reconciliation, partialrefunds/fulfilment/prod adapter remain open. See shop STOCK-RESERVATIONS.md.","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-07T15:59:27Z","started_at":"2026-09-07T11:35:32Z","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":"Fulfilment checkpoint: isolated transactional dispatch/delivery events, signed response feed/ack, immutable IDs, rotation and fresh Square verification. Fixture431 native queue service transitions with disconnected simulated printer passed19 ship+10 delivery checks; product/variant rows and operator468 unchanged. Woo receipts/tracking/email/browser verified in companion shop. Production ship UI/inventory fencing, post-dispatch refund/returns, email crash recovery, queue load/outage and fresh restore remain open; full goal active.","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-07T18:29:10Z","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} From 97d14c9a97ceacfab7e4cbd38c1d135bdf19ed57 Mon Sep 17 00:00:00 2001 From: Jonathan Gill Date: Mon, 7 Sep 2026 19:43:39 +0100 Subject: [PATCH 06/30] Reconcile verified refunds independently of manufacturing state --- .beads/issues.jsonl | 2 +- backend/app/commerce_bridge.py | 77 +++++++++++++++++++------- backend/tests/commerce/refunds_live.py | 38 +++++++++++++ issues.jsonl | 2 +- 4 files changed, 97 insertions(+), 22 deletions(-) create mode 100644 backend/tests/commerce/refunds_live.py diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index d1503c98..c2ce8aae 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -19,7 +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":"Fulfilment checkpoint: isolated transactional dispatch/delivery events, signed response feed/ack, immutable IDs, rotation and fresh Square verification. Fixture431 native queue service transitions with disconnected simulated printer passed19 ship+10 delivery checks; product/variant rows and operator468 unchanged. Woo receipts/tracking/email/browser verified in companion shop. Production ship UI/inventory fencing, post-dispatch refund/returns, email crash recovery, queue load/outage and fresh restore remain open; full goal active.","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-07T18:29:10Z","started_at":"2026-09-07T11:35:32Z","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":"Verified cumulative refund ledger/IDs added. Real £1 partial Square sandbox refund on delivered431 reconciles as PARTIALLY_REFUNDED while dispatch and completed jobs remain.10 HTTP/DB checks pass. Full post-dispatch refund implementation preserves physical history and records goods review, but pending browser confirmation for remaining£28.97 means full acceptance unproven. Production adapter, returns/line quantities, interruption/race acceptance remain open; see companion shop refund report.","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-07T18:43:28Z","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/backend/app/commerce_bridge.py b/backend/app/commerce_bridge.py index 08425994..6238cfe1 100644 --- a/backend/app/commerce_bridge.py +++ b/backend/app/commerce_bridge.py @@ -98,6 +98,8 @@ class Event(BaseModel): 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) @@ -110,6 +112,10 @@ class Event(BaseModel): 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') return self @@ -387,16 +393,28 @@ async def verify_payment(event: Event): p=r.json()['payment'] 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') - if event.state=='refunded': - completed=0 + 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 p.get('refund_ids',[]): + for refund_id in ids: refund=await client.get('https://connect.squareupsandbox.com/v2/refunds/'+refund_id, headers={'Authorization':'Bearer '+os.environ['SQUARE_SANDBOX_ACCESS_TOKEN'],'Square-Version':'2026-01-22'}) if refund.status_code!=200:raise HTTPException(503,'Refund verification unavailable') value=refund.json()['refund'] - if value['status']=='COMPLETED' and value['amount_money']['currency']=='GBP':completed+=value['amount_money']['amount'] - if completed!=event.total_pence:raise HTTPException(503,'Full refund completion is not yet verified by Square') + 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') @@ -415,7 +433,8 @@ async def receive(request:Request): 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') - return {'status':'duplicate','event_id':str(event.event_id)} + 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')) @@ -430,7 +449,11 @@ async def receive(request:Request): 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') - await verify_payment(event) + payment=await verify_payment(event) + if mapping and payment['_verified_refunded_pence'] Date: Tue, 8 Sep 2026 10:39:51 +0100 Subject: [PATCH 07/30] Fence native order effects and share durable commerce dispatch --- .beads/issues.jsonl | 2 +- backend/app/api/v1/orders.py | 23 +++++ backend/app/commerce_bridge.py | 5 +- .../app/services/commerce_order_ownership.py | 70 +++++++++++++++ backend/app/services/order_fulfillment.py | 7 ++ .../tests/commerce/native_ownership_live.py | 89 +++++++++++++++++++ issues.jsonl | 2 +- 7 files changed, 195 insertions(+), 3 deletions(-) create mode 100644 backend/app/services/commerce_order_ownership.py create mode 100644 backend/tests/commerce/native_ownership_live.py diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index c2ce8aae..5b009e03 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -19,7 +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":"Verified cumulative refund ledger/IDs added. Real £1 partial Square sandbox refund on delivered431 reconciles as PARTIALLY_REFUNDED while dispatch and completed jobs remain.10 HTTP/DB checks pass. Full post-dispatch refund implementation preserves physical history and records goods review, but pending browser confirmation for remaining£28.97 means full acceptance unproven. Production adapter, returns/line quantities, interruption/race acceptance remain open; see companion shop refund report.","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-07T18:43:28Z","started_at":"2026-09-07T11:35:32Z","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":"8 Sep: native order ownership fences and shared durable dispatch adapter implemented, mounted only in isolated test release. 24 actual native API/service/PostgreSQL and signed HTTP replay checks pass, including ordinary native stock regression in rolled-back fixtures; all protected tables unchanged. Fresh successful native dispatch/operator browser and production activation remain open.","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-08T09:39:42Z","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/backend/app/api/v1/orders.py b/backend/app/api/v1/orders.py index 1d6ac93e..4133d11a 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,9 @@ 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 +646,10 @@ 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 +820,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 +888,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 +1010,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 +1144,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 +1227,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/commerce_bridge.py b/backend/app/commerce_bridge.py index 6238cfe1..6f8312ca 100644 --- a/backend/app/commerce_bridge.py +++ b/backend/app/commerce_bridge.py @@ -313,7 +313,10 @@ 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') - digest=hashlib.sha256(body).hexdigest() + 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) diff --git a/backend/app/services/commerce_order_ownership.py b/backend/app/services/commerce_order_ownership.py new file mode 100644 index 00000000..e0fdbc90 --- /dev/null +++ b/backend/app/services/commerce_order_ownership.py @@ -0,0 +1,70 @@ +"""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: the currently available dispatch implementation accepts only the isolated DB. +""" +import hashlib +import json +import os +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") + if os.environ.get("COMMERCE_ISOLATED") != "true": + raise HTTPException(409, "WooCommerce dispatch adapter requires an activated release") + 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/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/tests/commerce/native_ownership_live.py b/backend/tests/commerce/native_ownership_live.py new file mode 100644 index 00000000..3a69beb7 --- /dev/null +++ b/backend/tests/commerce/native_ownership_live.py @@ -0,0 +1,89 @@ +"""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.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 async_session_maker() as db: + before = await protected(db) + 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/issues.jsonl b/issues.jsonl index c2ce8aae..5b009e03 100644 --- a/issues.jsonl +++ b/issues.jsonl @@ -19,7 +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":"Verified cumulative refund ledger/IDs added. Real £1 partial Square sandbox refund on delivered431 reconciles as PARTIALLY_REFUNDED while dispatch and completed jobs remain.10 HTTP/DB checks pass. Full post-dispatch refund implementation preserves physical history and records goods review, but pending browser confirmation for remaining£28.97 means full acceptance unproven. Production adapter, returns/line quantities, interruption/race acceptance remain open; see companion shop refund report.","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-07T18:43:28Z","started_at":"2026-09-07T11:35:32Z","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":"8 Sep: native order ownership fences and shared durable dispatch adapter implemented, mounted only in isolated test release. 24 actual native API/service/PostgreSQL and signed HTTP replay checks pass, including ordinary native stock regression in rolled-back fixtures; all protected tables unchanged. Fresh successful native dispatch/operator browser and production activation remain open.","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-08T09:39:42Z","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} From e5a422fbb1bfb8b1b41588ae00777dc53ab519c0 Mon Sep 17 00:00:00 2001 From: Jonathan Gill Date: Tue, 8 Sep 2026 10:55:33 +0100 Subject: [PATCH 08/30] Expose authenticated native fulfilment commands and verify browser order lifecycle --- .beads/issues.jsonl | 2 +- backend/app/commerce_bridge.py | 28 +++++++++ .../tests/commerce/native_fulfilment_live.py | 61 +++++++++++++++++++ issues.jsonl | 2 +- 4 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 backend/tests/commerce/native_fulfilment_live.py diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 5b009e03..8bf115f9 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -19,7 +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":"8 Sep: native order ownership fences and shared durable dispatch adapter implemented, mounted only in isolated test release. 24 actual native API/service/PostgreSQL and signed HTTP replay checks pass, including ordinary native stock regression in rolled-back fixtures; all protected tables unchanged. Fresh successful native dispatch/operator browser and production activation remain open.","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-08T09:39:42Z","started_at":"2026-09-07T11:35:32Z","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":"8 Sep: browser sandbox order557 paid£29.97 with two variants; native simulated queue completed; authenticated admin operator commands called native Ship/Deliver handlers successfully. 14manufacturing+15dispatch+14delivery backend checks and16Woo/operator/HTTP/connection-recovery/email checks pass. Stable native command replay/changed tracking rejection verified. Production activation/full Batchivo UI/promotion remain open.","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-08T09:55:32Z","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/backend/app/commerce_bridge.py b/backend/app/commerce_bridge.py index 6f8312ca..e54cee9f 100644 --- a/backend/app/commerce_bridge.py +++ b/backend/app/commerce_bridge.py @@ -363,6 +363,34 @@ async def record_dispatch(command: DispatchCommand, digest: str): 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) diff --git a/backend/tests/commerce/native_fulfilment_live.py b/backend/tests/commerce/native_fulfilment_live.py new file mode 100644 index 00000000..6a170a17 --- /dev/null +++ b/backend/tests/commerce/native_fulfilment_live.py @@ -0,0 +1,61 @@ +"""Native manufacturing and reconciliation for the real browser sandbox order557.""" +import asyncio, hashlib, hmac, json, logging, sys, 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];error=None + 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/issues.jsonl b/issues.jsonl index 5b009e03..8bf115f9 100644 --- a/issues.jsonl +++ b/issues.jsonl @@ -19,7 +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":"8 Sep: native order ownership fences and shared durable dispatch adapter implemented, mounted only in isolated test release. 24 actual native API/service/PostgreSQL and signed HTTP replay checks pass, including ordinary native stock regression in rolled-back fixtures; all protected tables unchanged. Fresh successful native dispatch/operator browser and production activation remain open.","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-08T09:39:42Z","started_at":"2026-09-07T11:35:32Z","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":"8 Sep: browser sandbox order557 paid£29.97 with two variants; native simulated queue completed; authenticated admin operator commands called native Ship/Deliver handlers successfully. 14manufacturing+15dispatch+14delivery backend checks and16Woo/operator/HTTP/connection-recovery/email checks pass. Stable native command replay/changed tracking rejection verified. Production activation/full Batchivo UI/promotion remain open.","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-08T09:55:32Z","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} From 5aad3f0d47740c021eea4ea5abc5b4f533153425 Mon Sep 17 00:00:00 2001 From: Jonathan Gill Date: Tue, 8 Sep 2026 15:21:37 +0100 Subject: [PATCH 09/30] Add guarded commerce runtime profiles and additive ledger migration --- .beads/issues.jsonl | 2 +- ...commerce_bridge_v1_add_commerce_ledgers.py | 31 +++++++ backend/app/commerce_bridge.py | 86 +++++++++++++------ backend/app/commerce_runtime.py | 58 +++++++++++++ .../app/services/commerce_order_ownership.py | 10 ++- .../tests/commerce/runtime_profiles_live.py | 41 +++++++++ .../tests/commerce/schema_rehearsal_live.py | 45 ++++++++++ .../tests/commerce/test_runtime_profiles.py | 40 +++++++++ issues.jsonl | 2 +- 9 files changed, 282 insertions(+), 33 deletions(-) create mode 100644 backend/alembic/versions/commerce_bridge_v1_add_commerce_ledgers.py create mode 100644 backend/app/commerce_runtime.py create mode 100644 backend/tests/commerce/runtime_profiles_live.py create mode 100644 backend/tests/commerce/schema_rehearsal_live.py create mode 100644 backend/tests/commerce/test_runtime_profiles.py diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 8bf115f9..72ae9236 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -19,7 +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":"8 Sep: browser sandbox order557 paid£29.97 with two variants; native simulated queue completed; authenticated admin operator commands called native Ship/Deliver handlers successfully. 14manufacturing+15dispatch+14delivery backend checks and16Woo/operator/HTTP/connection-recovery/email checks pass. Stable native command replay/changed tracking rejection verified. Production activation/full Batchivo UI/promotion remain open.","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-08T09:55:32Z","started_at":"2026-09-07T11:35:32Z","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":"8 Sep runtime checkpoint: explicit isolated/production database, provider and release identities; fixed bridge-only transaction tenant context; production metadata and customer requirements; location/order/reservation provider binding; additive ledger migration. Seven unit tests, 19 real sandbox/tenant checks and 19 actual PostgreSQL DDL rehearsal checks pass. Test deployment only. Production grants/native configuration, legacy public order lookup compatibility/reporting channel, final combined promotion/rollback remain open.","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-08T14:21:11Z","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/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..eeae8073 --- /dev/null +++ b/backend/alembic/versions/commerce_bridge_v1_add_commerce_ledgers.py @@ -0,0 +1,31 @@ +"""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/commerce_bridge.py b/backend/app/commerce_bridge.py index e54cee9f..ac07ed4d 100644 --- a/backend/app/commerce_bridge.py +++ b/backend/app/commerce_bridge.py @@ -1,8 +1,4 @@ -"""Isolated WooCommerce acceptance bridge using real Batchivo order/print-job models. - -This service deliberately refuses production databases. Promotion requires a reviewed -production adapter release, not removal of this guard during testing. -""" +"""WooCommerce bridge with explicit isolated and separately activated production profiles.""" import asyncio from contextlib import asynccontextmanager from datetime import datetime, timezone, timedelta @@ -21,18 +17,31 @@ from fastapi import FastAPI, HTTPException, Request from fastapi.responses import Response from pydantic import BaseModel, ConfigDict, Field, model_validator -from sqlalchemy import DateTime, Index, Integer, String, Text, UniqueConstraint, and_, or_, select, text +from sqlalchemy import event, inspect, DateTime, Index, Integer, String, Text, UniqueConstraint, and_, or_, select, text from sqlalchemy.dialects.postgresql import JSONB -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.orm import Session, Mapped, mapped_column +from sqlalchemy.ext.asyncio import async_sessionmaker -if os.environ.get('COMMERCE_ISOLATED') != 'true' or 'batchivo_commerce_test' not in os.environ.get('DATABASE_URL',''): - raise RuntimeError('Commerce acceptance bridge requires an isolated test database') -from app.database import Base, engine, async_session_maker +from app.commerce_runtime import CommerceRuntime +RUNTIME = CommerceRuntime.from_environment(os.environ) +from app.database import Base, 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,'after_begin') +def commerce_tenant_context(session,transaction,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' @@ -118,6 +127,12 @@ def valid(self): 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): @@ -129,7 +144,7 @@ async def enrich_job_options(db, event: Event): 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==f'WOO-TEST-{event.order_id}:{line.line_id}').with_for_update())).all() + 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'): @@ -212,11 +227,16 @@ async def locked_reservation_stock(db,lines): @asynccontextmanager async def commerce_lifespan(app): async with engine.begin() as conn: - await conn.run_sync(lambda sync:CommerceReservation.__table__.create(sync,checkfirst=True)) - await conn.run_sync(lambda sync:CommerceFulfilmentEvent.__table__.create(sync,checkfirst=True)) + 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 isolated commerce acceptance',docs_url=None,redoc_url=None,lifespan=commerce_lifespan) +app=FastAPI(title='Batchivo commerce integration',docs_url=None,redoc_url=None,lifespan=commerce_lifespan) @app.post('/reservations') async def reserve(request:Request): @@ -333,9 +353,9 @@ async def record_dispatch(command: DispatchCommand, digest: str): if command.state=='shipped': 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(f'WOO-TEST-{command.order_id}:%')).with_for_update())).all() + 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={f'WOO-TEST-{command.order_id}:{line["line_id"]}':line for line in mapping.snapshot['lines'] if (line['product_id'],line.get('variant_id')) not in finite} + 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 job.quantity!=expected[job.reference]['quantity'] for job in jobs): @@ -414,14 +434,21 @@ async def acknowledge_fulfilment(request: Request): @app.get('/health') async def health(): async with engine.connect() as conn:await conn.execute(text('SELECT 1')) - return {'status':'ok','environment':'isolated-test'} + return {'status':'ok','environment':RUNTIME.mode} async def verify_payment(event: Event): async with httpx.AsyncClient(timeout=20) as client: - r=await client.get('https://connect.squareupsandbox.com/v2/payments/'+event.payment_id, - headers={'Authorization':'Bearer '+os.environ['SQUARE_SANDBOX_ACCESS_TOKEN'],'Square-Version':'2026-01-22'}) - if r.status_code!=200:raise HTTPException(503,'Square sandbox payment verification unavailable') + 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) @@ -432,8 +459,8 @@ async def verify_payment(event: Event): 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('https://connect.squareupsandbox.com/v2/refunds/'+refund_id, - headers={'Authorization':'Bearer '+os.environ['SQUARE_SANDBOX_ACCESS_TOKEN'],'Square-Version':'2026-01-22'}) + 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': @@ -492,14 +519,15 @@ def identity(lines):return sorted([tuple(str(line.get(k)) for k in line_keys) fo 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,order_number=f'WOO-TEST-{event.order_id}',status='processing', + order=Order(tenant_id=TENANT,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',payment_id=event.payment_id,payment_status='COMPLETED', - confirmation_email_sent=False,internal_notes='Isolated WooCommerce acceptance order. Never dispatch to a real customer.') + 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} 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()) @@ -513,11 +541,11 @@ def identity(lines):return sorted([tuple(str(line.get(k)) for k in line_keys) fo 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 Date: Tue, 8 Sep 2026 15:22:21 +0100 Subject: [PATCH 10/30] Sync commerce runtime issue export From 1e0b18b21ace7563db7a79a2355a851e040f6475 Mon Sep 17 00:00:00 2001 From: Jonathan Gill Date: Tue, 8 Sep 2026 15:55:47 +0100 Subject: [PATCH 11/30] Fence legacy Square callbacks from WooCommerce-owned order state --- .beads/issues.jsonl | 2 +- .../app/services/square_webhook_service.py | 29 ++++++++- .../commerce/legacy_webhook_ownership_live.py | 62 +++++++++++++++++++ issues.jsonl | 2 +- 4 files changed, 90 insertions(+), 5 deletions(-) create mode 100644 backend/tests/commerce/legacy_webhook_ownership_live.py diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 72ae9236..e74fbed0 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -19,7 +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":"8 Sep runtime checkpoint: explicit isolated/production database, provider and release identities; fixed bridge-only transaction tenant context; production metadata and customer requirements; location/order/reservation provider binding; additive ledger migration. Seven unit tests, 19 real sandbox/tenant checks and 19 actual PostgreSQL DDL rehearsal checks pass. Test deployment only. Production grants/native configuration, legacy public order lookup compatibility/reporting channel, final combined promotion/rollback remain open.","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-08T14:21:11Z","started_at":"2026-09-07T11:35:32Z","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":"8 Sep: guarded runtime profiles and additive migration deployed to test;7 unit/19 provider+tenant/19 DDL checks pass. Found legacy Square callbacks competing with Woo order state; added mapping ownership guard and fail-closed signature configuration.48 real service/PostgreSQL signed fixture, duplicate and rollback checks pass, ordinary legacy behavior preserved. Production untouched. Remaining production grants/native public lookup/reporting channel/configuration and final promotion rehearsal. Keyboard sandbox574/refund reconciled in shop evidence.","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-08T14:55:46Z","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/backend/app/services/square_webhook_service.py b/backend/app/services/square_webhook_service.py index f2859e29..0efe9132 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,26 @@ 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/tests/commerce/legacy_webhook_ownership_live.py b/backend/tests/commerce/legacy_webhook_ownership_live.py new file mode 100644 index 00000000..29e21606 --- /dev/null +++ b/backend/tests/commerce/legacy_webhook_ownership_live.py @@ -0,0 +1,62 @@ +"""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,base64,hashlib,hmac,json,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/issues.jsonl b/issues.jsonl index 72ae9236..e74fbed0 100644 --- a/issues.jsonl +++ b/issues.jsonl @@ -19,7 +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":"8 Sep runtime checkpoint: explicit isolated/production database, provider and release identities; fixed bridge-only transaction tenant context; production metadata and customer requirements; location/order/reservation provider binding; additive ledger migration. Seven unit tests, 19 real sandbox/tenant checks and 19 actual PostgreSQL DDL rehearsal checks pass. Test deployment only. Production grants/native configuration, legacy public order lookup compatibility/reporting channel, final combined promotion/rollback remain open.","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-08T14:21:11Z","started_at":"2026-09-07T11:35:32Z","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":"8 Sep: guarded runtime profiles and additive migration deployed to test;7 unit/19 provider+tenant/19 DDL checks pass. Found legacy Square callbacks competing with Woo order state; added mapping ownership guard and fail-closed signature configuration.48 real service/PostgreSQL signed fixture, duplicate and rollback checks pass, ordinary legacy behavior preserved. Production untouched. Remaining production grants/native public lookup/reporting channel/configuration and final promotion rehearsal. Keyboard sandbox574/refund reconciled in shop evidence.","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-08T14:55:46Z","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} From 6a8a2d64a89d43e2f9fd4e126b44b574ee859931 Mon Sep 17 00:00:00 2001 From: Jonathan Gill Date: Wed, 9 Sep 2026 09:08:12 +0100 Subject: [PATCH 12/30] Guard commerce manufacturing against refunded or unverified payments --- .beads/issues.jsonl | 2 +- backend/app/commerce_bridge.py | 7 +- .../app/services/commerce_job_ownership.py | 119 ++++++++++ backend/app/services/print_queue_service.py | 18 +- .../backfill_commerce_job_identities.py | 94 ++++++++ .../scripts/test_commerce_manufacturing.py | 203 ++++++++++++++++++ .../test_commerce_paid_manufacturing.py | 67 ++++++ issues.jsonl | 2 +- 8 files changed, 507 insertions(+), 5 deletions(-) create mode 100644 backend/app/services/commerce_job_ownership.py create mode 100644 backend/scripts/backfill_commerce_job_identities.py create mode 100644 backend/scripts/test_commerce_manufacturing.py create mode 100644 backend/scripts/test_commerce_paid_manufacturing.py diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index e74fbed0..c72311c8 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -19,7 +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":"8 Sep: guarded runtime profiles and additive migration deployed to test;7 unit/19 provider+tenant/19 DDL checks pass. Found legacy Square callbacks competing with Woo order state; added mapping ownership guard and fail-closed signature configuration.48 real service/PostgreSQL signed fixture, duplicate and rollback checks pass, ordinary legacy behavior preserved. Production untouched. Remaining production grants/native public lookup/reporting channel/configuration and final promotion rehearsal. Keyboard sandbox574/refund reconciled in shop evidence.","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-08T14:55:46Z","started_at":"2026-09-07T11:35:32Z","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: isolated native manufacturing guards deployed; durable job UUID ownership, identity/delete protection, Square payment/refund readback and shared order lock gate assignment/start. 18 live-service refund/outage checks and paid native start with rollback; 48 existing print-queue tests pass in disposable DB. External Square refunds for582 and mixed refunds586 reconcile to cancelled jobs. Remaining: production native profile/RLS, partial-refund physical disposition/release workflow, combined promotion and failure drills. No production changes.","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-09T08:07:52Z","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/backend/app/commerce_bridge.py b/backend/app/commerce_bridge.py index ac07ed4d..829022aa 100644 --- a/backend/app/commerce_bridge.py +++ b/backend/app/commerce_bridge.py @@ -528,7 +528,7 @@ def identity(lines):return sorted([tuple(str(line.get(k)) for k in line_keys) fo 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} + 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') @@ -545,7 +545,10 @@ def identity(lines):return sorted([tuple(str(line.get(k)) for k in line_keys) fo 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:db.add(PrintJob(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}))) + 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: diff --git a/backend/app/services/commerce_job_ownership.py b/backend/app/services/commerce_job_ownership.py new file mode 100644 index 00000000..bafc6b64 --- /dev/null +++ b/backend/app/services/commerce_job_ownership.py @@ -0,0 +1,119 @@ +"""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"] != "COMPLETED" + ): + 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] + if ( + str(job.product_id) != line["product_id"] + or job.quantity != line["quantity"] + 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 payment.get("refund_ids") or payment.get("refunded_money", {}).get("amount", 0): + raise HTTPException(409, "Square refund activity requires review before new manufacturing") + return True diff --git a/backend/app/services/print_queue_service.py b/backend/app/services/print_queue_service.py index e2d4a258..75f673f1 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,11 @@ 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 +490,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/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..9a691201 --- /dev/null +++ b/backend/scripts/test_commerce_paid_manufacturing.py @@ -0,0 +1,67 @@ +"""Real paid sandbox582/native start, entirely inside a rolled-back transaction.""" + +import asyncio, 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/issues.jsonl b/issues.jsonl index e74fbed0..c72311c8 100644 --- a/issues.jsonl +++ b/issues.jsonl @@ -19,7 +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":"8 Sep: guarded runtime profiles and additive migration deployed to test;7 unit/19 provider+tenant/19 DDL checks pass. Found legacy Square callbacks competing with Woo order state; added mapping ownership guard and fail-closed signature configuration.48 real service/PostgreSQL signed fixture, duplicate and rollback checks pass, ordinary legacy behavior preserved. Production untouched. Remaining production grants/native public lookup/reporting channel/configuration and final promotion rehearsal. Keyboard sandbox574/refund reconciled in shop evidence.","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-08T14:55:46Z","started_at":"2026-09-07T11:35:32Z","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: isolated native manufacturing guards deployed; durable job UUID ownership, identity/delete protection, Square payment/refund readback and shared order lock gate assignment/start. 18 live-service refund/outage checks and paid native start with rollback; 48 existing print-queue tests pass in disposable DB. External Square refunds for582 and mixed refunds586 reconcile to cancelled jobs. Remaining: production native profile/RLS, partial-refund physical disposition/release workflow, combined promotion and failure drills. No production changes.","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-09T08:07:52Z","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} From 547a6f9ce338e9e0731ee687e98c0d84ea920bfa Mon Sep 17 00:00:00 2001 From: Jonathan Gill Date: Wed, 9 Sep 2026 09:09:01 +0100 Subject: [PATCH 13/30] Record manufacturing acceptance and remaining delivery gates From 7cfaa50e41f1e366a081f24d20c0d1377c73aa90 Mon Sep 17 00:00:00 2001 From: Jonathan Gill Date: Wed, 9 Sep 2026 09:47:05 +0100 Subject: [PATCH 14/30] Isolate commerce database privileges and scope storefront order lookup --- .beads/issues.jsonl | 2 +- backend/app/api/v1/shop.py | 12 ++- backend/app/commerce_bridge.py | 21 ++++- backend/app/services/commerce_database.py | 58 ++++++++++++ .../app/services/commerce_database_policy.py | 59 ++++++++++++ .../app/services/commerce_sales_channel.py | 29 ++++++ .../scripts/test_commerce_restricted_paid.py | 67 +++++++++++++ backend/scripts/test_commerce_role_runtime.py | 32 +++++++ backend/tests/api/test_shop_order_lookup.py | 94 +++++++++++++++++++ issues.jsonl | 2 +- 10 files changed, 368 insertions(+), 8 deletions(-) create mode 100644 backend/app/services/commerce_database.py create mode 100644 backend/app/services/commerce_database_policy.py create mode 100644 backend/app/services/commerce_sales_channel.py create mode 100644 backend/scripts/test_commerce_restricted_paid.py create mode 100644 backend/scripts/test_commerce_role_runtime.py create mode 100644 backend/tests/api/test_shop_order_lookup.py diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index c72311c8..cfc12f16 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -19,7 +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: isolated native manufacturing guards deployed; durable job UUID ownership, identity/delete protection, Square payment/refund readback and shared order lock gate assignment/start. 18 live-service refund/outage checks and paid native start with rollback; 48 existing print-queue tests pass in disposable DB. External Square refunds for582 and mixed refunds586 reconcile to cancelled jobs. Remaining: production native profile/RLS, partial-refund physical disposition/release workflow, combined promotion and failure drills. No production changes.","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-09T08:07:52Z","started_at":"2026-09-07T11:35:32Z","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: public shop lookup now scoped to tenant/channel with real string status; 7 HTTP/Postgres tests pass. Separate COMMERCE_DATABASE_URL engine and fixed-tenant restricted role deployed only to isolated test. 18 login/RLS,7 runtime identity/channel,18 manufacturing,19 provider checks pass. Actual sandbox601 purchase and external full refund delivered first attempts, job cancelled. Production remains native superuser with no commerce schema; full native candidate/migration/role install and combined promotion still required. See shop PRODUCTION-ROLE-READINESS-20260909.md.","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-09T08:47:05Z","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/backend/app/api/v1/shop.py b/backend/app/api/v1/shop.py index e8ebd593..02aa04f7 100644 --- a/backend/app/api/v1/shop.py +++ b/backend/app/api/v1/shop.py @@ -1644,16 +1644,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 +1670,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, diff --git a/backend/app/commerce_bridge.py b/backend/app/commerce_bridge.py index 829022aa..aa406d30 100644 --- a/backend/app/commerce_bridge.py +++ b/backend/app/commerce_bridge.py @@ -19,12 +19,15 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator from sqlalchemy import event, inspect, DateTime, Index, Integer, String, Text, UniqueConstraint, and_, or_, select, text from sqlalchemy.dialects.postgresql import JSONB -from sqlalchemy.orm import Session, Mapped, mapped_column +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 +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 @@ -36,8 +39,18 @@ 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. @@ -227,6 +240,8 @@ async def locked_reservation_stock(db,lines): @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)) @@ -519,7 +534,7 @@ def identity(lines):return sorted([tuple(str(line.get(k)) for k in line_keys) fo 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,order_number=RUNTIME.order_reference(event.order_id),status='processing', + 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',''), 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_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/scripts/test_commerce_restricted_paid.py b/backend/scripts/test_commerce_restricted_paid.py new file mode 100644 index 00000000..ec9866fe --- /dev/null +++ b/backend/scripts/test_commerce_restricted_paid.py @@ -0,0 +1,67 @@ +"""Real paid sandbox601/native start, entirely inside a rolled-back transaction.""" + +import asyncio, 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..9e108a33 --- /dev/null +++ b/backend/scripts/test_commerce_role_runtime.py @@ -0,0 +1,32 @@ +"""Read-only live credential separation and reporting-channel acceptance.""" +import asyncio,json,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_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/issues.jsonl b/issues.jsonl index c72311c8..cfc12f16 100644 --- a/issues.jsonl +++ b/issues.jsonl @@ -19,7 +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: isolated native manufacturing guards deployed; durable job UUID ownership, identity/delete protection, Square payment/refund readback and shared order lock gate assignment/start. 18 live-service refund/outage checks and paid native start with rollback; 48 existing print-queue tests pass in disposable DB. External Square refunds for582 and mixed refunds586 reconcile to cancelled jobs. Remaining: production native profile/RLS, partial-refund physical disposition/release workflow, combined promotion and failure drills. No production changes.","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-09T08:07:52Z","started_at":"2026-09-07T11:35:32Z","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: public shop lookup now scoped to tenant/channel with real string status; 7 HTTP/Postgres tests pass. Separate COMMERCE_DATABASE_URL engine and fixed-tenant restricted role deployed only to isolated test. 18 login/RLS,7 runtime identity/channel,18 manufacturing,19 provider checks pass. Actual sandbox601 purchase and external full refund delivered first attempts, job cancelled. Production remains native superuser with no commerce schema; full native candidate/migration/role install and combined promotion still required. See shop PRODUCTION-ROLE-READINESS-20260909.md.","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-09T08:47:05Z","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} From 438fbaedb311e1c7e5dacc4eaf46c8d6384f5439 Mon Sep 17 00:00:00 2001 From: Jonathan Gill Date: Wed, 9 Sep 2026 12:21:00 +0100 Subject: [PATCH 15/30] Add audited physical returns and idempotent inspected stock recovery --- .beads/issues.jsonl | 2 +- backend/app/commerce_bridge.py | 4 + backend/app/commerce_returns.py | 281 ++++++++++++++++++ .../tests/commerce/native_ownership_live.py | 4 +- backend/tests/commerce/returns_http_live.py | 157 ++++++++++ backend/tests/commerce/returns_live.py | 188 ++++++++++++ issues.jsonl | 2 +- 7 files changed, 635 insertions(+), 3 deletions(-) create mode 100644 backend/app/commerce_returns.py create mode 100644 backend/tests/commerce/returns_http_live.py create mode 100644 backend/tests/commerce/returns_live.py diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index cfc12f16..1483cbe8 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -19,7 +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: public shop lookup now scoped to tenant/channel with real string status; 7 HTTP/Postgres tests pass. Separate COMMERCE_DATABASE_URL engine and fixed-tenant restricted role deployed only to isolated test. 18 login/RLS,7 runtime identity/channel,18 manufacturing,19 provider checks pass. Actual sandbox601 purchase and external full refund delivered first attempts, job cancelled. Production remains native superuser with no commerce schema; full native candidate/migration/role install and combined promotion still required. See shop PRODUCTION-ROLE-READINESS-20260909.md.","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-09T08:47:05Z","started_at":"2026-09-07T11:35:32Z","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: public shop lookup now scoped to tenant/channel with real string status; 7 HTTP/Postgres tests pass. Separate COMMERCE_DATABASE_URL engine and fixed-tenant restricted role deployed only to isolated test. 18 login/RLS,7 runtime identity/channel,18 manufacturing,19 provider checks pass. Actual sandbox601 purchase and external full refund delivered first attempts, job cancelled. Production remains native superuser with no commerce schema; full native candidate/migration/role install and combined promotion still required. See shop PRODUCTION-ROLE-READINESS-20260909.md.\nImplemented signed physical returns receipt/inspection ledger with per-line quantities, finite-stock-only restock, actor/evidence audit, version conflict holds and exact replay. New browser sandbox order603 paid/refunded1898, simulated dispatch, receive/restock once; lost response recovery passed in Woo browser. Restricted-role rollback and HTTP checks pass. Native ownership regression corrected to use native connection for native CRUD;25 checks pass. Partial-line pre-dispatch cancellation/manufacturing release and production promotion remain open.","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-09T11:20:08Z","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/backend/app/commerce_bridge.py b/backend/app/commerce_bridge.py index aa406d30..5bcf9b8f 100644 --- a/backend/app/commerce_bridge.py +++ b/backend/app/commerce_bridge.py @@ -632,5 +632,9 @@ async def reconcile_job_options(): 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) + 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_returns.py b/backend/app/commerce_returns.py new file mode 100644 index 00000000..aab382e1 --- /dev/null +++ b/backend/app/commerce_returns.py @@ -0,0 +1,281 @@ +"""Physical returns, separate from Square refunds and immutable sold quantities. + +Only a shipped, provider-reconciled refunded order can receive returns. Receiving +is separate from inspection; 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): + from app.commerce_bridge import Event, verify_payment + + if 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" + ) + if not mapping.effects.get("refunded_pence") or order.payment_status not in ( + "PARTIALLY_REFUNDED", + "REFUNDED", + ): + raise HTTPException( + 409, "A completed, reconciled refund is required before this returns workflow" + ) + 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["refunded_pence"] + or payment.get("refunded_money", {}).get("amount", 0) != mapping.effects["refunded_pence"] + ): + raise HTTPException( + 409, "Square refund activity has changed; reconcile it before recording returns" + ) + + +def lines_for_review(mapping): + 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 <= line["quantity"]: + 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"], + "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["refunded_pence"], + "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["sold"] - 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["refunded_pence"], + } + 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/tests/commerce/native_ownership_live.py b/backend/tests/commerce/native_ownership_live.py index 3a69beb7..0fa31db2 100644 --- a/backend/tests/commerce/native_ownership_live.py +++ b/backend/tests/commerce/native_ownership_live.py @@ -19,6 +19,7 @@ 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 @@ -39,8 +40,9 @@ async def denied(name, operation): check(name, False) except HTTPException as error: check(name, error.status_code == 409) - async with async_session_maker() as db: + 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))) diff --git a/backend/tests/commerce/returns_http_live.py b/backend/tests/commerce/returns_http_live.py new file mode 100644 index 00000000..2f3b7dba --- /dev/null +++ b/backend/tests/commerce/returns_http_live.py @@ -0,0 +1,157 @@ +"""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(), + ), + ) + 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..8f740c31 --- /dev/null +++ b/backend/tests/commerce/returns_live.py @@ -0,0 +1,188 @@ +"""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) + 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/issues.jsonl b/issues.jsonl index cfc12f16..1483cbe8 100644 --- a/issues.jsonl +++ b/issues.jsonl @@ -19,7 +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: public shop lookup now scoped to tenant/channel with real string status; 7 HTTP/Postgres tests pass. Separate COMMERCE_DATABASE_URL engine and fixed-tenant restricted role deployed only to isolated test. 18 login/RLS,7 runtime identity/channel,18 manufacturing,19 provider checks pass. Actual sandbox601 purchase and external full refund delivered first attempts, job cancelled. Production remains native superuser with no commerce schema; full native candidate/migration/role install and combined promotion still required. See shop PRODUCTION-ROLE-READINESS-20260909.md.","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-09T08:47:05Z","started_at":"2026-09-07T11:35:32Z","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: public shop lookup now scoped to tenant/channel with real string status; 7 HTTP/Postgres tests pass. Separate COMMERCE_DATABASE_URL engine and fixed-tenant restricted role deployed only to isolated test. 18 login/RLS,7 runtime identity/channel,18 manufacturing,19 provider checks pass. Actual sandbox601 purchase and external full refund delivered first attempts, job cancelled. Production remains native superuser with no commerce schema; full native candidate/migration/role install and combined promotion still required. See shop PRODUCTION-ROLE-READINESS-20260909.md.\nImplemented signed physical returns receipt/inspection ledger with per-line quantities, finite-stock-only restock, actor/evidence audit, version conflict holds and exact replay. New browser sandbox order603 paid/refunded1898, simulated dispatch, receive/restock once; lost response recovery passed in Woo browser. Restricted-role rollback and HTTP checks pass. Native ownership regression corrected to use native connection for native CRUD;25 checks pass. Partial-line pre-dispatch cancellation/manufacturing release and production promotion remain open.","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-09T11:20:08Z","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} From c347a70e349bf505db27d31f389d0a99822b5060 Mon Sep 17 00:00:00 2001 From: Jonathan Gill Date: Wed, 9 Sep 2026 12:26:42 +0100 Subject: [PATCH 16/30] Allow verified physical returns before the financial refund --- backend/app/commerce_returns.py | 30 ++++++++++++------ backend/tests/commerce/returns_http_live.py | 7 +++++ backend/tests/commerce/returns_live.py | 35 +++++++++++++++++++++ 3 files changed, 62 insertions(+), 10 deletions(-) diff --git a/backend/app/commerce_returns.py b/backend/app/commerce_returns.py index aab382e1..0e22e3b4 100644 --- a/backend/app/commerce_returns.py +++ b/backend/app/commerce_returns.py @@ -1,7 +1,7 @@ """Physical returns, separate from Square refunds and immutable sold quantities. -Only a shipped, provider-reconciled refunded order can receive returns. Receiving -is separate from inspection; only inspected finite-stock goods increase stock. +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. """ @@ -82,12 +82,21 @@ async def verify_refunds(mapping, order): raise HTTPException( 409, "Stock was already released; return quantities require reconciliation" ) - if not mapping.effects.get("refunded_pence") or order.payment_status not in ( - "PARTIALLY_REFUNDED", - "REFUNDED", + 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, "A completed, reconciled refund is required before this returns workflow" + 409, "Reconcile the completed payment and any refunds before recording returns" ) try: payment = await verify_payment(Event.model_validate(mapping.snapshot)) @@ -97,8 +106,9 @@ async def verify_refunds(mapping, order): 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["refunded_pence"] - or payment.get("refunded_money", {}).get("amount", 0) != mapping.effects["refunded_pence"] + 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" @@ -147,7 +157,7 @@ async def review_returns(order_id): return { "order_id": order_id, "revision": revision(mapping, order), - "refunded_pence": mapping.effects["refunded_pence"], + "refunded_pence": mapping.effects.get("refunded_pence", 0), "lines": lines_for_review(mapping), "history": list(mapping.effects.get("returns", {}).get("receipts", {}).values()), } @@ -235,7 +245,7 @@ async def apply_return_in_transaction(db, command): "note": command.note, "at": datetime.now(UTC).isoformat(), "order_version": mapping.version, - "refunded_pence": effects["refunded_pence"], + "refunded_pence": effects.get("refunded_pence", 0), } mapping.effects = effects await db.flush() diff --git a/backend/tests/commerce/returns_http_live.py b/backend/tests/commerce/returns_http_live.py index 2f3b7dba..55fb8c7f 100644 --- a/backend/tests/commerce/returns_http_live.py +++ b/backend/tests/commerce/returns_http_live.py @@ -110,6 +110,13 @@ async def post(path, payload, signed=True): 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()), diff --git a/backend/tests/commerce/returns_live.py b/backend/tests/commerce/returns_live.py index 8f740c31..47b6a6cd 100644 --- a/backend/tests/commerce/returns_live.py +++ b/backend/tests/commerce/returns_live.py @@ -174,6 +174,41 @@ def command(action="receive", quantity=1, **kwargs): 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( { From 0d6938ffd8feb8db19d539936833d988975584be Mon Sep 17 00:00:00 2001 From: Jonathan Gill Date: Wed, 9 Sep 2026 12:45:20 +0100 Subject: [PATCH 17/30] Allow delivery facts after refund without reopening financial state --- backend/app/commerce_bridge.py | 12 +- backend/tests/commerce/late_delivery_live.py | 150 +++++++++++++++++++ 2 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 backend/tests/commerce/late_delivery_live.py diff --git a/backend/app/commerce_bridge.py b/backend/app/commerce_bridge.py index 5bcf9b8f..70dac706 100644 --- a/backend/app/commerce_bridge.py +++ b/backend/app/commerce_bridge.py @@ -362,10 +362,12 @@ async def record_dispatch(command: DispatchCommand, digest: str): 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 or order.payment_status!='COMPLETED' or mapping.state in ['cancelled','refunded']: - raise HTTPException(409,'Order cannot be dispatched') + 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': + if order.payment_status!='COMPLETED' 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() @@ -387,7 +389,11 @@ async def record_dispatch(command: DispatchCommand, digest: str): # Commerce has already committed stock/payment effects. Never deduct again here. if not order.fulfilled_at:order.fulfilled_at=order.shipped_at else: - if order.status!='shipped' or not previous or previous.payload['state']!='shipped': + # 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') diff --git a/backend/tests/commerce/late_delivery_live.py b/backend/tests/commerce/late_delivery_live.py new file mode 100644 index 00000000..fd119f25 --- /dev/null +++ b/backend/tests/commerce/late_delivery_live.py @@ -0,0 +1,150 @@ +"""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): + 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" + ) + 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()) From 48c4c49cb4699e19e25bdebcc0e30288feb627d5 Mon Sep 17 00:00:00 2001 From: Jonathan Gill Date: Wed, 9 Sep 2026 12:48:26 +0100 Subject: [PATCH 18/30] Preserve tenant RLS context in delivery rollback acceptance --- backend/tests/commerce/late_delivery_live.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/backend/tests/commerce/late_delivery_live.py b/backend/tests/commerce/late_delivery_live.py index fd119f25..6de9df97 100644 --- a/backend/tests/commerce/late_delivery_live.py +++ b/backend/tests/commerce/late_delivery_live.py @@ -33,6 +33,10 @@ def check(name, passed): ] 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") @@ -46,7 +50,10 @@ async def fingerprint(db): await connection.rollback() outer = await connection.begin() bridge.async_session_maker = async_sessionmaker( - connection, expire_on_commit=False, join_transaction_mode="create_savepoint" + connection, + expire_on_commit=False, + join_transaction_mode="create_savepoint", + sync_session_class=bridge.CommerceSession, ) try: async with bridge.async_session_maker() as db: From 24b0d5da941afebcfd1bd8dd7c4808488f091aed Mon Sep 17 00:00:00 2001 From: Jonathan Gill Date: Wed, 9 Sep 2026 14:26:46 +0100 Subject: [PATCH 19/30] Add audited partial-refund quantity disposition and release guards --- backend/app/commerce_bridge.py | 11 +- backend/app/commerce_disposition.py | 277 ++++++++++++++++++ backend/app/commerce_returns.py | 17 +- .../app/services/commerce_job_ownership.py | 15 +- 4 files changed, 307 insertions(+), 13 deletions(-) create mode 100644 backend/app/commerce_disposition.py diff --git a/backend/app/commerce_bridge.py b/backend/app/commerce_bridge.py index 70dac706..c2ffdce4 100644 --- a/backend/app/commerce_bridge.py +++ b/backend/app/commerce_bridge.py @@ -366,7 +366,8 @@ async def record_dispatch(command: DispatchCommand, digest: str): 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': - if order.payment_status!='COMPLETED' or mapping.state in ['cancelled','refunded']: + 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') @@ -375,13 +376,13 @@ async def record_dispatch(command: DispatchCommand, digest: str): 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 job.quantity!=expected[job.reference]['quantity'] for job in jobs): + 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 for job in jobs): + 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 payment.get('refund_ids') or payment.get('refunded_money',{}).get('amount',0): + 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 @@ -641,6 +642,8 @@ async def reconcile_job_options(): 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..c33baf81 --- /dev/null +++ b/backend/app/commerce_disposition.py @@ -0,0 +1,277 @@ +"""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 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 (remaining and job.quantity != remaining) + ): + 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 index 0e22e3b4..ddb1fd81 100644 --- a/backend/app/commerce_returns.py +++ b/backend/app/commerce_returns.py @@ -70,10 +70,10 @@ async def load_order(db, order_id): return mapping, order -async def verify_refunds(mapping, order): +async def verify_refunds(mapping, order, require_shipped=True): from app.commerce_bridge import Event, verify_payment - if not order.shipped_at: + if require_shipped and not order.shipped_at: raise HTTPException( 409, "Record a physical return only after dispatch; unshipped cancellations use the cancellation workflow", @@ -116,6 +116,8 @@ async def verify_refunds(mapping, order): 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 = [] @@ -129,7 +131,12 @@ def lines_for_review(mapping): received, restocked, written_off = ( counts.get(k, 0) for k in ("received", "restocked", "written_off") ) - if not 0 <= restocked + written_off <= received <= line["quantity"]: + if ( + not 0 + <= restocked + written_off + <= received + <= remaining_quantity(mapping.effects, line) + ): raise HTTPException(409, "Return quantities require reconciliation") rows.append( { @@ -138,6 +145,8 @@ def lines_for_review(mapping): "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, @@ -197,7 +206,7 @@ async def apply_return_in_transaction(db, command): 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["sold"] - line["received"]: + if command.quantity > line["shipped_quantity"] - line["received"]: raise HTTPException(409, "Received quantity exceeds the remaining sold quantity") counts["received"] += command.quantity else: diff --git a/backend/app/services/commerce_job_ownership.py b/backend/app/services/commerce_job_ownership.py index bafc6b64..73db5dae 100644 --- a/backend/app/services/commerce_job_ownership.py +++ b/backend/app/services/commerce_job_ownership.py @@ -76,9 +76,9 @@ async def verify_commerce_job_work(db, job, starting=False): 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"] != "COMPLETED" + 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 = ( @@ -100,9 +100,14 @@ async def verify_commerce_job_work(db, job, starting=False): 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 != line["quantity"] + or job.quantity != remaining or job.reference != runtime.job_reference(mapping["woo_order_id"], line["line_id"]) ): raise HTTPException( @@ -114,6 +119,6 @@ async def verify_commerce_job_work(db, job, starting=False): 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 payment.get("refund_ids") or payment.get("refunded_money", {}).get("amount", 0): + if not refund_release_valid(mapping["effects"], payment): raise HTTPException(409, "Square refund activity requires review before new manufacturing") return True From 52c4bfcbd1dd2b04c3f02de18463d4943d93feba Mon Sep 17 00:00:00 2001 From: Jonathan Gill Date: Wed, 9 Sep 2026 14:37:56 +0100 Subject: [PATCH 20/30] Verify partial refund quantities, manufacturing release and replay safety --- backend/app/commerce_disposition.py | 7 +- .../commerce/disposition_dispatch_live.py | 142 ++++++++++++ backend/tests/commerce/disposition_live.py | 202 ++++++++++++++++++ 3 files changed, 349 insertions(+), 2 deletions(-) create mode 100644 backend/tests/commerce/disposition_dispatch_live.py create mode 100644 backend/tests/commerce/disposition_live.py diff --git a/backend/app/commerce_disposition.py b/backend/app/commerce_disposition.py index c33baf81..78edb987 100644 --- a/backend/app/commerce_disposition.py +++ b/backend/app/commerce_disposition.py @@ -61,7 +61,7 @@ def refund_release_valid(effects, payment): async def review_in_transaction(db, order_id): - from app.commerce_bridge import TENANT, PrintJob + 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"): @@ -96,7 +96,10 @@ async def review_in_transaction(db, order_id): 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 (remaining and job.quantity != remaining) + 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( 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..33bba84c --- /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(l for l in snapshot["lines"] if l["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()) From 35d517885b56fa4150f9c9547de0295640ccef06 Mon Sep 17 00:00:00 2001 From: Jonathan Gill Date: Wed, 9 Sep 2026 14:39:43 +0100 Subject: [PATCH 21/30] Show a fresh review hold when another partial refund arrives --- backend/app/commerce_bridge.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/app/commerce_bridge.py b/backend/app/commerce_bridge.py index c2ffdce4..959009f0 100644 --- a/backend/app/commerce_bridge.py +++ b/backend/app/commerce_bridge.py @@ -580,6 +580,10 @@ def identity(lines):return sorted([tuple(str(line.get(k)) for k in line_keys) fo 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) From 8ca5ba2a11c0cb84a7bb1da51a09fa6fcc95ed76 Mon Sep 17 00:00:00 2001 From: Jonathan Gill Date: Wed, 9 Sep 2026 16:42:13 +0100 Subject: [PATCH 22/30] Deploy reviewed commerce-aware native image with zero-unavailable rollout --- infrastructure/k8s/backend/deployment.yaml | 49 +++++++++++++++++++--- issues.jsonl | 3 +- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/infrastructure/k8s/backend/deployment.yaml b/infrastructure/k8s/backend/deployment.yaml index a0a1b197..c9af1dff 100644 --- a/infrastructure/k8s/backend/deployment.yaml +++ b/infrastructure/k8s/backend/deployment.yaml @@ -60,6 +60,11 @@ metadata: namespace: batchivo spec: replicas: 2 + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + maxSurge: 1 selector: matchLabels: app: backend @@ -68,6 +73,7 @@ spec: labels: app: backend annotations: + mystmere/native-release: "35d517885b56fa4150f9c9547de0295640ccef06" prometheus.io/scrape: "true" prometheus.io/port: "9090" prometheus.io/path: "/metrics" @@ -81,7 +87,7 @@ spec: - name: harbor-creds initContainers: - name: run-migrations - image: registry.techize.co.uk/batchivo/batchivo-backend:b9d6466a3c586dca72ad06a256743e77bbb70a60 + image: registry.techize.co.uk/batchivo/batchivo-backend@sha256:277b3111c6f5c2cb9c77eb726a56b44ef81e317d20c77ae3d9f31a9d875aa521 imagePullPolicy: Always command: ["alembic", "upgrade", "heads"] securityContext: @@ -110,7 +116,7 @@ spec: value: "postgresql+psycopg://$(DB_USER):$(DB_PASSWORD)@$(DB_HOST):$(DB_PORT)/$(DB_NAME)" containers: - name: backend - image: registry.techize.co.uk/batchivo/batchivo-backend:b9d6466a3c586dca72ad06a256743e77bbb70a60 + image: registry.techize.co.uk/batchivo/batchivo-backend@sha256:277b3111c6f5c2cb9c77eb726a56b44ef81e317d20c77ae3d9f31a9d875aa521 imagePullPolicy: Always securityContext: allowPrivilegeEscalation: false @@ -153,9 +159,41 @@ spec: name: postgres-secret key: POSTGRES_PASSWORD - name: DATABASE_URL - value: "postgresql+psycopg://$(DB_USER):$(DB_PASSWORD)@$(DB_HOST):$(DB_PORT)/$(DB_NAME)" + value: "postgresql+psycopg://$(DB_USER):$(DB_PASSWORD)@postgres.batchivo.svc:5432/batchivo" - name: BUILD_SHA - value: "e2e08823ddafb5cca1ad736ace9f167d6f4bfac6" + value: "35d517885b56fa4150f9c9547de0295640ccef06" + - name: COMMERCE_MODE + value: "production" + - name: COMMERCE_ISOLATED + value: "false" + - name: COMMERCE_PRODUCTION_ENABLED + value: "true" + - name: COMMERCE_RELEASE_ID + value: "35d517885b56fa4150f9c9547de0295640ccef06" + - name: COMMERCE_TENANT_ID + value: "ad62b515-17b3-4830-bcb4-3f4c470c26e2" + - name: COMMERCE_SALES_CHANNEL_ID + value: "350fb516-f892-45e9-a0f9-052271c359a3" + - name: COMMERCE_DATABASE_URL + valueFrom: + secretKeyRef: + name: commerce-native-runtime + key: COMMERCE_DATABASE_URL + - name: COMMERCE_BRIDGE_SECRET + valueFrom: + secretKeyRef: + name: commerce-native-runtime + key: COMMERCE_BRIDGE_SECRET + - name: SQUARE_PRODUCTION_ACCESS_TOKEN + valueFrom: + secretKeyRef: + name: square-credentials + key: SQUARE_ACCESS_TOKEN + - name: SQUARE_PRODUCTION_LOCATION_ID + valueFrom: + secretKeyRef: + name: square-credentials + key: SQUARE_LOCATION_ID - name: BUILD_DATE value: "2026-08-10T08:48:05Z" - name: STORAGE_S3_ACCESS_KEY @@ -184,8 +222,9 @@ spec: initialDelaySeconds: 30 periodSeconds: 10 readinessProbe: + timeoutSeconds: 5 httpGet: - path: /health + path: /health/ready port: 8000 initialDelaySeconds: 5 periodSeconds: 5 diff --git a/issues.jsonl b/issues.jsonl index 88571d90..1483cbe8 100644 --- a/issues.jsonl +++ b/issues.jsonl @@ -1,4 +1,4 @@ -{"id":"batchivo-mjuv","title":"Close Batchivo blockers for Mystmere k3s cutover","description":"Resolve Batchivo-side blockers for the Mystmere Shopify migration: dynamic Square environment agreement, catalogue visibility parity, order reconciliation support, backup resilience and production validation.","acceptance_criteria":"Test-host payments use sandbox end to end; production payments remain production; public catalogue matches Shopify; backups have off-node recovery evidence; Batchivo remains Synced and Healthy.","status":"in_progress","priority":0,"issue_type":"task","assignee":"Jonathan Gill","owner":"jonathan@techize.co.uk","created_at":"2026-07-28T12:09:58Z","created_by":"Jonathan Gill","updated_at":"2026-07-28T12:10:04Z","started_at":"2026-07-28T12:10:04Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"batchivo-mjuv","title":"Close Batchivo blockers for Mystmere k3s cutover","description":"Resolve Batchivo-side blockers for the Mystmere Shopify migration: dynamic Square environment agreement, catalogue visibility parity, order reconciliation support, backup resilience and production validation.","acceptance_criteria":"Test-host payments use sandbox end to end; production payments remain production; public catalogue matches Shopify; backups have off-node recovery evidence; Batchivo remains Synced and Healthy.","status":"closed","priority":0,"issue_type":"task","assignee":"Jonathan Gill","owner":"jonathan@techize.co.uk","created_at":"2026-07-28T12:09:58Z","created_by":"Jonathan Gill","updated_at":"2026-07-28T13:02:41Z","started_at":"2026-07-28T12:10:04Z","closed_at":"2026-07-28T13:02:41Z","close_reason":"Batchivo production dependency work completed for Mystmere cutover: tenant Square runtime selection validated, two-replica frontend/backend healthy, inert Shopify credentials removed from deployment, off-node plus MinIO backup and isolated restore verified.","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"batchivo-4ke","title":"SEC: Silent tenant switching via X-Tenant-ID header","description":"**Severity:** HIGH\n**Category:** authorization_bypass\n**File:** backend/app/auth/dependencies.py:99-106\n\n## Description\nThe `get_current_tenant()` function allows users to switch tenants via `X-Tenant-ID` header but fails silently if unauthorized, falling back to user's first tenant without logging or alerting. This enables:\n1. Tenant enumeration via timing/behavior differences\n2. Operations executing on wrong tenant due to silent fallback\n3. No audit trail of unauthorized access attempts\n\n## Vulnerable Code\n```python\nif x_tenant_id:\n try:\n tenant_uuid = uuid.UUID(x_tenant_id)\n for ut in user_tenants:\n if ut.tenant_id == tenant_uuid:\n result = await db.execute(select(Tenant).where(Tenant.id == tenant_uuid))\n return result.scalar_one()\n except (ValueError, Exception):\n pass # Silent failure - falls back to first tenant\n```\n\n## Recommendation\nRaise explicit HTTP 403 error on unauthorized tenant access and log all attempts:\n```python\nif not user_tenant:\n logger.warning(f\"User {user.id} attempted unauthorized tenant access: {tenant_uuid}\")\n raise HTTPException(status_code=403, detail=\"Access denied to requested tenant\")\n```","status":"closed","priority":0,"issue_type":"bug","created_at":"2026-01-14T16:57:50Z","created_by":"jonathan","updated_at":"2026-01-14T17:37:59Z","closed_at":"2026-01-14T17:37:59Z","close_reason":"Fixed in commit 6907329","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"batchivo-cgg","title":"SEC: Tenant isolation bypass in payment processing","description":"**Severity:** HIGH\n**Category:** authorization_bypass / tenant_data_leakage\n**File:** backend/app/api/v1/payments.py:132-134\n\n## Description\nPayment processing endpoint fails to validate tenant ownership when looking up products. An attacker can specify arbitrary product IDs from other tenants in their payment request, causing cross-tenant data leakage.\n\n## Vulnerable Code\n```python\nproduct_result = await db.execute(select(Product).where(Product.id == item.product_id))\nproduct = product_result.scalar_one_or_none()\n```\n\n## Exploit Scenario\n1. Attacker discovers product UUID from Tenant A (via enumeration or leaked data)\n2. Attacker submits payment request for Tenant B with that product_id\n3. System creates order linking product from Tenant A\n4. Exposes sensitive product information (pricing, SKU) across tenant boundaries\n\n## Recommendation\nAdd tenant_id validation to product lookup:\n```python\nproduct_result = await db.execute(\n select(Product).where(\n Product.id == item.product_id,\n Product.tenant_id == shop_tenant.id # Enforce tenant isolation\n )\n)\n```","status":"closed","priority":0,"issue_type":"bug","created_at":"2026-01-14T16:57:49Z","created_by":"jonathan","updated_at":"2026-01-14T17:37:59Z","closed_at":"2026-01-14T17:37:59Z","close_reason":"Fixed in commit 6907329","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"batchivo-ami","title":"Fix backend Docker health check bug","description":"BLOCKER: Backend Dockerfile health check uses 'requests' package which is NOT in the dependencies. This will cause health checks to fail in production. Either add requests to deps or use curl/httpie instead.","status":"closed","priority":0,"issue_type":"bug","created_at":"2026-01-14T13:16:13Z","created_by":"jonathan","updated_at":"2026-01-14T13:20:06Z","closed_at":"2026-01-14T13:20:06Z","close_reason":"Fixed health check to use urllib.request instead of requests","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -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: public shop lookup now scoped to tenant/channel with real string status; 7 HTTP/Postgres tests pass. Separate COMMERCE_DATABASE_URL engine and fixed-tenant restricted role deployed only to isolated test. 18 login/RLS,7 runtime identity/channel,18 manufacturing,19 provider checks pass. Actual sandbox601 purchase and external full refund delivered first attempts, job cancelled. Production remains native superuser with no commerce schema; full native candidate/migration/role install and combined promotion still required. See shop PRODUCTION-ROLE-READINESS-20260909.md.\nImplemented signed physical returns receipt/inspection ledger with per-line quantities, finite-stock-only restock, actor/evidence audit, version conflict holds and exact replay. New browser sandbox order603 paid/refunded1898, simulated dispatch, receive/restock once; lost response recovery passed in Woo browser. Restricted-role rollback and HTTP checks pass. Native ownership regression corrected to use native connection for native CRUD;25 checks pass. Partial-line pre-dispatch cancellation/manufacturing release and production promotion remain open.","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-09T11:20:08Z","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} From 83f762480a54d7075abba78632a8bb6f87d84ef6 Mon Sep 17 00:00:00 2001 From: Jonathan Gill Date: Wed, 9 Sep 2026 16:42:44 +0100 Subject: [PATCH 23/30] Keep production configuration release scoped to deployment manifest --- issues.jsonl | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/issues.jsonl b/issues.jsonl index 1483cbe8..88571d90 100644 --- a/issues.jsonl +++ b/issues.jsonl @@ -1,4 +1,4 @@ -{"id":"batchivo-mjuv","title":"Close Batchivo blockers for Mystmere k3s cutover","description":"Resolve Batchivo-side blockers for the Mystmere Shopify migration: dynamic Square environment agreement, catalogue visibility parity, order reconciliation support, backup resilience and production validation.","acceptance_criteria":"Test-host payments use sandbox end to end; production payments remain production; public catalogue matches Shopify; backups have off-node recovery evidence; Batchivo remains Synced and Healthy.","status":"closed","priority":0,"issue_type":"task","assignee":"Jonathan Gill","owner":"jonathan@techize.co.uk","created_at":"2026-07-28T12:09:58Z","created_by":"Jonathan Gill","updated_at":"2026-07-28T13:02:41Z","started_at":"2026-07-28T12:10:04Z","closed_at":"2026-07-28T13:02:41Z","close_reason":"Batchivo production dependency work completed for Mystmere cutover: tenant Square runtime selection validated, two-replica frontend/backend healthy, inert Shopify credentials removed from deployment, off-node plus MinIO backup and isolated restore verified.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"batchivo-mjuv","title":"Close Batchivo blockers for Mystmere k3s cutover","description":"Resolve Batchivo-side blockers for the Mystmere Shopify migration: dynamic Square environment agreement, catalogue visibility parity, order reconciliation support, backup resilience and production validation.","acceptance_criteria":"Test-host payments use sandbox end to end; production payments remain production; public catalogue matches Shopify; backups have off-node recovery evidence; Batchivo remains Synced and Healthy.","status":"in_progress","priority":0,"issue_type":"task","assignee":"Jonathan Gill","owner":"jonathan@techize.co.uk","created_at":"2026-07-28T12:09:58Z","created_by":"Jonathan Gill","updated_at":"2026-07-28T12:10:04Z","started_at":"2026-07-28T12:10:04Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"batchivo-4ke","title":"SEC: Silent tenant switching via X-Tenant-ID header","description":"**Severity:** HIGH\n**Category:** authorization_bypass\n**File:** backend/app/auth/dependencies.py:99-106\n\n## Description\nThe `get_current_tenant()` function allows users to switch tenants via `X-Tenant-ID` header but fails silently if unauthorized, falling back to user's first tenant without logging or alerting. This enables:\n1. Tenant enumeration via timing/behavior differences\n2. Operations executing on wrong tenant due to silent fallback\n3. No audit trail of unauthorized access attempts\n\n## Vulnerable Code\n```python\nif x_tenant_id:\n try:\n tenant_uuid = uuid.UUID(x_tenant_id)\n for ut in user_tenants:\n if ut.tenant_id == tenant_uuid:\n result = await db.execute(select(Tenant).where(Tenant.id == tenant_uuid))\n return result.scalar_one()\n except (ValueError, Exception):\n pass # Silent failure - falls back to first tenant\n```\n\n## Recommendation\nRaise explicit HTTP 403 error on unauthorized tenant access and log all attempts:\n```python\nif not user_tenant:\n logger.warning(f\"User {user.id} attempted unauthorized tenant access: {tenant_uuid}\")\n raise HTTPException(status_code=403, detail=\"Access denied to requested tenant\")\n```","status":"closed","priority":0,"issue_type":"bug","created_at":"2026-01-14T16:57:50Z","created_by":"jonathan","updated_at":"2026-01-14T17:37:59Z","closed_at":"2026-01-14T17:37:59Z","close_reason":"Fixed in commit 6907329","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"batchivo-cgg","title":"SEC: Tenant isolation bypass in payment processing","description":"**Severity:** HIGH\n**Category:** authorization_bypass / tenant_data_leakage\n**File:** backend/app/api/v1/payments.py:132-134\n\n## Description\nPayment processing endpoint fails to validate tenant ownership when looking up products. An attacker can specify arbitrary product IDs from other tenants in their payment request, causing cross-tenant data leakage.\n\n## Vulnerable Code\n```python\nproduct_result = await db.execute(select(Product).where(Product.id == item.product_id))\nproduct = product_result.scalar_one_or_none()\n```\n\n## Exploit Scenario\n1. Attacker discovers product UUID from Tenant A (via enumeration or leaked data)\n2. Attacker submits payment request for Tenant B with that product_id\n3. System creates order linking product from Tenant A\n4. Exposes sensitive product information (pricing, SKU) across tenant boundaries\n\n## Recommendation\nAdd tenant_id validation to product lookup:\n```python\nproduct_result = await db.execute(\n select(Product).where(\n Product.id == item.product_id,\n Product.tenant_id == shop_tenant.id # Enforce tenant isolation\n )\n)\n```","status":"closed","priority":0,"issue_type":"bug","created_at":"2026-01-14T16:57:49Z","created_by":"jonathan","updated_at":"2026-01-14T17:37:59Z","closed_at":"2026-01-14T17:37:59Z","close_reason":"Fixed in commit 6907329","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"batchivo-ami","title":"Fix backend Docker health check bug","description":"BLOCKER: Backend Dockerfile health check uses 'requests' package which is NOT in the dependencies. This will cause health checks to fail in production. Either add requests to deps or use curl/httpie instead.","status":"closed","priority":0,"issue_type":"bug","created_at":"2026-01-14T13:16:13Z","created_by":"jonathan","updated_at":"2026-01-14T13:20:06Z","closed_at":"2026-01-14T13:20:06Z","close_reason":"Fixed health check to use urllib.request instead of requests","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -19,7 +19,6 @@ {"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: public shop lookup now scoped to tenant/channel with real string status; 7 HTTP/Postgres tests pass. Separate COMMERCE_DATABASE_URL engine and fixed-tenant restricted role deployed only to isolated test. 18 login/RLS,7 runtime identity/channel,18 manufacturing,19 provider checks pass. Actual sandbox601 purchase and external full refund delivered first attempts, job cancelled. Production remains native superuser with no commerce schema; full native candidate/migration/role install and combined promotion still required. See shop PRODUCTION-ROLE-READINESS-20260909.md.\nImplemented signed physical returns receipt/inspection ledger with per-line quantities, finite-stock-only restock, actor/evidence audit, version conflict holds and exact replay. New browser sandbox order603 paid/refunded1898, simulated dispatch, receive/restock once; lost response recovery passed in Woo browser. Restricted-role rollback and HTTP checks pass. Native ownership regression corrected to use native connection for native CRUD;25 checks pass. Partial-line pre-dispatch cancellation/manufacturing release and production promotion remain open.","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-09T11:20:08Z","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} From 22b71b72e664bb2eda6bb0e01f99e8b406219854 Mon Sep 17 00:00:00 2001 From: Jonathan Gill Date: Wed, 9 Sep 2026 17:39:18 +0100 Subject: [PATCH 24/30] Drain legacy shop writers safely and preserve retryable Square callbacks --- .beads/issues.jsonl | 2 +- .../versions/20260909_commerce_handover.py | 43 ++++ backend/app/api/v1/customer_account.py | 5 +- backend/app/api/v1/customer_auth.py | 23 +- backend/app/api/v1/payments.py | 42 ++-- backend/app/api/v1/shop.py | 46 ++-- backend/app/services/commerce_handover.py | 201 ++++++++++++++++++ backend/tests/api/test_shop_checkout.py | 8 +- .../integration/commerce_handover_http.py | 139 ++++++++++++ .../integration/commerce_handover_probe.py | 153 +++++++++++++ backend/tests/unit/test_payments_webhook.py | 81 +++++-- issues.jsonl | 2 +- 12 files changed, 669 insertions(+), 76 deletions(-) create mode 100644 backend/alembic/versions/20260909_commerce_handover.py create mode 100644 backend/app/services/commerce_handover.py create mode 100644 backend/tests/integration/commerce_handover_http.py create mode 100644 backend/tests/integration/commerce_handover_probe.py diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 1483cbe8..bb07d115 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -19,7 +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: public shop lookup now scoped to tenant/channel with real string status; 7 HTTP/Postgres tests pass. Separate COMMERCE_DATABASE_URL engine and fixed-tenant restricted role deployed only to isolated test. 18 login/RLS,7 runtime identity/channel,18 manufacturing,19 provider checks pass. Actual sandbox601 purchase and external full refund delivered first attempts, job cancelled. Production remains native superuser with no commerce schema; full native candidate/migration/role install and combined promotion still required. See shop PRODUCTION-ROLE-READINESS-20260909.md.\nImplemented signed physical returns receipt/inspection ledger with per-line quantities, finite-stock-only restock, actor/evidence audit, version conflict holds and exact replay. New browser sandbox order603 paid/refunded1898, simulated dispatch, receive/restock once; lost response recovery passed in Woo browser. Restricted-role rollback and HTTP checks pass. Native ownership regression corrected to use native connection for native CRUD;25 checks pass. Partial-line pre-dispatch cancellation/manufacturing release and production promotion remain open.","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-09T11:20:08Z","started_at":"2026-09-07T11:35:32Z","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/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/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..1999de57 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,17 @@ 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 +119,7 @@ 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 +177,7 @@ 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 +237,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 +281,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 +329,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 +380,7 @@ 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 +413,7 @@ 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/payments.py b/backend/app/api/v1/payments.py index 0c081bc6..4d13986c 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,7 @@ 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 +290,7 @@ 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 +560,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 +607,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 02aa04f7..432af26f 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, @@ -1712,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}" @@ -2023,7 +2031,7 @@ 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, @@ -2110,7 +2118,7 @@ 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/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/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/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/issues.jsonl b/issues.jsonl index 1483cbe8..bb07d115 100644 --- a/issues.jsonl +++ b/issues.jsonl @@ -19,7 +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: public shop lookup now scoped to tenant/channel with real string status; 7 HTTP/Postgres tests pass. Separate COMMERCE_DATABASE_URL engine and fixed-tenant restricted role deployed only to isolated test. 18 login/RLS,7 runtime identity/channel,18 manufacturing,19 provider checks pass. Actual sandbox601 purchase and external full refund delivered first attempts, job cancelled. Production remains native superuser with no commerce schema; full native candidate/migration/role install and combined promotion still required. See shop PRODUCTION-ROLE-READINESS-20260909.md.\nImplemented signed physical returns receipt/inspection ledger with per-line quantities, finite-stock-only restock, actor/evidence audit, version conflict holds and exact replay. New browser sandbox order603 paid/refunded1898, simulated dispatch, receive/restock once; lost response recovery passed in Woo browser. Restricted-role rollback and HTTP checks pass. Native ownership regression corrected to use native connection for native CRUD;25 checks pass. Partial-line pre-dispatch cancellation/manufacturing release and production promotion remain open.","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-09T11:20:08Z","started_at":"2026-09-07T11:35:32Z","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} From 4d63f8fbde048f76620030a84d54835b69fb383d Mon Sep 17 00:00:00 2001 From: Jonathan Gill Date: Wed, 9 Sep 2026 17:54:31 +0100 Subject: [PATCH 25/30] Prepare audited legacy write handover without changing storefront ownership --- infrastructure/k8s/backend/deployment.yaml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/infrastructure/k8s/backend/deployment.yaml b/infrastructure/k8s/backend/deployment.yaml index c9af1dff..6543b398 100644 --- a/infrastructure/k8s/backend/deployment.yaml +++ b/infrastructure/k8s/backend/deployment.yaml @@ -73,7 +73,7 @@ spec: labels: app: backend annotations: - mystmere/native-release: "35d517885b56fa4150f9c9547de0295640ccef06" + mystmere/native-release: "22b71b72e664bb2eda6bb0e01f99e8b406219854" prometheus.io/scrape: "true" prometheus.io/port: "9090" prometheus.io/path: "/metrics" @@ -87,7 +87,7 @@ spec: - name: harbor-creds initContainers: - name: run-migrations - image: registry.techize.co.uk/batchivo/batchivo-backend@sha256:277b3111c6f5c2cb9c77eb726a56b44ef81e317d20c77ae3d9f31a9d875aa521 + image: registry.techize.co.uk/batchivo/batchivo-backend@sha256:249a041494d2a6a4477c9a1a2ba60dbeee8d5e7974e67a9a0c9bdb0b97c27614 imagePullPolicy: Always command: ["alembic", "upgrade", "heads"] securityContext: @@ -116,7 +116,7 @@ spec: value: "postgresql+psycopg://$(DB_USER):$(DB_PASSWORD)@$(DB_HOST):$(DB_PORT)/$(DB_NAME)" containers: - name: backend - image: registry.techize.co.uk/batchivo/batchivo-backend@sha256:277b3111c6f5c2cb9c77eb726a56b44ef81e317d20c77ae3d9f31a9d875aa521 + image: registry.techize.co.uk/batchivo/batchivo-backend@sha256:249a041494d2a6a4477c9a1a2ba60dbeee8d5e7974e67a9a0c9bdb0b97c27614 imagePullPolicy: Always securityContext: allowPrivilegeEscalation: false @@ -161,7 +161,9 @@ spec: - name: DATABASE_URL value: "postgresql+psycopg://$(DB_USER):$(DB_PASSWORD)@postgres.batchivo.svc:5432/batchivo" - name: BUILD_SHA - value: "35d517885b56fa4150f9c9547de0295640ccef06" + value: "22b71b72e664bb2eda6bb0e01f99e8b406219854" + - name: COMMERCE_LEGACY_HANDOVER_ENABLED + value: "true" - name: COMMERCE_MODE value: "production" - name: COMMERCE_ISOLATED @@ -169,7 +171,7 @@ spec: - name: COMMERCE_PRODUCTION_ENABLED value: "true" - name: COMMERCE_RELEASE_ID - value: "35d517885b56fa4150f9c9547de0295640ccef06" + value: "22b71b72e664bb2eda6bb0e01f99e8b406219854" - name: COMMERCE_TENANT_ID value: "ad62b515-17b3-4830-bcb4-3f4c470c26e2" - name: COMMERCE_SALES_CHANNEL_ID From b059e021e3f0cadd846a27525a7b0385e75cfc85 Mon Sep 17 00:00:00 2001 From: Jonathan Gill Date: Fri, 11 Sep 2026 11:22:38 +0100 Subject: [PATCH 26/30] ci: validate pull requests and separate image builds from promotion --- .woodpecker/build.yml | 126 +- .woodpecker/test-fast.yml | 26 +- .woodpecker/test-integration.yml | 63 +- ...commerce_bridge_v1_add_commerce_ledgers.py | 86 +- backend/app/api/v1/customer_auth.py | 25 +- backend/app/api/v1/orders.py | 9 +- backend/app/api/v1/payments.py | 10 +- backend/app/api/v1/shop.py | 11 +- backend/app/commerce_bridge.py | 1631 ++++++++++++----- backend/app/commerce_runtime.py | 50 +- .../app/services/commerce_order_ownership.py | 13 +- backend/app/services/email_service.py | 4 +- backend/app/services/print_queue_service.py | 4 +- .../app/services/square_webhook_service.py | 14 +- backend/pyproject.toml | 2 + .../test_commerce_paid_manufacturing.py | 3 +- .../scripts/test_commerce_restricted_paid.py | 3 +- backend/scripts/test_commerce_role_runtime.py | 77 +- backend/tests/commerce/disposition_live.py | 6 +- backend/tests/commerce/fulfilment_live.py | 327 +++- .../commerce/legacy_webhook_ownership_live.py | 242 ++- .../tests/commerce/native_fulfilment_live.py | 276 ++- .../tests/commerce/native_ownership_live.py | 257 ++- backend/tests/commerce/refunds_live.py | 141 +- backend/tests/commerce/reservations_live.py | 367 +++- .../tests/commerce/runtime_profiles_live.py | 148 +- .../tests/commerce/schema_rehearsal_live.py | 153 +- .../tests/commerce/test_runtime_profiles.py | 98 +- 28 files changed, 3053 insertions(+), 1119 deletions(-) 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..2236e7b1 100644 --- a/.woodpecker/test-fast.yml +++ b/.woodpecker/test-fast.yml @@ -1,20 +1,13 @@ -# 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 @@ -40,8 +33,7 @@ steps: # ============================================ backend-unit: image: *ci_python_image - depends_on: - - scan-deps-backend + depends_on: [] environment: SECRET_KEY: test-secret-key-for-ci-testing-minimum-32-chars commands: @@ -59,11 +51,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..dafd2d03 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 @@ -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,7 @@ 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 + - 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/commerce_bridge_v1_add_commerce_ledgers.py b/backend/alembic/versions/commerce_bridge_v1_add_commerce_ledgers.py index eeae8073..870af2da 100644 --- a/backend/alembic/versions/commerce_bridge_v1_add_commerce_ledgers.py +++ b/backend/alembic/versions/commerce_bridge_v1_add_commerce_ledgers.py @@ -3,29 +3,83 @@ 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 + +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)] + 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']) + 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') + raise RuntimeError( + "Retain commerce payment/order ledgers during rollback; destructive downgrade is prohibited" + ) diff --git a/backend/app/api/v1/customer_auth.py b/backend/app/api/v1/customer_auth.py index 1999de57..ea105159 100644 --- a/backend/app/api/v1/customer_auth.py +++ b/backend/app/api/v1/customer_auth.py @@ -43,7 +43,12 @@ router = APIRouter() -@router.post("/register", response_model=CustomerTokenResponse, status_code=201, dependencies=[Depends(legacy_shop_write_guard)]) +@router.post( + "/register", + response_model=CustomerTokenResponse, + status_code=201, + dependencies=[Depends(legacy_shop_write_guard)], +) async def register_customer( data: CustomerRegister, tenant: ShopTenant, @@ -119,7 +124,9 @@ async def register_customer( ) -@router.post("/login", response_model=CustomerTokenResponse, dependencies=[Depends(legacy_shop_write_guard)]) +@router.post( + "/login", response_model=CustomerTokenResponse, dependencies=[Depends(legacy_shop_write_guard)] +) async def login_customer( data: CustomerLogin, tenant: ShopTenant, @@ -177,7 +184,11 @@ async def login_customer( ) -@router.post("/refresh", response_model=CustomerTokenResponse, dependencies=[Depends(legacy_refresh_write_guard)]) +@router.post( + "/refresh", + response_model=CustomerTokenResponse, + dependencies=[Depends(legacy_refresh_write_guard)], +) async def refresh_token( data: CustomerRefreshToken, db: AsyncSession = Depends(get_db), @@ -380,7 +391,9 @@ async def verify_email( return {"message": "Email verified successfully"} -@router.post("/resend-verification", status_code=200, dependencies=[Depends(legacy_customer_write_guard)]) +@router.post( + "/resend-verification", status_code=200, dependencies=[Depends(legacy_customer_write_guard)] +) async def resend_verification( customer: CurrentCustomer, db: AsyncSession = Depends(get_db), @@ -413,7 +426,9 @@ async def resend_verification( return {"message": "Verification email sent"} -@router.post("/change-password", status_code=200, dependencies=[Depends(legacy_customer_write_guard)]) +@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 4133d11a..6e76e75d 100644 --- a/backend/app/api/v1/orders.py +++ b/backend/app/api/v1/orders.py @@ -542,7 +542,10 @@ 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")): + 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 @@ -646,7 +649,9 @@ 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) + commerce = await dispatch_native_commerce( + db, order, tenant, "shipped", request.tracking_number, request.tracking_url + ) if commerce is not None: return commerce diff --git a/backend/app/api/v1/payments.py b/backend/app/api/v1/payments.py index 4d13986c..82a703ee 100644 --- a/backend/app/api/v1/payments.py +++ b/backend/app/api/v1/payments.py @@ -219,7 +219,11 @@ async def get_payment_config( ) -@router.post("/hosted-checkout", response_model=HostedCheckoutResponse, dependencies=[Depends(legacy_shop_write_guard)]) +@router.post( + "/hosted-checkout", + response_model=HostedCheckoutResponse, + dependencies=[Depends(legacy_shop_write_guard)], +) async def create_hosted_checkout( request: HostedCheckoutRequest, shop_context: ShopContext, @@ -290,7 +294,9 @@ async def create_hosted_checkout( ) -@router.post("/process", response_model=PaymentResponse, dependencies=[Depends(legacy_shop_write_guard)]) +@router.post( + "/process", response_model=PaymentResponse, dependencies=[Depends(legacy_shop_write_guard)] +) async def process_payment( request: PaymentRequest, shop_context: ShopContext, diff --git a/backend/app/api/v1/shop.py b/backend/app/api/v1/shop.py index 432af26f..bd0acaea 100644 --- a/backend/app/api/v1/shop.py +++ b/backend/app/api/v1/shop.py @@ -2031,7 +2031,11 @@ async def get_product_reviews( ) -@router.post("/products/{product_id}/reviews", response_model=ShopReviewSubmitResponse, dependencies=[Depends(legacy_product_write_guard)]) +@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, @@ -2118,7 +2122,10 @@ async def submit_product_review( ) -@router.post("/products/{product_id}/reviews/{review_id}/helpful", dependencies=[Depends(legacy_product_write_guard)]) +@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 index 959009f0..dc7c2606 100644 --- a/backend/app/commerce_bridge.py +++ b/backend/app/commerce_bridge.py @@ -1,4 +1,5 @@ """WooCommerce bridge with explicit isolated and separately activated production profiles.""" + import asyncio from contextlib import asynccontextmanager from datetime import datetime, timezone, timedelta @@ -17,637 +18,1347 @@ 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, Text, UniqueConstraint, and_, or_, select, text +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() +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'}) +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') + +@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('*')) + 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: + +@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)}) + 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) +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)) + __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) + __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) + __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') + 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') + 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 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') + 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) + +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) + 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) + 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') + model_config = ConfigDict(extra="forbid") event_id: UUID - order_id: int=Field(gt=0) - version: int=Field(gt=0) + 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) + 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') + + @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 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') + 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 + 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() + 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 + 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)) + __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') + 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') + 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') + 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') + model_config = ConfigDict(extra="forbid") reservation_id: UUID - order_id: int=Field(gt=0) + order_id: int = Field(gt=0) action: str - decline_code: 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') + 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}) + 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}) -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'])) + 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={} +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 + 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 + 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 + 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: + 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)) + 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') + 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()) + +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={} + 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)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': + 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') + 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} + 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') + model_config = ConfigDict(extra="forbid") product_id: UUID - variant_id: UUID|None=None + 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=[] + 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={} + 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 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 + 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') + 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') + 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))) + 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 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': + 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 + + 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 + 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 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} + 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) + 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') + +@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 + 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') + 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']}) + 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)}) + return signed_response({"ok": False, "code": error.status_code, "error": str(error.detail)}) + -@app.post('/fulfilment/pending') +@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]}) + 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') + +@app.post("/fulfilment/ack") async def acknowledge_fulfilment(request: Request): - body=await signed_body(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') + 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 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=[] + 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') + 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') + 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 + 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(): + +@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') + 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))) + 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 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'] AutoAssignResult: try: await verify_commerce_job_work(self.db, job) except HTTPException: - unassigned_reasons.append(f"Job {job.id}: Commerce payment/identity review prevents assignment") + 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 diff --git a/backend/app/services/square_webhook_service.py b/backend/app/services/square_webhook_service.py index 0efe9132..50005d70 100644 --- a/backend/app/services/square_webhook_service.py +++ b/backend/app/services/square_webhook_service.py @@ -244,9 +244,15 @@ async def _process_event( # 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} + 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() + 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: @@ -256,7 +262,9 @@ async def _process_event( 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") + result["actions"].append( + "Recorded provider event; WooCommerce commerce workflow owns business updates" + ) return result # Route legacy-owned events to their existing handlers. diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 124d2f2e..c642949e 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -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/test_commerce_paid_manufacturing.py b/backend/scripts/test_commerce_paid_manufacturing.py index 9a691201..89a9769b 100644 --- a/backend/scripts/test_commerce_paid_manufacturing.py +++ b/backend/scripts/test_commerce_paid_manufacturing.py @@ -1,6 +1,7 @@ """Real paid sandbox582/native start, entirely inside a rolled-back transaction.""" -import asyncio, json +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 diff --git a/backend/scripts/test_commerce_restricted_paid.py b/backend/scripts/test_commerce_restricted_paid.py index ec9866fe..478fe6f3 100644 --- a/backend/scripts/test_commerce_restricted_paid.py +++ b/backend/scripts/test_commerce_restricted_paid.py @@ -1,6 +1,7 @@ """Real paid sandbox601/native start, entirely inside a rolled-back transaction.""" -import asyncio, json +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 diff --git a/backend/scripts/test_commerce_role_runtime.py b/backend/scripts/test_commerce_role_runtime.py index 9e108a33..ed695043 100644 --- a/backend/scripts/test_commerce_role_runtime.py +++ b/backend/scripts/test_commerce_role_runtime.py @@ -1,5 +1,8 @@ """Read-only live credential separation and reporting-channel acceptance.""" -import asyncio,json,os + +import asyncio +import json +import os from uuid import UUID from fastapi import HTTPException from sqlalchemy import text @@ -7,26 +10,60 @@ 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) + 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) + 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.'})) + 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/commerce/disposition_live.py b/backend/tests/commerce/disposition_live.py index 33bba84c..328b59c0 100644 --- a/backend/tests/commerce/disposition_live.py +++ b/backend/tests/commerce/disposition_live.py @@ -57,9 +57,9 @@ async def fingerprint(db): product = await db.get( bridge.Product, __import__("uuid").UUID( - next(l for l in snapshot["lines"] if l["line_id"] == finite["line_id"])[ - "product_id" - ] + next( + line for line in snapshot["lines"] if line["line_id"] == finite["line_id"] + )["product_id"] ), ) stock = product.units_in_stock diff --git a/backend/tests/commerce/fulfilment_live.py b/backend/tests/commerce/fulfilment_live.py index 0e5bec9e..fc53b123 100644 --- a/backend/tests/commerce/fulfilment_live.py +++ b/backend/tests/commerce/fulfilment_live.py @@ -1,73 +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,hashlib,hmac,json,logging,os,sys,time -from uuid import UUID,uuid5,NAMESPACE_URL + +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,Order,PrintJob,JobStatus,Tenant,TENANT,SECRET,async_session_maker +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')) +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.'})) + 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/legacy_webhook_ownership_live.py b/backend/tests/commerce/legacy_webhook_ownership_live.py index 29e21606..0680aebb 100644 --- a/backend/tests/commerce/legacy_webhook_ownership_live.py +++ b/backend/tests/commerce/legacy_webhook_ownership_live.py @@ -3,60 +3,206 @@ 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,base64,hashlib,hmac,json,logging + +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 import select, text from sqlalchemy.ext.asyncio import AsyncSession -from app.commerce_bridge import RUNTIME,engine,TENANT,CommerceOrder +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.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) + +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)) + 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 index 6a170a17..7fd791be 100644 --- a/backend/tests/commerce/native_fulfilment_live.py +++ b/backend/tests/commerce/native_fulfilment_live.py @@ -1,61 +1,235 @@ """Native manufacturing and reconciliation for the real browser sandbox order557.""" -import asyncio, hashlib, hmac, json, logging, sys, time + +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.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 +ORDER = 557 + + async def main(): - checks=[];stage=sys.argv[1];error=None - 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'})) + 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 index 0fa31db2..ba840e9e 100644 --- a/backend/tests/commerce/native_ownership_live.py +++ b/backend/tests/commerce/native_ownership_live.py @@ -3,6 +3,7 @@ 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 @@ -17,7 +18,15 @@ 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.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 @@ -26,66 +35,234 @@ 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']] + 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)}) + 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") + 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) + 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 = "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'))) + 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)) + 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) + 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)) + 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)})) + 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 index 38f50a9d..cb61fde7 100644 --- a/backend/tests/commerce/refunds_live.py +++ b/backend/tests/commerce/refunds_live.py @@ -1,38 +1,113 @@ """Readback and rejected-event tests on real sandbox refunds for retained fixture431.""" -import asyncio,hashlib,hmac,json,os,time + +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,CommerceReceipt,TENANT,SECRET,async_session_maker,JobStatus +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)})) + 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 index bc09a7c1..20483cd3 100644 --- a/backend/tests/commerce/reservations_live.py +++ b/backend/tests/commerce/reservations_live.py @@ -1,92 +1,301 @@ """Explicit live isolated-DB/API acceptance; never collected as a mocked unit test.""" -import asyncio, hashlib, hmac, json, os, time + +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, PrintJob, Order, async_session_maker, TENANT +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) + 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}) + 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) + 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, 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'))) + 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)})) + 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()) +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/tests/commerce/runtime_profiles_live.py b/backend/tests/commerce/runtime_profiles_live.py index 3a21c292..6a6e4d28 100644 --- a/backend/tests/commerce/runtime_profiles_live.py +++ b/backend/tests/commerce/runtime_profiles_live.py @@ -1,41 +1,115 @@ """Read-only real sandbox verification after the runtime-profile rollout.""" -import asyncio,json,os + +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 +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) + 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 index 3b4eee04..2f62dd2a 100644 --- a/backend/tests/commerce/schema_rehearsal_live.py +++ b/backend/tests/commerce/schema_rehearsal_live.py @@ -1,45 +1,122 @@ """Rehearse additive DDL on real isolated PostgreSQL, rolling back every change.""" -import asyncio,importlib.util,json,sys + +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 +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) + 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 index 0479b47a..b3f825e0 100644 --- a/backend/tests/commerce/test_runtime_profiles.py +++ b/backend/tests/commerce/test_runtime_profiles.py @@ -1,40 +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'}) + 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') + 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'}) + 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) + 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'} + 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) + 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) + 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()) + 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') + 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() + 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() From f31d358644b5a2333ebde47ea2ea3d839b51131b Mon Sep 17 00:00:00 2001 From: Jonathan Gill Date: Fri, 11 Sep 2026 11:24:21 +0100 Subject: [PATCH 27/30] ci: provide PostgreSQL for unit fixtures and pin available Node image --- .woodpecker/test-fast.yml | 14 ++++++++++++-- .woodpecker/test-integration.yml | 1 - 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/.woodpecker/test-fast.yml b/.woodpecker/test-fast.yml index 2236e7b1..fc7d2b29 100644 --- a/.woodpecker/test-fast.yml +++ b/.woodpecker/test-fast.yml @@ -11,9 +11,17 @@ when: 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:15 + environment: + POSTGRES_USER: test + POSTGRES_PASSWORD: test + POSTGRES_DB: test_batchivo + steps: scan-deps-backend: image: *trivy_image @@ -29,12 +37,13 @@ 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: [] environment: + DATABASE_URL: postgresql+psycopg://test:test@postgres:5432/test_batchivo SECRET_KEY: test-secret-key-for-ci-testing-minimum-32-chars commands: - cd backend @@ -44,6 +53,7 @@ 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' - poetry run pytest tests/unit/ --tb=short -q # ============================================ diff --git a/.woodpecker/test-integration.yml b/.woodpecker/test-integration.yml index dafd2d03..4c720a4d 100644 --- a/.woodpecker/test-integration.yml +++ b/.woodpecker/test-integration.yml @@ -45,4 +45,3 @@ steps: - 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 - From 460569d121b41633d8921da145680eb3bf2dc466 Mon Sep 17 00:00:00 2001 From: Jonathan Gill Date: Fri, 11 Sep 2026 11:35:02 +0100 Subject: [PATCH 28/30] fix: repair release validation, refresh dependencies and frontend type safety --- .woodpecker/test-fast.yml | 2 +- .woodpecker/test-integration.yml | 2 +- backend/poetry.lock | 360 ++- backend/pyproject.toml | 2 +- .../tests/unit/test_square_webhook_service.py | 16 +- docs/operations/SINGLE-OWNER-RELEASES.md | 58 + frontend/.gitignore | 3 + frontend/package-lock.json | 2098 ++++++++++++----- frontend/package.json | 14 +- frontend/pnpm-lock.yaml | 1096 ++++----- frontend/src/App.test.tsx | 11 +- .../components/categories/CategoryList.tsx | 2 +- .../charts/EstimatedActualLineChart.tsx | 1 + .../components/dashboard/ActiveProduction.tsx | 4 +- .../src/components/dashboard/ActivityFeed.tsx | 7 +- .../components/dashboard/LowStockAlerts.tsx | 3 +- .../src/components/designers/DesignerList.tsx | 2 +- .../filaments/AddFilamentDialog.tsx | 18 +- .../components/guards/PlatformAdminGuard.tsx | 2 +- .../inventory/AddConsumableDialog.tsx | 2 +- .../inventory/AddPurchaseDialog.tsx | 2 +- .../components/inventory/AddSpoolDialog.tsx | 2 +- .../components/inventory/EditSpoolDialog.tsx | 2 +- .../components/inventory/SpoolList.test.tsx | 4 +- frontend/src/components/layout/AppLayout.tsx | 2 +- frontend/src/components/models/BOMEditor.tsx | 2 +- .../components/models/ComponentsEditor.tsx | 8 +- frontend/src/components/models/ModelForm.tsx | 2 +- .../onboarding/Step1BusinessInfo.tsx | 2 +- .../components/onboarding/Step3ShopSetup.tsx | 2 +- .../onboarding/Step4FirstProduct.tsx | 2 +- .../components/platform/PlatformLayout.tsx | 2 +- .../production-runs/CreateRunWizard.test.tsx | 26 +- .../production-runs/CreateRunWizard.tsx | 2 +- .../production-runs/EditRunDialog.test.tsx | 10 +- .../production-runs/ProductionRunForm.tsx | 20 +- .../products/ProductComponentsEditor.tsx | 4 +- .../src/components/products/ProductForm.tsx | 2 +- .../products/ProductModelsEditor.tsx | 4 +- .../products/ProductPricingEditor.tsx | 2 +- .../sales-channels/SalesChannelDetail.tsx | 2 +- .../sales-channels/SalesChannelForm.tsx | 2 +- .../src/components/settings/EtsySettings.tsx | 2 +- .../components/settings/GeneralSettings.tsx | 2 +- .../components/settings/SquareSettings.tsx | 2 +- .../src/components/settings/TeamSettings.tsx | 2 +- frontend/src/hooks/useModules.test.ts | 4 +- frontend/src/hooks/useOfflineSpools.test.ts | 13 + frontend/src/hooks/useSKU.test.ts | 14 +- frontend/src/hooks/useTracedMutation.ts | 13 +- frontend/src/lib/api.test.ts | 16 +- frontend/src/lib/api.ts | 1 + frontend/src/lib/api/categories.ts | 6 +- frontend/src/lib/api/designers.ts | 6 +- frontend/src/lib/api/production-runs.ts | 4 +- frontend/src/lib/api/products.ts | 10 +- frontend/src/lib/api/sku.test.ts | 11 +- frontend/src/lib/sentry.ts | 11 +- frontend/src/lib/telemetry.ts | 13 +- frontend/src/pages/ProductionRuns.tsx | 18 +- frontend/src/pages/Settings.test.tsx | 4 +- frontend/src/pages/help/HelpGuide.tsx | 6 +- frontend/src/pages/help/HelpIndex.tsx | 4 +- .../src/pages/platform/PlatformDashboard.tsx | 2 +- .../src/pages/platform/TenantDetailPage.tsx | 10 +- .../src/pages/platform/TenantModulesPage.tsx | 2 +- frontend/src/pages/platform/TenantsPage.tsx | 2 +- frontend/src/test/setup.ts | 8 + frontend/src/test/test-utils.tsx | 4 +- frontend/src/types/modules.ts | 1 + frontend/src/utils/chartDataUtils.test.ts | 13 +- frontend/src/utils/chartDataUtils.ts | 6 +- frontend/tsconfig.app.json | 1 + 73 files changed, 2479 insertions(+), 1541 deletions(-) create mode 100644 docs/operations/SINGLE-OWNER-RELEASES.md diff --git a/.woodpecker/test-fast.yml b/.woodpecker/test-fast.yml index fc7d2b29..993e3a42 100644 --- a/.woodpecker/test-fast.yml +++ b/.woodpecker/test-fast.yml @@ -16,7 +16,7 @@ variables: services: postgres: - image: registry.techize.co.uk/dockerhub-cache/postgres:15 + image: registry.techize.co.uk/dockerhub-cache/postgres@sha256:20edbde7749f822887a1a022ad526fde0a47d6b2be9a8364433605cf65099416 environment: POSTGRES_USER: test POSTGRES_PASSWORD: test diff --git a/.woodpecker/test-integration.yml b/.woodpecker/test-integration.yml index 4c720a4d..6fa5a329 100644 --- a/.woodpecker/test-integration.yml +++ b/.woodpecker/test-integration.yml @@ -15,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: 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 c642949e..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", 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..71e450b0 --- /dev/null +++ b/docs/operations/SINGLE-OWNER-RELEASES.md @@ -0,0 +1,58 @@ +# Batchivo single-owner releases + +## Status (11 September 2026) + +PR #202 introduces real Woodpecker PR validation and includes the native commerce +source already deployed in production. Branch policy migration and removal of the +Argo commit pin remain pending successful validation. Do not bypass the old rules +while this transition is incomplete. + +## 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. 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..a204513b 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -22,11 +22,12 @@ 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') }) @@ -41,7 +42,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 +51,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() {