From b7122be3a064581b0cac6ebb36cbd619749ab1fe Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Sat, 15 Aug 2026 03:21:08 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20SCA=20review=20round=20+=20gitea=20pipel?= =?UTF-8?q?ine=20green=20=E2=80=94=20GDPR=20audit=20scrub,=20backend=20tes?= =?UTF-8?q?t=20gaps,=20frontend=20SCA/Square-API,=20docs=20parity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 7 review agents (pipeline run, self-review, codebase-context, frontend-placement, backend testing-gaps, Square-API, docs-parity) audited the SCA-primary work. ALL findings fixed, including every pre-existing red CI job: GDPR (HIGH): - anonymize_user() now scrubs admin_audit_log.target_user_id (mirrors delete_guest_user) so 2fa_fallback_charge rows (customer id + card_last4 PII) no longer survive registered-user account deletion; gdpr test added BACKEND TEST GAPS (all 10): - delivery-unavailable 503 branch: prod-tag predicate test + dev-variant marker - twoFactorFallbackEnabled alias/case/default matrix tests + exported wrapper - insertTwoFAFallbackAudit details-JSON shape + audit-row assertions for all 6 gate sites (booking/tip/gift-card/payment-method/terminal/till, both actors) - CreateTerminalPayment.VerificationToken: passthrough, too-long 400, 2FA-skip, token-less fallback + SCA-required (new terminal_sca_test.go) - isVerificationRequiredError at all 5 charge sites (402 + code:verification_required) - customer_initiated handler-level assertions (MIT false admin / CIT true customer) - Mock: ApprovePendingVerification, ChallengeResult auto/deny, _deny token suffix, parseVerifyToken unit tests FRONTEND SCA + Square-API (CRITICAL): - tokenizeSavedCardWithVerification reads result.token (the verified token) not result.verificationResult (deprecated verifyBuyer shape — saved-card SCA could never succeed in production before); parseTokenizeVerificationResult pure fn extracted + pinned in square.test.ts; 'verified' with no token proceeds tokenless - HIGH: saved-card idempotency key regenerated after a definitive 402 (fresh token under the same key = IDEMPOTENCY_KEY_REUSED dead-loop); kept on 503/cancelled - challenge-cancelled copy no longer promises a 2FA fallback the UI doesn't show; 'waiting for approval in your banking app' state on CIT surfaces - sca-unavailable demotion resets per attempt; card selection disabled mid-challenge; genuine saved-card declines no longer relabeled 'requires verification'; modal-close guard during processing; retry affordance standardized PIPELINE (every red job now green): - prod-tag build break fixed (shared square stub + test_helpers_test.go, prod-safe) - govulncheck: x/image 0.45.0 bumped (x/text resolved); go mod tidy clean - race: TestDeleteAccount_InvalidatesSquareCustomerCache made deterministic - DAV_ADMIN_PASSWORD placeholder in .env.example (compose config passes) - frontend: prettier 28 files, eslint, a11y 38 errors, knip (currentZIndex), deps in-range, audit vulns (nanoid/postcss) — all fixed; 67 vitest cases DOCS PARITY (6 DRIFTs + 5 GAPs): payments doc Ch4/Ch14/Appendix A, Technical Manual 2FA + counter-reset + payment sections, README test counts + SNAPSHOT_ENC_KEY, Feature Catalog, .env.example REQUIRE_2FA — SCA-primary/2FA-backup posture verified against code everywhere Verified: 26/26 dev + 24/24 prod packages, both vet tags, golangci-lint/staticcheck/ gosec 0 on both tags, gitleaks clean, 2,464 backend + 67 frontend tests. --- .env.example | 23 +- README.md | 8 +- backend/.golangci.yml | 2 +- backend/go.mod | 8 +- backend/go.sum | 16 +- backend/handlers/auth/local.go | 4 +- backend/handlers/bookings/bookings.go | 5 - backend/handlers/payments/errors_test.go | 140 +- backend/handlers/payments/giftcards.go | 9 +- backend/handlers/payments/handlers.go | 6 +- backend/handlers/payments/refunds.go | 7 +- backend/handlers/payments/sweep.go | 4 +- .../handlers/payments/terminal_sca_test.go | 393 ++++++ backend/handlers/payments/till_test.go | 1 - .../payments/twofa_delivery_prod_test.go | 59 + .../twofa_delivery_variant_dev_test.go | 11 + .../twofa_delivery_variant_prod_test.go | 9 + backend/handlers/payments/twofa_test.go | 215 +++- backend/handlers/scheduling/default-hours.go | 8 +- .../handlers/scheduling/exceptional-hours.go | 9 +- .../handlers/scheduling/time_blockers_test.go | 3 +- backend/handlers/user/gdpr_test.go | 69 +- backend/handlers/user/test_helpers_test.go | 37 + backend/handlers/user/twofa.go | 25 +- backend/handlers/user/twofa_test.go | 17 + backend/handlers/user/user_coverage_test.go | 26 +- backend/internal/jobs/cleanup_test.go | 9 +- backend/internal/square/square_dev_test.go | 219 +++- backend/testutils/square_stub.go | 92 ++ frontend/eslint.a11y.config.js | 12 + frontend/package-lock.json | 1123 ++++++++++------- .../account/EditRequestModal.svelte | 12 +- .../admin/BookingCreateModal.svelte | 6 +- .../lib/components/admin/BookingModal.svelte | 44 +- .../components/admin/BusinessSettings.svelte | 4 +- .../lib/components/admin/HolidayHours.svelte | 7 +- .../components/admin/RescheduleModal.svelte | 10 +- .../lib/components/admin/TillPurchases.svelte | 193 +-- .../lib/components/admin/TimeBlockers.svelte | 5 +- .../src/lib/components/admin/UserModal.svelte | 4 +- .../components/admin/WalkInCreateModal.svelte | 8 +- .../components/admin/WeeklySchedule.svelte | 7 +- .../lib/components/booking/BookingFlow.svelte | 104 +- .../components/layout/BusinessHours.svelte | 3 +- .../components/payments/CardSelection.svelte | 6 +- .../components/payments/MockCardForm.svelte | 6 +- .../payments/OverflowTipConfirm.svelte | 17 +- .../components/payments/PaymentModal.svelte | 63 +- .../payments/SquareCardInput.svelte | 102 +- .../lib/components/payments/TipPayment.svelte | 124 +- .../payments/TwoFactorCodeInput.svelte | 4 +- .../payments/UserPaymentModal.svelte | 83 +- .../today/CurrentAppointment.svelte | 9 +- .../components/today/PendingApprovals.svelte | 3 +- .../lib/components/today/TodayCalendar.svelte | 5 +- .../lib/components/today/TodayStats.svelte | 7 +- .../ui/dialog/dialog-content.svelte | 2 +- .../src/lib/components/ui/dialog/zindex.ts | 5 - .../src/lib/components/ui/input/input.svelte | 2 +- .../lib/components/ui/policyPopover.svelte | 2 +- frontend/src/lib/square/square.test.ts | 111 +- frontend/src/lib/square/square.ts | 74 +- frontend/src/lib/types/booking.ts | 3 +- frontend/src/lib/utils/timeSlots.ts | 7 +- frontend/src/routes/+layout.svelte | 2 +- frontend/src/routes/account/+page.svelte | 108 +- .../routes/admin/notifications/+page.svelte | 3 +- .../src/routes/admin/schedule/+page.svelte | 15 +- .../routes/cancellation-policy/+page.svelte | 77 +- frontend/src/routes/login/+page.svelte | 10 +- frontend/src/routes/pay-tip/[id]/+page.svelte | 2 +- .../src/routes/privacy-policy/+page.svelte | 103 +- frontend/src/routes/tip/+page.svelte | 2 +- frontend/src/routes/today/+page.svelte | 6 +- init-scripts/init-script.sql | 9 + obsidian/Crussell/Overview.md | 2 +- obsidian/Crussell/Technical Manual.md | 10 +- .../Crussell/payments and money processes.md | 21 +- 78 files changed, 2861 insertions(+), 1120 deletions(-) create mode 100644 backend/handlers/payments/terminal_sca_test.go create mode 100644 backend/handlers/payments/twofa_delivery_prod_test.go create mode 100644 backend/handlers/payments/twofa_delivery_variant_dev_test.go create mode 100644 backend/handlers/payments/twofa_delivery_variant_prod_test.go create mode 100644 backend/handlers/user/test_helpers_test.go create mode 100644 backend/testutils/square_stub.go diff --git a/.env.example b/.env.example index f97d51f..18e455a 100644 --- a/.env.example +++ b/.env.example @@ -55,18 +55,23 @@ SQUARE_ENVIRONMENT=mock # deliberately route a dev build to the real production API. Never set in a # deployed production build. SQUARE_ALLOW_REAL_API= -# 2FA — merchant-level authorization gate on saved-card online payments (NOT -# PSD2 SCA; Square buyer verification is the SCA mechanism and is wired for -# new-card charges). Kept as an additional fraud control until Square buyer -# verification is wired for saved-card charges. Enforcement is FAIL-CLOSED: +# 2FA — backup merchant-level authorization gate on saved-card online payments. +# NOT PSD2 SCA: Square buyer verification (3-D Secure / SCA) is the PRIMARY +# authorisation for saved-card charges and is wired for both new-card and +# saved-card charges. The 2FA gate fires only when SCA is unavailable (e.g. a +# customer's bank does not support the in-app approval flow) and every such +# fallback charge is audited (admin_audit_log 2fa_fallback_charge). Enforcement +# is FAIL-CLOSED: # ON unless REQUIRE_2FA explicitly disables it (false/0/off/no, case-insensitive) # OR SQUARE_ENVIRONMENT explicitly equals one of mock/dev/development/test. # Empty or unknown SQUARE_ENVIRONMENT values are treated as production-enforced # (a mistyped env var can never silently disarm the gate; the backend logs a # startup warning in that case). Set REQUIRE_2FA=false only in controlled # environments. -# Code delivery: there is NO email/SMS transport yet. The verification code is -# delivered via the server log (a [2FA]-prefixed line). In enforced/production +# Code delivery: the intended channel is email/SMS (the method chosen at +# setup) — NOT wired yet. Until it lands, the verification code is delivered +# via the server log (a [2FA]-prefixed line) when the operator opts into +# TWO_FACTOR_ALLOW_LOG_DELIVERY=true (see below); in enforced/production # environments an operator must relay the logged code to the user out-of-band; # the API never returns the code while enforcement is ON. REQUIRE_2FA=true @@ -136,9 +141,11 @@ DAV_BASE_URL=http://localhost:8080 # DAV_ADMIN_PASSWORD — REQUIRED, FAIL-CLOSED. sabredav/server.php refuses to # start when unset or set to a known weak/default value ('admin' etc.) — this # server exposes customer PII vCards, so no public default credential is ever -# acceptable. Generate a strong random value: +# acceptable. The placeholder below satisfies compose validation only; it MUST +# be replaced before any deployment (compose.yml fails fast via +# ${DAV_ADMIN_PASSWORD:?} if it is ever unset). Generate a strong random value: # openssl rand -hex 32 -DAV_ADMIN_PASSWORD= +DAV_ADMIN_PASSWORD=changeme-admin-password # Logging # Set to "true" to disable ANSI color escape sequences in log output diff --git a/README.md b/README.md index a12933b..0f05181 100644 --- a/README.md +++ b/README.md @@ -74,12 +74,18 @@ docker compose up --build -d `SQUARE_WEBHOOK_NOTIFICATION_URL` and `SQUARE_WEBHOOK_SIGNATURE_KEY` in `.env` must exactly match the webhook subscription configured in the Square Dashboard. An unset URL defaults to `http://localhost:8080/webhooks/square`, which is fail-closed (503 without the signing key, 403 on missing/bad signature). If you don't need webhooks, leave both empty — the handler still rejects cleanly. +### Request snapshot encryption (`SNAPSHOT_ENC_KEY`) + +The backend replays a byte-identical request to Square when it rescues a stale pending payment, so every charge's exact request body is stored on the pending row. Those snapshots contain the buyer's email and saved-card (`ccof:`) tokens — personal data — so in non-mock (sandbox/production) deployments they are encrypted at rest with AES-256-GCM under `SNAPSHOT_ENC_KEY` (a base64-encoded 32-byte key; generate with `openssl rand -base64 32`). If the key is unset or invalid, the backend logs a one-time CRITICAL warning at startup and falls back to plaintext storage — a warning, not a refusal, because money-safety first: losing a replayable snapshot would strand pending rows forever. The dev mock stores snapshots in plaintext (no real data). + ### Two-factor authentication (2FA) `REQUIRE_2FA` gates the **2FA backup** for saved-card online payments. Square **PSD2 SCA** (buyer verification via `tokenizeWithVerification`) is now the **primary authorisation** for saved-card charges — a customer-initiated stored-credential charge is a PSR 2017-regulated transaction, and Square's verification token both satisfies SCA and shifts chargeback liability to the card scheme. The homegrown 2FA gate fires **only when SCA is unavailable** (e.g. a customer's bank does not support the in-app approval flow) and is **fail-closed**: enforcement is ON by default for any `SQUARE_ENVIRONMENT` except an explicit `mock`/`dev`/`development`/`test` value — empty or unknown values are treated as production-enforced. Disable it with `REQUIRE_2FA=false` or an explicit mock env. The intended 2FA delivery channel is email/SMS (the method chosen at setup), **not yet wired**; until it lands, the 6-digit code is delivered via the server log (`[2FA]` prefix; the operator relays it) — but only in production builds when `TWO_FACTOR_ALLOW_LOG_DELIVERY=true` is set (an explicit, insecure opt-in); without it, production code issuance fails closed (503) and no user can complete 2FA setup, which then 403s every enforced saved-card payment on the fallback path. Every admin mint-or-reuse of a fallback code is recorded in the admin audit log, and every admin saved-card charge writes its own audit row. `TWO_FACTOR_PEPPER` (backend `.env`) is a server-side pepper for HMAC-hashing 2FA codes: with it set, codes are hashed as HMAC-SHA256 keyed by the pepper; if unset, dev/test builds fall back to the legacy unsalted SHA-256 digest with a one-time warning. It is **required in production builds** — code issuance fails closed when it is unset, because an unsalted digest in the 1M code space would be offline-brute-forceable from a log/DB leak (mirroring the fail-fast `JWT_SECRET_KEY` check). Generate a strong random value with `openssl rand -base64 32`. +`TWO_FACTOR_FALLBACK` (backend `.env`, defaults to `true`) is the SCA-primary/2FA-backup posture switch. While it is on, a saved-card charge that carries no Square verification token (SCA unavailable — e.g. the customer's bank cannot run the in-app approval flow) may fall back to the homegrown 2FA code gate. Set `TWO_FACTOR_FALLBACK=false` for a security-first **SCA-only posture**: a token-less saved-card charge is then rejected 402 `verification_required` — the frontend surfaces the SCA challenge, and if the bank cannot complete it the charge fails — and cannot proceed via 2FA at all. Every charge actually authorised by the fallback writes an audited `2fa_fallback_charge` admin-audit-log row (SCA not performed, card last four, charge reference). + ### Local dev (tmux) ```bash @@ -97,7 +103,7 @@ Default logins (password: `password`): ```bash cd backend && go build -o bin/backend ./main.go cd frontend && npm ci && npm run build -cd backend && go test -tags "test,dev" -count=1 -parallel 8 ./... # 2,333 tests compiled under the test,dev tags, as of 14 Aug 2026 (~2min) +cd backend && go test -tags "test,dev" -count=1 -parallel 8 ./... # 2,440 backend test functions under the test,dev tags + 61 frontend vitest cases, as of 15 Aug 2026 (~2min) cd backend && go test -tags "test,dev" -count=1 -race -timeout 480s ./... # race detector (all packages, ~4min) # NOTE: -count=N>1 is unreliable for handlers/payments and handlers/webhooks — # those suites share package-global state (Square mock ledger, in-memory webhook diff --git a/backend/.golangci.yml b/backend/.golangci.yml index 351c37b..9737009 100644 --- a/backend/.golangci.yml +++ b/backend/.golangci.yml @@ -17,7 +17,7 @@ linters: linters-settings: errcheck: exclude-functions: - - encoding/json.Encoder.Encode + - (*encoding/json.Encoder).Encode - io.WriteString - (io.Closer).Close diff --git a/backend/go.mod b/backend/go.mod index 1d48a62..2cb42b4 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -14,7 +14,7 @@ require ( github.com/kovidgoyal/imaging v1.8.21 github.com/robfig/cron/v3 v3.0.1 github.com/stretchr/testify v1.11.1 - golang.org/x/text v0.38.0 + golang.org/x/text v0.41.0 ) require ( @@ -54,8 +54,8 @@ require ( github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect github.com/valyala/fastjson v1.6.10 // indirect - golang.org/x/image v0.43.0 // indirect - golang.org/x/sync v0.21.0 // indirect + golang.org/x/image v0.45.0 // indirect + golang.org/x/sync v0.22.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) @@ -70,5 +70,5 @@ require ( github.com/nyaruka/phonenumbers v1.8.0 github.com/segmentio/asm v1.2.1 // indirect golang.org/x/crypto v0.53.0 - golang.org/x/sys v0.46.0 // indirect + golang.org/x/sys v0.47.0 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 5d27096..14ee226 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -126,14 +126,14 @@ github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADT github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= -golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY= -golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= -golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0= +golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/backend/handlers/auth/local.go b/backend/handlers/auth/local.go index 83973d7..c215f61 100644 --- a/backend/handlers/auth/local.go +++ b/backend/handlers/auth/local.go @@ -485,7 +485,7 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) { return } - if err := json.NewEncoder(w).Encode(auth.AuthResponse{ + if err := json.NewEncoder(w).Encode(auth.AuthResponse{ // #nosec G117 — the refresh token is the intended part of the login response contract Token: tokenString, JTI: jti, RefreshToken: refreshToken, @@ -561,7 +561,7 @@ func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) { return } - if err := json.NewEncoder(w).Encode(auth.AuthResponse{ + if err := json.NewEncoder(w).Encode(auth.AuthResponse{ // #nosec G117 — the refresh token is the intended part of the refresh-token response contract Token: newToken, JTI: jti, RefreshToken: newRefreshToken, diff --git a/backend/handlers/bookings/bookings.go b/backend/handlers/bookings/bookings.go index 0c6421a..b4eb00b 100644 --- a/backend/handlers/bookings/bookings.go +++ b/backend/handlers/bookings/bookings.go @@ -339,11 +339,6 @@ type AdminBookingDetail struct { DurationMinutes int `json:"duration_minutes"` } -// roundTo2 rounds a float64 to 2 decimal places -func roundTo2(f float64) float64 { - return float64(int(f*100+0.5)) / 100 -} - // Helper function to parse query parameters func parseGetAllBookingsRequest(r *http.Request) GetAllBookingsRequest { req := GetAllBookingsRequest{ diff --git a/backend/handlers/payments/errors_test.go b/backend/handlers/payments/errors_test.go index 7adcd8c..db1eafa 100644 --- a/backend/handlers/payments/errors_test.go +++ b/backend/handlers/payments/errors_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "net/http" + "net/http/httptest" "reflect" "strings" "testing" @@ -103,9 +104,9 @@ func TestChargeFailureStatus(t *testing.T) { // classifications. func TestChargeFailureStatus_RetryableCarveOuts(t *testing.T) { tests := []struct { - name string - err error - want int + name string + err error + want int }{ {"structured 429 rate limited (retryable) → 503", structuredSquareAPIError(t, http.StatusTooManyRequests), http.StatusServiceUnavailable}, {"structured 408 request timeout (ambiguous) → 503", structuredSquareAPIError(t, http.StatusRequestTimeout), http.StatusServiceUnavailable}, @@ -565,6 +566,139 @@ func TestCreateTillSale_SCARequired_ReturnsStructured402(t *testing.T) { require.Equal(t, "verification_required", body["code"], "an SCA-required charge must surface the structured verification_required code") require.Contains(t, body["error"], "card issuer requires verification") } + +// ============================================================================= +// Verification-required surfacing at the OTHER 4 charge sites (booking, tip, +// terminal, gift-card). The till site is covered by +// TestCreateTillSale_SCARequired_ReturnsStructured402 above. +// ============================================================================= + +func assertStructuredVerificationRequired(t *testing.T, w *httptest.ResponseRecorder) { + t.Helper() + require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String()) + var body map[string]string + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + require.Equal(t, "verification_required", body["code"], "an SCA-required charge must surface the structured verification_required code") + require.Contains(t, body["error"], "card issuer requires verification") +} + +func assertPlain402(t *testing.T, w *httptest.ResponseRecorder) { + t.Helper() + require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String()) + require.NotContains(t, w.Body.String(), "verification_required", "a real decline must stay a plain 402, never the SCA challenge body") +} + +// TestVerificationRequiredSurfacing_AllChargeSites drives a Square +// CARD_DECLINED_VERIFICATION_REQUIRED failure through the booking, tip, +// terminal-saved-card, and gift-card charge sites: each must surface 402 with +// the structured {code:verification_required} body (so the frontend triggers +// the 3DS challenge), while a real CARD_DECLINED decline at the same site +// stays a plain 402. +func TestVerificationRequiredSurfacing_AllChargeSites(t *testing.T) { + scaErr := func(t *testing.T) error { + return structuredSquareErrorFull(t, http.StatusPaymentRequired, "CARD_DECLINED_VERIFICATION_REQUIRED", "PAYMENT_METHOD_ERROR") + } + declineErr := func(t *testing.T) error { + return structuredSquareErrorFull(t, http.StatusPaymentRequired, "CARD_DECLINED", "PAYMENT_METHOD_ERROR") + } + installErr := func(t *testing.T, err error) { + t.Helper() + origClient := SquareClient + SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient(), createErr: err} + t.Cleanup(func() { SquareClient = origClient }) + } + + t.Run("booking_site_sca_required", func(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed") + installErr(t, scaErr(t)) + cardToken := "cnon:sca-booking" + req := CreateBookingPaymentRequest{Amount: 2500, PaymentType: "deposit", NewCardToken: &cardToken, IdempotencyKey: "sca-site-booking"} + assertStructuredVerificationRequired(t, makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)) + }) + + t.Run("booking_site_plain_decline", func(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed") + installErr(t, declineErr(t)) + cardToken := "cnon:decline-booking" + req := CreateBookingPaymentRequest{Amount: 2500, PaymentType: "deposit", NewCardToken: &cardToken, IdempotencyKey: "decline-site-booking"} + assertPlain402(t, makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)) + }) + + t.Run("tip_site_sca_required", func(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + userID, bookingID, _ := setupTestDataPast(t, ctx, tx) + userToken := jwt.GenerateUserToken(userID) + _, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed") + require.NoError(t, err) + installErr(t, scaErr(t)) + cardToken := "cnon:sca-tip" + req := CreateTipPaymentRequest{Amount: 500, NewCardToken: &cardToken, IdempotencyKey: "sca-site-tip"} + assertStructuredVerificationRequired(t, makePaymentRequest(withNonGuest(CreateTipPayment), "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)) + }) + + t.Run("tip_site_plain_decline", func(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + userID, bookingID, _ := setupTestDataPast(t, ctx, tx) + userToken := jwt.GenerateUserToken(userID) + _, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed") + require.NoError(t, err) + installErr(t, declineErr(t)) + cardToken := "cnon:decline-tip" + req := CreateTipPaymentRequest{Amount: 500, NewCardToken: &cardToken, IdempotencyKey: "decline-site-tip"} + assertPlain402(t, makePaymentRequest(withNonGuest(CreateTipPayment), "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)) + }) + + t.Run("terminal_saved_card_site_sca_required", func(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + userID, bookingID, _ := setupTestData(t, ctx, tx) + cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_sca_terminal", "VISA", "4242") + require.NoError(t, err) + adminID, err := fixtures.CreateTestAdminUser(tx) + require.NoError(t, err) + adminToken := jwt.GenerateTestToken(adminID, "admin") + installErr(t, scaErr(t)) + req := CreateTerminalPaymentRequest{Amount: 5000, PaymentType: "full", PaymentMethod: strPtr("saved_card"), UserSavedCardID: &cardID, IdempotencyKey: "sca-site-terminal-" + bookingID} + assertStructuredVerificationRequired(t, makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)) + }) + + t.Run("terminal_saved_card_site_plain_decline", func(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + userID, bookingID, _ := setupTestData(t, ctx, tx) + cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_decline_terminal", "VISA", "4242") + require.NoError(t, err) + adminID, err := fixtures.CreateTestAdminUser(tx) + require.NoError(t, err) + adminToken := jwt.GenerateTestToken(adminID, "admin") + installErr(t, declineErr(t)) + req := CreateTerminalPaymentRequest{Amount: 5000, PaymentType: "full", PaymentMethod: strPtr("saved_card"), UserSavedCardID: &cardID, IdempotencyKey: "decline-site-terminal-" + bookingID} + assertPlain402(t, makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)) + }) + + t.Run("gift_card_site_sca_required", func(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + token := jwt.GenerateUserToken(userID) + installErr(t, scaErr(t)) + cardToken := "cnon:sca-giftcard" + req := BuyGiftCardRequest{Amount: 2000, RecipientType: "self", NewCardToken: &cardToken, IdempotencyKey: "sca-site-giftcard"} + assertStructuredVerificationRequired(t, makePaymentRequest(BuyGiftCard, "POST", "/api/user/giftcards/buy", req, token, ctx)) + }) + + t.Run("gift_card_site_plain_decline", func(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + token := jwt.GenerateUserToken(userID) + installErr(t, declineErr(t)) + cardToken := "cnon:decline-giftcard" + req := BuyGiftCardRequest{Amount: 2000, RecipientType: "self", NewCardToken: &cardToken, IdempotencyKey: "decline-site-giftcard"} + assertPlain402(t, makePaymentRequest(BuyGiftCard, "POST", "/api/user/giftcards/buy", req, token, ctx)) + }) +} + // the dedicated add-card endpoint: with REQUIRE_2FA enforced and the user NOT // having completed 2FA setup, persisting a card is blocked with 403 and no card // row is created — the save-card endpoint is not an un-gated side door. diff --git a/backend/handlers/payments/giftcards.go b/backend/handlers/payments/giftcards.go index de786ed..354dd38 100644 --- a/backend/handlers/payments/giftcards.go +++ b/backend/handlers/payments/giftcards.go @@ -2313,12 +2313,13 @@ func assessGiftCardCancellation(ctx context.Context, q db.Querier, code string, st.CancellationReason = "The 14-day cancellation period has expired" default: paymentID, _, paymentOK, perr := findGiftCardPurchasePayment(ctx, q, purchaserID, "", purchaseAmount, purchasedAt) - if perr != nil { + switch { + case perr != nil: log.Printf("Failed to locate purchase payment for gift card %s: %v", code, perr) st.CancellationReason = "The original purchase payment could not be verified" - } else if !paymentOK { + case !paymentOK: st.CancellationReason = "The original purchase payment could not be found" - } else { + default: // A completed/pending refund row means the money already // returned (or is in flight) — the card cannot be cancelled a // second time. @@ -2628,7 +2629,7 @@ func cancelGiftCardForUser(ctx context.Context, w http.ResponseWriter, r *http.R // returned. Refund the DIFFERENCE via Square with a fresh // deterministic key before neutralising, so the un-refunded remainder // is never silently swallowed. - refundAmount = refundAmount - priorAmount + refundAmount -= priorAmount refundKey = paymentID + "-gccancel-diff-" + strconv.FormatInt(int64(math.Round(refundAmount*100)), 10) log.Printf("Gift-card purchase %s has a prior partial refund of £%.2f — issuing the £%.2f remainder", paymentID, priorAmount, refundAmount) case "pending": diff --git a/backend/handlers/payments/handlers.go b/backend/handlers/payments/handlers.go index 64d7ca3..7ef9ebd 100644 --- a/backend/handlers/payments/handlers.go +++ b/backend/handlers/payments/handlers.go @@ -3099,7 +3099,6 @@ func buildTerminalSplitRecords(primary PaymentRecord, info *BookingPaymentInfo, bal.IdempotencyKey = &k } records = append(records, bal) - splitIdx++ } if tipAmount > 0.004 { @@ -3309,10 +3308,7 @@ func isDefinitiveCardSaveFailure(err error) bool { case "SOURCE_USED", "CARD_TOKEN_USED", "CARD_TOKEN_EXPIRED", "INVALID_CARD": return true } - if square.ErrorCategory(err) == "INVALID_REQUEST_ERROR" { - return true - } - return false + return square.ErrorCategory(err) == "INVALID_REQUEST_ERROR" } func RefundPayment(w http.ResponseWriter, r *http.Request) { diff --git a/backend/handlers/payments/refunds.go b/backend/handlers/payments/refunds.go index 4604786..ed27b71 100644 --- a/backend/handlers/payments/refunds.go +++ b/backend/handlers/payments/refunds.go @@ -695,11 +695,12 @@ func ProcessCancellationRefundTx( log.Printf("Failed to check loyalty stamp refund for booking %s: %v", bookingID, err) } else if loyaltyUsed { loyaltyTag, loyaltyErr := tx.Exec(ctx, "UPDATE users SET loyalty_stamps = loyalty_stamps + $1 WHERE id = $2", LoyaltyStampCost, bookingUserID) - if loyaltyErr != nil { + switch { + case loyaltyErr != nil: log.Printf("Failed to refund loyalty stamps for booking %s: %v", bookingID, loyaltyErr) - } else if loyaltyTag.RowsAffected() == 0 { + case loyaltyTag.RowsAffected() == 0: slog.Error("CRITICAL: loyalty stamp refund UPDATE affected 0 rows — user not found", "user_id", bookingUserID, "booking_id", bookingID) - } else { + default: log.Printf("Refunded %d loyalty stamps to user %s after cancellation of booking %s", LoyaltyStampCost, bookingUserID, bookingID) } } diff --git a/backend/handlers/payments/sweep.go b/backend/handlers/payments/sweep.go index bd722c5..1154479 100644 --- a/backend/handlers/payments/sweep.go +++ b/backend/handlers/payments/sweep.go @@ -2013,7 +2013,9 @@ func recordTerminalPaymentTx(ctx context.Context, tx pgx.Tx, checkoutID, booking appliedCampaignsBeforeInsert := false if hasTip && bookingInfo != nil && bErr == nil { if pendingCampaignDiscountAmount(ctx, tx, bookingID, bookingUserID, bookingInfo.TotalAmount) > 0.004 { - applyEligibleCampaignsAtPayment(ctx, tx, bookingID, bookingUserID, nil) + if applyErr := applyEligibleCampaignsAtPayment(ctx, tx, bookingID, bookingUserID, nil); applyErr != nil { + slog.Error("Failed to apply eligible campaigns before terminal payment insert", "booking_id", bookingID, "err", applyErr) + } appliedCampaignsBeforeInsert = true } } diff --git a/backend/handlers/payments/terminal_sca_test.go b/backend/handlers/payments/terminal_sca_test.go new file mode 100644 index 0000000..fc55020 --- /dev/null +++ b/backend/handlers/payments/terminal_sca_test.go @@ -0,0 +1,393 @@ +//go:build test && dev + +package payments + +// Tests for the CreateTerminalPayment SCA/2FA decision model: the +// VerificationToken field (handlers.go:114, validation 440-444, extraction +// 843-846, gate skip 1024, forwarding 1113), the admin-actor 2FA-fallback +// audit rows on the terminal and till saved-card surfaces, and the +// customer_initiated classification (Square's SCA/liability-shift signal) on +// every saved-card charge site. +// +// Tests that flip REQUIRE_2FA/SQUARE_ENVIRONMENT via t.Setenv stay sequential +// (no t.Parallel) — see the note at the top of twofa_test.go. They use +// SQUARE_ENVIRONMENT=staging (enforced, but square.NewDevClient() returns the +// in-memory mock — helperEnvEnforce2FA's production would panic a dev-build +// NewDevClient, see twofa_gate_consume_test.go). + +import ( + "encoding/json" + "net/http" + "testing" + + "crussell/internal/square" + "crussell/testutils" + "crussell/testutils/fixtures" + "crussell/testutils/jwt" + + "github.com/stretchr/testify/require" +) + +// helperEnvEnforce2FAStaging flips the env to an ENFORCED non-mock Square env +// that still resolves to the in-memory mock client. +func helperEnvEnforce2FAStaging(t *testing.T) { + t.Helper() + t.Setenv("REQUIRE_2FA", "true") + t.Setenv("SQUARE_ENVIRONMENT", "staging") +} + +// installRecordingClient wraps the dev mock in the shared recording client so +// tests can assert the exact CreatePaymentReq the handler sent to Square. +func installRecordingClient(t *testing.T) *recordingPaymentClient { + t.Helper() + origClient := SquareClient + rec := &recordingPaymentClient{SquareClient: square.NewDevClient()} + SquareClient = rec + t.Cleanup(func() { SquareClient = origClient }) + return rec +} + +// TestTerminalSavedCard_VerificationToken_Passthrough pins (d): a saved-card +// charge through CreateTerminalPayment forwards the SCA verification token +// verbatim on the CreatePaymentReq. +func TestTerminalSavedCard_VerificationToken_Passthrough(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + userID, bookingID, _ := setupTestData(t, ctx, tx) + cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_terminal_sca", "VISA", "4242") + require.NoError(t, err) + adminID, err := fixtures.CreateTestAdminUser(tx) + require.NoError(t, err) + adminToken := jwt.GenerateTestToken(adminID, "admin") + + rec := installRecordingClient(t) + + vrf := "vrf_terminal_sca_1" + req := CreateTerminalPaymentRequest{ + Amount: 5000, + PaymentType: "full", + PaymentMethod: strPtr("saved_card"), + UserSavedCardID: &cardID, + IdempotencyKey: "terminal-sca-passthrough-" + bookingID, + VerificationToken: &vrf, + } + + w := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + rec.mu.Lock() + got := rec.lastReq.VerificationToken + rec.mu.Unlock() + require.Equal(t, vrf, got, "the SCA verification token must be forwarded to Square on the CreatePaymentReq") +} + +// TestTerminalSavedCard_VerificationToken_TooLong_Rejected pins (d): an +// oversized verification_token is rejected with 400 by ValidateVerificationToken +// before any charge branch runs. +func TestTerminalSavedCard_VerificationToken_TooLong_Rejected(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + _, bookingID, _ := setupTestData(t, ctx, tx) + adminID, err := fixtures.CreateTestAdminUser(tx) + require.NoError(t, err) + adminToken := jwt.GenerateTestToken(adminID, "admin") + + big := make([]byte, 600) + for i := range big { + big[i] = 'a' + } + token := string(big) + req := CreateTerminalPaymentRequest{ + Amount: 5000, + PaymentType: "full", + PaymentMethod: strPtr("saved_card"), + IdempotencyKey: "terminal-sca-too-long-" + bookingID, + VerificationToken: &token, + } + + w := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx) + require.Equal(t, http.StatusBadRequest, w.Code, "an oversized verification_token must be rejected with 400, body: %s", w.Body.String()) +} + +// TestTerminalSavedCard_VerificationToken_Skips2FA pins (d): when the charge +// carries a Square verification_token (SCA performed), the 2FA gate is SKIPPED +// even for a user with NO 2FA setup — a charge that would 403 on the fallback +// path succeeds via the token. +func TestTerminalSavedCard_VerificationToken_Skips2FA(t *testing.T) { + helperEnvEnforce2FAStaging(t) + ctx, tx := testutils.SetupTestTx(t) + + userID, bookingID, _ := setupTestData(t, ctx, tx) + cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_terminal_sca_skip", "VISA", "4242") + require.NoError(t, err) + adminID, err := fixtures.CreateTestAdminUser(tx) + require.NoError(t, err) + adminToken := jwt.GenerateTestToken(adminID, "admin") + + installRecordingClient(t) + + vrf := "vrf_terminal_sca_skip_1" + req := CreateTerminalPaymentRequest{ + Amount: 5000, + PaymentType: "full", + PaymentMethod: strPtr("saved_card"), + UserSavedCardID: &cardID, + IdempotencyKey: "terminal-sca-skip-" + bookingID, + VerificationToken: &vrf, + } + + w := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx) + require.Equal(t, http.StatusOK, w.Code, "SCA performed — the token must skip the 2FA gate, body: %s", w.Body.String()) + + var auditCount int + require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM admin_audit_log WHERE action_type = '2fa_fallback_charge'`).Scan(&auditCount)) + require.Zero(t, auditCount, "an SCA-authorized terminal charge must not write a 2FA-fallback audit row") +} + +// TestTerminalSavedCard_2FAFallback_Audit_AdminActor pins (c)+(d): a token-less +// terminal saved-card charge falls back to the card owner's 2FA code and writes +// a strict 2fa_fallback_charge audit row for the ADMIN actor (admin_id = the +// charging admin, target_user_id = the card owner). +func TestTerminalSavedCard_2FAFallback_Audit_AdminActor(t *testing.T) { + helperEnvEnforce2FAStaging(t) + ctx, tx := testutils.SetupTestTx(t) + + userID, bookingID, _ := setupTestData(t, ctx, tx) + seedTwoFAPendingCode(t, tx, userID, "334411") + cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_terminal_sca_fallback", "VISA", "4242") + require.NoError(t, err) + adminID, err := fixtures.CreateTestAdminUser(tx) + require.NoError(t, err) + adminToken := jwt.GenerateTestToken(adminID, "admin") + + rec := installRecordingClient(t) + + req := CreateTerminalPaymentRequest{ + Amount: 5000, + PaymentType: "full", + PaymentMethod: strPtr("saved_card"), + UserSavedCardID: &cardID, + IdempotencyKey: "terminal-2fa-fallback-" + bookingID, + VerificationCode: "334411", + } + + w := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + rec.mu.Lock() + got := rec.lastReq.VerificationToken + rec.mu.Unlock() + require.Empty(t, got, "a 2FA-fallback charge must not carry a verification token") + + assertTwoFAFallbackAuditDetails(t, ctx, tx, userID, adminID, bookingID, "admin saved-card charge authorized via 2FA fallback (SCA unavailable)", "4242") +} + +// TestTerminalSavedCard_2FAFallbackDisabled_402 pins (d): when the deployment +// opts out of the 2FA fallback (TWO_FACTOR_FALLBACK=false), a token-less saved- +// card terminal charge is denied 402 with the structured verification_required +// body the frontend keys on to run the SCA challenge. +func TestTerminalSavedCard_2FAFallbackDisabled_402(t *testing.T) { + helperEnvEnforce2FAStaging(t) + t.Setenv("TWO_FACTOR_FALLBACK", "false") + ctx, tx := testutils.SetupTestTx(t) + + userID, bookingID, _ := setupTestData(t, ctx, tx) + seedTwoFAPendingCode(t, tx, userID, "998800") + cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_terminal_sca_no_fallback", "VISA", "4242") + require.NoError(t, err) + adminID, err := fixtures.CreateTestAdminUser(tx) + require.NoError(t, err) + adminToken := jwt.GenerateTestToken(adminID, "admin") + + installRecordingClient(t) + + req := CreateTerminalPaymentRequest{ + Amount: 5000, + PaymentType: "full", + PaymentMethod: strPtr("saved_card"), + UserSavedCardID: &cardID, + IdempotencyKey: "terminal-sca-no-fallback-" + bookingID, + VerificationCode: "998800", + } + + w := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx) + require.Equal(t, http.StatusPaymentRequired, w.Code, "a token-less charge with the fallback disabled must be denied 402, body: %s", w.Body.String()) + var body map[string]string + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + require.Equal(t, "verification_required", body["code"]) +} + +// TestTillSavedCard_2FAFallback_Audit_AdminActor pins (c): an admin till +// saved-card sale authorized by the 2FA fallback writes a strict +// 2fa_fallback_charge audit row for the ADMIN actor with the till sale id as +// the reference. +func TestTillSavedCard_2FAFallback_Audit_AdminActor(t *testing.T) { + helperEnvEnforce2FAStaging(t) + ctx, tx := testutils.SetupTestTx(t) + + adminID, err := fixtures.CreateTestAdminUser(tx) + require.NoError(t, err) + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + adminToken := jwt.GenerateTestToken(adminID, "admin") + seedTwoFAPendingCode(t, tx, userID, "665544") + cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_till_audit", "VISA", "4242") + require.NoError(t, err) + + installRecordingClient(t) + + key := "2fa-till-fallback-audit" + req := TillSaleRequest{ + ItemType: "gift_card", + Action: "create", + Amount: 50.00, + PaymentMethod: "saved_card", + UserSavedCardID: &cardID, + UserID: &userID, + IdempotencyKey: key, + VerificationCode: "665544", + } + + w := makePaymentRequest(CreateTillSale, "POST", "/api/admin/till/sale", req, adminToken, ctx) + require.Contains(t, []int{http.StatusOK, http.StatusCreated}, w.Code, w.Body.String()) + + var tillSaleID string + require.NoError(t, tx.QueryRow(ctx, `SELECT id FROM till_sales WHERE idempotency_key = $1`, key).Scan(&tillSaleID)) + assertTwoFAFallbackAuditDetails(t, ctx, tx, userID, adminID, tillSaleID, "admin till saved-card charge authorized via 2FA fallback (SCA unavailable)", "4242") +} + +// TestCustomerInitiated_ChargeClassification pins (f): the customer_initiated +// flag on the CreatePaymentReq — false for the ADMIN surfaces (terminal +// saved-card handlers.go:1121, till saved-card till.go:1179: merchant- +// initiated) and true for the CUSTOMER surfaces (booking handlers.go:2467, +// tip handlers.go:4708, gift-card giftcards.go:1676). +func TestCustomerInitiated_ChargeClassification(t *testing.T) { + t.Run("admin_terminal_saved_card_merchant_initiated", func(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + userID, bookingID, _ := setupTestData(t, ctx, tx) + cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_ci_terminal", "VISA", "4242") + require.NoError(t, err) + adminID, err := fixtures.CreateTestAdminUser(tx) + require.NoError(t, err) + adminToken := jwt.GenerateTestToken(adminID, "admin") + rec := installRecordingClient(t) + + req := CreateTerminalPaymentRequest{ + Amount: 5000, + PaymentType: "full", + PaymentMethod: strPtr("saved_card"), + UserSavedCardID: &cardID, + IdempotencyKey: "ci-terminal-" + bookingID, + } + w := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + rec.mu.Lock() + cd := rec.lastReq.CustomerDetails + rec.mu.Unlock() + require.NotNil(t, cd, "an admin saved-card terminal charge must carry customer_details") + require.False(t, cd.CustomerInitiated, "an admin-initiated terminal saved-card charge is merchant-initiated") + }) + + t.Run("admin_till_saved_card_merchant_initiated", func(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + adminID, err := fixtures.CreateTestAdminUser(tx) + require.NoError(t, err) + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + adminToken := jwt.GenerateTestToken(adminID, "admin") + cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_ci_till", "VISA", "4242") + require.NoError(t, err) + rec := installRecordingClient(t) + + req := TillSaleRequest{ + ItemType: "gift_card", + Action: "create", + Amount: 50.00, + PaymentMethod: "saved_card", + UserSavedCardID: &cardID, + UserID: &userID, + IdempotencyKey: "ci-till", + } + w := makePaymentRequest(CreateTillSale, "POST", "/api/admin/till/sale", req, adminToken, ctx) + require.Contains(t, []int{http.StatusOK, http.StatusCreated}, w.Code, w.Body.String()) + + rec.mu.Lock() + cd := rec.lastReq.CustomerDetails + rec.mu.Unlock() + require.NotNil(t, cd, "an admin till saved-card charge must carry customer_details") + require.False(t, cd.CustomerInitiated, "an admin-initiated till saved-card charge is merchant-initiated") + }) + + t.Run("customer_booking_cardholder_initiated", func(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + userID, bookingID, _ := setupTestData(t, ctx, tx) + userToken := jwt.GenerateUserToken(userID) + rec := installRecordingClient(t) + + cardToken := "cnon:ci-booking" + req := CreateBookingPaymentRequest{ + Amount: 5000, + PaymentType: "full", + NewCardToken: &cardToken, + IdempotencyKey: "ci-booking-" + bookingID, + } + w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + rec.mu.Lock() + cd := rec.lastReq.CustomerDetails + rec.mu.Unlock() + require.NotNil(t, cd) + require.True(t, cd.CustomerInitiated, "a customer booking charge is cardholder-initiated") + }) + + t.Run("customer_tip_cardholder_initiated", func(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + userID, bookingID, _ := setupTestDataPast(t, ctx, tx) + userToken := jwt.GenerateUserToken(userID) + _, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed") + require.NoError(t, err) + rec := installRecordingClient(t) + + cardToken := "cnon:ci-tip" + req := CreateTipPaymentRequest{ + Amount: 500, + NewCardToken: &cardToken, + IdempotencyKey: "ci-tip-" + bookingID, + } + w := makePaymentRequest(withNonGuest(CreateTipPayment), "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + rec.mu.Lock() + cd := rec.lastReq.CustomerDetails + rec.mu.Unlock() + require.NotNil(t, cd) + require.True(t, cd.CustomerInitiated, "a customer tip charge is cardholder-initiated") + }) + + t.Run("customer_gift_card_cardholder_initiated", func(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + token := jwt.GenerateUserToken(userID) + cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_ci_gc", "VISA", "4242") + require.NoError(t, err) + rec := installRecordingClient(t) + + req := BuyGiftCardRequest{ + Amount: 2000, + RecipientType: "self", + CardID: &cardID, + IdempotencyKey: "ci-gc", + } + w := makePaymentRequest(BuyGiftCard, "POST", "/api/user/giftcards/buy", req, token, ctx) + require.Contains(t, []int{http.StatusOK, http.StatusCreated}, w.Code, w.Body.String()) + + rec.mu.Lock() + cd := rec.lastReq.CustomerDetails + rec.mu.Unlock() + require.NotNil(t, cd) + require.True(t, cd.CustomerInitiated, "a customer gift-card purchase is cardholder-initiated") + }) +} diff --git a/backend/handlers/payments/till_test.go b/backend/handlers/payments/till_test.go index 6359f5d..5bbae33 100644 --- a/backend/handlers/payments/till_test.go +++ b/backend/handlers/payments/till_test.go @@ -656,7 +656,6 @@ func TestGetTillCheckoutStatus_EmptyCheckoutID(t *testing.T) { w := httptest.NewRecorder() GetTillCheckoutStatus(w, req) - req = adminRequestCtx(req) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) diff --git a/backend/handlers/payments/twofa_delivery_prod_test.go b/backend/handlers/payments/twofa_delivery_prod_test.go new file mode 100644 index 0000000..2404c3f --- /dev/null +++ b/backend/handlers/payments/twofa_delivery_prod_test.go @@ -0,0 +1,59 @@ +//go:build !dev + +package payments + +// Tests for the PRODUCTION 2FA delivery predicate (twofa_delivery_prod.go). +// +// LIMITATION (documented): the 503 "2FA requires an email or SMS delivery +// channel" branch in requireTwoFactorForCardAccess (twofa.go:193) is only +// reachable when twoFADeliveryAvailable() returns false, which happens ONLY in +// a production build (!dev && !test). Under BOTH required test runs — the +// "test,dev" run and the "test,!dev" prod-shape run — the dev/test delivery +// variant (twofa_delivery_dev.go, build tag `dev || test`) is the compiled +// function and is trivially true, so the 503 branch cannot be exercised there. +// The two test invocations DO however compile this file, and the prod-variant +// marker (twofaDeliveryProdVariant) tells the test which delivery function is +// live: a genuine production build (no dev/test tags, e.g. `go test ./...`) +// compiles twofa_delivery_prod.go, and this test then asserts the real prod +// predicate end to end. + +import ( + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestTwoFADeliveryAvailable_ProdPredicate asserts the production gating that +// twofa_delivery_prod.go implements: TWO_FACTOR_ALLOW_LOG_DELIVERY unset → +// no channel (false), exactly "true" → channel (true), any other value → +// no channel. In a dev/test build the marker is false and the test skips, +// because the always-true dev variant is compiled and the 503 branch is +// unreachable (documented limitation — see the file header). +func TestTwoFADeliveryAvailable_ProdPredicate(t *testing.T) { + if !twofaDeliveryProdVariant { + t.Skip("twoFADeliveryAvailable() is the dev/test build's trivially-true variant (twofa_delivery_dev.go, `dev || test`); the 503 delivery-unavailable branch is unreachable under the test tag — see the file header for the documented limitation") + } + + t.Run("unset_env_is_no_channel", func(t *testing.T) { + os.Unsetenv("TWO_FACTOR_ALLOW_LOG_DELIVERY") + require.False(t, twoFADeliveryAvailable(), "production without the explicit opt-in must have NO 2FA delivery channel") + }) + + t.Run("empty_env_is_no_channel", func(t *testing.T) { + os.Setenv("TWO_FACTOR_ALLOW_LOG_DELIVERY", "") + require.False(t, twoFADeliveryAvailable()) + }) + + t.Run("exact_true_is_a_channel", func(t *testing.T) { + os.Setenv("TWO_FACTOR_ALLOW_LOG_DELIVERY", "true") + require.True(t, twoFADeliveryAvailable(), "the explicit insecure log-delivery opt-in must open the channel") + }) + + t.Run("any_other_value_is_no_channel", func(t *testing.T) { + for _, v := range []string{"1", "yes", "on", "True", "TRUE", "false"} { + os.Setenv("TWO_FACTOR_ALLOW_LOG_DELIVERY", v) + require.False(t, twoFADeliveryAvailable(), "value %q must NOT open the delivery channel (exact 'true' only)", v) + } + }) +} diff --git a/backend/handlers/payments/twofa_delivery_variant_dev_test.go b/backend/handlers/payments/twofa_delivery_variant_dev_test.go new file mode 100644 index 0000000..cc07331 --- /dev/null +++ b/backend/handlers/payments/twofa_delivery_variant_dev_test.go @@ -0,0 +1,11 @@ +//go:build dev || test + +package payments + +// twofaDeliveryProdVariant reports whether the PRODUCTION delivery predicate +// (twofa_delivery_prod.go, !dev && !test) is the compiled function in this +// build. Under `dev` OR `test` tags the dev/test delivery variant +// (twofa_delivery_dev.go) is compiled instead — always true, so the 503 +// delivery-unavailable branch is unreachable there. +//lint:ignore U1000 referenced only from the prod-tag test (twofa_delivery_prod_test.go, !dev && !test); deliberately unused under dev/test tags +const twofaDeliveryProdVariant = false diff --git a/backend/handlers/payments/twofa_delivery_variant_prod_test.go b/backend/handlers/payments/twofa_delivery_variant_prod_test.go new file mode 100644 index 0000000..a24a2fd --- /dev/null +++ b/backend/handlers/payments/twofa_delivery_variant_prod_test.go @@ -0,0 +1,9 @@ +//go:build !dev && !test + +package payments + +// twofaDeliveryProdVariant reports whether the PRODUCTION delivery predicate +// (twofa_delivery_prod.go, !dev && !test) is the compiled function in this +// build. True only in a genuine production build — neither dev nor test tag — +// where twoFADeliveryAvailable() gates on TWO_FACTOR_ALLOW_LOG_DELIVERY. +const twofaDeliveryProdVariant = true diff --git a/backend/handlers/payments/twofa_test.go b/backend/handlers/payments/twofa_test.go index 9bb5114..0760d2a 100644 --- a/backend/handlers/payments/twofa_test.go +++ b/backend/handlers/payments/twofa_test.go @@ -17,6 +17,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" "testing" "crussell/db" @@ -141,7 +142,8 @@ func TestRequireTwoFactorForCardAccess_FallbackDisabled_402Structured(t *testing // trail: a 2FA-fallback saved-card charge (no verification token, code verified) // must write an admin_audit_log row with action_type '2fa_fallback_charge' for // the customer actor — the operator can distinguish SCA-authorized charges from -// fallback-authorized ones. +// fallback-authorized ones — and the row's details JSON must carry the strict +// shape (sca_performed:false, fallback_reason, card_last4, reference_id, notes). func TestTwoFactorEnforced_BookingSavedCard_Fallback_Audits(t *testing.T) { helperEnvEnforce2FA(t) ctx, tx := testutils.SetupTestTx(t) @@ -163,12 +165,31 @@ func TestTwoFactorEnforced_BookingSavedCard_Fallback_Audits(t *testing.T) { w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) - var auditCount int - require.NoError(t, tx.QueryRow(ctx, ` - SELECT COUNT(*) FROM admin_audit_log - WHERE action_type = '2fa_fallback_charge' AND target_user_id = $1 AND admin_id = $1 - `, userID).Scan(&auditCount)) - require.Equal(t, 1, auditCount, "a 2FA-fallback saved-card charge must write a strict audit row for the customer actor") + details := assertTwoFAFallbackAuditDetails(t, ctx, tx, userID, userID, bookingID, "saved-card charge authorized via 2FA fallback (SCA unavailable)", "4242") + require.Equal(t, false, details["sca_performed"]) + require.Equal(t, "verification_unavailable", details["fallback_reason"]) +} + +// assertTwoFAFallbackAuditDetails reads the most recent '2fa_fallback_charge' +// admin_audit_log row for (target_user_id, admin_id) and asserts the strict +// insertTwoFAFallbackAudit details shape: sca_performed=false, +// fallback_reason="verification_unavailable", card_last4, reference_id, notes. +func assertTwoFAFallbackAuditDetails(t *testing.T, ctx context.Context, q db.Querier, userID, adminID, referenceID, notes, wantLast4 string) map[string]any { + t.Helper() + var detailsJSON []byte + require.NoError(t, q.QueryRow(ctx, ` + SELECT details FROM admin_audit_log + WHERE action_type = '2fa_fallback_charge' AND target_user_id = $1 AND admin_id = $2 + ORDER BY created_at DESC LIMIT 1 + `, userID, adminID).Scan(&detailsJSON)) + var details map[string]any + require.NoError(t, json.Unmarshal(detailsJSON, &details)) + require.Equal(t, false, details["sca_performed"], "details.sca_performed must be false") + require.Equal(t, "verification_unavailable", details["fallback_reason"], "details.fallback_reason") + require.Equal(t, wantLast4, details["card_last4"], "details.card_last4") + require.Equal(t, referenceID, details["reference_id"], "details.reference_id") + require.Equal(t, notes, details["notes"], "details.notes") + return details } // TestTwoFactorEnforced_BookingSavedCard_SCA_Skips_Audit pins that an SCA- @@ -202,6 +223,125 @@ func TestTwoFactorEnforced_BookingSavedCard_SCA_Skips_Audit(t *testing.T) { require.Zero(t, auditCount, "an SCA-authorized charge must not write a 2FA-fallback audit row") } +// TestTwoFactorEnforced_TipSavedCard_Fallback_Audit pins the strict audit +// trail on the TIP SAVE gate (CreateTipPayment with save_card=true): the +// token-less save is authorized by the 2FA fallback, and the resulting +// charge writes a 2fa_fallback_charge row for the customer actor. +func TestTwoFactorEnforced_TipSavedCard_Fallback_Audit(t *testing.T) { + helperEnvEnforce2FA(t) + ctx, tx := testutils.SetupTestTx(t) + + userID, bookingID, _ := setupTestDataPast(t, ctx, tx) + userToken := jwt.GenerateUserToken(userID) + seedTwoFAPendingCode(t, tx, userID, "556600") + _, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed") + require.NoError(t, err) + + cardToken := "cnon:2fa-tip-save-audit" + req := CreateTipPaymentRequest{ + Amount: 500, + NewCardToken: &cardToken, + SaveCard: true, + IdempotencyKey: "2fa-tip-save-audit", + VerificationCode: "556600", + } + + w := makePaymentRequest(withNonGuest(CreateTipPayment), "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + assertTwoFAFallbackAuditDetails(t, ctx, tx, userID, userID, bookingID, "saved-card tip charge authorized via 2FA fallback (SCA unavailable)", "4242") +} + +// TestTwoFactorEnforced_TipChargeSavedCard_Fallback_Audit pins the strict audit +// trail on the TIP CHARGE gate (CreateTipPayment charging an existing saved +// card): the token-less charge is authorized by the 2FA fallback and writes a +// 2fa_fallback_charge row for the customer actor. +func TestTwoFactorEnforced_TipChargeSavedCard_Fallback_Audit(t *testing.T) { + helperEnvEnforce2FA(t) + ctx, tx := testutils.SetupTestTx(t) + + userID, bookingID, _ := setupTestDataPast(t, ctx, tx) + userToken := jwt.GenerateUserToken(userID) + seedTwoFAPendingCode(t, tx, userID, "112211") + _, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed") + require.NoError(t, err) + cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_tip_charge_audit", "VISA", "4242") + require.NoError(t, err) + + req := CreateTipPaymentRequest{ + Amount: 500, + CardID: &cardID, + IdempotencyKey: "2fa-tip-charge-audit", + VerificationCode: "112211", + } + + w := makePaymentRequest(withNonGuest(CreateTipPayment), "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + assertTwoFAFallbackAuditDetails(t, ctx, tx, userID, userID, bookingID, "saved-card tip charge authorized via 2FA fallback (SCA unavailable)", "4242") +} + +// TestTwoFactorEnforced_BuyGiftCard_SavedCard_Fallback_Audit pins the strict +// audit trail on the gift-card purchase saved-card gate (giftcards.go): the +// token-less charge is authorized by the 2FA fallback and writes a +// 2fa_fallback_charge row for the customer actor with the payment id as the +// reference. +func TestTwoFactorEnforced_BuyGiftCard_SavedCard_Fallback_Audit(t *testing.T) { + helperEnvEnforce2FA(t) + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + token := jwt.GenerateUserToken(userID) + seedTwoFAPendingCode(t, tx, userID, "778811") + cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_gc_audit", "VISA", "4242") + require.NoError(t, err) + + key := "2fa-buy-gc-fallback-audit" + req := BuyGiftCardRequest{ + Amount: 2000, + RecipientType: "self", + CardID: &cardID, + IdempotencyKey: key, + VerificationCode: "778811", + } + + w := makePaymentRequest(BuyGiftCard, "POST", "/api/user/giftcards/buy", req, token, ctx) + require.Contains(t, []int{http.StatusOK, http.StatusCreated}, w.Code, w.Body.String()) + + var payID string + require.NoError(t, tx.QueryRow(ctx, `SELECT id FROM payments WHERE idempotency_key = $1`, key).Scan(&payID)) + assertTwoFAFallbackAuditDetails(t, ctx, tx, userID, userID, payID, "gift-card purchase authorized via 2FA fallback (SCA unavailable)", "4242") +} + +// TestTwoFactorEnforced_PaymentMethodSave_Fallback_Audit pins the strict audit +// trail on the add-card save gate (handlers.go CreatePaymentMethod): persisting +// a card via the 2FA fallback writes a 2fa_fallback_charge row for the customer +// actor with an empty reference id (no charge reference exists for a save). +func TestTwoFactorEnforced_PaymentMethodSave_Fallback_Audit(t *testing.T) { + helperEnvEnforce2FA(t) + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + t.Cleanup(func() { + InvalidateSquareCustomerCache(userID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM user_saved_cards WHERE user_id = $1`, userID) + }) + token := jwt.GenerateUserToken(userID) + seedTwoFAPendingCode(t, tx, userID, "998877") + + req := CreatePaymentMethodRequest{ + CardToken: "cnon:2fa-pm-save-audit", + VerificationCode: "998877", + } + + w := makePaymentRequest(CreatePaymentMethod, "POST", "/api/user/payment-methods", req, token, ctx) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + assertTwoFAFallbackAuditDetails(t, ctx, tx, userID, userID, "", "card persisted via 2FA fallback (SCA unavailable)", "4242") +} + // TestRequireTwoFactorForCardAccess_NotEnforced verifies the dev/mock path // allows every request without touching the DB (no user rows are consulted). // Uses an explicit mock env: empty SQUARE_ENVIRONMENT now defaults to ENFORCED @@ -631,3 +771,64 @@ func TestTwoFactorEnforced_CreateTipPayment_SavedCard_Retry_ReturnsCompleted(t * require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'tip'", bookingID).Scan(&tipCount)) require.Equal(t, 1, tipCount, "the retry must not create a second tip") } + +// TestTwoFactorFallbackEnabled pins the TWO_FACTOR_FALLBACK policy-switch +// parse matrix (twofa.go:100-107): false/0/off/no are case-insensitive +// DISABLES; every other value — empty, unset, unknown, true/1/on/yes — keeps +// the fallback enabled (the shipped default). The exported +// PaymentService.TwoFactorFallbackEnabled wrapper must match the unexported +// predicate. Sequential (no t.Parallel): flips process-global env vars. +func TestTwoFactorFallbackEnabled(t *testing.T) { + tests := []struct { + name string + val string + want bool + }{ + {"false disables", "false", false}, + {"zero disables", "0", false}, + {"off disables", "off", false}, + {"no disables", "no", false}, + {"case_insensitive_False_disables", "False", false}, + {"case_insensitive_OFF_disables", "OFF", false}, + {"case_insensitive_No_disables", "No", false}, + {"empty_keeps_enabled", "", true}, + {"unknown_keeps_enabled", "enable", true}, + {"true_keeps_enabled", "true", true}, + {"one_keeps_enabled", "1", true}, + {"on_keeps_enabled", "on", true}, + {"yes_keeps_enabled", "yes", true}, + {"case_insensitive_TRUE_keeps_enabled", "TRUE", true}, + {"case_insensitive_ON_keeps_enabled", "ON", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("TWO_FACTOR_FALLBACK", tt.val) + require.Equal(t, tt.want, twoFactorFallbackEnabled()) + require.Equal(t, tt.want, NewPaymentService().TwoFactorFallbackEnabled(), "exported wrapper must match twoFactorFallbackEnabled") + }) + } + t.Run("unset_keeps_enabled_default", func(t *testing.T) { + prev, had := os.LookupEnv("TWO_FACTOR_FALLBACK") + os.Unsetenv("TWO_FACTOR_FALLBACK") + defer func() { + if had { + os.Setenv("TWO_FACTOR_FALLBACK", prev) + } else { + os.Unsetenv("TWO_FACTOR_FALLBACK") + } + }() + require.True(t, twoFactorFallbackEnabled()) + require.True(t, NewPaymentService().TwoFactorFallbackEnabled()) + }) +} + +// TestTwoFADeliveryAvailable_DevBuild_TriviallyTrue documents the dev/test +// delivery predicate: twofa_delivery_dev.go (`dev || test`) always reports a +// delivery channel (the [2FA] log relay), so the 503 "2FA requires an email or +// SMS delivery channel" branch (twofa.go:193) is UNREACHABLE in this build. +// The production predicate — TWO_FACTOR_ALLOW_LOG_DELIVERY gating — is covered +// by twofa_delivery_prod_test.go in a !dev build (see its header for the +// documented limitation). +func TestTwoFADeliveryAvailable_DevBuild_TriviallyTrue(t *testing.T) { + require.True(t, twoFADeliveryAvailable()) +} diff --git a/backend/handlers/scheduling/default-hours.go b/backend/handlers/scheduling/default-hours.go index 09b5e90..93a490d 100644 --- a/backend/handlers/scheduling/default-hours.go +++ b/backend/handlers/scheduling/default-hours.go @@ -1046,7 +1046,7 @@ func GetDefaultHoursConflictingBookings(w http.ResponseWriter, r *http.Request) bookingLondon := ob.StartTime.In(londonLocation) ourWeekday := int((bookingLondon.Weekday() + 6) % 7) - proposed, _ := proposedByWeekday[ourWeekday] + proposed := proposedByWeekday[ourWeekday] isConflict := false if !proposed.IsOpen { isConflict = true @@ -1204,7 +1204,11 @@ func GetScheduledDefaultHoursChange(w http.ResponseWriter, r *http.Request) { } var scheduledHours []DefaultHours - json.Unmarshal([]byte(*hoursJSON), &scheduledHours) + if err := json.Unmarshal([]byte(*hoursJSON), &scheduledHours); err != nil { + log.Printf("Failed to unmarshal scheduled default hours JSON: %v", err) + http.Error(w, "server error", http.StatusInternalServerError) + return + } resp := ScheduledHoursChange{ EffectiveDate: *effDate, diff --git a/backend/handlers/scheduling/exceptional-hours.go b/backend/handlers/scheduling/exceptional-hours.go index b095a89..7e7a95a 100644 --- a/backend/handlers/scheduling/exceptional-hours.go +++ b/backend/handlers/scheduling/exceptional-hours.go @@ -483,7 +483,7 @@ func GetConflictingBookingsForExceptionHandler(w http.ResponseWriter, r *http.Re // Go: Sun=0,Mon=1,...,Sat=6 → Our convention: Mon=0,...,Sun=6 ourWeekday := int((bookingLondon.Weekday() + 6) % 7) - proposed, _ := proposedByWeekday[ourWeekday] + proposed := proposedByWeekday[ourWeekday] isConflict := false if !proposed.IsOpen { @@ -501,13 +501,14 @@ func GetConflictingBookingsForExceptionHandler(w http.ResponseWriter, r *http.Re // For midnight-crossing bookings (endMinutes < startMinutes), the booking // extends past midnight and always conflicts with daily hours since the // day's open window cannot span past midnight. - if startMinutes < 0 || endMinutes < 0 { + switch { + case startMinutes < 0 || endMinutes < 0: // parse error — treat as conflict isConflict = true - } else if endMinutes < startMinutes { + case endMinutes < startMinutes: // Booking crosses midnight — always a conflict with daily hours isConflict = true - } else if startMinutes < propStartMinutes || endMinutes > propEndMinutes { + case startMinutes < propStartMinutes || endMinutes > propEndMinutes: isConflict = true } } diff --git a/backend/handlers/scheduling/time_blockers_test.go b/backend/handlers/scheduling/time_blockers_test.go index e6ff05b..1fd634f 100644 --- a/backend/handlers/scheduling/time_blockers_test.go +++ b/backend/handlers/scheduling/time_blockers_test.go @@ -30,6 +30,7 @@ import ( "crussell/handlers/payments" "crussell/internal/square" "crussell/mw" + "crussell/testutils" "crussell/testutils/fixtures" "github.com/go-chi/chi/v5" @@ -3888,7 +3889,7 @@ func TestAnonymizeStaleGuestAccounts_InvalidatesSquareCustomerCache(t *testing.T } origSquare := payments.SquareClient - payments.SquareClient = square.NewDevClient() + payments.SquareClient = testutils.NewTestSquareClient() defer func() { payments.SquareClient = origSquare }() t.Cleanup(func() { payments.InvalidateSquareCustomerCache(guestID) }) diff --git a/backend/handlers/user/gdpr_test.go b/backend/handlers/user/gdpr_test.go index 7b96486..dded57a 100644 --- a/backend/handlers/user/gdpr_test.go +++ b/backend/handlers/user/gdpr_test.go @@ -15,9 +15,7 @@ import ( "crussell/testutils" "crussell/testutils/fixtures" "crussell/testutils/jwt" -) - -// ============================================================ +) // ============================================================ // GetGDPRExportHandler Tests // ============================================================ @@ -503,6 +501,71 @@ func TestAnonymizeUser_RetainsEditRequestNotes(t *testing.T) { } } +// TestAnonymizeUser_ScrubsAdminAuditLog2FAFallback closes the GDPR erasure gap +// for admin_audit_log: a '2fa_fallback_charge' row (insertTwoFAFallbackAudit, +// handlers/payments) carries target_user_id = the erased user PLUS +// details.card_last4 — the audit row MUST survive erasure (GDPR Art 30 records +// of processing / financial audit trail) but de-identified: the user link is +// NULLed exactly as delete_guest_user() does (which scrubs target_user_id only, +// leaving details untouched — anonymize_user mirrors that consistency). +func TestAnonymizeUser_ScrubsAdminAuditLog2FAFallback(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + + // A 2FA-fallback audit row for a CIT saved-card charge: target_user_id is + // the customer and admin_id is the customer's own userID (the CIT actor — + // see insertTwoFAFallbackAudit). details carries the card_last4 PII. + var auditID string + err = tx.QueryRow(ctx, ` + INSERT INTO admin_audit_log (admin_id, action_type, target_user_id, details) + VALUES ($1, '2fa_fallback_charge', $1, $2::jsonb) + RETURNING id + `, userID, `{"sca_performed": false, "fallback_reason": "verification_unavailable", "card_last4": "4242", "reference_id": "booking123", "notes": "saved-card charge authorized via 2FA fallback (SCA unavailable)"}`).Scan(&auditID) + if err != nil { + t.Fatalf("failed to insert 2fa_fallback_charge audit row: %v", err) + } + + _, err = tx.Exec(ctx, `SELECT anonymize_user($1)`, userID) + if err != nil { + t.Fatalf("anonymize_user failed: %v", err) + } + + // The audit row survives erasure (audit retention) but is de-identified: + // target_user_id is NULLed. details is left untouched, mirroring + // delete_guest_user() exactly (it scrubs target_user_id only). + var targetUserID interface{} + var details json.RawMessage + err = tx.QueryRow(ctx, ` + SELECT target_user_id, details FROM admin_audit_log WHERE id = $1 + `, auditID).Scan(&targetUserID, &details) + if err != nil { + t.Fatalf("failed to query audit row after anonymization: %v", err) + } + if targetUserID != nil { + t.Errorf("expected target_user_id to be NULL after anonymization, got %v", targetUserID) + } + if len(details) == 0 { + t.Error("expected the audit row to survive erasure (retained, de-identified)") + } + + // No residual audit rows may still reference the erased user. + var remaining int + err = tx.QueryRow(ctx, ` + SELECT COUNT(*) FROM admin_audit_log WHERE target_user_id = $1 + `, userID).Scan(&remaining) + if err != nil { + t.Fatalf("failed to count residual audit rows: %v", err) + } + if remaining != 0 { + t.Errorf("expected 0 audit rows still referencing the erased user, got %d", remaining) + } +} + func TestAnonymizeUser_ClearsNotificationPrefs(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) diff --git a/backend/handlers/user/test_helpers_test.go b/backend/handlers/user/test_helpers_test.go new file mode 100644 index 0000000..284a35b --- /dev/null +++ b/backend/handlers/user/test_helpers_test.go @@ -0,0 +1,37 @@ +//go:build test + +package user + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "crussell/mw" + + "github.com/stretchr/testify/require" +) + +// deleteAccountRequest builds the DELETE /api/user/account request the handler +// requires (finding 3): the current password in the body, plus a 2FA code +// when one is supplied (enforced environments + 2FA-enabled users only). +// +// Defined in a shared, non-dev test helper file so prod-tag (test,!dev) test +// builds can keep exercising the deletion path (gdpr_test.go, profile_test.go) +// even though the coverage suite in user_coverage_test.go is dev-only. +func deleteAccountRequest(t *testing.T, ctx context.Context, userID, password, code string) *http.Request { + t.Helper() + body := map[string]string{"current_password": password} + if code != "" { + body["verification_code"] = code + } + b, err := json.Marshal(body) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodDelete, "/api/user/account", bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) + return req +} diff --git a/backend/handlers/user/twofa.go b/backend/handlers/user/twofa.go index ef23550..63c3843 100644 --- a/backend/handlers/user/twofa.go +++ b/backend/handlers/user/twofa.go @@ -74,30 +74,13 @@ var errTwoFADeliveryUnavailable = errors.New("2FA requires an email or SMS deliv // imports neither handlers/user nor handlers/payments, so the payments // card-access gate can verify a real 2FA challenge without an import cycle // (B11c). The identifiers below are thin aliases/wrappers so the HTTP handlers -// and the existing tests keep their original names. - -// twoFAMaxAttempts is the number of consecutive failed verify attempts allowed -// before the pending code is invalidated and a new one must be requested. -const twoFAMaxAttempts = twofa.MaxAttempts - -// twoFAAttemptWindow bounds how long a per-user attempt counter lives before -// resetting, and doubles as the stale-entry eviction horizon for the map. -const twoFAAttemptWindow = twofa.AttemptWindow +// keep their original names. // hashTwoFACode returns the hex digest of a verification code as stored in the // DB (pepper-driven HMAC-SHA256, or the legacy plain SHA-256 when the pepper is // unset). Delegates to the shared implementation. func hashTwoFACode(code string) string { return twofa.Hash(code) } -// legacyHashTwoFACode returns the pre-pepper plain SHA-256 digest. -func legacyHashTwoFACode(code string) string { return twofa.LegacyHash(code) } - -// verifyTwoFACodeHash reports whether reqCode matches a stored pending-code -// digest, always in constant time. Delegates to the shared implementation. -func verifyTwoFACodeHash(reqCode, storedHash string) (match, legacy bool) { - return twofa.VerifyHash(reqCode, storedHash) -} - // twoFAAttemptState aliases the shared per-user attempt state. type twoFAAttemptState = twofa.AttemptState @@ -116,12 +99,6 @@ func twoFAMintThrottled(st *twoFAAttemptState, now time.Time) bool { return !st.LastMintAt.IsZero() && now.Sub(st.LastMintAt) < twoFAMintCooldown } -// twoFAResetAttempts zeroes the shared per-user attempt counter in place. -// Called on successful verify only — a fresh code mint must NOT reset it (B11b). -func twoFAResetAttempts(userID string) { - twofa.ResetAttempts(userID) -} - // deliverTwoFACode generates a fresh verification code and persists only its // digest plus the pending expiry (updating two_factor_method when method is // non-empty). diff --git a/backend/handlers/user/twofa_test.go b/backend/handlers/user/twofa_test.go index 168ee37..bb16029 100644 --- a/backend/handlers/user/twofa_test.go +++ b/backend/handlers/user/twofa_test.go @@ -44,6 +44,23 @@ func twofaEnvEnforced(t *testing.T) { t.Setenv("SQUARE_ENVIRONMENT", "production") } +// twoFAMaxAttempts is the number of consecutive failed verify attempts allowed +// before the pending code is invalidated and a new one must be requested. +const twoFAMaxAttempts = twofa.MaxAttempts + +// twoFAAttemptWindow bounds how long a per-user attempt counter lives before +// resetting, and doubles as the stale-entry eviction horizon for the map. +const twoFAAttemptWindow = twofa.AttemptWindow + +// legacyHashTwoFACode returns the pre-pepper plain SHA-256 digest. +func legacyHashTwoFACode(code string) string { return twofa.LegacyHash(code) } + +// verifyTwoFACodeHash reports whether reqCode matches a stored pending-code +// digest, always in constant time. Delegates to the shared implementation. +func verifyTwoFACodeHash(reqCode, storedHash string) (match, legacy bool) { + return twofa.VerifyHash(reqCode, storedHash) +} + func twofaEnvUnenforced(t *testing.T) { t.Helper() // Explicit mock env: empty SQUARE_ENVIRONMENT now defaults to ENFORCED diff --git a/backend/handlers/user/user_coverage_test.go b/backend/handlers/user/user_coverage_test.go index 0b655cd..5f82f63 100644 --- a/backend/handlers/user/user_coverage_test.go +++ b/backend/handlers/user/user_coverage_test.go @@ -45,23 +45,6 @@ import ( // DeleteAccountHandler Coverage Tests // ============================================================================= -// deleteAccountRequest builds the DELETE /api/user/account request the handler -// now requires (finding 3): the current password in the body, plus a 2FA code -// when one is supplied (enforced environments + 2FA-enabled users only). -func deleteAccountRequest(t *testing.T, ctx context.Context, userID, password, code string) *http.Request { - t.Helper() - body := map[string]string{"current_password": password} - if code != "" { - body["verification_code"] = code - } - b, err := json.Marshal(body) - require.NoError(t, err) - req := httptest.NewRequest(http.MethodDelete, "/api/user/account", bytes.NewReader(b)) - req.Header.Set("Content-Type", "application/json") - req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) - return req -} - // TestDeleteAccount_Unauthorized verifies that deleting an account without // setting user ID in context returns 401 Unauthorized. func TestDeleteAccount_Unauthorized(t *testing.T) { @@ -996,8 +979,15 @@ func TestDeleteAccount_SkipsSharedSquareCustomer(t *testing.T) { // the same (anonymized) user must re-mint a fresh Square customer instead of // reusing the deleted one's stale cached id. func TestDeleteAccount_InvalidatesSquareCustomerCache(t *testing.T) { + // Use a prod-safe in-memory Square stand-in whose delete methods always + // succeed. The handler fires an ASYNC goroutine for Square erasure; if a + // delete fails it raises a critical notification through notifyCtx, which + // in tests carries the request transaction — writing to the SAME pgx.Tx the + // test below reads, racing it (pgx LRUCache/PgConn). Deterministic success + // keeps the goroutine on its own background pool context, so the test's tx + // is never touched concurrently. savedSquareClient := payments.SquareClient - payments.SquareClient = square.NewDevClient() + payments.SquareClient = testutils.NewTestSquareClient() t.Cleanup(func() { payments.SquareClient = savedSquareClient }) ctx, tx := testutils.SetupTestTx(t) diff --git a/backend/internal/jobs/cleanup_test.go b/backend/internal/jobs/cleanup_test.go index 5b0ff38..162cb5c 100644 --- a/backend/internal/jobs/cleanup_test.go +++ b/backend/internal/jobs/cleanup_test.go @@ -14,6 +14,7 @@ import ( "crussell/db" "crussell/handlers/payments" "crussell/internal/square" + "crussell/testutils" "crussell/testutils/testdb" ) @@ -378,13 +379,15 @@ func (c *recordingErasureClient) customerDeletes() []string { return append([]string(nil), c.deletedCustomers...) } -// newErasureTestClient builds a recording client over the dev mock, forcing +// newErasureTestClient builds a recording client over a prod-safe in-memory +// Square stand-in (testutils.NewTestSquareClient compiles under both dev and +// prod build tags, unlike the dev-only square.NewDevClient), forcing // SQUARE_ENVIRONMENT=mock so a developer's production env var can never panic -// NewDevClient mid-test. +// a dev NewDevClient mid-test. func newErasureTestClient(t *testing.T) *recordingErasureClient { t.Helper() t.Setenv("SQUARE_ENVIRONMENT", "mock") - return &recordingErasureClient{SquareClient: square.NewDevClient()} + return &recordingErasureClient{SquareClient: testutils.NewTestSquareClient()} } // seedErasureOutboxRow inserts a soft-deleted user_saved_cards row that the diff --git a/backend/internal/square/square_dev_test.go b/backend/internal/square/square_dev_test.go index f2224bb..534440e 100644 --- a/backend/internal/square/square_dev_test.go +++ b/backend/internal/square/square_dev_test.go @@ -1403,20 +1403,20 @@ func TestDevClient_CreatePayment_RejectsRawPAN(t *testing.T) { {"amex", "378282246310005"}, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := client.CreatePayment(ctx, CreatePaymentReq{ - Amount: 5000, - Currency: "GBP", - SourceID: tt.pan, - IdempotencyKey: "raw-pan-" + tt.name, - ReferenceID: "booking-raw", - }) - require.Error(t, err, "raw PAN must be rejected for production parity") - assert.Nil(t, result) - assert.Contains(t, err.Error(), "invalid source_id") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := client.CreatePayment(ctx, CreatePaymentReq{ + Amount: 5000, + Currency: "GBP", + SourceID: tt.pan, + IdempotencyKey: "raw-pan-" + tt.name, + ReferenceID: "booking-raw", }) - } + require.Error(t, err, "raw PAN must be rejected for production parity") + assert.Nil(t, result) + assert.Contains(t, err.Error(), "invalid source_id") + }) + } } // TestDevClient_CreatePayment_CardOnFileRequiresCustomerID verifies the mock @@ -2456,3 +2456,196 @@ func TestDevClient_CreatePayment_SavedCardVerification_Grandfathered(t *testing. }) require.Equal(t, "CARD_DECLINED_VERIFICATION_REQUIRED", ErrorCode(err), "a non-grandfathered card must stay gated") } + +// ============================================================================= +// Deterministic verification-token parsing and prefix binding +// ============================================================================= + +// TestParseVerifyToken pins the deterministic verify_mock__ +// [_ok|_deny] encoding: the outcome suffix defaults to approval, "_deny" sets +// denied=true, and anything malformed is not parseable (an opaque token — the +// same shape as real Square's verification tokens). +func TestParseVerifyToken(t *testing.T) { + tests := []struct { + name string + token string + wantOK bool + wantDenied bool + wantAmount int64 + wantPrefix string + }{ + {"explicit ok suffix", "verify_mock_mock_5000_ok", true, false, 5000, "mock"}, + {"deny suffix sets denied", "verify_mock_mock_5000_deny", true, true, 5000, "mock"}, + {"no suffix defaults to approval", "verify_mock_4242_2500", true, false, 2500, "4242"}, + {"prefix with underscores", "verify_mock_visa_3000_deny", true, true, 3000, "visa"}, + {"opaque token is not parseable", "vrf_opaque_123", false, false, 0, ""}, + {"marker with empty rest", "verify_mock_", false, false, 0, ""}, + {"non-numeric amount", "verify_mock_mock_abc_ok", false, false, 0, ""}, + {"missing amount", "verify_mock_mock_ok", false, false, 0, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parsed, ok := parseVerifyToken(tt.token) + require.Equal(t, tt.wantOK, ok) + if ok { + require.Equal(t, tt.wantDenied, parsed.denied) + require.Equal(t, tt.wantAmount, parsed.amount) + require.Equal(t, tt.wantPrefix, parsed.prefix) + } + }) + } +} + +// TestVerificationTokenPrefixForSource pins the card prefix a verify_mock_* +// token binds to for a source_id: the SAME derivation the dev frontend uses, so +// the two sides cannot drift. +func TestVerificationTokenPrefixForSource(t *testing.T) { + assert.Equal(t, "4242", verificationTokenPrefixForSource("cnon:test-card")) + assert.Equal(t, "4111", verificationTokenPrefixForSource("cnon:visa")) + assert.Equal(t, "5555", verificationTokenPrefixForSource("cnon:mastercard")) + assert.Equal(t, "3782", verificationTokenPrefixForSource("cnon:amex")) + assert.Equal(t, "mock", verificationTokenPrefixForSource("ccof:mock_card")) + assert.Equal(t, "abc", verificationTokenPrefixForSource("ccof:abc")) + assert.Equal(t, "abcd", verificationTokenPrefixForSource("ccof:abcd")) + assert.Equal(t, "", verificationTokenPrefixForSource("unknown-source")) +} + +// TestVerificationTokenInvalidError pins Square's VERIFICATION_TOKEN_INVALID +// rejection builder: a definitive payment error with the PAYMENT_METHOD_ERROR +// category and a 400 status. +func TestVerificationTokenInvalidError(t *testing.T) { + err := verificationTokenInvalidError("vrf_bad", "ccof:card_1") + require.Equal(t, "VERIFICATION_TOKEN_INVALID", ErrorCode(err)) + require.Equal(t, "PAYMENT_METHOD_ERROR", ErrorCategory(err)) + require.Equal(t, http.StatusBadRequest, ErrorStatusCode(err)) + require.True(t, IsDefinitivePaymentError(err), "VERIFICATION_TOKEN_INVALID must classify as a definitive payment error") +} + +// ============================================================================= +// Challenge resolution: ApprovePendingVerification, ChallengeResult auto/deny, +// and the stateless _deny token suffix +// ============================================================================= + +// TestDevClient_ApprovePendingVerification_OpaqueTokenResolution locks the +// shared-state challenge resolution for OPAQUE (real-Square-shaped) tokens: a +// token for a pending challenge is invalid, ApprovePendingVerification marks +// the challenge approved, and the next tokenized charge then succeeds. +func TestDevClient_ApprovePendingVerification_OpaqueTokenResolution(t *testing.T) { + client := NewDevClient().(*MockClient) + client.SimulateSavedCardVerificationRequired = true + ctx := context.Background() + const ccof = "ccof:mock_approve" + + _, err := client.CreatePayment(ctx, CreatePaymentReq{ + Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_appr", IdempotencyKey: "approve-gate", + }) + require.Equal(t, "CARD_DECLINED_VERIFICATION_REQUIRED", ErrorCode(err)) + + _, err = client.CreatePayment(ctx, CreatePaymentReq{ + Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_appr", IdempotencyKey: "approve-before", + VerificationToken: "vrf_opaque_not_approved", + }) + require.Equal(t, "VERIFICATION_TOKEN_INVALID", ErrorCode(err), "an opaque token for a still-pending challenge must be invalid") + + client.ApprovePendingVerification(ccof) + + result, err := client.CreatePayment(ctx, CreatePaymentReq{ + Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_appr", IdempotencyKey: "approve-after", + VerificationToken: "vrf_opaque_approved_1", + }) + require.NoError(t, err) + require.Equal(t, "COMPLETED", result.Status, "an approved challenge must resolve the opaque token") +} + +// TestDevClient_ApprovePendingVerification_CreatesIfMissing locks the +// create-if-missing behavior: approving a card with NO recorded challenge still +// lets a subsequent tokenized charge through. +func TestDevClient_ApprovePendingVerification_CreatesIfMissing(t *testing.T) { + client := NewDevClient().(*MockClient) + client.SimulateSavedCardVerificationRequired = true + ctx := context.Background() + const ccof = "ccof:mock_approve_fresh" + + client.ApprovePendingVerification(ccof) + + result, err := client.CreatePayment(ctx, CreatePaymentReq{ + Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_appr2", IdempotencyKey: "approve-fresh", + VerificationToken: "vrf_opaque_fresh_1", + }) + require.NoError(t, err) + require.Equal(t, "COMPLETED", result.Status) +} + +// TestDevClient_ChallengeResult_Auto locks ChallengeResult="auto": the banking- +// app challenge resolves itself as approved at gate time, so the next tokenized +// retry succeeds WITHOUT an explicit ApprovePendingVerification call. +func TestDevClient_ChallengeResult_Auto(t *testing.T) { + client := NewDevClient().(*MockClient) + client.SimulateSavedCardVerificationRequired = true + client.ChallengeResult = "auto" + ctx := context.Background() + const ccof = "ccof:mock_auto" + + _, err := client.CreatePayment(ctx, CreatePaymentReq{ + Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_auto", IdempotencyKey: "auto-gate", + }) + require.Equal(t, "CARD_DECLINED_VERIFICATION_REQUIRED", ErrorCode(err)) + + result, err := client.CreatePayment(ctx, CreatePaymentReq{ + Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_auto", IdempotencyKey: "auto-retry", + VerificationToken: "vrf_opaque_auto_1", + }) + require.NoError(t, err, "ChallengeResult=auto must pre-approve the challenge so the retry succeeds") + require.Equal(t, "COMPLETED", result.Status) +} + +// TestDevClient_ChallengeResult_Deny locks ChallengeResult="deny": the buyer +// denies every banking-app challenge, so ANY verification token — including a +// statelessly-approved deterministic one — is definitively rejected. +func TestDevClient_ChallengeResult_Deny(t *testing.T) { + client := NewDevClient().(*MockClient) + client.SimulateSavedCardVerificationRequired = true + client.ChallengeResult = "deny" + ctx := context.Background() + const ccof = "ccof:mock_deny_all" + + _, err := client.CreatePayment(ctx, CreatePaymentReq{ + Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_deny", IdempotencyKey: "deny-1", + VerificationToken: "verify_mock_mock_5000_ok", + }) + require.Error(t, err) + require.Equal(t, "VERIFICATION_TOKEN_INVALID", ErrorCode(err), "a buyer denial must reject even a deterministically-ok token") + + _, err = client.CreatePayment(ctx, CreatePaymentReq{ + Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_deny", IdempotencyKey: "deny-2", + VerificationToken: "verify_mock_mock_5000_deny", + }) + require.Equal(t, "VERIFICATION_TOKEN_INVALID", ErrorCode(err)) +} + +// TestDevClient_VerifyMockDenyTokenSuffix locks the stateless _deny token +// suffix: verify_mock___deny encodes a buyer denial and is +// rejected with VERIFICATION_TOKEN_INVALID even with NO shared-state challenge, +// while the _ok encoding of the SAME binding succeeds (the control). +func TestDevClient_VerifyMockDenyTokenSuffix(t *testing.T) { + client := NewDevClient().(*MockClient) + client.SimulateSavedCardVerificationRequired = true + ctx := context.Background() + const ccof = "ccof:mock_deny_suffix" + + _, err := client.CreatePayment(ctx, CreatePaymentReq{ + Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_deny2", IdempotencyKey: "deny-suffix", + VerificationToken: "verify_mock_mock_5000_deny", + }) + require.Error(t, err) + require.Equal(t, "VERIFICATION_TOKEN_INVALID", ErrorCode(err)) + require.Equal(t, http.StatusBadRequest, ErrorStatusCode(err)) + require.True(t, IsDefinitivePaymentError(err), "a _deny token is a definitive rejection") + + result, err := client.CreatePayment(ctx, CreatePaymentReq{ + Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_deny2", IdempotencyKey: "deny-suffix-ok", + VerificationToken: "verify_mock_mock_5000_ok", + }) + require.NoError(t, err) + require.Equal(t, "COMPLETED", result.Status, "the _ok encoding of the same binding must succeed (control)") +} diff --git a/backend/testutils/square_stub.go b/backend/testutils/square_stub.go new file mode 100644 index 0000000..188ed92 --- /dev/null +++ b/backend/testutils/square_stub.go @@ -0,0 +1,92 @@ +//go:build test + +package testutils + +import ( + "context" + "crypto/sha256" + "fmt" + "sync" + "time" + + "crussell/clock" + "crussell/internal/square" +) + +// NewTestSquareClient returns an in-memory SquareClient for tests that must +// compile under BOTH the dev ("test,dev") and prod ("test,!dev") build tags. +// The real dev mock (internal/square.NewDevClient) is only compiled under the +// dev tag, so prod-tag tests cannot reference it. This lightweight stand-in +// implements just the surface the GDPR erasure / cache-invalidation tests +// exercise (CreateCustomer, CreateCardOnFile, DeleteCustomer, +// DeleteCardOnFile); any other method panics (never called by those tests). +// +// CreateCustomer is deterministic per email: re-provisioning the SAME email +// returns the same id, while a different email (e.g. the anonymized +// anon-{id}@anon.invalid address) mints a different id — the property the +// cache-invalidation tests assert on. +func NewTestSquareClient() square.SquareClient { + return &testSquareClient{ + customersByEmail: map[string]*square.CustomerResult{}, + } +} + +// testSquareClient is a minimal in-memory SquareClient for test-only use. +// It embeds the square.SquareClient interface so it satisfies the full +// interface without implementing every method; only the methods below are +// overridden and actually called by the erasure/cache tests. +type testSquareClient struct { + square.SquareClient // embedded interface — satisfies SquareClient; unoverridden methods panic if called + + mu sync.Mutex + seq int + customersByEmail map[string]*square.CustomerResult +} + +func (c *testSquareClient) CreateCustomer(ctx context.Context, name, email string) (*square.CustomerResult, error) { + c.mu.Lock() + defer c.mu.Unlock() + if existing, ok := c.customersByEmail[email]; ok { + return existing, nil + } + sum := sha256.Sum256([]byte(email)) + customer := &square.CustomerResult{ + ID: "cus_test_" + fmt.Sprintf("%x", sum)[:12], + Email: email, + CreatedAt: clock.Now().UTC().Format(time.RFC3339), + } + c.customersByEmail[email] = customer + return customer, nil +} + +func (c *testSquareClient) CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*square.CardOnFile, error) { + c.mu.Lock() + defer c.mu.Unlock() + c.seq++ + return &square.CardOnFile{ + ID: fmt.Sprintf("test_card_%d", c.seq), + CardID: fmt.Sprintf("ccof:test_%d", c.seq), + Brand: "VISA", + Last4: "4242", + ExpMonth: 12, + ExpYear: 2030, + ReferenceID: userID, + Enabled: true, + }, nil +} + +func (c *testSquareClient) DeleteCardOnFile(ctx context.Context, cardID string) error { + return nil +} + +func (c *testSquareClient) DeleteCustomer(ctx context.Context, customerID string) error { + c.mu.Lock() + defer c.mu.Unlock() + for email, customer := range c.customersByEmail { + if customer.ID == customerID { + delete(c.customersByEmail, email) + return nil + } + } + return nil +} diff --git a/frontend/eslint.a11y.config.js b/frontend/eslint.a11y.config.js index 82266ed..3e38bec 100644 --- a/frontend/eslint.a11y.config.js +++ b/frontend/eslint.a11y.config.js @@ -28,6 +28,18 @@ export default defineConfig( { languageOptions: { globals: { ...globals.browser, ...globals.node } + }, + rules: { + // Match eslint.config.js: underscore-prefixed variables are + // intentionally unused (catch params, no-op callbacks, map keys). + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_' + } + ] } }, { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9296118..c7ba23a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -124,44 +124,10 @@ "wasm-feature-detect": "^1.5.1" } }, - "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "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/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", "dev": true, "license": "MIT", "dependencies": { @@ -221,19 +187,6 @@ } } }, - "node_modules/@eslint/compat/node_modules/@eslint/core": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", - "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, "node_modules/@eslint/config-array": { "version": "0.21.2", "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", @@ -262,7 +215,7 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/core": { + "node_modules/@eslint/config-helpers/node_modules/@eslint/core": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", @@ -275,6 +228,19 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, "node_modules/@eslint/eslintrc": { "version": "3.3.6", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", @@ -349,31 +315,44 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@floating-ui/core": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", - "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", "dev": true, "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.2.11" + "@floating-ui/utils": "^0.2.12" } }, "node_modules/@floating-ui/dom": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", - "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", "dev": true, "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.7.5", - "@floating-ui/utils": "^0.2.11" + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" } }, "node_modules/@floating-ui/utils": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", - "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", "dev": true, "license": "MIT" }, @@ -444,9 +423,9 @@ } }, "node_modules/@internationalized/date": { - "version": "3.12.2", - "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.2.tgz", - "integrity": "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==", + "version": "3.12.3", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.3.tgz", + "integrity": "sha512-fuLX+3ZKLsxI73y8b01EG/WjHb6gE6weCqlfawPO27kBWGMh9G1yH6Csv1uU7/cac9H2GHmOMt6CjmuQ1aia4Q==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -528,9 +507,9 @@ } }, "node_modules/@lucide/svelte": { - "version": "1.24.0", - "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.24.0.tgz", - "integrity": "sha512-yXwewA7ANQ5hfaSDrvsecosWjZn5RglzeXUZRSnxeANBskpNwblOkEJTqD0ujDdNKIKL8E9eVc2U/P3ziJr7OA==", + "version": "1.31.0", + "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.31.0.tgz", + "integrity": "sha512-pgPuLEJdF0UpsajG9eSw0YvqXVuDxB+/x58BRiC4xE/KEHiopOqT1id3KCUvRN/PUmQkN1F26sslcs/1jutlBQ==", "dev": true, "license": "ISC", "peerDependencies": { @@ -650,29 +629,10 @@ "pbf": "bin/pbf" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", - "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.3" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, "node_modules/@oxc-project/types": { - "version": "0.139.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", - "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "version": "0.144.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz", + "integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==", "dev": true, "license": "MIT", "funding": { @@ -687,9 +647,9 @@ "license": "MIT" }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", - "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz", + "integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==", "cpu": [ "arm64" ], @@ -704,9 +664,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", - "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==", "cpu": [ "arm64" ], @@ -721,9 +681,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", - "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz", + "integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==", "cpu": [ "x64" ], @@ -738,9 +698,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", - "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz", + "integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==", "cpu": [ "x64" ], @@ -755,9 +715,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", - "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz", + "integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==", "cpu": [ "arm" ], @@ -772,9 +732,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", - "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz", + "integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==", "cpu": [ "arm64" ], @@ -789,9 +749,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", - "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz", + "integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==", "cpu": [ "arm64" ], @@ -806,9 +766,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", - "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz", + "integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==", "cpu": [ "ppc64" ], @@ -823,9 +783,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", - "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz", + "integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==", "cpu": [ "s390x" ], @@ -840,9 +800,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", - "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz", + "integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==", "cpu": [ "x64" ], @@ -857,9 +817,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", - "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz", + "integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==", "cpu": [ "x64" ], @@ -874,9 +834,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", - "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz", + "integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==", "cpu": [ "arm64" ], @@ -890,29 +850,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", - "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.1.6" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", - "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz", + "integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==", "cpu": [ "arm64" ], @@ -927,9 +868,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", - "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz", + "integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==", "cpu": [ "x64" ], @@ -958,9 +899,9 @@ "license": "MIT" }, "node_modules/@sveltejs/acorn-typescript": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz", - "integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==", + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz", + "integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -978,9 +919,9 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.69.2", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.69.2.tgz", - "integrity": "sha512-CMdPDbYjRwRu4KXTxBVMuOpFPCt1i/v0ANennotec+K9Cmb2e3w2yYzJiC6Vh/WSvm9Khi5sJMZa0rJPqfHlDw==", + "version": "2.70.2", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.2.tgz", + "integrity": "sha512-RzRoRpuR2KXqc5yMO0akQHDZeT4AslOlznGITURsqHaVbtyYP4Wn3eE3gxj9JcDyNYO0crkxhdwFHc+2vkVm6w==", "dev": true, "license": "MIT", "dependencies": { @@ -1020,9 +961,9 @@ } }, "node_modules/@sveltejs/load-config": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.0.tgz", - "integrity": "sha512-1LgZ/qUqSoq+QorD83lk2hka79Px0wXNW2q5V1nZlxGhQgw1jrsIbVz5YiCeucVLo4XvFLjXukUaQjIiqowkcg==", + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.3.tgz", + "integrity": "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==", "dev": true, "license": "MIT", "engines": { @@ -1030,14 +971,14 @@ } }, "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.2.0.tgz", - "integrity": "sha512-1SpkuMSRLfugrVX+IrKfE1RUegzo8AQzKQ6qQPfVzbcWi5IhuTPaKb5ZrLpucleFznkc4/RTeSPoRnGWFxX+EQ==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.3.0.tgz", + "integrity": "sha512-QbRoJyD92e9R0ufeQIWRHrCC0ObcqSv/aBDdrQMoU+sypav3cDx5wytdQ6GLdXjEMO6xjrXGzfkUygng8JMv0A==", "dev": true, "license": "MIT", "dependencies": { "deepmerge": "^4.3.1", - "magic-string": "^0.30.21", + "magic-string": "^1.0.0", "obug": "^2.1.0", "vitefu": "^1.1.2" }, @@ -1049,6 +990,16 @@ "vite": "^8.0.0-beta.7 || ^8.0.0" } }, + "node_modules/@sveltejs/vite-plugin-svelte/node_modules/magic-string": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.0.tgz", + "integrity": "sha512-ptco+HFxTLgjafSLim2LojBSwfg5feBjd+SqyiwdGkzC38UPdZy3zgrHMI2CoTf5fJL38tbHMYWVzIH8BxGqJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/@swc/helpers": { "version": "0.5.23", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", @@ -1060,49 +1011,49 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", - "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "5.21.6", + "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.3.2" + "tailwindcss": "4.3.3" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", - "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", "dev": true, "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.3.2", - "@tailwindcss/oxide-darwin-arm64": "4.3.2", - "@tailwindcss/oxide-darwin-x64": "4.3.2", - "@tailwindcss/oxide-freebsd-x64": "4.3.2", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", - "@tailwindcss/oxide-linux-x64-musl": "4.3.2", - "@tailwindcss/oxide-wasm32-wasi": "4.3.2", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", - "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", "cpu": [ "arm64" ], @@ -1117,9 +1068,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", - "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", "cpu": [ "arm64" ], @@ -1134,9 +1085,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", - "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", "cpu": [ "x64" ], @@ -1151,9 +1102,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", - "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", "cpu": [ "x64" ], @@ -1168,9 +1119,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", - "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", "cpu": [ "arm" ], @@ -1185,9 +1136,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", - "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", "cpu": [ "arm64" ], @@ -1202,9 +1153,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", - "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", "cpu": [ "arm64" ], @@ -1219,9 +1170,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", - "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", "cpu": [ "x64" ], @@ -1236,9 +1187,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", - "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", "cpu": [ "x64" ], @@ -1253,9 +1204,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", - "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -1283,9 +1234,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", - "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", "cpu": [ "arm64" ], @@ -1300,9 +1251,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", - "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", "cpu": [ "x64" ], @@ -1317,31 +1268,20 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz", - "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", "dev": true, "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.3.2", - "@tailwindcss/oxide": "4.3.2", - "tailwindcss": "4.3.2" + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, - "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/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -1388,9 +1328,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.1.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", - "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", "dev": true, "license": "MIT", "dependencies": { @@ -1405,17 +1345,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.63.0.tgz", - "integrity": "sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", + "integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.63.0", - "@typescript-eslint/type-utils": "8.63.0", - "@typescript-eslint/utils": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/type-utils": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -1428,15 +1368,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.63.0", + "@typescript-eslint/parser": "^8.67.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", "dev": true, "license": "MIT", "engines": { @@ -1444,16 +1384,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.63.0.tgz", - "integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", + "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.63.0", - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/typescript-estree": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", "debug": "^4.4.3" }, "engines": { @@ -1469,14 +1409,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz", - "integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz", + "integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.63.0", - "@typescript-eslint/types": "^8.63.0", + "@typescript-eslint/tsconfig-utils": "^8.67.0", + "@typescript-eslint/types": "^8.67.0", "debug": "^4.4.3" }, "engines": { @@ -1491,14 +1431,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz", - "integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz", + "integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0" + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1509,9 +1449,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz", - "integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz", + "integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==", "dev": true, "license": "MIT", "engines": { @@ -1526,15 +1466,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.63.0.tgz", - "integrity": "sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz", + "integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/typescript-estree": "8.63.0", - "@typescript-eslint/utils": "8.63.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -1551,9 +1491,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz", - "integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", + "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", "dev": true, "license": "MIT", "engines": { @@ -1565,16 +1505,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz", - "integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz", + "integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.63.0", - "@typescript-eslint/tsconfig-utils": "8.63.0", - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0", + "@typescript-eslint/project-service": "8.67.0", + "@typescript-eslint/tsconfig-utils": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -1603,26 +1543,26 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.5" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -1632,16 +1572,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.63.0.tgz", - "integrity": "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz", + "integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.63.0", - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/typescript-estree": "8.63.0" + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1656,13 +1596,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz", - "integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz", + "integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/types": "8.67.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -1831,9 +1771,9 @@ } }, "node_modules/@zxcvbn-ts/core": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@zxcvbn-ts/core/-/core-4.1.2.tgz", - "integrity": "sha512-RQmxWB3AMI+HGQErQdUv6Aq32aQhp6xOxrfgCP0+T9MsLZoP3xtLHuT8O8VojsUxdmQVZfJlYkYb1A0wOwIS+Q==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@zxcvbn-ts/core/-/core-4.2.0.tgz", + "integrity": "sha512-tuwzwOcYVca9MufHuUvzKGRyL04iP52OjCNoRDuGXjzzrtPiPcl8r5QtOotbx0AyTR/CMcDTU9qLkN16b9fXiw==", "license": "MIT", "dependencies": { "fastest-levenshtein": "1.0.16" @@ -1846,9 +1786,9 @@ "license": "MIT" }, "node_modules/@zxcvbn-ts/language-common": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@zxcvbn-ts/language-common/-/language-common-4.1.2.tgz", - "integrity": "sha512-uJlBzhC9/KjPImqdnc1/lPxmdn4xKbkruN5p1mASWkXA0gli+GZ5LrVL+dqscA8Pcf4OfudE56TtCWeHljJOvA==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@zxcvbn-ts/language-common/-/language-common-4.1.3.tgz", + "integrity": "sha512-g2bg4skmlJG9aJqC/nWziZzgYpSe/f3rKeGobrkdFiOrfZVFqqzUgC6BUQWuX54Sua9j0Jxxt+1bf4f/Mal1Kg==", "license": "MIT", "dependencies": { "@zxcvbn-ts/dictionary-compression": "^3.0.1" @@ -1864,9 +1804,9 @@ } }, "node_modules/acorn": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", - "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", "bin": { @@ -2000,35 +1940,10 @@ "svelte": "^5.33.0" } }, - "node_modules/bits-ui/node_modules/runed": { - "version": "0.35.1", - "resolved": "https://registry.npmjs.org/runed/-/runed-0.35.1.tgz", - "integrity": "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==", - "dev": true, - "funding": [ - "https://github.com/sponsors/huntabyte", - "https://github.com/sponsors/tglide" - ], - "license": "MIT", - "dependencies": { - "dequal": "^2.0.3", - "esm-env": "^1.0.0", - "lz-string": "^1.5.0" - }, - "peerDependencies": { - "@sveltejs/kit": "^2.21.0", - "svelte": "^5.7.0" - }, - "peerDependenciesMeta": { - "@sveltejs/kit": { - "optional": true - } - } - }, "node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -2237,9 +2152,9 @@ } }, "node_modules/devalue": { - "version": "5.8.1", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", - "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.0.tgz", + "integrity": "sha512-RWrqdArjvPbsATEhOPUo6Wndc/iWnkWKlhIrdlF3zMMYo/c3CVtoaVAyLtWxz5h8nSlkHzxnzV2uLydPXmtF+A==", "dev": true, "license": "MIT" }, @@ -2250,9 +2165,9 @@ "license": "ISC" }, "node_modules/enhanced-resolve": { - "version": "5.21.6", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", - "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", "dev": true, "license": "MIT", "dependencies": { @@ -2360,9 +2275,9 @@ } }, "node_modules/eslint-plugin-svelte": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.20.0.tgz", - "integrity": "sha512-AElKLVt7Hjy4d7ljwhrhw9hux60DCxCNkmK8cY/aAXvjs8tpR7PvU4DlyI/SA1PaJww1gh0wPGo2pbyURuEwxQ==", + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.23.0.tgz", + "integrity": "sha512-n9jRklDqy0+W834568a4ZIZTK7DHXxvvHHxxGP8nNq7N//pOZMubttskHOquyGgfTR4Z05q2NdGNlsTpIEA48w==", "dev": true, "license": "MIT", "dependencies": { @@ -2436,6 +2351,19 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/esm-env": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", @@ -2475,9 +2403,9 @@ } }, "node_modules/esrap": { - "version": "2.2.13", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.13.tgz", - "integrity": "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA==", + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.4.tgz", + "integrity": "sha512-CHnJXZDfBeOAjLIGNy/0BEsmDu5+irHgUXRW9pGptxCbO3uyjJ98crilzFgiBqizW94UodjX3GAPLg/dhEnUfA==", "dev": true, "license": "MIT", "dependencies": { @@ -2638,9 +2566,9 @@ } }, "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", "dev": true, "license": "ISC" }, @@ -2679,9 +2607,9 @@ } }, "node_modules/globals": { - "version": "17.7.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", - "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", + "version": "17.11.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.11.0.tgz", + "integrity": "sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==", "dev": true, "license": "MIT", "engines": { @@ -2862,9 +2790,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -3464,9 +3392,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -3497,9 +3425,9 @@ "license": "MIT" }, "node_modules/obug": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", - "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -3633,9 +3561,9 @@ } }, "node_modules/postcss": { - "version": "8.5.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", - "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -3653,7 +3581,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -3756,9 +3684,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz", - "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.5.tgz", + "integrity": "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw==", "dev": true, "license": "MIT", "dependencies": { @@ -3786,9 +3714,9 @@ } }, "node_modules/prettier": { - "version": "3.9.5", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz", - "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", "dev": true, "license": "MIT", "bin": { @@ -3816,9 +3744,9 @@ } }, "node_modules/prettier-plugin-tailwindcss": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.8.0.tgz", - "integrity": "sha512-V8ITGH87yuBDF6JpEZTOVlUz/saAwqb8f3HRgUj8Lh+tGCcrmorhsLpYqzygwFwK0PE2Ib6Mv3M7T/uE2tZV1g==", + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.8.1.tgz", + "integrity": "sha512-iaFMYqDsE4ffdDkn5qup0j5f2aCEBFZrdrZnvu9QKTlWx/iGPeQ4HHu7b7fCPMxeo9nwQBiOAh2nSypdFYWJkw==", "dev": true, "license": "MIT", "engines": { @@ -3950,13 +3878,13 @@ } }, "node_modules/rolldown": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", - "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz", + "integrity": "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.139.0", + "@oxc-project/types": "=0.144.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -3966,21 +3894,45 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.1.5", - "@rolldown/binding-darwin-arm64": "1.1.5", - "@rolldown/binding-darwin-x64": "1.1.5", - "@rolldown/binding-freebsd-x64": "1.1.5", - "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", - "@rolldown/binding-linux-arm64-gnu": "1.1.5", - "@rolldown/binding-linux-arm64-musl": "1.1.5", - "@rolldown/binding-linux-ppc64-gnu": "1.1.5", - "@rolldown/binding-linux-s390x-gnu": "1.1.5", - "@rolldown/binding-linux-x64-gnu": "1.1.5", - "@rolldown/binding-linux-x64-musl": "1.1.5", - "@rolldown/binding-openharmony-arm64": "1.1.5", - "@rolldown/binding-wasm32-wasi": "1.1.5", - "@rolldown/binding-win32-arm64-msvc": "1.1.5", - "@rolldown/binding-win32-x64-msvc": "1.1.5" + "@rolldown/binding-android-arm64": "1.2.4", + "@rolldown/binding-darwin-arm64": "1.2.4", + "@rolldown/binding-darwin-x64": "1.2.4", + "@rolldown/binding-freebsd-x64": "1.2.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.4", + "@rolldown/binding-linux-arm64-gnu": "1.2.4", + "@rolldown/binding-linux-arm64-musl": "1.2.4", + "@rolldown/binding-linux-ppc64-gnu": "1.2.4", + "@rolldown/binding-linux-s390x-gnu": "1.2.4", + "@rolldown/binding-linux-x64-gnu": "1.2.4", + "@rolldown/binding-linux-x64-musl": "1.2.4", + "@rolldown/binding-openharmony-arm64": "1.2.4", + "@rolldown/binding-win32-arm64-msvc": "1.2.4", + "@rolldown/binding-win32-x64-msvc": "1.2.4" + } + }, + "node_modules/runed": { + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.35.1.tgz", + "integrity": "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "esm-env": "^1.0.0", + "lz-string": "^1.5.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.21.0", + "svelte": "^5.7.0" + }, + "peerDependenciesMeta": { + "@sveltejs/kit": { + "optional": true + } } }, "node_modules/sade": { @@ -4017,16 +3969,16 @@ "license": "MIT" }, "node_modules/shadcn-svelte": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/shadcn-svelte/-/shadcn-svelte-1.4.1.tgz", - "integrity": "sha512-lpvnuHJOMf/CRlOMo5h7HpmdpcWmqVoPyvnCcgpJWJWzVkiwmisF80mR54GIAExSTSk1g4+ere3QrSty2PCIIg==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/shadcn-svelte/-/shadcn-svelte-1.5.0.tgz", + "integrity": "sha512-fcxTeUwEIvELkL/h0Vg2i/9Sf6exAmLG5u3GXc0aN8CA+3fNffKQnfrTdMgkjUEmx9XP619I+wcJOvlr5aDWYQ==", "dev": true, "license": "MIT", "dependencies": { "commander": "^14.0.0", "node-fetch-native": "^1.6.4", "postcss": "^8.5.10", - "tailwind-merge": "^3.0.0" + "tailwind-merge": "^3.6.0" }, "bin": { "shadcn-svelte": "dist/index.mjs" @@ -4141,9 +4093,9 @@ } }, "node_modules/svelte": { - "version": "5.56.4", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz", - "integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==", + "version": "5.56.9", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.9.tgz", + "integrity": "sha512-VT8kSnlEg8069w7AiCcAk3Yf5xvMnrGTagVOmU/OpOLHaHnNqXhWZCH/4EVga/bT/HtWhvE6/fHrXLErx7OnJA==", "dev": true, "license": "MIT", "dependencies": { @@ -4169,14 +4121,14 @@ } }, "node_modules/svelte-check": { - "version": "4.7.2", - "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.2.tgz", - "integrity": "sha512-GoS4XJdGswlq0rIT1vtFLzJY1bvHtY37McY9H9Gkm1Ggw/ICdZYn8J/Z8Yi0BEL0i3R4+jtaWVePjyppMlij/A==", + "version": "4.7.6", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.6.tgz", + "integrity": "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", - "@sveltejs/load-config": "^0.2.0", + "@sveltejs/load-config": "^0.2.3", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", @@ -4190,7 +4142,7 @@ }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", - "typescript": ">=5.0.0" + "typescript": "^5.0.0 || ^6.0.0" } }, "node_modules/svelte-easy-crop": { @@ -4235,9 +4187,9 @@ } }, "node_modules/svelte-sonner": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/svelte-sonner/-/svelte-sonner-1.1.1.tgz", - "integrity": "sha512-5cd3p7wa4cq0NsqslMwdlPb7x1JglEZ/GKrLePWNr5bCxR1nagAVrY01FRFrXfUGs41miLt3C327+8XJo5BzZw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/svelte-sonner/-/svelte-sonner-1.2.1.tgz", + "integrity": "sha512-N00LOWb60fjKXULbpngku+7E9Krb16KpNq4/Eax5Vu7o1Dpbca55QpaLPsB+yp82JIEdFhfJuv5cVXLoe0ZtVA==", "dev": true, "license": "MIT", "dependencies": { @@ -4285,31 +4237,6 @@ "svelte": "^5.30.2" } }, - "node_modules/svelte-toolbelt/node_modules/runed": { - "version": "0.35.1", - "resolved": "https://registry.npmjs.org/runed/-/runed-0.35.1.tgz", - "integrity": "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==", - "dev": true, - "funding": [ - "https://github.com/sponsors/huntabyte", - "https://github.com/sponsors/tglide" - ], - "license": "MIT", - "dependencies": { - "dequal": "^2.0.3", - "esm-env": "^1.0.0", - "lz-string": "^1.5.0" - }, - "peerDependencies": { - "@sveltejs/kit": "^2.21.0", - "svelte": "^5.7.0" - }, - "peerDependenciesMeta": { - "@sveltejs/kit": { - "optional": true - } - } - }, "node_modules/tabbable": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz", @@ -4329,13 +4256,13 @@ } }, "node_modules/tailwind-variants": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.2.2.tgz", - "integrity": "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.3.1.tgz", + "integrity": "sha512-4pAvwUtM4HKBiRZftncAbpn6V9Hhwoa5Fl7O2u5zbp7Z5Cvu+/o/6+176WY3WCEES209543quG8zFIcXCsc5Jw==", "dev": true, "license": "MIT", "engines": { - "node": ">=16.x", + "node": ">=16.9.x", "pnpm": ">=7.x" }, "peerDependencies": { @@ -4345,13 +4272,16 @@ "peerDependenciesMeta": { "tailwind-merge": { "optional": true + }, + "tailwindcss": { + "optional": true } } }, "node_modules/tailwindcss": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", - "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", "dev": true, "license": "MIT" }, @@ -4487,16 +4417,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.63.0.tgz", - "integrity": "sha512-xgwXyzG4sK9ALkBxbyGkTMMOS+imnW65iPhxCQMK83KhxyoDNW7l+IDqEf9vMdoUidHpOoS967RCq4eMiTexwQ==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.67.0.tgz", + "integrity": "sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.63.0", - "@typescript-eslint/parser": "8.63.0", - "@typescript-eslint/typescript-estree": "8.63.0", - "@typescript-eslint/utils": "8.63.0" + "@typescript-eslint/eslint-plugin": "8.67.0", + "@typescript-eslint/parser": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4535,16 +4465,16 @@ "license": "MIT" }, "node_modules/vite": { - "version": "8.1.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", - "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", "dev": true, "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", + "lightningcss": "^1.33.0", "picomatch": "^4.0.5", - "postcss": "^8.5.16", - "rolldown": "~1.1.4", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", "tinyglobby": "^0.2.17" }, "bin": { @@ -4561,7 +4491,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.3.0", + "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -4612,6 +4542,267 @@ } } }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/vitefu": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", @@ -4723,9 +4914,9 @@ } }, "node_modules/wasm-feature-detect": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz", - "integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.9.0.tgz", + "integrity": "sha512-zonE+xlIIYtxPy++L24ow0hAD8CICb4+FgPyROd3buyXIqsJvUEDkBgfCCoXOd1Hu3DUr0GOfnPIdcGV+YpNaA==", "license": "Apache-2.0" }, "node_modules/which": { diff --git a/frontend/src/lib/components/account/EditRequestModal.svelte b/frontend/src/lib/components/account/EditRequestModal.svelte index 8fd35a8..4bcb583 100644 --- a/frontend/src/lib/components/account/EditRequestModal.svelte +++ b/frontend/src/lib/components/account/EditRequestModal.svelte @@ -1,6 +1,6 @@
@@ -585,13 +590,13 @@ async function runTillSavedCardSCA(body: Record): Promise >
{#if giftCardAmountTooHigh} -

Gift card amount exceeds maximum (£{GIFT_CARD_MAX_AMOUNT})

+

+ Gift card amount exceeds maximum (£{GIFT_CARD_MAX_AMOUNT}) +

{/if} -

Gift card limit £{GIFT_CARD_MAX_AMOUNT} per transaction

+

+ Gift card limit £{GIFT_CARD_MAX_AMOUNT} per transaction +

{:else} + + {/if} import { onMount, onDestroy } from 'svelte'; import { formatTime } from '$lib/utils/timeSlots'; + import { SvelteDate } from 'svelte/reactivity'; type DefaultHours = { weekday: number; // 0=Mon, 1=Tue, ..., 6=Sun @@ -80,7 +81,7 @@ const today = getLondonDate(); const dayOfWeek = today.getDay(); // 0=Sun const offset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek; - const monday = new Date(today); + const monday = new SvelteDate(today); monday.setDate(today.getDate() + offset); return fmtDate(monday); } diff --git a/frontend/src/lib/components/payments/CardSelection.svelte b/frontend/src/lib/components/payments/CardSelection.svelte index 285238c..39a06d3 100644 --- a/frontend/src/lib/components/payments/CardSelection.svelte +++ b/frontend/src/lib/components/payments/CardSelection.svelte @@ -115,7 +115,9 @@

Two-factor authentication is required to use online card payments. - Enable it in your account settings. + Enable it in your account settings.

{/if} @@ -127,7 +129,7 @@ - + diff --git a/frontend/src/lib/components/payments/PaymentModal.svelte b/frontend/src/lib/components/payments/PaymentModal.svelte index aa0def5..6a4eb62 100644 --- a/frontend/src/lib/components/payments/PaymentModal.svelte +++ b/frontend/src/lib/components/payments/PaymentModal.svelte @@ -9,13 +9,13 @@ import type { Booking, BookingService, BookingDiscount } from '$lib/types/booking'; import { apiFetch } from '$lib/utils/api'; import { + CARD_VERIFICATION_RETRY_MESSAGE, campaignDiscountPence, - isSavedCardVerificationRequired, isTwoFactorVerificationGateFailure, isVerificationRequiredSignal, sanitizeDecimalInput, shouldFallbackTo2FA, - SAVED_CARD_VERIFICATION_MESSAGE, + SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE, submitPaymentWithRetry, VERIFICATION_REQUIRED_MESSAGE, adminRequestNewTwoFactorCode, @@ -491,6 +491,20 @@ onClose(); } + // True while a charge (or the out-of-band SCA challenge the customer must + // approve in their banking app) is in flight — ESC/overlay close must be + // blocked then, because the charge may still land. + function isChargeInFlight(status: PaymentStatus): boolean { + return ( + status === 'card-processing' || + status === 'card-polling' || + status === 'cash-confirming' || + status === 'gift-confirming' || + status === 'saved-card-processing' || + status === 'saved-card-waiting-sca' + ); + } + // Called from the success state's Done button: notify the parent (so it can // refresh the booking/payment data) and then close the modal. Kept separate // from handleClose so a success state never closes without the callback. @@ -852,7 +866,9 @@ await runSavedCardSCA(chargeAmount); return; } - const err = new Error(extractErrorMessage(errData) || 'Failed to process saved card payment'); + const err = new Error( + extractErrorMessage(errData) || 'Failed to process saved card payment' + ); (err as { bodyText?: string }).bodyText = errData; throw err; } @@ -879,16 +895,14 @@ onComplete(paymentResult); } catch (_err) { status = 'error'; - // A definitive 402 on the saved-card path means the issuer still - // requires verification. A structured verification-required signal - // surfaces the SCA-first guidance; the legacy saved-card check is the - // fallback for generic 402s (the SCA flow above intercepts the - // structured ones, so this is the belt-and-braces path). + // A 402 carrying the structured verification-required signal (or the + // dev/mock text parity) surfaces the SCA-first guidance. A plain + // decline 402 shows the normal decline error — it must not be + // relabeled "requires verification". let msg = _err instanceof Error ? _err.message : 'Failed to process saved card payment'; const bodyText = (_err as { bodyText?: string })?.bodyText ?? ''; - const scaVerificationRequired = isVerificationRequiredSignal(responseStatus, bodyText); - if (scaVerificationRequired || isSavedCardVerificationRequired(responseStatus, true)) { - msg = scaVerificationRequired ? VERIFICATION_REQUIRED_MESSAGE : SAVED_CARD_VERIFICATION_MESSAGE; + if (isVerificationRequiredSignal(responseStatus, bodyText)) { + msg = VERIFICATION_REQUIRED_MESSAGE; } // B6/B10: a 2FA verification-gate rejection (missing/invalid/expired // code, brute-force lockout) is recoverable — keep the code populated @@ -1004,13 +1018,20 @@ status = 'error'; error = result.outcome === 'sca-unavailable' - ? `${VERIFICATION_REQUIRED_MESSAGE} In-app approval isn't available for this card — enter the verification code instead.` - : "Card verification was cancelled or didn't complete. Try again, or enter the verification code instead."; + ? `${VERIFICATION_REQUIRED_MESSAGE} ${SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE}` + : CARD_VERIFICATION_RETRY_MESSAGE; toast.error(error); } - !open && handleClose()}> + { + if (open) return; + if (isChargeInFlight(status)) return; + handleClose(); + }} +> Take Payment @@ -1250,7 +1271,7 @@ {/if} + {#if waitingForSCA} +
+
+
+
+

+ Approve this payment in your banking app on your phone… +

+

+ The payment is waiting for your approval. This may take a few moments. +

+
+
+
+ {/if} +