diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..96178ab --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +.git +.gitignore +.github +node_modules +pb_data +data +coverage +*.log +.env +.env.* +!/.env.example +src +test +dist +README.md +PLAN.md +ARCHITECTURE.md +RESEARCH.md +IMPLEMENTATION_SPEC.md diff --git a/.env.example b/.env.example index 3326123..d01d2a9 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,38 @@ -PORT=3000 -HOST=0.0.0.0 -TICKET_TTL_MINUTES=2 -UPI_ID= -UPI_PAYEE_NAME= +# Required in normal serve mode +UPI_ID=operator@examplebank +UPI_PAYEE_NAME=PayGate Operator +PAYGATE_API_KEY=replace-with-at-least-24-random-characters +SMS_WEBHOOK_SECRET=replace-with-a-different-at-least-24-character-secret + +# Persistence / external URL +PB_DATA_DIR=/app/pb_data + +# DDM lifecycle +PAYMENT_TTL=5m +PAYMENT_QUARANTINE=24h + +# PocketBase request rate limits +PAYGATE_RATE_LIMITS_ENABLED=true + +# Optional Google Messages connector +GMESSAGES_ENABLED=false +# GMESSAGES_SESSION_PATH=/app/pb_data/gmessages/session.json + +# Optional signed outgoing payment webhooks +OUTGOING_WEBHOOK_URL= +OUTGOING_WEBHOOK_SECRET= + +# Migration-only compatibility for the old Android relay at POST /api/webhook. +# Leave disabled for new deployments. When enabled, WEBHOOK_SECRET is required. +LEGACY_SMS_WEBHOOK_ENABLED=false WEBHOOK_SECRET= + +# Other legacy prototype aliases still understood during migration: +# UPI_NAME=PayGate Operator +# TICKET_TTL_MINUTES=5 +# AMOUNT_QUARANTINE_HOURS=24 +# PAYMENT_WEBHOOK_URL= +# PAYMENT_WEBHOOK_SECRET= + +# Tests only; do not enable in production. +PAYGATE_TEST_MODE=false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 86cf03c..8a211ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,52 +2,61 @@ name: CI on: push: - branches: [main] + branches: [main, rebuild-pocketbase] + pull_request: + +permissions: + contents: read jobs: - test: + validate: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - uses: actions/setup-node@v6 with: - node-version: 22 + node-version: "22.23.1" cache: npm - - run: npm ci - - run: npm run typecheck - - run: npm test - deploy: - needs: test - runs-on: ubuntu-latest - steps: - - name: Wait for Dokploy auto-deploy + - name: Install frontend dependencies + run: npm ci + + - name: Frontend dependency audit + run: npm audit --audit-level=high + + - name: Frontend typecheck + run: npm run typecheck + + - name: Frontend production build + run: npm run build + + - name: Go formatting check shell: bash - env: - URL: ${{ secrets.DOKPLOY_URL }} - COMPOSE_ID: ${{ secrets.DOKPLOY_APP_ID }} - TOKEN: ${{ secrets.DOKPLOY_API_TOKEN }} - SHA: ${{ github.sha }} - run: | - echo "Waiting for Dokploy deployment for commit ${SHA}..." - sleep 30 - for i in $(seq 1 30); do - DATA=$(curl -s "${URL}/api/compose.one?composeId=${COMPOSE_ID}" -H "x-api-key: ${TOKEN}") - STATUS=$(echo "$DATA" | jq -r --arg sha "${SHA}" ' - [.deployments[] | select(.description | contains($sha)) | .status][0] // "not_found" - ') - echo "Attempt ${i}: commit ${SHA} deployment status = ${STATUS}" - if [ "${STATUS}" = "done" ]; then - echo "Deployment succeeded" - exit 0 - elif [ "${STATUS}" = "error" ]; then - echo "Deployment failed" - exit 1 - elif [ "${STATUS}" = "not_found" ]; then - echo "Deployment not yet created by Dokploy..." - fi - sleep 10 - done - echo "Timed out waiting for deployment" - exit 1 + run: test -z "$(gofmt -l cmd internal migrations)" + + - name: Diff whitespace check + run: git diff --check + + - name: Go tests + run: go test -count=1 ./... + + - name: Go race tests + run: go test -race -count=1 ./internal/... + + - name: Go vet + run: go vet ./... + + - name: Static analysis + run: go run honnef.co/go/tools/cmd/staticcheck@v0.7.0 ./... + + - name: Go vulnerability scan + run: go run golang.org/x/vuln/cmd/govulncheck@v1.6.0 ./... + - name: Build production container + run: docker build --pull -t paygate-ci:${{ github.sha }} . diff --git a/.gitignore b/.gitignore index 1b39148..fb039d1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,63 +1,40 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -/node_modules -**/node_modules -/.pnp -.pnp.* -.yarn/* -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/versions - -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build -/data -/src/server/admin/public +# Dependencies +node_modules/ + +# Runtime data (PocketBase / legacy prototype) +pb_data/ +data/ +*.db +*.db-shm +*.db-wal + +# Build/test output +dist/ +build/ +coverage/ +*.test +*.out +*.tsbuildinfo -# misc -.DS_Store +# Environment / secrets +.env +.env.local +.env.production +.env.*.local *.pem -# debug +# Logs / temporary files +*.log npm-debug.log* yarn-debug.log* yarn-error.log* -.pnpm-debug.log* - -# env files (can opt-in for committing if needed) -.env -.env.local -.env.production -/pg-testing* - -# vercel -.vercel - -# typescript -*.tsbuildinfo -next-env.d.ts +.DS_Store +.tmp/ +tmp/ +# Tooling +.vscode/ +.idea/ +.vercel/ .dev.vars -.wrangler - -# build artifacts -/dist - -# test files -test*.js - -# logs & test output -*.log -e2e_error.txt -test-out.txt -mail.txt -e2e_log*.txt +.wrangler/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..9dcc2a9 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,361 @@ +# PayGate — Implemented Architecture + +## 1. Scope + +PayGate is a single-operator, self-hosted UPI payment-verification service. A payer sends money directly to the configured UPI destination. PayGate observes bank-credit evidence and correlates it with a previously created payment. + +It is not a fund-custody, settlement or acquiring system. The current product boundary intentionally avoids multi-merchant tenancy and provider abstractions. + +## 2. Runtime + +```text + ┌────────────────────────────┐ + │ Android phone │ + │ receives bank-credit SMS │ + └──────────────┬─────────────┘ + │ + ┌───────────────────┴──────────────────┐ + │ │ + Google Messages/libgm legacy Android relay + │ │ + └───────────────────┬──────────────────┘ + ▼ + ┌─────────────────────────┐ + │ PayGate binary │ + │ │ + │ custom Go HTTP routes │ + │ payment service │ + │ SMS parser/ingestion │ + │ webhook outbox worker │ + │ optional libgm manager │ + │ PocketBase framework │ + │ React static assets │ + └────────────┬────────────┘ + │ + ▼ + PocketBase SQLite + /app/pb_data +``` + +One process and one SQLite database are deliberate. This workload does not need Redis, Kafka, a second service or a separate logging database. + +## 3. PocketBase boundary + +PocketBase 0.39.9 supplies: + +- SQLite connection and transactions; +- migrations/collection definitions; +- authentication; +- SSE realtime for the operator UI; +- cron scheduling; +- request logging; +- backups; +- the low-level `/_/` administration UI. + +Custom Go code owns payment state transitions. PocketBase record APIs expose domain collections read-only to authenticated records from the `users` auth collection. Collection create/update/delete rules are locked. + +The application UI is a separate embedded React/Vite build at `/`; PocketBase `/_/` is not modified. + +## 4. Collections + +### `users` + +PocketBase auth collection for normal PayGate operator accounts. Public self-registration is disabled; accounts are created administratively. + +### `payments` + +Important fields: + +| Field | Purpose | +|---|---| +| `created_at` | Business creation timestamp used for evidence eligibility | +| `requested_amount` | Whole requested amount in paise | +| `payable_amount` | Exact DDM amount in paise | +| `status` | pending/paid/expired/cancelled/late | +| `expires_at` | Pending deadline | +| `reuse_after` | Amount quarantine deadline | +| `rrn` | UPI reference, unique when non-empty | +| `upi_id` | Parsed payer UPI ID when available | +| `payer_name` | Parsed payer identity when available | +| `paid_at` | Best-known evidence occurrence time | +| `external_id` | Caller/order correlation | +| `idempotency_key` | Unique non-empty create key | +| `metadata` | Caller JSON metadata | + +PocketBase's own `created` and `updated` autodate fields are also present. `created_at` is separate because business correlation must use an explicit timestamp written by the payment transaction rather than relying on framework metadata semantics. + +### `sms_events` + +Durable evidence/audit records: + +- source: `android_webhook`, `gmessages`, or `manual`; +- source event ID; +- sender/body; +- original/effective message timestamp; +- parsed amount/RRN/UPI ID/payer name; +- processing status; +- matched payment relation; +- parsing/matching error; +- small raw connector metadata. + +`(source, source_event_id)` is unique when the provider ID is non-empty. + +### `webhook_deliveries` + +Durable outbox/delivery state: + +- stable event ID and event name; +- payment relation; +- destination URL; +- immutable request body; +- attempts/status; +- next attempt timestamp; +- sending lease timestamp; +- response code/error/delivery timestamps. + +## 5. Money and DDM allocation + +Money is never represented by floating point in the domain layer. + +For requested whole rupees `R`: + +```text +requestedPaise = R * 100 +candidate = requestedPaise + suffix +suffix ∈ [1, 99] +``` + +`.00` is intentionally excluded. The maximum accepted requested amount reserves 99 paise of `int64` headroom so `requestedPaise + 99` cannot overflow. + +Creation runs inside a SQLite transaction: + +1. expire due pending payments; +2. resolve an existing idempotency key if supplied; +3. choose a randomized starting suffix; +4. probe all 99 suffixes cyclically; +5. a candidate is unavailable while any existing payment with that payable amount has `reuse_after > now`; +6. persist the first available candidate with `expires_at` and `reuse_after`; +7. fail with `AMOUNT_CAPACITY_EXHAUSTED` if all 99 are blocked. + +The suffix start is injectable for deterministic tests but uses cryptographic randomness in production. + +## 6. Lifecycle and quarantine + +```text + create + │ + ▼ + pending + / │ \ + pay expire cancel + │ │ │ + ▼ ▼ ▼ + paid expired cancelled + \ / + \ / + exact late credit + │ + ▼ + late +``` + +Expiry is persisted and evaluated both by scheduled work and before operations that depend on current state. No business truth depends on an in-memory timer. + +`reuse_after` protects a fingerprint from immediate reassignment. Paid/cancelled/late transitions start a fresh quarantine window from the processing time. An expired payment retains the creation-time `expires_at + quarantine` reservation. + +## 7. Evidence-time guard + +Amount quarantine alone is insufficient once a suffix is eventually reused: a provider may reconnect and deliver an old historical message. + +Every normalized SMS has `OccurredAt`. For Google Messages this comes from the provider message timestamp. The SMS service clamps missing/future timestamps to ingestion time. + +Automatic matching requires: + +```text +payment.created_at <= evidence.OccurredAt +``` + +Therefore an old catch-up message cannot confirm a payment that did not exist when the SMS occurred, even if the same amount has since been reused. + +Legacy webhook clients that omit a timestamp cannot provide this protection and are treated as occurring at ingestion time. This is one reason the compatibility route is temporary. + +## 8. SMS transaction + +SMS ingestion is one database transaction for the evidence record and payment state transition: + +1. validate source and storage-size bounds; +2. deduplicate `(source, source_event_id)`; +3. persist the raw SMS event as `received`; +4. ignore unrelated messages after keeping the audit record; +5. parse the bank-credit message; +6. require exact amount and RRN for automatic confirmation; +7. find an existing payment by RRN: + - same amount → idempotent duplicate; + - different amount → fail `RRN_AMOUNT_MISMATCH`; +8. exact-match an eligible pending payment; +9. otherwise exact-match an eligible expired/cancelled payment still quarantined and mark `late`; +10. update the SMS record with result/relation; +11. enqueue webhook work in the same transaction if required; +12. wake the delivery worker only after commit. + +Network calls never occur inside the SQLite transaction. + +## 9. Bank parser + +The current parser is deliberately narrow and fail-closed around tested Kotak credit-message forms. It extracts: + +- received INR amount; +- UPI RRN/reference; +- UPI ID where present; +- payer/from text where present. + +OTP/debit/unrelated messages are ignored rather than interpreted as payment evidence. Derived text fields are bounded before persistence so an unusual SMS cannot cause a collection-validation 500. + +Expanding to another bank should add concrete parser fixtures first rather than loosening the existing regex indiscriminately. + +## 10. Google Messages connector + +`internal/gmessages` wraps libgm behind an ingestion callback. The payment service itself has no dependency on libgm types. + +Responsibilities: + +- load/save pairing state under the persistent data directory; +- filesystem permissions: session file `0600`, private parent directory; +- connect/reconnect/backoff; +- monitor paired/connected/phone-responsive state; +- ignore outgoing messages; +- prefilter for bank-credit-like text before copying it into PayGate; +- preserve provider message ID and timestamp; +- process old/catch-up events through the same idempotent ingestion path; +- expose operator pairing/reconnect/unpair controls. + +Pairing refuses to replace an already-valid session. The operator must unpair first. + +The connector is optional. Payment/SMS records remain valid if Google's private protocol changes. + +## 11. HTTP boundary + +Custom routes: + +```text +POST /api/payments +GET /api/payments/{id} +POST /api/payments/{id}/cancel +POST /api/events/sms +POST /api/webhook # opt-in legacy compatibility +GET /api/paygate/health +GET /api/config # authenticated operator +GET /api/dashboard # authenticated operator +GET /api/connector/gmessages/status # authenticated operator +POST /api/connector/gmessages/pair +POST /api/connector/gmessages/pair/refresh +POST /api/connector/gmessages/reconnect +DELETE /api/connector/gmessages/pair +``` + +PocketBase owns `/api/health`, auth, records and realtime endpoints. + +The SPA wildcard explicitly refuses `api` and `_` namespaces so malformed/unknown API calls cannot accidentally return `index.html` with status 200. + +Request bodies have route-level limits in addition to collection field validation. + +## 12. Authentication + +State-changing payment API calls accept either: + +- `Authorization: Bearer `; or +- a valid authenticated `users` PocketBase token used by the operator UI. + +SMS ingestion uses a separate `X-Webhook-Secret`. + +The migration compatibility route `/api/webhook` is separately gated by `LEGACY_SMS_WEBHOOK_ENABLED` and the old `WEBHOOK_SECRET`; it never substitutes for the primary `SMS_WEBHOOK_SECRET`. + +PocketBase's built-in rate limiter is enabled by default by the PayGate startup configuration. + +## 13. Outgoing webhook outbox + +A state transition schedules a `webhook_deliveries` row inside the same transaction. The worker claims due rows with a transactional state change to `sending`, preventing two worker passes from intentionally delivering the same row simultaneously. + +A stale sending lease is recovered after a process crash. Retry timing is persisted. A successful HTTP 2xx response marks the delivery `delivered`; failures are retried until the configured fixed attempt ceiling and then become `exhausted`. + +The signature is HMAC-SHA256 over: + +```text +unixTimestamp + "." + rawJSONBody +``` + +The stable event ID lets consumers handle network-level duplicate delivery safely. + +## 14. UI/realtime + +The UI reads `payments`, `sms_events` and `webhook_deliveries` through authenticated PocketBase record APIs and subscribes to PocketBase realtime events. It uses custom PayGate routes for state changes. + +Authentication is refreshed periodically; a failed authenticated API response clears the local auth store instead of leaving the UI in a misleading signed-in state. + +Payment-create UI retries keep the same idempotency key until form values change or a request succeeds. + +## 15. Process lifecycle + +At startup: + +1. strict environment parsing; +2. PocketBase boot/migrations; +3. serve-time validation of required/strong secrets and URLs; +4. enable configured PocketBase rate limiting; +5. start webhook worker; +6. start libgm only when enabled and paired. + +Cron jobs also check expirations and wake webhook delivery processing. The worker itself has an internal timer, so delayed delivery does not depend on a single cron tick. + +At termination the root context is cancelled and the connector is disconnected cleanly. + +## 16. Persistence and backups + +All durable state lives under the configured PocketBase data directory. In the production image that is `/app/pb_data`. + +A deployment without a persistent mount there is invalid. Recreating a task/container must not recreate the database. + +The old prototype database is not migrated into the new schema automatically. Before the production cutover the old task data is separately backed up for rollback/reference. + +## 17. Failure philosophy + +PayGate fails closed where a false confirmation is possible: + +- ambiguous exact amount → no payment confirmation; +- RRN/amount contradiction → error, no reassignment; +- old evidence predating a reused payment → unmatched; +- missing RRN → audit error, no automatic confirmation; +- unavailable Google Messages → persisted core remains intact; +- outgoing merchant webhook unavailable → payment state remains committed and delivery retries durably. + +## 18. Project layout + +```text +cmd/payment-api/ process/CLI wiring +internal/api/ HTTP routes and auth boundary +internal/config/ strict environment configuration +internal/domain/ domain types/errors +internal/gmessages/ libgm adapter/session lifecycle +internal/money/ integer-INR helpers +internal/payments/ allocation/state machine/matching +internal/sms/ parser and durable ingestion +internal/webhooks/ durable outbound delivery worker +internal/web/ embedded compiled UI +migrations/ PocketBase collection schema +web/ React/Vite source +``` + +## 19. Core invariants + +1. Payment amounts are integer paise. +2. Requested amount is whole rupees; DDM owns `.01`–`.99`. +3. SQLite is the source of truth. +4. Payment creation and amount reservation are one transaction. +5. Evidence never matches by whole-rupee base. +6. RRN cannot silently move between different amounts. +7. A message cannot confirm a payment created after that message occurred when an occurrence timestamp is known. +8. Reused fingerprints pass through quarantine. +9. External HTTP calls never run inside a payment/SMS transaction. +10. Domain writes are backend-owned. +11. Google Messages is a replaceable evidence connector, not the payment model. +12. `/app/pb_data` must be persistent in production. diff --git a/Dockerfile b/Dockerfile index b305b48..66782ab 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,20 +1,32 @@ -# Stage 1: Build server -FROM node:22-alpine AS server-build -WORKDIR /app -COPY package*.json tsconfig*.json eslint.config.js vitest.config.ts ./ +# syntax=docker/dockerfile:1.7 + +FROM node:22.23.1-bookworm-slim AS web-build +WORKDIR /src +COPY package.json package-lock.json ./ RUN npm ci -COPY src/server/ ./src/server/ -COPY src/types/ ./src/types/ -RUN npm run build +COPY web ./web +RUN npm run typecheck && npm run build + +FROM golang:1.25.12-bookworm AS go-build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY cmd ./cmd +COPY internal ./internal +COPY migrations ./migrations +COPY --from=web-build /src/internal/web/dist ./internal/web/dist +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath \ + -ldflags="-s -w" -o /out/paygate ./cmd/payment-api +RUN mkdir -p /out/pb_data -# Stage 2: Production -FROM node:22-alpine -RUN apk add --no-cache dumb-init +FROM gcr.io/distroless/static-debian12:nonroot WORKDIR /app -COPY --from=server-build /app/dist/ ./dist/ -COPY --from=server-build /app/node_modules/ ./node_modules/ -COPY package*.json ./ +COPY --from=go-build --chown=nonroot:nonroot /out/paygate /app/paygate +COPY --from=go-build --chown=nonroot:nonroot /out/pb_data /app/pb_data +COPY --chown=nonroot:nonroot LICENSE NOTICE /app/ +USER nonroot:nonroot EXPOSE 3000 -VOLUME ["/app/data"] -HEALTHCHECK --interval=30s --timeout=5s --retries=3 CMD node -e "fetch('http://localhost:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" -CMD ["dumb-init", "node", "dist/server/index.js"] +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD ["/app/paygate", "healthcheck"] +ENTRYPOINT ["/app/paygate"] +CMD ["serve", "--http=0.0.0.0:3000"] diff --git a/IMPLEMENTATION_SPEC.md b/IMPLEMENTATION_SPEC.md new file mode 100644 index 0000000..d8ab596 --- /dev/null +++ b/IMPLEMENTATION_SPEC.md @@ -0,0 +1,132 @@ +# PayGate Rebuild — Implementation Specification + +This specification defined the clean implementation on `rebuild-pocketbase`. The old Node/Fastify implementation was prototype/reference material and is removed from the rebuilt tree once equivalent behaviour is covered by tests. + +## Product boundary + +Personal, self-hosted UPI payment verification for projects owned by one operator. Money goes directly to the configured UPI account. The service does not hold or settle funds. + +## Required stack + +- Go 1.25.x. +- PocketBase v0.39.9 embedded as the Go application framework. +- PocketBase SQLite as the only database. +- React + Vite + TypeScript operator UI embedded in the Go binary. +- `go.mau.fi/mautrix-gmessages`/libgm v0.2605.0 as an optional Google Messages connector. +- One application process/container and one persistent `pb_data` volume. + +## DDM invariants + +- API `amount` is a positive whole number of INR rupees. Reject paise/fractional requested amounts. +- All domain money is integer paise. +- `requested_amount = amount * 100`. +- Allocate `payable_amount` only in `requested_amount + 1..99`; `.00` is excluded. +- Never spill into the next whole rupee. If all 99 suffixes are unavailable, return `AMOUNT_CAPACITY_EXHAUSTED`. +- Reserve enough `int64` headroom that adding suffix 99 cannot overflow. +- Exact `payable_amount` is the payment correlation key. Never floor to a base amount for matching. +- Allocation is transactional/durable; there is no in-memory decimal pool. +- Default payment TTL is 5 minutes, configurable. +- Every resolved/expired amount enters configurable quarantine (default 24 hours) before reuse. +- At creation, `reuse_after = expires_at + quarantine`. Paying/cancelling/late-paying resets it to processing time + quarantine. +- A late SMS for a still-quarantined expired/cancelled payment attaches to that old payment as `late`. +- Provider message occurrence time is retained. Evidence may not confirm a payment created after the evidence occurred. +- Duplicate source events and duplicate same-amount RRNs are idempotent. +- Same RRN with a different amount is an anomaly and must not confirm another payment. +- Restart must not expire valid payments or lose allocation state. + +## Data model + +PocketBase migrations create: + +1. `users` auth collection for the operator dashboard. +2. `payments`: explicit business `created_at`, requested/payable amount, status (`pending|paid|expired|cancelled|late`), expiry/quarantine, RRN, payer evidence, `paid_at`, external ID, idempotency key, metadata. +3. `sms_events`: source (`android_webhook|gmessages|manual`), provider ID, sender/body, message time, parsed evidence, processing status, matched payment, error/raw connector metadata. +4. `webhook_deliveries`: durable outgoing webhook outbox/retries. + +Use non-empty uniqueness for RRN, idempotency key and `(source, source_event_id)`. Authenticated records from the `users` collection may read domain records; direct record writes remain locked so business invariants cannot be bypassed. + +## SMS/payment processing + +- Strictly parse the tested Kotak bank-credit formats first. +- Extract exact amount, RRN/UPI Ref and optional UPI ID/payer name. +- Require exact amount and RRN for automatic paid/late transitions. +- Persist SMS evidence before/with processing. +- Matching and payment transition occur transactionally. +- Bound request/derived fields before PocketBase validation. +- `POST /api/events/sms` is the primary strong-secret endpoint. +- `POST /api/webhook` is an explicitly enabled migration-only compatibility route for the old Android relay. + +## HTTP API + +- `POST /api/payments`: create; API key or operator auth; `Idempotency-Key` supported. +- `GET /api/payments/{id}`: public limited status; sensitive bank evidence omitted. +- `POST /api/payments/{id}/cancel`: API key or operator auth. +- `POST /api/events/sms`: primary SMS secret; legacy `{sms}` and richer metadata shapes supported. +- `POST /api/webhook`: old route only when `LEGACY_SMS_WEBHOOK_ENABLED=true`, authenticated with separate `WEBHOOK_SECRET`. +- `GET /api/health`: PocketBase liveness used by the container healthcheck. +- `GET /api/paygate/health`: PayGate DB/readiness and redacted connector summary. +- `GET /api/config`: authenticated safe configuration/connector status. +- `GET /api/dashboard`: authenticated summary. +- connector status/pair/refresh/reconnect/unpair routes are authenticated. + +The SPA must never swallow unknown `/api/*` or `/_/*` routes. + +Create response includes requested amount, payable amount, expiry, payment ID and a UPI URI with configured `pa`, `pn`, exact `am`, `cu=INR`, and payment ID in `tr`/`tn`. Verification does not depend on `tr`. + +## Authentication and secrets + +- External API: `Authorization: Bearer $PAYGATE_API_KEY`. +- Primary SMS: `X-Webhook-Secret: $SMS_WEBHOOK_SECRET`. +- Legacy route: separate `WEBHOOK_SECRET`, opt-in only. +- Dashboard: PocketBase `users`; the SPA never receives PocketBase superuser credentials. +- `UPI_ID`, API key and primary SMS secret are required in non-test mode. +- Primary API/SMS secrets have a minimum strength requirement. +- Never log secret values. +- Optional outgoing webhook requires valid absolute URL + HMAC secret. +- Invalid boolean/duration environment values fail startup instead of silently defaulting. +- PocketBase rate limits are enabled by default through PayGate configuration. + +## Outgoing webhooks + +When configured, persist/send `payment.paid`, `payment.late`, `payment.expired` and `payment.cancelled`. Sign `${timestamp}.${rawBody}` using HMAC-SHA256 and expose stable event ID/timestamp/signature headers. Retry failures durably with bounded backoff. Restart must not lose pending deliveries; stale sending leases must recover. + +## Realtime and UI + +Use PocketBase realtime for authenticated operator views. No custom WebSocket server. + +UI pages: Login, Dashboard, Payments/create/details, SMS Events, Webhook Deliveries, Settings/connector. PocketBase `/_/` remains available for raw administration/debugging. + +## libgm connector + +libgm is optional infrastructure, not the payment model. + +- Persist AuthData under `pb_data/gmessages/session.json` with restrictive permissions. +- Console/API QR pairing refreshes short-lived QR tokens. +- Refuse accidental replacement of an existing valid pairing; unpair first. +- When enabled/paired, connect on serve, persist token refreshes, process incoming text `WrappedMessage` events into the same SMS service, and reconnect/back off on failure. +- Keep provider message ID and original timestamp, including catch-up/old events. +- Report paired/connected/phone-responsive/timestamp/error state to authenticated operator views. +- Application remains healthy when unpaired/offline. +- Connector is read-only; do not send SMS/RCS. +- Real phone QR scanning is intentionally deferred until the operator performs the device test. + +## Expiry/background work + +Persist timestamps as truth. PocketBase cron checks expiry and wakes webhook retry processing. Lazy expiry in create/match/status paths prevents correctness from depending on cron timing. The webhook worker also runs its own periodic wake cycle. + +## Deployment + +- Multi-stage Node/Go build with a non-root distroless runtime. +- Listen on port 3000 for existing Dokploy routing. +- Runtime data directory `/app/pb_data` **must** be a Dokploy persistent mount before switching `main`. +- Healthcheck uses PocketBase `/api/health`. +- Keep an external backup of the old prototype DB; do not silently mix schemas. +- The weak old Android secret may be retained only behind the opt-in migration route until the relay is upgraded, then removed. + +## Required validation + +Tests cover money boundaries, all 99 DDM slots, concurrency/exhaustion, restart persistence, exact matching, expiry/quarantine/late payments, delayed catch-up after amount reuse, duplicate/contradictory RRN, provider dedupe, parser variants, auth/redaction/body limits, config strictness, collection rules, webhook HMAC/retries/claim recovery, connector session handling and API namespace behaviour. + +Frontend must typecheck/build. Final source must pass uncached tests, race tests, `go vet`, formatting, `git diff --check`, dependency audit and a production Docker build. + +Before moving `main`, run the final image on a fresh temporary volume and exercise health, API auth/validation, creation/idempotency, SMS matching, duplicate handling, redaction, container recreation and database persistence. Review the final diff multiple times for stale prototype code, secret leakage, dead code, unsafe direct writes, matching errors and deployment persistence mistakes. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..1b68877 --- /dev/null +++ b/NOTICE @@ -0,0 +1,20 @@ +PayGate +Copyright (C) 2026 Sourav / Phloraxx contributors + +This project is distributed under GNU AGPL-3.0-or-later because it directly +uses libgm from mautrix-gmessages. + +Third-party components include: + +- mautrix-gmessages / libgm + Copyright (C) Tulir Asokan and contributors + License: GNU Affero General Public License v3 or later + https://github.com/mautrix/gmessages + +- PocketBase + Copyright (c) 2022-present Gani Georgiev + License: MIT + https://github.com/pocketbase/pocketbase + +See each dependency's source distribution for its complete copyright and +license notices. diff --git a/PLAN.md b/PLAN.md index 4a29b3a..a371626 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,762 +1,219 @@ -# Payment Gateway v2 — Architecture Plan - -A zero-fee UPI payment gateway for college events using **Dynamic Decimal Matching (DDM)**. Single-server, SQLite-primary, Appwrite-secondary. - ---- - -## 1. High-Level Architecture - -``` -┌──────────────────────────────────────────────────────────────────────┐ -│ Docker Container │ -│ │ -│ Fastify Server (Node.js 22) │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ Routes │ │ -│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────────────┐ │ │ -│ │ │ Ticket │ │ Webhook │ │ Admin │ │ WS Upgrade │ │ │ -│ │ │ Routes │ │ Route │ │ Routes │ │ + Test Routes │ │ │ -│ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ └───────┬────────┘ │ │ -│ │ │ │ │ │ │ │ -│ │ ┌────┴────────────┴────────────┴────────────────┴────────┐ │ │ -│ │ │ Services Layer │ │ │ -│ │ │ TicketSvc DecimalPoolSvc PaymentSvc ExpirySvc │ │ │ -│ │ │ SmsParser AppwriteSync Logger │ │ │ -│ │ └──────────────────────────┬──────────────────────────────┘ │ │ -│ │ │ │ │ -│ │ ┌──────────────────────────┴──────────────────────────────┐ │ │ -│ │ │ Data Layer │ │ │ -│ │ │ better-sqlite3 (WAL mode, persistent Docker volume) │ │ │ -│ │ └─────────────────────────────────────────────────────────┘ │ │ -│ └───────────────────────────────────────────────────────────────┘ │ -│ │ -│ Volumes: ./data → /app/data │ -│ ├── payments.db │ -│ └── logs.db │ -│ │ -└──────────────────────────────┬──────────────────────────────────────────┘ - │ fire-and-forget write - ┌────────┴────────┐ - │ Appwrite │ ← External apps read from here - │ (secondary DB) │ - └─────────────────┘ -``` - -## 2. Technology Stack - -| Layer | Choice | Rationale | -|---|---|---| -| Runtime | **Node.js 22** (Alpine Docker) | Single process, no cold starts | -| Web framework | **Fastify 5** + TypeScript | Fastest Node.js framework, plugin ecosystem | -| Primary DB | **better-sqlite3** (WAL mode) | Synchronous API, zero network overhead, crash-safe | -| Secondary DB | **Appwrite** (fire-and-forget sync) | External apps read from here; payment path never depends on it | -| WebSocket | **@fastify/websocket** | In-process pub/sub per ticket room | -| Validation | **TypeBox** | Schema → JSON Schema → validation + TypeScript types | -| Logging | **Pino** (Fastify default) | Structured JSON, fast, SQLite + WS stream | -| Admin auth | **Passkey (WebAuthn)** via `@simplewebauthn/server` + session cookie via `@fastify/cookie` | Biometric/PIN login. No password to leak, no brute-force. Cookie set after successful WebAuthn assertion. | -| Admin UI | **React + Vite + TypeScript** | Build step → static files served by Fastify | -| Container | **Docker** + Portainer | Portainer webhook for CI/CD | -| Testing | **Vitest** | Fast, native ESM | -| Health | **dumb-init** | Proper signal handling in Docker | - -## 3. Database Schema - -Two separate SQLite databases — no write contention between critical path and logging. - -### `data/payments.db` — Core Payment Data - -All monetary values stored as INTEGER paisa. ₹100.03 → `10003`. This avoids floating-point precision issues and enables exact comparisons. - -```sql -PRAGMA journal_mode = WAL; -PRAGMA foreign_keys = ON; - -CREATE TABLE tickets ( - id TEXT PRIMARY KEY, -- "TICKET" + UnixMs timestamp - amount INTEGER NOT NULL, -- paisa: 10003 for ₹100.03 - status TEXT NOT NULL DEFAULT 'pending' - CHECK(status IN ('pending','paid','cancelled','expired')), - base_amount INTEGER NOT NULL, -- integer rupees in paisa: 10000 for ₹100 - decimal_val INTEGER NOT NULL, -- 0-99 decimal for pool tracking - sender_name TEXT, - rrn TEXT UNIQUE, -- UPI reference number (dedup) - upi_id TEXT, - paid_at TEXT, -- ISO 8601 - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE INDEX idx_tickets_status ON tickets(status); -CREATE INDEX idx_tickets_amount ON tickets(amount); -CREATE INDEX idx_tickets_rrn ON tickets(rrn); -CREATE INDEX idx_tickets_decimal ON tickets(base_amount, decimal_val, status); - -CREATE TABLE authenticators ( - id TEXT PRIMARY KEY, -- Base64URL credential ID - public_key TEXT NOT NULL, -- Base64URL public key (COSEPublicKey) - counter INTEGER NOT NULL DEFAULT 0, -- Signature counter - device_name TEXT, -- e.g. "Chrome on Windows" - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); - --- Single-use one-time code for first-time registration -CREATE TABLE one_time_codes ( - code TEXT PRIMARY KEY, - used INTEGER NOT NULL DEFAULT 0, -- 0 = unused, 1 = used - created_at TEXT NOT NULL DEFAULT (datetime('now')) -); -``` - -### `data/logs.db` — Separate Database for Logs - -```sql -PRAGMA journal_mode = WAL; - -CREATE TABLE logs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - level TEXT NOT NULL CHECK(level IN ('info','warn','error','debug')), - message TEXT NOT NULL, - meta TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE INDEX idx_logs_level ON logs(level); -CREATE INDEX idx_logs_created ON logs(created_at); - --- Ring buffer via periodic cleanup (not a trigger): every 500 inserts, keep max 10k rows -``` - -## 4. Decimal Allocation Engine (DDM Core) - -All amounts stored as integer paisa internally. Conversion helpers: -- `toPaisa(100.03)` → `10003` -- `fromPaisa(10003)` → `100.03` - -### In-Memory Data Structure - -```typescript -class DecimalPool { - // One free-list per base_amount (in paisa) - // Key: 10000 for ₹100 - // Value: queue [10000, 10001, ..., 10099] - private pools: Map; - - // Currently allocated amounts - private allocated: Map; -} -``` - -### State Machine - -``` - ┌──────────┐ - │ FREE │ ← Available in pool - └────┬─────┘ - │ pop on create - ┌────▼─────┐ - │ PENDING │ ← TTL timer running (2 min) - └────┬─────┘ - │ - ┌────────┼────────┐ - │ │ │ - paid ▼ expired ▼ cancelled ▼ -┌──────┐ ┌────────┐ ┌──────────┐ -│ PAID │ │EXPIRED │ │CANCELLED │ -└──────┘ └───┬────┘ └────┬─────┘ - │ │ - └─────┬─────┘ - │ push back to pool - ┌────▼─────┐ - │ FREE │ - └──────────┘ -``` - -### Allocation Rules - -| Condition | Behavior | -|---|---| -| Free queue non-empty | Pop from front (e.g., `10003`) | -| Free queue empty | Increment integer by 100 paisa, add new block of 100 | -| Ticket expired/cancelled | Push amount to back of free queue | -| Ticket paid | Do not immediately return amount to free queue; keep it reserved permanently by default, or release only after a configurable cooldown/manual admin action | -| Server restart | Mark all pending as expired, rebuild free queues | -| Pool full (all 100 in use) | New integer block created as safety valve | - -Paid decimal amounts are treated as consumed so delayed, duplicated, or re-sent bank SMS messages cannot accidentally confirm a newer pending ticket that reused the same amount. This trades a small amount drift over time for safer matching. If reuse is needed later, add a `PAID_REUSE_COOLDOWN_HOURS` setting and only return paid decimals after the bank SMS duplication window has clearly passed. - -### Lifecycle Example - -``` -Base: ₹100 → 10000 paisa -Pool: [10000, 10001, ..., 10099] - -t=0: Create → pop 10000 → pool: [10001..10099] -t=1: Create → pop 10001 → pool: [10002..10099] -... -t=99: Create → pop 10099 → pool: [] -t=100: Pool empty → new block: 10100..10199 → pop 10100 -t=101: Pay for 10000 → mark paid; keep 10000 reserved -t=102: Create → pop 10101 → pool: [10102..10199] -t=103: Expire 10001 → push back → pool: [10102..10199, 10001] - -API returns: fromPaisa(10101) → 101.01 -SMS lookup: "₹101.01" → toPaisa(101.01) → 10101 → WHERE amount = 10101 -``` - -## 5. API Endpoints - -> **Amount format**: API uses decimal (e.g. `100.03`). Internally stored as integer paisa. -> -> **Admin auth**: Signed HttpOnly cookie via `@fastify/cookie`. Login sets `token=s:` with `HttpOnly; SameSite=Strict; Path=/api/admin`. Cookie is auto-sent on all admin requests and WebSocket upgrades. - -### Public - -| Method | Path | Auth | Request | Response | -|---|---|---|---|---| -| `POST` | `/api/ticket` | — | `{ amount: 100 }` | `{ ticketId, amount, expiresAt, createdAt }` | -| `GET` | `/api/status/:id` | — | — | `{ ticketId, amount, status, paidAt, senderName, rrn }` | -| `WS` | `/api/ws?ticketId=X` | — | — | `payment_update` / `expired` / `shutdown` events | - -### Webhook (SMS) - -| Method | Path | Auth | Request | Response | -|---|---|---|---|---| -| `POST` | `/api/webhook` | `X-Webhook-Secret` header | `{ sms: "..." }` | `{ status, ticketId, action }` | - -**Generic SMS:** -``` -TICKET1709123456789 SOURAV paid you ₹100.03 -``` - -**Kotak SMS:** -``` -Confirmed payment for Received Rs.100.03 in your Kotak Bank AC X4959 from user@oksbi on 08-03-26.UPI Ref:606703736479. -``` - -**Matching:** -1. Try generic: extract `TICKET(\d+)` + amount → lookup by ticketId -2. Try Kotak: extract `Rs.X.YY` → `SELECT * FROM tickets WHERE amount = ? AND status = 'pending' LIMIT 1` -3. Verify RRN not duplicate (UNIQUE constraint) -4. Mark paid, push WS event, free decimal, fire-and-forget Appwrite sync - -### Admin (passkey + cookie-based auth) - -First-time setup: -1. Admin visits `/admin` — no authenticators registered → redirects to `/admin/setup` -2. `GET /api/admin/setup/status` returns `{ needs_setup: true, has_one_time_code: true }` -3. Admin enters `ONE_TIME_CODE` → `POST /api/admin/setup/verify-code { code }` validates it -4. `GET /api/admin/register/begin` returns WebAuthn registration options (challenge, rp, user) -5. Browser calls `navigator.credentials.create(options)` → biometric/PIN prompt -6. `POST /api/admin/register/complete { credential }` verifies and stores public key → sets session cookie -7. One-time code is marked used - -Subsequent logins: -1. `GET /api/admin/login/begin` returns WebAuthn authentication options (challenge + allowCredentials) -2. Browser calls `navigator.credentials.get(options)` → biometric/PIN prompt -3. `POST /api/admin/login/complete { assertion }` verifies signature → sets signed HttpOnly session cookie - -| Method | Path | Purpose | -|---|---|---| -| `GET` | `/api/admin/setup/status` | Check if setup is needed | -| `POST` | `/api/admin/setup/verify-code` | `{ code }` → validates one-time code | -| `GET` | `/api/admin/register/begin` | WebAuthn registration options (after code verified) | -| `POST` | `/api/admin/register/complete` | `{ credential }` → stores public key | -| `GET` | `/api/admin/login/begin` | WebAuthn assertion options | -| `POST` | `/api/admin/login/complete` | `{ assertion }` → verifies → sets cookie | -| `POST` | `/api/admin/logout` | Clears session cookie | -| `GET` | `/api/admin/session` | Check if cookie is valid (used by SPA on load) | -| `GET` | `/api/admin/tickets` | List + filter + paginate + export | -| `GET` | `/api/admin/tickets/:id` | Ticket detail | -| `PATCH` | `/api/admin/tickets/:id` | Update fields | -| `POST` | `/api/admin/tickets/:id/mark-paid` | Mark paid shortcut | -| `POST` | `/api/admin/tickets/:id/cancel` | Cancel shortcut | -| `GET` | `/api/admin/stats` | Aggregate stats | -| `GET` | `/api/admin/logs` | Query logs with filters | -| `GET` | `/api/admin/pool` | Current decimal pool state | -| `POST` | `/api/admin/sync/full` | Re-sync all tickets to Appwrite | -| `WS` | `/api/admin/ws` | Real-time events + log stream (cookie sent automatically) | - -### Test (within admin, uses real API) - -| Method | Path | Purpose | -|---|---|---| -| `POST` | `/api/admin/test/ticket` | Create test ticket (bypasses rate limits) | -| `POST` | `/api/admin/test/webhook` | Simulate SMS webhook | -| `WS` | `/api/admin/test/ws?ticketId=X` | Test WebSocket | - -## 6. Rate Limiting - -| Scope | Limit | Justification | -|---|---|---| -| **Ticket creation** | 5/min/IP | Prevents decimal pool exhaustion by bad actors. 5/min is enough for human-paced registration on campus NAT. | -| **Webhook** | 30/min/IP | Single phone sending SMS — generous buffer | -| **Status polling** | 60/min/IP | Prevents polling abuse | -| **Admin endpoints** | 30/min/IP (per session) | Admin dashboard | -| **Admin login / register** | 10/min/IP | Passkey auth — rate limit on WebAuthn challenge endpoints | -| **WebSocket connects** | 20/min/IP | Connection flood prevention | -| **Health check** | No limit | Required for Docker health checks | - -All limits use `@fastify/rate-limit` — in-memory sliding window per IP. - -## 7. WebSocket Architecture - -```typescript -// Single process, all in-memory -class WsManager { - ticketRooms: Map>; // ticketId → connections - adminSockets: Map>; // admin sessions - heartbeatTimers: Map; -} -``` - -| Event | Source | Destination | Payload | -|---|---|---|---| -| `payment_update` | Webhook | Ticket room | `{ type, status, paidAt, senderName }` | -| `expired` | Expiry timer | Ticket room | `{ type, reason: "timeout" }` | -| `shutdown` | Graceful shutdown | All rooms | `{ type, reason: "restart", reconnectMs }` | -| `ticket_update` | Status change | Admin sockets | `{ type, action, ticket }` | -| `log_entry` | Logger | Admin sockets | `{ type, level, message, meta }` | - -Heartbeat: Server pings every 30s. Closes connection after 30s no response. - -## 8. Appwrite Sync (Fire-and-Forget) - -SQLite is the source of truth. Appwrite is a convenience replica for external apps. - -``` -SQLite write (ticket create / update) - ↓ -Immediately fire Appwrite REST call (no await, no block) - ↓ - Success → done - Failure → log the error. Alerts visible in admin logs. -``` - -No queue, no worker, no polling. On crash, SQLite has all the data. Admin uses `POST /api/admin/sync/full` to re-sync if Appwrite was down. - -## 9. Server Lifecycle - -### Startup -1. Open SQLite, run `PRAGMA integrity_check` -2. Run migrations (CREATE TABLE IF NOT EXISTS) -3. Crash recovery: if any `pending` tickets exist → expire them -4. Build DecimalPool from scratch (all decimals free) -5. Seed `one_time_codes` from env if not already seeded (auto-generate if not set) -6. Register routes, WS handlers -7. Listen on PORT 3000 -8. Health check returns 200 → Portainer marks healthy - -### Graceful Shutdown -``` -SIGTERM received -1. Fastify.close() → stop accepting new connections (503 for in-flight) -2. UPDATE tickets SET status='expired' WHERE status='pending' -3. Clear all expiry timers -4. Broadcast WS: { type: "shutdown", reason: "restart", reconnectMs: 3000 } -5. Close all WebSocket connections -6. SQLite WAL checkpoint -7. Exit (process.exit(0)) -``` - -### Crash Recovery -On next startup: expire any stale pending tickets, rebuild pool from scratch. - -### Health Check -``` -GET /health → 200 -{ - "status": "healthy", - "uptime": 3600, - "db": "ok", - "appwrite_reachable": true, - "pool": { "base_amount": 100, "pending": 23, "free": 77 } -} -``` - -## 10. Error Handling - -```json -{ - "error": { - "code": "POOL_EXHAUSTED", - "message": "All payment slots are currently occupied. Please try again.", - "details": { "pending_count": 100, "ttl_seconds": 120 } - } -} -``` - -| Code | HTTP | Trigger | -|---|---|---| -| `INVALID_AMOUNT` | 400 | Missing, non-numeric, or ≤ 0 amount | -| `TICKET_NOT_FOUND` | 404 | ticketId doesn't exist | -| `POOL_EXHAUSTED` | 503 | All decimal slots in use | -| `RRN_DUPLICATE` | 409 | Payment with same RRN already processed | -| `AMOUNT_MISMATCH` | 400 | Webhook amount doesn't match ticket | -| `TICKET_ALREADY_RESOLVED` | 409 | Ticket already paid/cancelled/expired | -| `WEBHOOK_UNAUTHORIZED` | 401 | Bad webhook secret | -| `ADMIN_UNAUTHORIZED` | 401 | Invalid/missing/expired session cookie | -| `RATE_LIMITED` | 429 | Rate limit exceeded | -| `INTERNAL_ERROR` | 500 | Unexpected server error | - -## 11. Logging Architecture - -### What Gets Logged - -``` -Every HTTP request: - { level, time, req_id, method, path, status, duration_ms, ip, error } - -Business events: - - Ticket created: { level, message, meta: { ticketId, amount, decimal, pool_free } } - - Payment confirmed: { level, message, meta: { ticketId, amount, sender, rrn, match_method } } - - Decimal pool full: { level, message, meta: { base_amount, pending_count, new_integer } } - - Decimal freed: { level, message, meta: { amount, reason } } - - Ticket expired: { level, message, meta: { ticketId, amount } } - -Errors: - - Appwrite sync failure:{ level, message, meta: { ticket_id, error } } - - Webhook auth failure: { level, message, meta: { ip, reason } } - - Invalid input: { level, message, meta: { ip, validation_error } } -``` - -### Storage + Routing - -``` -Pino logger - ├── Console (stdout, structured JSON) → Docker log collector - ├── data/logs.db (separate DB, ring-buffer via periodic cleanup: every 500 inserts, keep max 10k rows) → Admin API - └── WebSocket broadcast → Admin dashboard real-time stream -``` - -## 12. Admin Dashboard (React + Vite SPA) - -Operational dashboard style with Tailwind CSS. Inter font, dark mode default, dense tables, clear status colors, restrained motion, and high-contrast controls. The UI should feel calm, fast, and built for repeated admin work rather than decorative. Use subtle transitions only where they clarify state changes. - -### Pages - -| Page | Route | Features | -|---|---|---| -| **Login** | `/` | Passkey WebAuthn authentication. Auto-redirect if cookie valid. | -| **Setup** | `/setup` | First-time passkey registration. One-time code entry → biometric/PIN prompt. | -| **Overview** | `/overview` | Compact stats cards (total tickets, pending, paid today, revenue). Decimal pool usage gauge. Recent activity feed. | -| **Tickets** | `/tickets` | Searchable, filterable, sortable table with pagination. Row click → drawer with detail view. Export CSV/JSON. | -| **Decimal Pool** | `/pool` | Usage visualization for free, pending, expired/cancelled reusable, and paid-reserved decimals. Table of active decimals with status badges. | -| **Test Harness** | `/test` | Create ticket, simulate webhook, test WebSocket — all against real API endpoints. | -| **Logs** | `/logs` | Log table with level badges. Search + filter. Real-time stream via WebSocket. | -| **Settings** | `/settings` | Environment config display. Appwrite sync status + "Re-sync All" button. | - -### Design System - -``` -Colors: - bg-primary: #0a0a0f (deep black-blue) - bg-secondary: #12121a (card backgrounds) - bg-tertiary: #1a1a2e (hover states) - accent: #6366f1 (indigo-500 - primary action) - accent-glow: #818cf8 (indigo-400 - hover glow) - text: #f1f5f9 (slate-50) - text-secondary:#94a3b8 (slate-400) - border: #1e293b (slate-800) - success: #22c55e (green-500) - warning: #f59e0b (amber-500) - error: #ef4444 (red-500) - -Typography: - font-family: 'Inter', system-ui, sans-serif - scale: 12 / 14 / 16 / 18 / 20 / 24 / 30 - -Components (Tailwind CSS): - Panel: bg-secondary border border-border rounded-lg - Button: h-9 px-3 rounded-md bg-accent hover:bg-accent-glow transition-colors - Input: h-9 bg-tertiary/50 border-border rounded-md focus:ring-accent - Badge: px-2 py-0.5 rounded-full text-xs font-medium - Table: compact rows, sticky header, row hover bg-tertiary, visible empty/error states - Sidebar: fixed left, w-60, clear active state, icon + label navigation - Drawer: slide-in from right for ticket detail and admin actions - Skeleton: shimmer animation for loading states -``` - -### Build - -``` -# Dev -cd admin && vite dev → localhost:5173 (proxied to :3000) - -# Production -cd admin && vite build → output: server/admin/public/ - -# Fastify serves it -app.register(fastifyStatic, { root: join(__dirname, 'admin/public') }); -``` - -### WebAuthn Flow (Setup) - -``` -1. GET /api/admin/setup/status → { needs_setup: true } -2. POST /api/admin/setup/verify-code → { code: "XXXX-XXXX" } -3. GET /api/admin/register/begin → { publicKey: CreationOptions } -4. Browser: navigator.credentials.create(publicKey) -5. POST /api/admin/register/complete → { credential: ... } → sets cookie -``` - -### WebAuthn Flow (Login) - -``` -1. GET /api/admin/login/begin → { publicKey: RequestOptions } -2. Browser: navigator.credentials.get(publicKey) -3. POST /api/admin/login/complete → { assertion: ... } → sets cookie -``` - -## 13. Project Structure - -``` -payment-gateway/ -├── src/ -│ ├── server/ # Fastify backend -│ │ ├── index.ts # Entry point + graceful shutdown -│ │ ├── app.ts # Fastify bootstrap -│ │ ├── config.ts # Typed env config -│ │ ├── db/ -│ │ │ ├── connection.ts # Two DB connections: payments + logs -│ │ │ └── schema.ts # CREATE TABLE statements -│ │ ├── routes/ -│ │ │ ├── ticket.ts # POST /api/ticket, GET /api/status/:id -│ │ │ ├── webhook.ts # POST /api/webhook -│ │ │ ├── admin.ts # All /api/admin/* routes -│ │ │ ├── health.ts # GET /health -│ │ │ ├── ws.ts # WS upgrade handlers -│ │ │ └── test.ts # /api/admin/test/* routes -│ │ ├── services/ -│ │ │ ├── ticket.service.ts # CRUD, expiry timers -│ │ │ ├── decimal.service.ts # DecimalPool (in-memory + SQLite) -│ │ │ ├── payment.service.ts # SMS parsing + matching -│ │ │ ├── appwrite.service.ts # Fire-and-forget + full re-sync -│ │ │ ├── expiry.service.ts # TTL scheduling + cleanup -│ │ │ ├── logger.service.ts # Pino → logs.db + WS -│ │ │ └── auth.service.ts # WebAuthn registration, verification, passkey management -│ │ ├── ws/ -│ │ │ ├── manager.ts # Connection pools, heartbeat -│ │ │ └── handlers.ts # Ticket + Admin WS handlers -│ │ ├── middleware/ -│ │ │ ├── auth.ts # Verify signed HttpOnly cookie -│ │ │ ├── request-logger.ts # Log all HTTP requests -│ │ │ └── error-handler.ts # Global error handler -│ │ ├── plugins/ -│ │ │ └── static.ts # Serve admin SPA -│ │ └── admin/ -│ │ └── public/ # Built React SPA output -│ ├── admin/ # React + Vite SPA source -│ │ ├── index.html -│ │ ├── vite.config.ts -│ │ ├── package.json -│ │ └── src/ -│ │ ├── main.tsx -│ │ ├── App.tsx -│ │ ├── api/ -│ │ │ ├── client.ts -│ │ │ ├── tickets.ts -│ │ │ ├── logs.ts -│ │ │ └── auth.ts -│ │ ├── pages/ -│ │ │ ├── Login.tsx -│ │ │ ├── Setup.tsx -│ │ │ ├── Overview.tsx -│ │ │ ├── Tickets.tsx -│ │ │ ├── DecimalPool.tsx -│ │ │ ├── Logs.tsx -│ │ │ ├── TestHarness.tsx -│ │ │ └── Settings.tsx -│ │ ├── components/ -│ │ │ ├── GlassCard.tsx -│ │ │ ├── Sidebar.tsx -│ │ │ ├── StatsCard.tsx -│ │ │ ├── PoolGauge.tsx -│ │ │ ├── StatusBadge.tsx -│ │ │ └── DataTable.tsx -│ │ └── hooks/ -│ │ ├── useWebSocket.ts -│ │ └── useWebAuthn.ts -│ └── types/ -│ └── index.ts -├── data/ # Docker volume (gitignored) -│ ├── payments.db -│ └── logs.db -├── Dockerfile -├── docker-compose.yml -├── .env.example -├── .github/workflows/deploy.yml -├── package.json -├── tsconfig.json -└── README.md -``` - -## 14. Docker + CI/CD - -### Dockerfile - -```dockerfile -# Stage 1: Build admin SPA -FROM node:22-alpine AS admin-build -WORKDIR /app -COPY src/admin/package*.json ./ -RUN npm ci -COPY src/admin/ ./ -RUN npm run build - -# Stage 2: Build server -FROM node:22-alpine AS server-build -WORKDIR /app -COPY package*.json tsconfig*.json ./ -RUN npm ci -COPY src/server/ ./src/server/ -COPY src/types/ ./src/types/ -RUN npm run build - -# Stage 3: Production -FROM node:22-alpine -RUN apk add --no-cache dumb-init -WORKDIR /app -COPY --from=admin-build /app/dist/ ./admin/public/ -COPY --from=server-build /app/dist/ ./dist/ -COPY --from=server-build /app/node_modules/ ./node_modules/ -COPY package*.json ./ -EXPOSE 3000 -VOLUME ["/app/data"] -HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ - CMD node -e "fetch('http://localhost:3000/health').then(r=>process.exit(r.ok?0:1))" -CMD ["dumb-init", "node", "dist/server/index.js"] -``` - -### docker-compose.yml - -```yaml -services: - app: - build: . - ports: ["3000:3000"] - volumes: - - payment_data:/app/data - environment: - - PORT=3000 - - TICKET_TTL_MINUTES=2 - - ONE_TIME_CODE=${ONE_TIME_CODE} - - COOKIE_SECRET=${COOKIE_SECRET} - - WEBHOOK_SECRET=${WEBHOOK_SECRET} - - APPWRITE_ENDPOINT=${APPWRITE_ENDPOINT} - - APPWRITE_API_KEY=${APPWRITE_API_KEY} - - APPWRITE_PROJECT_ID=${APPWRITE_PROJECT_ID} - - APPWRITE_DATABASE_ID=${APPWRITE_DATABASE_ID} - - APPWRITE_COLLECTION_ID=${APPWRITE_COLLECTION_ID} - restart: unless-stopped - healthcheck: - test: ["CMD", "node", "-e", "fetch('http://localhost:3000/health').then(r=>process.exit(r.ok?0:1))"] - interval: 30s - timeout: 5s - retries: 3 - -volumes: - payment_data: -``` - -### GitHub Actions - -```yaml -name: Deploy -on: - push: - branches: [main] -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: docker/setup-buildx-action@v3 - - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/build-push-action@v5 - with: - push: true - tags: ghcr.io/${{ github.repository }}:latest - - name: Trigger Portainer deploy - run: curl -X POST "${{ secrets.PORTAINER_WEBHOOK_URL }}" -``` - -## 15. Testing Plan - -Testing uses Vitest for backend unit/integration coverage and focused React component tests for admin workflows. Critical payment behavior should be tested before UI polish. - -### Backend Tests - -| Area | Coverage | -|---|---| -| Decimal allocation | Allocates unique paisa amounts per base amount, exhausts 100 slots into the next block, returns only expired/cancelled amounts, and does not immediately reuse paid amounts | -| Ticket lifecycle | Create → pending, expiry timer → expired, cancel → cancelled, mark-paid → paid, already-resolved tickets reject duplicate transitions | -| SMS parsing | Generic ticket SMS and Kotak SMS parse amount, ticket ID, sender, UPI ID, and RRN correctly | -| Payment matching | Exact amount lookup, RRN duplicate rejection, amount mismatch rejection, ambiguous amount match refusal if more than one pending ticket is found | -| Restart recovery | Startup expires stale pending tickets and rebuilds decimal pools from SQLite state | -| Webhook security | Missing/bad `X-Webhook-Secret` returns 401 and uses constant-time secret comparison | -| Admin auth | WebAuthn challenges are single-use, expire correctly, and session cookies gate all admin routes | -| Appwrite sync | SQLite write succeeds even when Appwrite fails; failures are logged and full re-sync can be triggered | -| Rate limits | Ticket, webhook, status, login, and WebSocket limits return 429 at expected thresholds | - -### Admin UI Tests - -| Area | Coverage | -|---|---| -| Auth flow | Setup, login, logout, expired session redirect | -| Tickets table | Search, filters, sorting, pagination, export buttons, detail drawer | -| Test harness | Creates a real test ticket, simulates webhook, observes WebSocket update | -| Logs | Filters by level/text and appends real-time log entries | -| Pool view | Shows free, pending, paid-reserved, and reusable decimal states clearly | -| Responsive layout | Main dashboard pages remain usable on desktop and mobile widths | - -### End-to-End Smoke Tests - -1. Start server against a temporary SQLite data directory. -2. Create a ticket for `100`. -3. Confirm the returned amount is exact and unique. -4. Post a matching webhook SMS. -5. Verify ticket status changes to `paid`. -6. Verify WebSocket receives `payment_update`. -7. Create another ticket and verify the previously paid decimal is not reused. -8. Restart the server and verify stale pending tickets become `expired`. - -## 16. Security Checklist - -| Concern | Mitigation | -|---|---| -| **SQL injection** | Parameterized queries (better-sqlite3) | -| **Timing attacks** | `crypto.timingSafeEqual` for all secret comparisons | -| **XSS** | React's default escaping + CSP headers | -| **Session theft** | HttpOnly + signed + SameSite=Strict cookie. Not accessible to JS. | -| **Webhook auth** | Shared secret via `X-Webhook-Secret` header only, constant-time comparison | -| **Passkey (WebAuthn)** | No password to leak. Biometric/PIN bound to device. Phishing-resistant. Public key stored in `authenticators` table. | -| **Decimal exhaustion** | 5/min/IP rate limit + 100-slot pool + 2min TTL + next-block safety valve | -| **Server crash** | WAL mode, expiry on restart, SQLite volume persists | -| **Dependencies** | Minimal surface: Fastify + plugins + better-sqlite3 + React | - -## 17. Environment Variables - -| Variable | Required | Default | Description | -|---|---|---|---| -| `PORT` | No | 3000 | HTTP server port | -| `HOST` | No | 0.0.0.0 | Bind address | -| `TICKET_TTL_MINUTES` | No | 2 | How long a pending ticket lives | -| `ONE_TIME_CODE` | Yes* | Auto-generated | One-time setup code for passkey registration (printed to logs on first start) | -| `COOKIE_SECRET` | Yes | — | Secret for signing admin session cookie | -| `WEBHOOK_SECRET` | Yes | — | Shared secret for SMS webhook (`X-Webhook-Secret` header) | -| `APPWRITE_ENDPOINT` | No | — | Appwrite server URL (if sync enabled) | -| `APPWRITE_PROJECT_ID` | No* | — | Appwrite project ID | -| `APPWRITE_API_KEY` | No* | — | Appwrite API key | -| `APPWRITE_DATABASE_ID` | No* | — | Appwrite database ID | -| `APPWRITE_COLLECTION_ID` | No* | — | Appwrite collection ID | - -*Required only if Appwrite sync is configured. - ---- - -_Plan generated from codebase analysis and design discussions — May 2026_ +# PayGate — Rebuild Implementation and Acceptance Status + +This document records what was implemented and what must be proven before/after the production cutover. + +## Completed implementation + +### Application base + +- [x] Replace Fastify/Node backend with Go. +- [x] Embed PocketBase 0.39.9 as the application framework. +- [x] Use one PocketBase SQLite database. +- [x] Add explicit Go migrations. +- [x] Preserve PocketBase `/_/` unchanged. +- [x] Embed a React/Vite operator UI into the Go binary. +- [x] Build a single non-root distroless production image. +- [x] Persist runtime data under `/app/pb_data`. + +### Payment core + +- [x] Store money as integer paise. +- [x] Accept only positive whole-rupee requested amounts. +- [x] Reserve `.01`–`.99`; never allocate `.00`. +- [x] Never spill DDM into the next rupee. +- [x] Reserve int64 headroom for the largest request. +- [x] Allocate transactionally in SQLite. +- [x] Randomize the starting suffix in production. +- [x] Persist payment expiry/quarantine timestamps. +- [x] Remove in-memory business timers/pools. +- [x] Exact-match full payable paise. +- [x] Add `pending`, `paid`, `expired`, `cancelled`, `late` states. +- [x] Add idempotent create keys. +- [x] Quarantine paid/cancelled/expired/late fingerprints. +- [x] Reject ambiguous automatic matches. +- [x] Deduplicate RRN. +- [x] Detect same-RRN/different-amount contradictions. +- [x] Reject old evidence that predates a reused payment. + +### SMS ingestion + +- [x] Persist `sms_events` evidence. +- [x] Scope provider dedupe to `(source, source_event_id)`. +- [x] Preserve provider/message timestamp. +- [x] Clamp missing/future timestamps safely. +- [x] Parse tested Kotak bank-credit variants. +- [x] Ignore unrelated/OTP messages. +- [x] Require amount and RRN for automatic confirmation. +- [x] Bound input/derived fields before PocketBase validation. +- [x] Add strong primary `/api/events/sms` secret. +- [x] Add explicit opt-in `/api/webhook` migration compatibility. + +### Google Messages + +- [x] Integrate libgm as an optional connector. +- [x] Persist/restore libgm AuthData. +- [x] Restrict session file/directory permissions. +- [x] Use provider MessageID and timestamp. +- [x] Ignore outgoing messages. +- [x] Privacy-prefilter bank-credit-like messages before ingestion. +- [x] Handle connected/degraded/phone-response events. +- [x] Back off reconnect attempts. +- [x] Add QR pairing command/API. +- [x] Auto-refresh short-lived QR data. +- [x] Refuse accidental re-pair over an existing session. +- [ ] Scan/complete a real phone QR pairing — intentionally deferred by operator request. + +### API/security + +- [x] API-key or operator-auth protection for payment writes. +- [x] Public limited status endpoint with bank evidence redacted. +- [x] Strict JSON decoding/unknown-field rejection. +- [x] Request body limits. +- [x] External ID/idempotency key/metadata bounds. +- [x] Strict startup environment parsing. +- [x] Minimum primary secret length. +- [x] URL validation for configured public/outgoing URLs. +- [x] PocketBase rate limits enabled by default. +- [x] Unknown `/api/*` routes remain API 404s, not SPA HTML. +- [x] `users`-only domain read rules; direct record writes locked. + +### Outgoing webhooks + +- [x] Durable delivery table/outbox. +- [x] Enqueue in the same payment transaction. +- [x] Network delivery after commit. +- [x] Stable event ID. +- [x] Timestamped HMAC-SHA256 signature. +- [x] Transactional claim. +- [x] Persisted retry schedule. +- [x] Stale `sending` lease recovery after restart. +- [x] Exhaustion state after retry ceiling. + +### UI + +- [x] Operator login. +- [x] Dashboard stats. +- [x] Realtime payment list. +- [x] Payment creation. +- [x] Payment detail/evidence. +- [x] Cancellation. +- [x] SMS evidence view. +- [x] Outgoing webhook-delivery view. +- [x] Connector health/settings. +- [x] QR rendering/refresh. +- [x] Periodic auth refresh and 401 sign-out. +- [x] UI create retries preserve idempotency key. + +## Automated verification + +The final branch must pass all of these after the last code change: + +```bash +npm ci +npm audit +npm run typecheck +npm run build + +test -z "$(gofmt -l cmd internal migrations)" +go test -count=1 ./... +go test -race -count=1 ./... +go vet ./... +git diff --check + +docker build --pull -t paygate-rebuild:test . +``` + +Important automated scenarios include: + +- all 99 fingerprints and exhaustion; +- concurrent allocation uniqueness; +- no `.00`/spillover; +- amount overflow boundary; +- idempotent creation/conflict; +- exact match; +- duplicate/contradictory RRN; +- expiry and late payment; +- quarantine and post-quarantine reuse; +- delayed old SMS after amount reuse; +- source-event dedupe; +- source validation and field bounds; +- OTP/unrelated SMS handling; +- durable webhook HMAC/retry/concurrent claim; +- session file permissions; +- libgm timestamp/message extraction; +- API auth, redaction, request limits and route namespace behaviour; +- migration access rules. + +## Fresh-container acceptance test + +Before replacing `main`, use a **new temporary Docker volume** with the final image and prove: + +1. clean startup and migrations; +2. `/api/health` healthy; +3. `/api/paygate/health` healthy; +4. compiled UI served at `/`; +5. unknown `/api/...` is a 404; +6. unauthenticated payment create denied; +7. fractional requested amount rejected; +8. authenticated payment create succeeds; +9. same idempotency key replays the same payment; +10. exact SMS event changes it to paid; +11. duplicate provider/RRN event is idempotent; +12. public status does not expose RRN/payer evidence; +13. database survives container stop/removal/recreation on the same volume; +14. Docker health transitions to healthy after recreation. + +QR scanning is excluded from this acceptance pass by explicit operator request. + +## Production cutover checklist + +### Protect the old deployment + +- [x] Identify current `main-payment-17aqux` service. +- [x] Discover that the old service has no persistent mount. +- [x] Back up the prototype task data before changes. +- [ ] Reconfirm backup path/readability immediately before cutover. + +### Prepare Dokploy + +- [ ] Add a persistent volume or bind mount to `/app/pb_data`. +- [ ] Preserve the current UPI destination/payee configuration. +- [ ] Generate a new strong `PAYGATE_API_KEY`. +- [ ] Generate a new strong `SMS_WEBHOOK_SECRET`. +- [ ] Keep the old `WEBHOOK_SECRET` only if the current Android relay must survive the first cutover. +- [ ] Set `LEGACY_SMS_WEBHOOK_ENABLED=true` only for that transition. +- [ ] Keep Google Messages disabled until real QR testing if not paired yet. +- [ ] Confirm no secret is printed into logs/history during the change. + +### Branch/cutover + +- [ ] Final independent diff/review pass. +- [ ] Commit the rebuild on `rebuild-pocketbase`. +- [ ] Push and confirm CI. +- [ ] Advance/merge `main` only after CI and fresh-container acceptance are green. +- [ ] Trigger Dokploy deployment for the `main` commit. +- [ ] Verify service health, UI and API through the actual production route. +- [ ] Create a harmless validation payment if appropriate. +- [ ] Recreate/restart the production task and prove its PocketBase state persists. +- [ ] Verify Docker/Dokploy reports healthy after restart. + +## Post-cutover migration cleanup + +Once the new Android endpoint or Google Messages path is confirmed: + +- rotate/update the Android relay to `/api/events/sms` with `SMS_WEBHOOK_SECRET` and timestamps/provider IDs; +- set `LEGACY_SMS_WEBHOOK_ENABLED=false`; +- remove the old weak `WEBHOOK_SECRET` from Dokploy; +- pair/test Google Messages with the real phone; +- measure ingestion/matching latency and missed-event rate before treating libgm as the primary source. + +## Definition of v1 done + +For this rebuild, v1 is considered complete when: + +- the final source/tests/container are green; +- `main` contains the reviewed rebuild; +- Dokploy runs that `main` build; +- `/app/pb_data` is persistent across a production task replacement; +- the API/UI/payment/SMS legacy path are verified in production; +- no known correctness/security blocker remains except the explicitly deferred real-phone QR validation. diff --git a/README.md b/README.md index 4532b72..5bf164e 100644 --- a/README.md +++ b/README.md @@ -1,529 +1,326 @@ -
+# PayGate -# Payment API +Self-hosted UPI payment verification for applications that receive money directly into a configured UPI/bank account. -**v0.6.81** +PayGate creates a unique payable amount using a paise fingerprint, observes bank-credit SMS evidence, matches the **exact** amount and UPI reference, persists the payment lifecycle in PocketBase/SQLite, and exposes status through an HTTP API, realtime operator UI and optional signed outgoing webhooks. -A zero-fee UPI payment gateway that uses **Dynamic Decimal Matching** to resolve payments by parsing bank SMS notifications. Built with Fastify, SQLite, and TypeScript. +PayGate does **not** custody, route or settle funds. The payer pays the configured UPI account directly. SMS-based verification is an evidence mechanism, not a bank/acquirer API and not a settlement guarantee. -
+## Runtime -
+The production deployment is intentionally one small service: -![Node](https://img.shields.io/badge/node-%3E%3D22-339933?logo=node.js) -![TypeScript](https://img.shields.io/badge/TypeScript-5.8-3178C6?logo=typescript) -![Fastify](https://img.shields.io/badge/Fastify-5-000000?logo=fastify) -![SQLite](https://img.shields.io/badge/SQLite-better_sqlite3-003B57?logo=sqlite) +```text +Android phone / bank SMS + │ + ├── Google Messages → libgm ──┐ + │ │ + └── legacy Android relay ─────┤ + ▼ + ┌──────────────────────────┐ + │ PayGate (Go) │ + │ │ + │ PocketBase 0.39.9 │ + │ SQLite + migrations │ + │ payment/SMS services │ + │ webhook outbox/worker │ + │ optional libgm manager │ + │ React/Vite operator UI │ + └────────────┬─────────────┘ + │ + /app/pb_data + persistent volume +``` -
+There is no Redis, external queue, second database or custom realtime server. PocketBase provides SQLite, auth, SSE realtime, cron, logs, backups and its raw admin UI at `/_/`. ---- +## Payment model -## Table of Contents +A caller requests a **whole rupee** amount. PayGate reserves one of 99 paise fingerprints for that rupee value. -- [Architecture](#architecture) -- [Data Flow](#data-flow) -- [Dynamic Decimal Matching](#dynamic-decimal-matching) -- [SMS Processing](#sms-processing) -- [Timer & Expiry System](#timer--expiry-system) -- [API Reference](#api-reference) -- [Database Schema](#database-schema) -- [Configuration](#configuration) -- [Project Structure](#project-structure) -- [Running](#running) +Example: ---- +```text +requested ₹100 +possible payable amounts: ₹100.01 ... ₹100.99 +``` -## Architecture +`.00` is never allocated and allocation never spills into the next rupee. All money is integer paise internally. -``` -Fastify Server -│ -├── Routes ────────▶ Services ──────────▶ SQLite (app.db) -│ POST /api/ticket TicketService tickets table -│ GET /api/status DecimalPool -│ POST /api/webhook PaymentService -│ GET /api/health -│ -├── Middleware -│ Rate limit (@fastify/rate-limit) -│ Request logger (Pino) -│ Error handler (unified JSON responses) -│ -├── In-Memory State -│ Map> -│ Map (expiry timers) -``` +The reservation is transactional and persisted. A payment remains `pending` until it is paid, cancelled or expires. Resolved/expired amounts are quarantined before reuse so a delayed SMS cannot normally confirm a newer payment. -### Components +An additional stale-evidence guard compares the SMS/provider occurrence timestamp with the payment's persisted `created_at`: an old Google Messages catch-up event that predates a reused payment cannot confirm that newer payment. -| Component | Role | -|-----------|------| -| **Fastify** | HTTP server with built-in rate limiting (`@fastify/rate-limit`), schema validation (`@sinclair/typebox`), and security headers (`@fastify/helmet`) | -| **TicketService** | Ticket CRUD via prepared statements. Manages per-ticket `setTimeout` handles for TTL expiry and decimal release | -| **DecimalPoolService** | In-memory pool of taken decimal values. `allocate()` finds the first free decimal 0-99, `release()` removes from the taken set | -| **PaymentService** | Parses incoming SMS using regex, dispatches to `confirmFromBankSms()` or `fillFromGenericSms()` | -| **SQLite** | Single `tickets` table in WAL mode. Synchronous driver (`better-sqlite3`) — no connection pool overhead | +A finite quarantine cannot make amount-only verification mathematically unique forever: someone who deliberately pays an old QR **after** its amount has eventually been reused creates a new bank transaction at the current time and is indistinguishable from a new payer if the bank evidence exposes only amount/RRN. PayGate therefore fails closed where it can, keeps a long configurable quarantine, and treats official bank/acquirer references as the long-term path when stronger correlation is required. ---- +Statuses: -## Data Flow +- `pending` +- `paid` +- `expired` +- `cancelled` +- `late` — an exact credit arrived after expiry/cancellation but while that amount was still quarantined -### Ticket Creation +## API -``` -POST /api/ticket { amount: 100 } - - 1. toPaisa(100) → 10000 paisa - 2. DecimalPool.allocate(10000) - → base = 10000 - → scan 0..99, decimal 00 is free - → mark 00 as taken - → return { amount: 10000, baseAmount: 10000, decimalVal: 0 } - 3. INSERT INTO tickets (...) VALUES ('TICKET...', 10000, 'pending', 10000, 0) - 4. setTimeout(() => onTtlReached(ticket), 2 * 60_000) - 5. Response: { ticketId: 'TICKET...', amount: 100, status: 'pending' } - -ticket.amount = 10000 paisa = ₹100.00 -``` +### Create a payment -### Payment Confirmation +```http +POST /api/payments +Authorization: Bearer +Idempotency-Key: +Content-Type: application/json +{ + "amount": 100, + "externalId": "order-123", + "metadata": {"cart": "abc"} +} ``` -Two SMSes arrive at POST /api/webhook { sms: "..." } - - BANK SMS (settlement notification): - "Received Rs.100.00 from user@paytm UPI Ref:123456789" - → parseSms → method: "bank", amount: 10000 - → confirmFromBankSms - → SELECT WHERE base_amount = 10000 AND status = 'pending' - → markPaid(ticket) - → UPDATE status = 'paid', rrn = '123456789' - → clearTimers() (cancel expiry + grace timers) - → DecimalPool.release(10000, 0) - → Response: { action: "marked_paid", ticketId: "TICKET..." } - - GENERIC SMS (UPI app notification, includes ticketId): - "TICKET17123456780000 SOURAV paid you ₹100.00 UPI Ref:123456789" - → parseSms → method: "generic", ticketId: "TICKET...", senderName: "SOURAV" - → fillFromGenericSms - → UPDATE sender_name = 'SOURAV' WHERE id = 'TICKET...' - → Response: { action: "name_filled", ticketId: "TICKET..." } -``` - -The bank SMS is the authoritative payment signal. The generic SMS only fills the payer's name. Either can arrive first — `fillSenderName` works regardless of payment status. ---- +`amount` may also be an integer string such as `"100"`. Fractional requested amounts are rejected. -## Dynamic Decimal Matching +Example response: -### Problem +```json +{ + "id": "...", + "paymentId": "...", + "requestedAmount": 100, + "requestedAmountPaise": 10000, + "payableAmount": "100.37", + "payableAmountPaise": 10037, + "status": "pending", + "expiresAt": "...", + "paidAt": null, + "externalId": "order-123", + "upiUri": "upi://pay?..." +} +``` -UPI payments only provide a transaction amount and reference number. Without a payment gateway callback, there is no way to know which customer paid for which ticket when multiple tickets share the same price. +The same `Idempotency-Key` and identical parameters return the original payment. Reusing a key with different parameters returns `IDEMPOTENCY_CONFLICT`. -### Solution +### Read public payment status -Replace the standard `₹100.00` amount with a unique `₹100.xx` amount drawn from a pool of 100 decimal variations. The exact amount encodes which ticket was paid. +```http +GET /api/payments/{id} +``` -### Pool Structure +This deliberately returns the limited public payment view. Bank evidence such as RRN, payer UPI ID and payer name is not exposed here. -``` -Map> +### Cancel -base 10000 (₹100): Set{ 00, 03, 07, 15 } → 96 free slots -base 10100 (₹101): Set{ 01, 02 } → 98 free slots -base 10200 (₹102): Set{ } → 100 free slots (untouched) +```http +POST /api/payments/{id}/cancel +Authorization: Bearer ``` -The pool stores only **taken** decimals. Free decimals are anything in 0..99 not in the set. +### Primary SMS ingestion -### Allocation +```http +POST /api/events/sms +X-Webhook-Secret: +Content-Type: application/json -``` -allocate(10000 paisa): - 1. base = baseAmountFromPaisa(10000) → 10000 - 2. set = pools.get(10000) ?? new Set() - 3. for i = 0..99: - if !set.has(i): set.add(i); return { amount: 10000 + i, baseAmount: 10000, decimalVal: i } - 4. All 100 taken → spillover - for block = 10100, 10200, ...: - set = pools.get(block) ?? new Set() - if set.size < 100: return allocateFromBlock(block) - 5. throw POOL_EXHAUSTED +{ + "sms": "Received Rs.100.37 ... UPI Ref:123456789012", + "source": "android_webhook", + "sourceId": "provider-message-id", + "sender": "bank-sender", + "timestamp": "2026-07-25T12:34:56Z" +} ``` -Sequential allocation (0, 1, 2, ...) is used rather than random to minimise fragmentation. +`source` must be `android_webhook`, `gmessages` or `manual`. Supplying `sourceId` and the original `timestamp` is strongly recommended for durable deduplication and stale-message protection. -### Release Semantics +### Legacy Android relay -| Trigger | Decimal Release | Rationale | -|---------|----------------|-----------| -| **Paid** | Immediate | RRN deduplication in the DB prevents the same decimal from being double-matched | -| **Expired** | 30s delay | Prevents rapid recycling: a delayed SMS could match a freshly re-allocated decimal | -| **Cancelled** | 30s delay | Same anti-race protection | +`POST /api/webhook` accepts the old `{ "sms": "..." }` payload only when `LEGACY_SMS_WEBHOOK_ENABLED=true`. It authenticates with the separate legacy `WEBHOOK_SECRET` and always records the source as `android_webhook`. -### Spillover +This compatibility route exists for migration only. The production default is disabled. Rotate the old relay to `SMS_WEBHOOK_SECRET` and `/api/events/sms`, then disable the legacy route. -When all 100 decimal slots for a base amount are taken, the next integer block is used. For example, ticket #101 for `₹100` will be allocated `₹101.00`. The price drift is bounded by the number of concurrent tickets at that price point. +### Health -### Startup Recovery +- `GET /api/health` — PocketBase liveness endpoint, used by the container healthcheck. +- `GET /api/paygate/health` — PayGate readiness, database state and a redacted connector summary. -On server restart: +Unknown `/api/*` paths remain JSON 404 responses; the React SPA fallback never converts API errors into HTML 200 responses. -```sql -UPDATE tickets SET status = 'expired' WHERE status = 'pending'; -SELECT base_amount, decimal_val, status FROM tickets; -``` - -1. All pending tickets are mass-expired (TTL state was in-memory and lost) -2. The pool is rebuilt from remaining `pending` and `paid` ticket decimals -3. No per-ticket timers are restored — fresh timers are created for new tickets +## Matching and idempotency ---- +Bank SMS processing follows these rules: -## SMS Processing +1. persist the SMS evidence event; +2. parse exact amount, RRN and optional payer information; +3. reject automatic matching if amount or RRN is missing; +4. treat an already-seen RRN with the same amount as an idempotent duplicate; +5. treat the same RRN with a different amount as `RRN_AMOUNT_MISMATCH`; +6. match a pending payment only by the exact payable paise amount and eligible timestamp; +7. if no pending payment matches, check an expired/cancelled payment still in quarantine and mark it `late`; +8. never silently assign ambiguous evidence. -### SMS Formats +Google Messages catch-up messages retain their provider message timestamp. Legacy relays that omit a timestamp are treated as arriving at ingestion time, so upgrading the legacy relay to send timestamps is recommended. -**Bank SMS** (settlement notification, no ticket ID): -``` -Confirmed payment for Received Rs.100.00 in your Kotak Bank AC X4959 -from user@oksbi on 08-03-26.UPI Ref:606703736480. -``` -→ Extracts `amount` (10000 paisa), `rrn`, `upi_id` → matches by `base_amount` → marks paid +## Outgoing webhooks -**Generic** (UPI app notification, includes ticket ID): -``` -TICKET17123456780000 SOURAV paid you ₹100.00 UPI Ref:606703736479 -``` -→ Extracts `ticketId`, `senderName` → matches by ID → fills `sender_name` +Configure `OUTGOING_WEBHOOK_URL` and `OUTGOING_WEBHOOK_SECRET` to receive payment lifecycle events. -### RRN Deduplication +Events currently include: -The `rrn` column has a `UNIQUE` constraint. If two webhook calls arrive with the same RRN (duplicate delivery), the second `UPDATE` throws a constraint error, which is caught and surfaced as `RRN_DUPLICATE`. This prevents the same transaction from marking two different tickets as paid. +- `payment.paid` +- `payment.late` +- `payment.expired` +- `payment.cancelled` ---- +Delivery records are written transactionally to `webhook_deliveries`; network I/O happens only after the transaction commits. The worker uses durable retries and recovers stale `sending` leases after process restarts. -## Timer & Expiry System +Headers: -Each ticket has three associated timers managed in-memory: - -``` -Ticket Created -│ -├── 2 min TTL ──────────▶ onTtlReached() -│ │ -│ ├── 30s grace period -│ │ ticket stays 'pending', bank SMS can still arrive -│ │ -│ ▼ -│ graceExpired() -│ │ -│ ├── UPDATE status = 'expired' -│ └── 30s ──▶ DecimalPool.release() -│ -├── Paid (via bank SMS) ──▶ clearTimers() -│ └── DecimalPool.release() (immediate) -│ -└── Cancelled ─────────────▶ clearTimers() - └── 30s ──▶ DecimalPool.release() +```text +X-PayGate-Event-Id: +X-PayGate-Timestamp: +X-PayGate-Signature: v1= ``` -On server restart, all timers are lost. The startup routine mass-expires any remaining pending tickets to maintain consistency: +Signature input: -```sql -UPDATE tickets SET status = 'expired', updated_at = datetime('now') -WHERE status = 'pending'; +```text +. ``` ---- - -## API Reference +Consumers should verify the HMAC with `OUTGOING_WEBHOOK_SECRET` and deduplicate by event ID. -### `POST /api/ticket` +## Operator UI -Create a payment ticket. A unique decimal amount is allocated from the pool. +The React UI at `/` provides: -``` -Rate limit: 5 requests/minute/IP -``` - -**Request:** -```json -{ "amount": 100 } -``` - -**Response `200`:** -```json -{ - "ticketId": "TICKET17123456780000", - "amount": 100, - "amountPaisa": 10000, - "status": "pending", - "createdAt": "2026-06-01T12:00:00" -} -``` +- dashboard and payment statistics; +- payment creation; +- realtime payment list/details; +- cancellation; +- SMS evidence records; +- outgoing webhook delivery records; +- Google Messages connector status and QR pairing controls; +- safe non-secret configuration status. -**Errors:** -| Code | Status | Condition | -|------|--------|-----------| -| `INVALID_AMOUNT` | 400 | Amount is not a positive number with ≤2 decimals | -| `POOL_EXHAUSTED` | 503 | All decimal slots for this and adjacent blocks are taken | +Operator accounts use the PocketBase `users` auth collection. Domain collections are read-only through PocketBase APIs for authenticated `users`; state-changing payment operations go through custom Go handlers. Direct domain writes are locked. ---- +PocketBase's own `/_/` interface remains available to superusers for low-level administration, logs, backups and schema inspection. -### `GET /api/status/:id` +## Google Messages connector -Get the current status of a ticket. +The optional connector uses `go.mau.fi/mautrix-gmessages/pkg/libgm`. -``` -Rate limit: 60 requests/minute/IP -``` +Set: -**Response `200`:** -```json -{ - "ticketId": "TICKET17123456780000", - "amount": 100, - "amountPaisa": 10000, - "status": "paid", - "createdAt": "2026-06-01T12:00:00", - "paidAt": "2026-06-01T12:02:30", - "senderName": "SOURAV", - "rrn": "606703736479", - "upiId": "user@paytm" -} +```text +GMESSAGES_ENABLED=true ``` -`status` is one of: `pending`, `paid`, `cancelled`, `expired`. +The connector: -**Errors:** -| Code | Status | Condition | -|------|--------|-----------| -| `TICKET_NOT_FOUND` | 404 | No ticket with the given ID | +- restores a persisted session from `pb_data/gmessages/session.json`; +- stores the session with restrictive filesystem permissions; +- connects using libgm's event-driven relay/long-poll implementation; +- forwards only incoming messages that resemble supported bank-credit SMS text; +- records the Google message ID and original timestamp; +- reconnects/backoffs on connection failures; +- reports paired/connected/phone-responsive state; +- supports QR pairing and automatic QR refresh. ---- +Starting a new pairing is refused while a valid session is already paired; explicitly unpair first. -### `POST /api/webhook` +**Live phone QR scanning is the one intentionally deferred acceptance test.** The connector code is integrated and unit-tested, but the private Google Messages protocol can change and must be validated with the actual phone before relying on it as the only ingestion source. -Receive a bank SMS notification. Requires the `X-Webhook-Secret` header. +## Configuration -``` -Rate limit: 30 requests/minute/IP -``` +Copy `.env.example` and provide secrets through your deployment system rather than committing them. -**Headers:** -``` -X-Webhook-Secret: -``` +Required in normal serve mode: -**Request:** -```json -{ "sms": "Confirmed payment for Received Rs.100.00 in your Kotak Bank AC X4959 from user@oksbi on 08-03-26.UPI Ref:606703736480." } +```text +UPI_ID= +PAYGATE_API_KEY= # minimum 24 characters +SMS_WEBHOOK_SECRET= # minimum 24 characters ``` -**Response `200` (Bank SMS):** -```json -{ - "status": "ok", - "ticketId": "TICKET17123456780000", - "action": "marked_paid", - "ticket": { "ticketId": "...", "amount": 100, "status": "paid", ... } -} -``` +Common optional values: -**Response `200` (Generic):** -```json -{ - "status": "ok", - "ticketId": "TICKET17123456780000", - "action": "name_filled", - "ticket": { "ticketId": "...", "amount": 100, "status": "pending", ... } -} +```text +UPI_PAYEE_NAME=PayGate +PAYMENT_TTL=5m +PAYMENT_QUARANTINE=24h +PAYGATE_RATE_LIMITS_ENABLED=true +GMESSAGES_ENABLED=false +GMESSAGES_SESSION_PATH= +OUTGOING_WEBHOOK_URL= +OUTGOING_WEBHOOK_SECRET= +PB_DATA_DIR=./pb_data ``` -**Errors:** -| Code | Status | Condition | -|------|--------|-----------| -| `WEBHOOK_UNAUTHORIZED` | 401 | Missing or invalid `X-Webhook-Secret` | -| `TICKET_NOT_FOUND` | 404 | Bank SMS: no pending ticket matches the amount. Generic: no ticket with the given ID | -| `AMOUNT_MISMATCH` | 400 | Bank SMS: multiple pending tickets match the same amount | -| `RRN_DUPLICATE` | 409 | The RRN has already been processed for a different ticket | -| `INVALID_AMOUNT` | 400 | Unrecognised SMS format | +Legacy prototype variables `UPI_NAME`, `TICKET_TTL_MINUTES`, `AMOUNT_QUARANTINE_HOURS` and `PAYMENT_WEBHOOK_*` remain understood where documented in `.env.example`. Invalid booleans/durations fail startup instead of silently falling back. ---- +`PAYGATE_TEST_MODE=true` is for controlled tests only and bypasses normal required-config validation. -### `GET /health` - -``` -Rate limit: none -``` +## Docker -**Response `200`:** -```json -{ "status": "healthy", "uptime": 3600, "db": "ok" } +```bash +cp .env.example .env +# edit .env +docker compose up -d --build ``` ---- - -## Rate Limits - -| Endpoint | Limit | Scope | -|----------|-------|-------| -| `POST /api/ticket` | 5 requests / minute | Per IP | -| `GET /api/status/:id` | 60 requests / minute | Per IP | -| `POST /api/webhook` | 30 requests / minute | Per IP | -| `GET /health` | Unlimited | — | -| All others | 100 requests / minute | Per IP | - -Limits are enforced by `@fastify/rate-limit` using an in-memory sliding window. - ---- - -## Database Schema - -### `tickets` - -```sql -CREATE TABLE IF NOT EXISTS tickets ( - id TEXT PRIMARY KEY, - amount INTEGER NOT NULL, - status TEXT NOT NULL DEFAULT 'pending' - CHECK(status IN ('pending','paid','cancelled','expired')), - base_amount INTEGER NOT NULL, - decimal_val INTEGER NOT NULL, - sender_name TEXT, - rrn TEXT UNIQUE, - upi_id TEXT, - paid_at TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); -``` +The only state that must survive container replacement is mounted at: -| Column | Type | Notes | -|--------|------|-------| -| `id` | TEXT | Format: `TICKET{timestamp}{counter}`, e.g. `TICKET17123456780000` | -| `amount` | INTEGER | Total amount in paisa (base + decimal), e.g. `10003` = ₹100.03 | -| `status` | TEXT | `pending` / `paid` / `cancelled` / `expired` | -| `base_amount` | INTEGER | Floor to nearest rupee in paisa, e.g. `10000` for ₹100.xx | -| `decimal_val` | INTEGER | The allocated decimal 0-99 | -| `rrn` | TEXT | UPI reference number, unique across all tickets | - -### Indexes - -```sql -idx_tickets_status ON tickets(status) -idx_tickets_amount ON tickets(amount) -idx_tickets_rrn ON tickets(rrn) -idx_tickets_decimal ON tickets(base_amount, decimal_val, status) -idx_tickets_created ON tickets(created_at) +```text +/app/pb_data ``` ---- +Do not deploy this image without a persistent volume/bind mount there. PocketBase SQLite, migrations, operator accounts, SMS evidence, outgoing webhook state, backups and the Google Messages session all depend on that directory. -## Configuration +## First operator account -| Variable | Default | Description | -|----------|---------|-------------| -| `PORT` | `3000` | HTTP server port | -| `HOST` | `0.0.0.0` | Bind address | -| `TICKET_TTL_MINUTES` | `2` | Time before a pending ticket expires (in-memory timer) | -| `UPI_ID` | — | UPI ID shown on tickets (e.g. `college@upi`) | -| `UPI_PAYEE_NAME` | — | Payee name for ticket display | -| `WEBHOOK_SECRET` | random | Shared secret for `X-Webhook-Secret` header verification | -| `DATA_DIR` | `data` | Directory for the SQLite database file | -| `LOG_LEVEL` | `info` | Pino log level: `trace`, `debug`, `info`, `warn`, `error`, `fatal` | +PocketBase can create the first superuser through `/_/`, or from the container CLI with PocketBase's `superuser upsert` command. After that, create a normal record in the `users` auth collection for the PayGate operator UI. ---- +## Tests and CI -## Project Structure - -``` -src/ -├── server/ -│ ├── index.ts # Entry point, startup, graceful shutdown -│ ├── config.ts # Environment variable loader -│ ├── app.ts # Fastify app assembly (routes, plugins) -│ ├── errors.ts # AppError class + status code map -│ ├── money.ts # Paisa/rupee conversion utilities -│ ├── db/ -│ │ ├── connection.ts # SQLite open/close with WAL pragmas -│ │ └── schema.ts # CREATE TABLE statements -│ ├── middleware/ -│ │ ├── error-handler.ts # Unified error response format -│ │ └── request-logger.ts # Pino request logging -│ ├── routes/ -│ │ ├── health.ts # GET /health -│ │ ├── ticket.ts # POST /api/ticket, GET /api/status/:id -│ │ └── webhook.ts # POST /api/webhook -│ └── services/ -│ ├── decimal.service.ts # DDM pool: allocate, release, rebuild -│ ├── payment.service.ts # SMS parsing, bank/generic dispatch -│ └── ticket.service.ts # Ticket CRUD, timer management -├── types/ -│ └── index.ts # Ticket, TicketResponse, ParsedSms interfaces -└── test/ # Vitest test suite - ├── helpers.ts - ├── decimal.test.ts - ├── money.test.ts - ├── payment.test.ts - └── routes.test.ts -``` - ---- - -## Running +Local validation: ```bash -# Install -npm install - -# Development (with file watching) -npm run dev - -# TypeScript check +npm ci npm run typecheck - -# Tests -npm test - -# Production build npm run build -# Start production -npm start +gofmt -w cmd internal migrations +go test -count=1 ./... +go test -race -count=1 ./... +go vet ./... + +docker build -t paygate . ``` -### Docker +CI performs frontend install/typecheck/build, Go formatting, unit/integration tests, race tests, vet and a production container build. Deployment is intentionally not automatic: the persistent-volume and environment cutover is an operator action. -```bash -# Build -docker build -t ddm-payment-gateway . - -# Run -docker run -d \ - -p 3000:3000 \ - -v payment_data:/app/data \ - --env-file .env \ - ddm-payment-gateway - -# With docker-compose -docker compose up -d -``` +## Security notes -### Testing +- Treat `PAYGATE_API_KEY`, SMS secrets, PocketBase auth tokens and the libgm session as credentials. +- The Google Messages session can represent a paired Messages-for-Web client and is stored outside normal collections with restrictive permissions. +- Keep `/app/pb_data` private and backed up. +- Use HTTPS at the reverse proxy. +- PocketBase rate limits are enabled by default in PayGate. +- Do not leave the weak legacy `/api/webhook` compatibility route enabled after migration. +- A false-positive payment confirmation is worse than a delayed/manual review; matching therefore fails closed on ambiguity. +- SMS delivery and the private Google Messages protocol have no end-to-end latency/SLA guarantee. -``` -npm test # Run all tests -npm run test:watch # Watch mode -``` +## Licence + +This repository directly links against `libgm`, which is AGPL-3.0. The rebuilt project is therefore distributed under the GNU Affero General Public License; see `LICENSE` and `NOTICE`. + +A future proprietary/commercial distribution needs a separate licensing review rather than assuming architectural separation removes AGPL obligations. -The test suite covers: +## More detail -- Decimal pool allocation, spillover, and recovery -- SMS parsing for both bank and generic formats -- Route integration (create ticket, status check, webhook) -- Webhook authentication rejection -- Duplicate RRN rejection -- Immediate reuse of paid decimals +- `ARCHITECTURE.md` — implemented system design and invariants +- `PLAN.md` — implementation/acceptance status +- `RESEARCH.md` — technical research and constraints behind the design +- `IMPLEMENTATION_SPEC.md` — rebuild requirements used during implementation diff --git a/RESEARCH.md b/RESEARCH.md new file mode 100644 index 0000000..09ba6ac --- /dev/null +++ b/RESEARCH.md @@ -0,0 +1,227 @@ +# PayGate — Research and Implementation Findings + +This document records the technical facts that shaped the PocketBase/libgm rebuild and the implementation-specific problems discovered while validating it. + +## 1. Prototype audit + +The original TypeScript/Fastify service proved that a direct-to-UPI payment can be correlated from bank SMS evidence, but several prototype decisions were not safe foundations for a durable service. + +Findings from the original implementation: + +- amount state/expiry relied partly on process memory; +- restart behaviour could invalidate pending tickets; +- the advertised decimal matching path reduced a received amount to its whole-rupee base before selecting pending tickets, so two requests such as `₹100.01` and `₹100.02` could become ambiguous; +- database state lived in SQLite without the production deployment actually mounting its data directory persistently; +- the Android relay was an external dependency but the server interface itself only needed an SMS string plus shared secret; +- RRN uniqueness was a useful idea and was retained; +- integer money helpers were a useful idea and were retained. + +The rebuild therefore kept exact-amount allocation, SMS parsing and RRN idempotency while replacing the state/runtime architecture. + +## 2. PocketBase 0.39.9 + +PocketBase is embedded as a Go framework rather than deployed as a second process. + +Upstream capabilities used directly by this project: + +- `RunInTransaction` for short SQLite transactions; +- Go migrations and programmatically constructed collections; +- auth collections and API rules; +- custom HTTP routes through `OnServe`; +- SSE realtime record subscriptions; +- scheduled cron jobs; +- backup/log/admin facilities; +- a built-in rate-limit middleware/rule model; +- per-route request body limits. + +Reference: + +- https://pocketbase.io/docs/go-overview/ +- https://pocketbase.io/docs/go-migrations/ +- https://github.com/pocketbase/pocketbase + +### Implementation-specific PocketBase findings + +Several behaviours were verified against the exact v0.39.9 source/tests and then covered by PayGate tests: + +1. Base collections only receive the system `id` field automatically; explicit `created`/`updated` autodate fields are required when the application wants them. +2. PocketBase filter date comparisons should receive PocketBase/RFC3339-formatted date values rather than relying on arbitrary Go `time.Time` binding. A validation experiment showed the same stored date failed a `<=` filter when bound as `time.Time` and matched when bound as a PocketBase-formatted date/string. PayGate therefore normalises every business date filter through one helper. +3. Collection index expressions are parsed/validated by PocketBase; a partial-index `IN (...)` expression used during the first migration draft was rejected and was replaced with an accepted boolean expression. +4. A wildcard static SPA route can still catch unknown API paths if it is not explicitly guarded. PayGate excludes `api` and `_` namespaces from its SPA fallback. +5. PocketBase rate-limit support exists but its settings must be enabled. PayGate enables configured rate limiting at serve time. + +These are reasons the project pins and tests against a specific PocketBase version rather than assuming behaviour from older examples. + +## 3. Why SQLite remains appropriate here + +The current boundary is one operator and one service instance. SQLite gives: + +- atomic allocation/state transitions; +- simple backup/recovery; +- very low deployment overhead; +- enough concurrency for this workload when transactions are kept short. + +The application deliberately performs no external HTTP request inside a SQLite transaction. Outgoing webhooks use an outbox record and worker. Google Messages ingestion is normalised before entering payment matching. + +A move to PostgreSQL would make sense if the product becomes a multi-merchant horizontally scaled service, but it would add operational complexity without solving a current requirement. + +## 4. libgm / mautrix-gmessages + +Pinned module: + +```text +go.mau.fi/mautrix-gmessages v0.2605.0 +``` + +Upstream source: + +- https://github.com/mautrix/gmessages +- https://pkg.go.dev/go.mau.fi/mautrix-gmessages/pkg/libgm + +The source contains the pieces needed for a standalone connector rather than browser-DOM automation: + +- pairing; +- request crypto; +- protobuf schemas; +- private Google/Tachyon authentication; +- token refresh; +- long-poll/event handling; +- acknowledgements/session state; +- phone responsiveness events; +- old/catch-up message events. + +`AuthData` contains sensitive cryptographic/device/session material including request crypto, refresh key, browser/mobile device identities, Tachyon auth token, IDs and cookies. PayGate therefore treats the JSON session like a credential, stores it outside normal API collections and enforces private filesystem permissions. + +The connector is kept behind a small ingestion callback: payment-domain code never receives libgm protobuf types. This is a reliability boundary, not a claim that process/package separation changes licence obligations. + +## 5. libgm licence + +mautrix-gmessages/libgm is GNU AGPL-3.0. The upstream repository also contains special licence exceptions for named parties such as Beeper/Element; those exceptions do not automatically apply to this project. + +Because this implementation directly links libgm into the PayGate binary, the rebuilt repository is distributed under AGPL terms (`LICENSE`, `NOTICE`). A future closed-source commercial distribution needs an actual licensing/legal decision, for example obtaining an appropriate licence or independently replacing the connector. It should not rely on an assumption that simply moving AGPL code to another process removes obligations. + +## 6. Google Messages constraints + +Google's official Messages-for-Web documentation describes a paired computer experience where the computer communicates through the phone. Important operational consequences: + +- the phone remains part of the system; +- the phone needs cellular service to receive SMS; +- the phone/data path needs internet connectivity for Messages-for-Web synchronisation; +- background-data/battery restrictions can interfere with connectivity; +- paired devices expose sensitive message content and should be treated as trusted devices; +- pairings can become inactive/unpaired; +- only one computer is documented as active at a time even though multiple may be paired. + +Official references: + +- https://support.google.com/messages/answer/7611075 +- https://support.google.com/messages/answer/9077245 +- https://developer.android.com/training/monitoring-device-state/doze-standby +- https://developer.android.com/develop/connectivity/network-ops/data-saver + +For a reliable installation, the intended phone setup is a dedicated/controlled Android device, stable power, stable Wi-Fi/mobile data, Google Messages allowed background/unrestricted data, and battery optimisation disabled for Messages where the device offers that control. + +## 7. Latency + +There is no published end-to-end Google Messages-for-Web latency SLA that PayGate can safely promise. + +Total observed latency is the sum of: + +```text +bank/core banking +→ mobile operator SMS delivery +→ phone Messages app +→ Google Messages relay +→ libgm event +→ PayGate persistence/parser/match +→ optional outgoing webhook +``` + +The final PayGate-local stages should be small under normal load; upstream bank/carrier/phone/network stages can be delayed or offline for an unbounded period. + +This is why: + +- payment state is durable; +- old/catch-up events are accepted and deduplicated; +- message occurrence time is retained; +- expired fingerprints are quarantined; +- an old event cannot confirm a payment created later; +- connector health is visible to the operator. + +Real p50/p95/p99 latency must be measured with the actual bank/phone/network after live QR pairing. + +## 8. Why browser automation was rejected + +Automating `messages.google.com/web` with Playwright/Puppeteer would couple payment verification to DOM structure, browser sessions and UI changes. libgm already exposes the underlying event/protocol implementation needed by the bridge ecosystem, so browser automation would add fragility rather than reduce it. + +A custom clean-room protocol implementation is technically possible but would need to reproduce pairing, crypto, token refresh, protobuf/version tracking, long-polling, ack/session logic, catch-up handling and protocol changes. That is not justified for the current product while libgm works and the connector is isolated from the payment domain. + +## 9. Evidence correlation research finding + +A quarantine window by itself is not a complete stale-payment defence. + +Scenario: + +1. Payment A uses `₹100.01`. +2. A expires and its 24-hour quarantine eventually ends. +3. Payment B later reuses `₹100.01`. +4. Google Messages reconnects and emits an old SMS for A. + +If matching only looks at current amount/status, that historical event can falsely confirm B. + +The implemented fix is an evidence-time invariant: + +```text +payment.created_at <= sms.OccurredAt +``` + +Provider timestamps are therefore domain-relevant evidence, not just observability metadata. A dedicated automated test reproduces reuse followed by delayed Google Messages catch-up and verifies B remains pending. + +This timestamp guard solves delayed *historical delivery*, but not a payer who initiates a brand-new transfer from an old QR after the same amount has legitimately been reused. If the bank SMS exposes only amount and a new RRN, that new transfer is observationally identical to the current payment. No finite quarantine can remove this ambiguity forever; it only reduces its probability. Deployments needing cryptographic/provider-level correlation should move to official acquiring/bank APIs rather than pretending SMS/DDM supplies a guarantee it cannot. + +## 10. RRN semantics + +RRN is used as strong deduplication evidence: + +- first exact amount + new RRN can resolve a payment; +- repeated RRN + same amount is idempotent; +- repeated RRN + different amount is an anomaly and does not resolve another payment. + +The current single-UPI-account scope makes a global non-empty RRN uniqueness constraint practical. If PayGate becomes multi-account/multi-rail, the uniqueness scope should be revisited against the semantics of those official connectors rather than copied unchanged. + +## 11. Legacy Android relay finding + +Production inspection showed the old service uses the route `/api/webhook` and a legacy `WEBHOOK_SECRET`. The existing deployed secret is extremely weak, so promoting it to the new primary SMS secret would undermine the rebuild. + +The compatibility strategy is therefore: + +- primary product route: `/api/events/sms` + strong `SMS_WEBHOOK_SECRET`; +- old route: `/api/webhook` + old `WEBHOOK_SECRET`, available only when `LEGACY_SMS_WEBHOOK_ENABLED=true`; +- legacy route always identifies the source as `android_webhook`; +- log a startup warning while compatibility is enabled; +- remove/disable it after the phone relay is upgraded or Google Messages is proven. + +This preserves cutover continuity without making the weak legacy credential the default security model. + +## 12. Deployment finding + +Inspection of the running Dokploy Swarm service showed the old production task has no persistent mount. Its SQLite file is therefore coupled to the task/container filesystem and can be lost on replacement. + +A backup was taken before rebuild/cutover work. The new image uses `/app/pb_data`, and production acceptance explicitly requires task recreation with the same PocketBase state still present. + +## 13. Current conclusion + +The small, robust architecture for the present scope is: + +```text +Go + embedded PocketBase/SQLite ++ exact persisted DDM payment service ++ durable SMS evidence ++ optional libgm adapter ++ legacy relay only as a migration bridge ++ durable signed webhook outbox ++ embedded React operator UI ++ one persistent pb_data volume +``` + +The remaining external uncertainty is not the payment/database architecture; it is live behaviour of Google's private Messages protocol on the real phone. That QR/device test is intentionally deferred and should be treated as a connector acceptance test, not as proof of the payment core. diff --git a/cmd/payment-api/main.go b/cmd/payment-api/main.go new file mode 100644 index 0000000..4739089 --- /dev/null +++ b/cmd/payment-api/main.go @@ -0,0 +1,135 @@ +package main + +import ( + "context" + "fmt" + "log" + "log/slog" + "net/http" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "github.com/Phloraxx/payment-api/internal/api" + "github.com/Phloraxx/payment-api/internal/config" + "github.com/Phloraxx/payment-api/internal/gmessages" + "github.com/Phloraxx/payment-api/internal/payments" + "github.com/Phloraxx/payment-api/internal/sms" + "github.com/Phloraxx/payment-api/internal/webhooks" + _ "github.com/Phloraxx/payment-api/migrations" + "github.com/pocketbase/pocketbase" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/plugins/migratecmd" + "github.com/rs/zerolog" + "github.com/spf13/cobra" +) + +func main() { + cfg, err := config.Load() + if err != nil { + log.Fatal(err) + } + app := pocketbase.NewWithConfig(pocketbase.Config{ + DefaultDataDir: filepath.Clean(cfg.DataDir), + HideStartBanner: false, + }) + migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{Automigrate: false}) + + zeroLogger := zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: "15:04:05"}).With().Timestamp().Logger() + stdLogger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) + + webhookService := webhooks.NewService(app, cfg) + webhookService.Logger = stdLogger + paymentService := payments.NewService(app, cfg, webhookService) + smsService := sms.NewService(app, paymentService) + gmessagesManager := gmessages.NewManager(cfg, zeroLogger, func(input sms.Input) error { + _, err := smsService.Ingest(input) + return err + }) + api.New(cfg, paymentService, smsService, gmessagesManager).Register(app) + registerPairCommand(app, cfg, zeroLogger) + registerHealthcheckCommand(app) + + rootCtx, rootCancel := context.WithCancel(context.Background()) + app.OnServe().BindFunc(func(e *core.ServeEvent) error { + rateLimits := &e.App.Settings().RateLimits + rateLimits.Enabled = cfg.RateLimitsEnabled + rateLimits.Rules = append([]core.RateLimitRule{ + {Label: "POST /api/events/sms", MaxRequests: 60, Duration: 60}, + {Label: "POST /api/webhook", MaxRequests: 30, Duration: 60}, + {Label: "POST /api/payments", MaxRequests: 120, Duration: 60}, + }, rateLimits.Rules...) + if err := cfg.ValidateServe(); err != nil { + return err + } + if cfg.LegacySMSWebhookEnabled { + stdLogger.Warn("legacy /api/webhook compatibility route is enabled; rotate the old relay to SMS_WEBHOOK_SECRET and disable it") + } + go webhookService.Run(rootCtx) + gmessagesManager.Start(rootCtx) + return e.Next() + }) + app.OnTerminate().BindFunc(func(e *core.TerminateEvent) error { + rootCancel() + gmessagesManager.Stop() + return e.Next() + }) + + app.Cron().MustAdd("paygate-expire-payments", "* * * * *", func() { + count, err := paymentService.ExpireDue() + if err != nil { + stdLogger.Error("payment expiry job failed", "error", err) + return + } + if count > 0 { + stdLogger.Info("expired due payments", "count", count) + } + }) + app.Cron().MustAdd("paygate-webhook-retries", "* * * * *", func() { + webhookService.Wake() + }) + + if err := app.Start(); err != nil { + log.Fatal(err) + } +} + +func registerPairCommand(app *pocketbase.PocketBase, cfg config.Config, logger zerolog.Logger) { + var qrPNG string + cmd := &cobra.Command{ + Use: "gmessages-pair", + Short: "Pair the optional Google Messages connector", + RunE: func(cmd *cobra.Command, args []string) error { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + return gmessages.PairConsole(ctx, cfg.GMessagesSessionPath, qrPNG, logger) + }, + } + cmd.Flags().StringVar(&qrPNG, "qr-png", "", "also write/refresh the pairing QR as a PNG file") + app.RootCmd.AddCommand(cmd) +} + +func registerHealthcheckCommand(app *pocketbase.PocketBase) { + var endpoint string + cmd := &cobra.Command{ + Use: "healthcheck", + Short: "Check a running PayGate/PocketBase HTTP server", + Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { + client := &http.Client{Timeout: 3 * time.Second} + response, err := client.Get(endpoint) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("health endpoint returned HTTP %d", response.StatusCode) + } + return nil + }, + } + cmd.Flags().StringVar(&endpoint, "url", "http://127.0.0.1:3000/api/health", "health endpoint URL") + app.RootCmd.AddCommand(cmd) +} diff --git a/docker-compose.yml b/docker-compose.yml index ad67200..2f808bc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,17 +1,14 @@ services: - app: - build: . + paygate: + build: + context: . ports: - "3000:3000" + env_file: + - .env volumes: - - payment_data:/app/data - env_file: .env + - paygate_data:/app/pb_data restart: unless-stopped - healthcheck: - test: ["CMD", "node", "-e", "fetch('http://localhost:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] - interval: 30s - timeout: 5s - retries: 3 volumes: - payment_data: + paygate_data: diff --git a/eslint.config.js b/eslint.config.js deleted file mode 100644 index ea68cdb..0000000 --- a/eslint.config.js +++ /dev/null @@ -1,17 +0,0 @@ -import js from "@eslint/js"; -import tseslint from "typescript-eslint"; - -export default tseslint.config( - js.configs.recommended, - ...tseslint.configs.recommended, - { - ignores: ["dist/**", "node_modules/**", "src/admin/dist/**", "src/server/admin/public/**"], - }, - { - files: ["**/*.ts", "**/*.tsx"], - rules: { - "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], - }, - }, -); diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9d95692 --- /dev/null +++ b/go.mod @@ -0,0 +1,49 @@ +module github.com/Phloraxx/payment-api + +go 1.25.12 + +require ( + github.com/mdp/qrterminal/v3 v3.2.1 + github.com/pocketbase/dbx v1.12.0 + github.com/pocketbase/pocketbase v0.39.9 + github.com/rs/zerolog v1.35.1 + github.com/spf13/cobra v1.10.2 + go.mau.fi/mautrix-gmessages v0.2605.0 + rsc.io/qr v0.2.0 +) + +require ( + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/disintegration/imaging v1.6.2 // indirect + github.com/domodwyer/mailyak/v3 v3.6.2 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fatih/color v1.19.0 // indirect + github.com/fsnotify/fsnotify v1.10.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/ganigeorgiev/fexpr v0.6.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-colorable v0.1.15 // indirect + github.com/mattn/go-isatty v0.0.23 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/pocketbase/ozzo-validation/v4 v4.3.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + go.mau.fi/util v0.9.9 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect + golang.org/x/image v0.44.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/term v0.45.0 // indirect + golang.org/x/text v0.40.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + modernc.org/libc v1.74.1 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.54.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..734f9a8 --- /dev/null +++ b/go.sum @@ -0,0 +1,148 @@ +github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= +github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8= +github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/ganigeorgiev/fexpr v0.6.0 h1:Fza3O/QMBKEudUvxV862qe6GjxM60GJjjKytdp+VQus= +github.com/ganigeorgiev/fexpr v0.6.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE= +github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA= +github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 h1:du0WGc8xSKq/++e0cglxhS/mXVqsR7+c7jLEi5Vqduw= +github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ= +github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4= +github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA= +github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs= +github.com/pocketbase/ozzo-validation/v4 v4.3.0 h1:uKBDVma7bZqgR2a6AwE+k9hkuDFfiZMpBHQdZ1z3iQs= +github.com/pocketbase/ozzo-validation/v4 v4.3.0/go.mod h1:6XNjSTw/Jb2F8LOkKO3oyzIWExbrGiYoS4uVxVwz90g= +github.com/pocketbase/pocketbase v0.39.9 h1:zmrvbWJwBlb+iXst02uD6zNqNpiyG86KtlOJjd+Bhx4= +github.com/pocketbase/pocketbase v0.39.9/go.mod h1:6l4ZFFa8kNkxLSumxzVm+DtczfUz86Wqow0bQYiXEkQ= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= +github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.mau.fi/mautrix-gmessages v0.2605.0 h1:WbDNDC7GivaW92g2qrx6ibfTZ0R5+CfJunJv/NICsW4= +go.mau.fi/mautrix-gmessages v0.2605.0/go.mod h1:Z6pREkXWiGfhhfZyj3VHgoT6ttPpFSoxEIrLhVYT6q8= +go.mau.fi/util v0.9.9 h1:ujDeXCo07HBor5oQLyO1tHklupmqVmPgasc53d7q/NE= +go.mau.fi/util v0.9.9/go.mod h1:pqt4Vcrt+5gcH/CgrHZg11qSx+b34o6mknGzOEA6waY= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw= +golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I= +golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +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.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +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= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc= +modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ= +modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog= +modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/internal/api/api.go b/internal/api/api.go new file mode 100644 index 0000000..0d0857a --- /dev/null +++ b/internal/api/api.go @@ -0,0 +1,396 @@ +package api + +import ( + "crypto/subtle" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "time" + + "github.com/Phloraxx/payment-api/internal/config" + "github.com/Phloraxx/payment-api/internal/domain" + "github.com/Phloraxx/payment-api/internal/gmessages" + "github.com/Phloraxx/payment-api/internal/money" + "github.com/Phloraxx/payment-api/internal/payments" + "github.com/Phloraxx/payment-api/internal/sms" + appweb "github.com/Phloraxx/payment-api/internal/web" + "github.com/pocketbase/pocketbase/apis" + "github.com/pocketbase/pocketbase/core" +) + +const ( + maxPaymentRequestBytes int64 = (1 << 20) + (64 << 10) + maxSMSRequestBytes int64 = 128 << 10 +) + +type API struct { + Config config.Config + Payments *payments.Service + SMS *sms.Service + GMessages *gmessages.Manager +} + +func New(cfg config.Config, paymentService *payments.Service, smsService *sms.Service, manager *gmessages.Manager) *API { + return &API{Config: cfg, Payments: paymentService, SMS: smsService, GMessages: manager} +} + +func (a *API) Register(app core.App) { + app.OnServe().BindFunc(func(e *core.ServeEvent) error { + e.Router.POST("/api/payments", a.createPayment).Bind(apis.BodyLimit(maxPaymentRequestBytes)) + e.Router.GET("/api/payments/{id}", a.getPayment) + e.Router.POST("/api/payments/{id}/cancel", a.cancelPayment) + e.Router.POST("/api/events/sms", a.ingestSMS).Bind(apis.BodyLimit(maxSMSRequestBytes)) + e.Router.POST("/api/webhook", a.ingestLegacySMS).Bind(apis.BodyLimit(maxSMSRequestBytes)) + e.Router.GET("/api/paygate/health", a.health) + e.Router.GET("/api/config", a.getConfig) + e.Router.GET("/api/dashboard", a.dashboard) + e.Router.GET("/api/connector/gmessages/status", a.gmessagesStatus) + e.Router.POST("/api/connector/gmessages/pair", a.gmessagesPair) + e.Router.POST("/api/connector/gmessages/pair/refresh", a.gmessagesPairRefresh) + e.Router.POST("/api/connector/gmessages/reconnect", a.gmessagesReconnect) + e.Router.DELETE("/api/connector/gmessages/pair", a.gmessagesUnpair) + + // Keep API/admin namespaces out of the SPA fallback. Unknown API routes + // must remain real 404s rather than HTML 200 responses. + static := apis.Static(appweb.Assets(), true) + e.Router.GET("/{path...}", func(event *core.RequestEvent) error { + path := strings.TrimPrefix(event.Request.URL.Path, "/") + if path == "api" || strings.HasPrefix(path, "api/") || path == "_" || strings.HasPrefix(path, "_/") { + return event.NotFoundError("route not found", nil) + } + return static(event) + }) + return e.Next() + }) +} + +type createPaymentBody struct { + Amount json.RawMessage `json:"amount"` + ExternalID string `json:"externalId"` + Metadata json.RawMessage `json:"metadata"` +} + +func (a *API) createPayment(e *core.RequestEvent) error { + if !a.authorizedWrite(e) { + return e.UnauthorizedError("API key or dashboard authentication is required", nil) + } + var body createPaymentBody + if err := decodeJSON(e, &body); err != nil { + return e.BadRequestError("invalid JSON body", err) + } + if len(body.Amount) == 0 { + return writeDomainError(e, domain.InvalidAmount()) + } + amount, err := money.ParseWholeRupees(body.Amount) + if err != nil { + return writeDomainError(e, domain.InvalidAmount()) + } + var metadata any + if len(body.Metadata) > 0 && string(body.Metadata) != "null" { + if err := json.Unmarshal(body.Metadata, &metadata); err != nil { + return e.BadRequestError("metadata must be valid JSON", err) + } + } + + payment, replayed, err := a.Payments.Create(payments.CreateInput{ + AmountRupees: amount, + ExternalID: strings.TrimSpace(body.ExternalID), + Metadata: metadata, + IdempotencyKey: strings.TrimSpace(e.Request.Header.Get("Idempotency-Key")), + }) + if err != nil { + return writeDomainError(e, err) + } + status := http.StatusCreated + if replayed { + status = http.StatusOK + e.Response.Header().Set("X-Idempotent-Replayed", "true") + } + return e.JSON(status, payments.CreateResponse(payment, a.Config)) +} + +func (a *API) getPayment(e *core.RequestEvent) error { + payment, err := a.Payments.Get(e.Request.PathValue("id")) + if err != nil { + return writeDomainError(e, err) + } + // Public by design, but intentionally omits RRN, UPI ID, payer data and raw SMS. + return e.JSON(http.StatusOK, payments.PublicPayment(payment)) +} + +func (a *API) cancelPayment(e *core.RequestEvent) error { + if !a.authorizedWrite(e) { + return e.UnauthorizedError("API key or dashboard authentication is required", nil) + } + payment, err := a.Payments.Cancel(e.Request.PathValue("id")) + if err != nil { + return writeDomainError(e, err) + } + return e.JSON(http.StatusOK, payments.PublicPayment(payment)) +} + +type smsBody struct { + SMS string `json:"sms"` + Body string `json:"body"` + Sender string `json:"sender"` + Source string `json:"source"` + SourceID string `json:"sourceId"` + Timestamp string `json:"timestamp"` +} + +func (a *API) ingestSMS(e *core.RequestEvent) error { + if !constantTimeEqual(a.Config.SMSWebhookSecret, e.Request.Header.Get("X-Webhook-Secret")) { + return e.UnauthorizedError("invalid webhook secret", nil) + } + return a.ingestSMSBody(e, false) +} + +func (a *API) ingestLegacySMS(e *core.RequestEvent) error { + if !a.Config.LegacySMSWebhookEnabled { + return e.NotFoundError("route not found", nil) + } + if !constantTimeEqual(a.Config.LegacySMSWebhookSecret, e.Request.Header.Get("X-Webhook-Secret")) { + return e.UnauthorizedError("invalid webhook secret", nil) + } + return a.ingestSMSBody(e, true) +} + +func (a *API) ingestSMSBody(e *core.RequestEvent, legacy bool) error { + var body smsBody + if err := decodeJSON(e, &body); err != nil { + return e.BadRequestError("invalid JSON body", err) + } + text := body.SMS + if text == "" { + text = body.Body + } + messageTime := time.Time{} + if strings.TrimSpace(body.Timestamp) != "" { + parsed, err := time.Parse(time.RFC3339, body.Timestamp) + if err != nil { + return e.BadRequestError("timestamp must be RFC3339", err) + } + messageTime = parsed + } + source := strings.TrimSpace(body.Source) + if source == "" { + source = "android_webhook" + } + if legacy { + // The compatibility route always identifies itself as the Android relay; + // callers cannot forge a different connector source through it. + source = "android_webhook" + } + result, err := a.SMS.Ingest(sms.Input{ + Source: source, + SourceEventID: body.SourceID, + Sender: body.Sender, + Body: text, + MessageTime: messageTime, + RawPayload: map[string]any{ + "sender": body.Sender, + "source": source, + "sourceId": body.SourceID, + "timestamp": body.Timestamp, + }, + }) + if err != nil { + // A domain parsing/matching error is persisted in sms_events before this + // response is returned, so it remains debuggable and does not vanish. + if _, ok := err.(*domain.Error); ok { + return writeDomainErrorWithData(e, err, map[string]any{"event": result}) + } + return writeDomainError(e, err) + } + status := http.StatusAccepted + if result.Duplicate { + status = http.StatusOK + } + return e.JSON(status, result) +} + +func (a *API) health(e *core.RequestEvent) error { + var one int + if err := e.App.DB().NewQuery("SELECT 1").Row(&one); err != nil || one != 1 { + return e.JSON(http.StatusServiceUnavailable, map[string]any{ + "status": "unhealthy", "ready": false, "db": "error", "connector": a.publicConnectorStatus(), + }) + } + return e.JSON(http.StatusOK, map[string]any{ + "status": "healthy", "ready": true, "db": "ok", "connector": a.publicConnectorStatus(), + }) +} + +func (a *API) getConfig(e *core.RequestEvent) error { + if !a.dashboardAuth(e) { + return e.UnauthorizedError("dashboard authentication is required", nil) + } + return e.JSON(http.StatusOK, map[string]any{ + "upiId": a.Config.UPIID, + "upiPayeeName": a.Config.UPIPayeeName, + "paymentTtlSeconds": int64(a.Config.PaymentTTL / time.Second), + "quarantineSeconds": int64(a.Config.AmountQuarantine / time.Second), + "webhookConfigured": a.Config.OutgoingWebhookURL != "", + "rateLimitsEnabled": a.Config.RateLimitsEnabled, + "legacySMSWebhookEnabled": a.Config.LegacySMSWebhookEnabled, + "connector": a.connectorStatus(), + }) +} + +func (a *API) dashboard(e *core.RequestEvent) error { + if !a.dashboardAuth(e) { + return e.UnauthorizedError("dashboard authentication is required", nil) + } + stats, err := a.Payments.Stats() + if err != nil { + return e.InternalServerError("failed to load dashboard", err) + } + return e.JSON(http.StatusOK, map[string]any{"stats": stats, "connector": a.connectorStatus()}) +} + +func (a *API) gmessagesStatus(e *core.RequestEvent) error { + if !a.dashboardAuth(e) { + return e.UnauthorizedError("dashboard authentication is required", nil) + } + return e.JSON(http.StatusOK, a.connectorStatus()) +} + +func (a *API) gmessagesPair(e *core.RequestEvent) error { + if !a.dashboardAuth(e) { + return e.UnauthorizedError("dashboard authentication is required", nil) + } + if a.GMessages == nil { + return e.BadRequestError("Google Messages connector is unavailable", nil) + } + qrURL, err := a.GMessages.BeginPair() + if err != nil { + return e.BadRequestError("failed to start Google Messages pairing", err) + } + return e.JSON(http.StatusOK, map[string]any{"qrUrl": qrURL, "status": a.GMessages.Status()}) +} + +func (a *API) gmessagesPairRefresh(e *core.RequestEvent) error { + if !a.dashboardAuth(e) { + return e.UnauthorizedError("dashboard authentication is required", nil) + } + if a.GMessages == nil { + return e.BadRequestError("Google Messages connector is unavailable", nil) + } + qrURL, err := a.GMessages.RefreshPair() + if err != nil { + return e.BadRequestError("failed to refresh Google Messages pairing", err) + } + return e.JSON(http.StatusOK, map[string]any{"qrUrl": qrURL, "status": a.GMessages.Status()}) +} + +func (a *API) gmessagesReconnect(e *core.RequestEvent) error { + if !a.dashboardAuth(e) { + return e.UnauthorizedError("dashboard authentication is required", nil) + } + if a.GMessages == nil { + return e.BadRequestError("Google Messages connector is unavailable", nil) + } + if err := a.GMessages.Reconnect(); err != nil { + return e.BadRequestError("failed to reconnect Google Messages", err) + } + return e.JSON(http.StatusOK, a.GMessages.Status()) +} + +func (a *API) gmessagesUnpair(e *core.RequestEvent) error { + if !a.dashboardAuth(e) { + return e.UnauthorizedError("dashboard authentication is required", nil) + } + if a.GMessages == nil { + return e.BadRequestError("Google Messages connector is unavailable", nil) + } + if err := a.GMessages.Unpair(); err != nil { + return e.InternalServerError("failed to unpair Google Messages", err) + } + return e.JSON(http.StatusOK, a.GMessages.Status()) +} + +func (a *API) authorizedWrite(e *core.RequestEvent) bool { + return a.dashboardAuth(e) || bearerMatches(a.Config.APIKey, e.Request.Header.Get("Authorization")) +} + +func (a *API) dashboardAuth(e *core.RequestEvent) bool { + return e.Auth != nil && e.Auth.Collection() != nil && e.Auth.Collection().Name == "users" +} + +func (a *API) connectorStatus() gmessages.Status { + if a.GMessages == nil { + return gmessages.Status{Enabled: false, State: "disabled"} + } + return a.GMessages.Status() +} + +func (a *API) publicConnectorStatus() map[string]any { + status := a.connectorStatus() + return map[string]any{ + "enabled": status.Enabled, + "state": status.State, + "paired": status.Paired, + "connected": status.Connected, + "phoneResponsive": status.PhoneResponsive, + "lastConnectedAt": status.LastConnectedAt, + "lastMessageAt": status.LastMessageAt, + } +} + +func bearerMatches(expected, header string) bool { + if expected == "" { + return false + } + parts := strings.SplitN(strings.TrimSpace(header), " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + return false + } + return constantTimeEqual(expected, strings.TrimSpace(parts[1])) +} + +func constantTimeEqual(expected, actual string) bool { + if expected == "" || actual == "" || len(expected) != len(actual) { + return false + } + return subtle.ConstantTimeCompare([]byte(expected), []byte(actual)) == 1 +} + +func decodeJSON(e *core.RequestEvent, dst any) error { + decoder := json.NewDecoder(e.Request.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(dst); err != nil { + return err + } + var extra any + if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { + if err == nil { + return errors.New("request body must contain exactly one JSON value") + } + return err + } + return nil +} + +func writeDomainError(e *core.RequestEvent, err error) error { + return writeDomainErrorWithData(e, err, nil) +} + +func writeDomainErrorWithData(e *core.RequestEvent, err error, extra map[string]any) error { + var domainErr *domain.Error + if errors.As(err, &domainErr) { + payload := map[string]any{ + "error": map[string]any{ + "code": domainErr.Code, + "message": domainErr.Message, + "details": domainErr.Details, + }, + } + for key, value := range extra { + payload[key] = value + } + return e.JSON(domainErr.Status, payload) + } + return e.InternalServerError("internal server error", err) +} diff --git a/internal/api/api_test.go b/internal/api/api_test.go new file mode 100644 index 0000000..b4d46e7 --- /dev/null +++ b/internal/api/api_test.go @@ -0,0 +1,213 @@ +package api + +import ( + "net/http" + "strings" + "testing" + "time" + + "github.com/Phloraxx/payment-api/internal/config" + "github.com/Phloraxx/payment-api/internal/payments" + "github.com/Phloraxx/payment-api/internal/sms" + _ "github.com/Phloraxx/payment-api/migrations" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/tests" +) + +func apiTestFactory(t testing.TB, before func(*tests.TestApp, *payments.Service)) *tests.TestApp { + return apiTestFactoryWithConfig(t, nil, before) +} + +func apiTestFactoryWithConfig(t testing.TB, configure func(*config.Config), before func(*tests.TestApp, *payments.Service)) *tests.TestApp { + app, err := tests.NewTestApp() + if err != nil { + t.Fatalf("create PocketBase test app: %v", err) + } + cfg := config.Config{ + UPIID: "operator@bank", UPIPayeeName: "PayGate", + APIKey: "api-secret", SMSWebhookSecret: "sms-secret", + PaymentTTL: 5 * time.Minute, AmountQuarantine: 24 * time.Hour, + } + if configure != nil { + configure(&cfg) + } + paymentService := payments.NewService(app, cfg, nil) + paymentService.SuffixStart = func() (int64, error) { return 1, nil } + smsService := sms.NewService(app, paymentService) + if before != nil { + before(app, paymentService) + } + New(cfg, paymentService, smsService, nil).Register(app) + return app +} + +func TestPaymentAPIAuthenticationAndAmountValidation(t *testing.T) { + scenarios := []tests.ApiScenario{ + { + Name: "create requires auth", Method: http.MethodPost, URL: "/api/payments", + Body: strings.NewReader(`{"amount":100}`), + TestAppFactory: func(t testing.TB) *tests.TestApp { return apiTestFactory(t, nil) }, + ExpectedStatus: http.StatusUnauthorized, + ExpectedContent: []string{"API key or dashboard authentication is required"}, + }, + { + Name: "fractional amount rejected", Method: http.MethodPost, URL: "/api/payments", + Headers: map[string]string{"Authorization": "Bearer api-secret", "Content-Type": "application/json"}, + Body: strings.NewReader(`{"amount":100.01}`), + TestAppFactory: func(t testing.TB) *tests.TestApp { return apiTestFactory(t, nil) }, + ExpectedStatus: http.StatusBadRequest, + ExpectedContent: []string{"INVALID_AMOUNT"}, + }, + { + Name: "unknown JSON field rejected", Method: http.MethodPost, URL: "/api/payments", + Headers: map[string]string{"Authorization": "Bearer api-secret", "Content-Type": "application/json"}, + Body: strings.NewReader(`{"amount":100,"surprise":true}`), + TestAppFactory: func(t testing.TB) *tests.TestApp { return apiTestFactory(t, nil) }, + ExpectedStatus: http.StatusBadRequest, + ExpectedContent: []string{"Invalid JSON body."}, + }, + { + Name: "oversized payment body rejected", Method: http.MethodPost, URL: "/api/payments", + Headers: map[string]string{"Authorization": "Bearer api-secret", "Content-Type": "application/json"}, + Body: strings.NewReader(`{"amount":100,"metadata":"` + strings.Repeat("a", int(maxPaymentRequestBytes)) + `"}`), + TestAppFactory: func(t testing.TB) *tests.TestApp { return apiTestFactory(t, nil) }, + ExpectedStatus: http.StatusRequestEntityTooLarge, + ExpectedContent: []string{"Request entity too large"}, + }, + { + Name: "valid whole rupee payment", Method: http.MethodPost, URL: "/api/payments", + Headers: map[string]string{"Authorization": "Bearer api-secret", "Content-Type": "application/json", "Idempotency-Key": "http-test-idem"}, + Body: strings.NewReader(`{"amount":100,"externalId":"order-http"}`), + TestAppFactory: func(t testing.TB) *tests.TestApp { return apiTestFactory(t, nil) }, + ExpectedStatus: http.StatusCreated, + ExpectedContent: []string{`"requestedAmount":100`, `"payableAmount":"100.01"`, `"externalId":"order-http"`, `upi://pay?`}, + }, + } + for i := range scenarios { + scenarios[i].Test(t) + } +} + +func TestPublicPaymentStatusRedactsSensitiveEvidence(t *testing.T) { + const paymentID = "paytest00000001" + scenario := tests.ApiScenario{ + Name: "public status redacts payer evidence", Method: http.MethodGet, URL: "/api/payments/" + paymentID, + TestAppFactory: func(t testing.TB) *tests.TestApp { + return apiTestFactory(t, func(app *tests.TestApp, _ *payments.Service) { + collection, err := app.FindCollectionByNameOrId("payments") + if err != nil { + t.Fatal(err) + } + record := core.NewRecord(collection) + record.Id = paymentID + record.Set("created_at", time.Now().Add(-time.Minute)) + record.Set("requested_amount", 10000) + record.Set("payable_amount", 10001) + record.Set("status", "paid") + record.Set("expires_at", time.Now().Add(time.Minute)) + record.Set("reuse_after", time.Now().Add(time.Hour)) + record.Set("rrn", "123456789012") + record.Set("upi_id", "private@upi") + record.Set("payer_name", "Private Person") + record.Set("paid_at", time.Now()) + record.Set("external_id", "private-order-123") + if err := app.Save(record); err != nil { + t.Fatal(err) + } + }) + }, + ExpectedStatus: http.StatusOK, + ExpectedContent: []string{`"id":"` + paymentID + `"`, `"status":"paid"`, `"payableAmount":"100.01"`}, + NotExpectedContent: []string{"123456789012", "private@upi", "Private Person", "private-order-123", `"rrn"`, `"payerName"`, `"externalId"`}, + } + scenario.Test(t) +} + +func TestLegacySMSWebhookMatchesPayment(t *testing.T) { + const paymentID = "smstest00000001" + scenario := tests.ApiScenario{ + Name: "legacy sms webhook", Method: http.MethodPost, URL: "/api/events/sms", + Headers: map[string]string{"X-Webhook-Secret": "sms-secret", "Content-Type": "application/json"}, + Body: strings.NewReader(`{"sms":"Confirmed payment for Received Rs.100.01 in your Kotak Bank AC X4959 from user@oksbi.UPI Ref:606703736479."}`), + TestAppFactory: func(t testing.TB) *tests.TestApp { + return apiTestFactory(t, func(app *tests.TestApp, _ *payments.Service) { + collection, err := app.FindCollectionByNameOrId("payments") + if err != nil { + t.Fatal(err) + } + record := core.NewRecord(collection) + record.Id = paymentID + record.Set("created_at", time.Now().Add(-time.Minute)) + record.Set("requested_amount", 10000) + record.Set("payable_amount", 10001) + record.Set("status", "pending") + record.Set("expires_at", time.Now().Add(5*time.Minute)) + record.Set("reuse_after", time.Now().Add(24*time.Hour)) + if err := app.Save(record); err != nil { + t.Fatal(err) + } + }) + }, + ExpectedStatus: http.StatusAccepted, + ExpectedContent: []string{`"status":"matched"`, `"action":"marked_paid"`, `"paymentId":"` + paymentID + `"`}, + AfterTestFunc: func(t testing.TB, app *tests.TestApp, _ *http.Response) { + record, err := app.FindRecordById("payments", paymentID) + if err != nil { + t.Fatal(err) + } + if record.GetString("status") != "paid" || record.GetString("rrn") != "606703736479" { + t.Fatalf("payment after webhook = status=%s rrn=%s", record.GetString("status"), record.GetString("rrn")) + } + }, + } + scenario.Test(t) +} + +func TestSMSWebhookSecretAndHealthRoutes(t *testing.T) { + scenarios := []tests.ApiScenario{ + {Name: "bad sms secret", Method: http.MethodPost, URL: "/api/events/sms", Headers: map[string]string{"X-Webhook-Secret": "wrong", "Content-Type": "application/json"}, Body: strings.NewReader(`{"sms":"Received Rs.100.01 UPI Ref:123456789012"}`), TestAppFactory: func(t testing.TB) *tests.TestApp { return apiTestFactory(t, nil) }, ExpectedStatus: http.StatusUnauthorized, ExpectedContent: []string{"Invalid webhook secret."}}, + {Name: "rich health", Method: http.MethodGet, URL: "/api/paygate/health", TestAppFactory: func(t testing.TB) *tests.TestApp { return apiTestFactory(t, nil) }, ExpectedStatus: http.StatusOK, ExpectedContent: []string{`"status":"healthy"`, `"db":"ok"`}}, + {Name: "PocketBase health remains available", Method: http.MethodGet, URL: "/api/health", TestAppFactory: func(t testing.TB) *tests.TestApp { return apiTestFactory(t, nil) }, ExpectedStatus: http.StatusOK, ExpectedContent: []string{`"code":200`}}, + {Name: "invalid sms source", Method: http.MethodPost, URL: "/api/events/sms", Headers: map[string]string{"X-Webhook-Secret": "sms-secret", "Content-Type": "application/json"}, Body: strings.NewReader(`{"sms":"Received Rs.100.01 UPI Ref:123456789012","source":"bogus"}`), TestAppFactory: func(t testing.TB) *tests.TestApp { return apiTestFactory(t, nil) }, ExpectedStatus: http.StatusBadRequest, ExpectedContent: []string{"INVALID_SMS_SOURCE"}}, + {Name: "unknown API path stays JSON 404", Method: http.MethodGet, URL: "/api/not-a-paygate-route", TestAppFactory: func(t testing.TB) *tests.TestApp { return apiTestFactory(t, nil) }, ExpectedStatus: http.StatusNotFound, NotExpectedContent: []string{"PayGate"}}, + } + for i := range scenarios { + scenarios[i].Test(t) + } +} + +func TestLegacyWebhookAliasIsExplicitlyGated(t *testing.T) { + disabled := tests.ApiScenario{ + Name: "legacy webhook disabled", Method: http.MethodPost, URL: "/api/webhook", + Headers: map[string]string{"X-Webhook-Secret": "legacy-secret", "Content-Type": "application/json"}, + Body: strings.NewReader(`{"sms":"Received Rs.777.77 from user@oksbi UPI Ref:777788889999"}`), + TestAppFactory: func(t testing.TB) *tests.TestApp { return apiTestFactory(t, nil) }, + ExpectedStatus: http.StatusNotFound, + ExpectedContent: []string{"Route not found."}, + } + disabled.Test(t) + + enabled := tests.ApiScenario{ + Name: "legacy webhook enabled", Method: http.MethodPost, URL: "/api/webhook", + Headers: map[string]string{"X-Webhook-Secret": "legacy-secret", "Content-Type": "application/json"}, + Body: strings.NewReader(`{"sms":"Received Rs.777.77 from user@oksbi UPI Ref:777788889999","source":"gmessages"}`), + TestAppFactory: func(t testing.TB) *tests.TestApp { + return apiTestFactoryWithConfig(t, func(cfg *config.Config) { + cfg.LegacySMSWebhookEnabled = true + cfg.LegacySMSWebhookSecret = "legacy-secret" + }, nil) + }, + ExpectedStatus: http.StatusAccepted, + ExpectedContent: []string{`"status":"unmatched"`}, + AfterTestFunc: func(t testing.TB, app *tests.TestApp, _ *http.Response) { + records, err := app.FindRecordsByFilter("sms_events", "", "-created", 1, 0) + if err != nil || len(records) != 1 { + t.Fatalf("legacy event records = %d, %v", len(records), err) + } + if got := records[0].GetString("source"); got != "android_webhook" { + t.Fatalf("legacy route source = %q; want android_webhook", got) + } + }, + } + enabled.Test(t) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..dc5c4db --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,205 @@ +package config + +import ( + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +const minPrimarySecretLength = 24 + +type Config struct { + DataDir string + UPIID string + UPIPayeeName string + APIKey string + SMSWebhookSecret string + LegacySMSWebhookSecret string + LegacySMSWebhookEnabled bool + PaymentTTL time.Duration + AmountQuarantine time.Duration + OutgoingWebhookURL string + OutgoingWebhookSecret string + GMessagesEnabled bool + GMessagesSessionPath string + TestMode bool + RateLimitsEnabled bool +} + +func Load() (Config, error) { + legacyTTL, err := legacyMinutesEnv("TICKET_TTL_MINUTES", 5*time.Minute) + if err != nil { + return Config{}, err + } + paymentTTL, err := durationEnv("PAYMENT_TTL", legacyTTL) + if err != nil { + return Config{}, err + } + legacyQuarantine, err := legacyHoursEnv("AMOUNT_QUARANTINE_HOURS", 24*time.Hour) + if err != nil { + return Config{}, err + } + quarantine, err := durationEnv("PAYMENT_QUARANTINE", legacyQuarantine) + if err != nil { + return Config{}, err + } + gmessagesEnabled, err := boolEnv("GMESSAGES_ENABLED", false) + if err != nil { + return Config{}, err + } + testMode, err := boolEnv("PAYGATE_TEST_MODE", false) + if err != nil { + return Config{}, err + } + rateLimitsEnabled, err := boolEnv("PAYGATE_RATE_LIMITS_ENABLED", true) + if err != nil { + return Config{}, err + } + legacyEnabled, err := boolEnv("LEGACY_SMS_WEBHOOK_ENABLED", false) + if err != nil { + return Config{}, err + } + + dataDir := strings.TrimSpace(env("PB_DATA_DIR", "./pb_data")) + cfg := Config{ + DataDir: dataDir, + UPIID: strings.TrimSpace(os.Getenv("UPI_ID")), + UPIPayeeName: strings.TrimSpace(firstNonEmpty(os.Getenv("UPI_PAYEE_NAME"), os.Getenv("UPI_NAME"), "PayGate")), + APIKey: strings.TrimSpace(os.Getenv("PAYGATE_API_KEY")), + SMSWebhookSecret: strings.TrimSpace(os.Getenv("SMS_WEBHOOK_SECRET")), + LegacySMSWebhookSecret: strings.TrimSpace(os.Getenv("WEBHOOK_SECRET")), + LegacySMSWebhookEnabled: legacyEnabled, + PaymentTTL: paymentTTL, + AmountQuarantine: quarantine, + OutgoingWebhookURL: strings.TrimSpace(firstNonEmpty(os.Getenv("OUTGOING_WEBHOOK_URL"), os.Getenv("PAYMENT_WEBHOOK_URL"))), + OutgoingWebhookSecret: strings.TrimSpace(firstNonEmpty(os.Getenv("OUTGOING_WEBHOOK_SECRET"), os.Getenv("PAYMENT_WEBHOOK_SECRET"))), + GMessagesEnabled: gmessagesEnabled, + TestMode: testMode, + RateLimitsEnabled: rateLimitsEnabled, + } + cfg.GMessagesSessionPath = strings.TrimSpace(os.Getenv("GMESSAGES_SESSION_PATH")) + if cfg.GMessagesSessionPath == "" { + cfg.GMessagesSessionPath = filepath.Join(cfg.DataDir, "gmessages", "session.json") + } + return cfg, nil +} + +func (c Config) ValidateServe() error { + if c.TestMode { + return nil + } + var missing []string + if c.UPIID == "" { + missing = append(missing, "UPI_ID") + } + if c.APIKey == "" { + missing = append(missing, "PAYGATE_API_KEY") + } + if c.SMSWebhookSecret == "" { + missing = append(missing, "SMS_WEBHOOK_SECRET") + } + if len(missing) > 0 { + return fmt.Errorf("missing required configuration: %s", strings.Join(missing, ", ")) + } + if len(c.APIKey) < minPrimarySecretLength { + return fmt.Errorf("PAYGATE_API_KEY must be at least %d characters", minPrimarySecretLength) + } + if len(c.SMSWebhookSecret) < minPrimarySecretLength { + return fmt.Errorf("SMS_WEBHOOK_SECRET must be at least %d characters", minPrimarySecretLength) + } + if c.PaymentTTL <= 0 { + return errors.New("PAYMENT_TTL must be positive") + } + if c.AmountQuarantine < 0 { + return errors.New("PAYMENT_QUARANTINE cannot be negative") + } + if c.LegacySMSWebhookEnabled && c.LegacySMSWebhookSecret == "" { + return errors.New("WEBHOOK_SECRET is required when LEGACY_SMS_WEBHOOK_ENABLED=true") + } + if c.OutgoingWebhookURL != "" { + if err := validateHTTPURL("OUTGOING_WEBHOOK_URL", c.OutgoingWebhookURL); err != nil { + return err + } + if len(c.OutgoingWebhookSecret) < minPrimarySecretLength { + return fmt.Errorf("OUTGOING_WEBHOOK_SECRET must be at least %d characters when OUTGOING_WEBHOOK_URL is configured", minPrimarySecretLength) + } + } + return nil +} + +func validateHTTPURL(name, value string) error { + parsed, err := url.Parse(value) + if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return fmt.Errorf("%s must be an absolute http(s) URL", name) + } + return nil +} + +func env(name, fallback string) string { + if v := os.Getenv(name); v != "" { + return v + } + return fallback +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +func boolEnv(name string, fallback bool) (bool, error) { + value := strings.TrimSpace(os.Getenv(name)) + if value == "" { + return fallback, nil + } + parsed, err := strconv.ParseBool(value) + if err != nil { + return false, fmt.Errorf("%s must be true or false: %w", name, err) + } + return parsed, nil +} + +func durationEnv(name string, fallback time.Duration) (time.Duration, error) { + value := strings.TrimSpace(os.Getenv(name)) + if value == "" { + return fallback, nil + } + parsed, err := time.ParseDuration(value) + if err != nil { + return 0, fmt.Errorf("%s is not a valid duration: %w", name, err) + } + return parsed, nil +} + +func legacyMinutesEnv(name string, fallback time.Duration) (time.Duration, error) { + value := strings.TrimSpace(os.Getenv(name)) + if value == "" { + return fallback, nil + } + n, err := strconv.Atoi(value) + if err != nil || n <= 0 { + return 0, fmt.Errorf("%s must be a positive integer number of minutes", name) + } + return time.Duration(n) * time.Minute, nil +} + +func legacyHoursEnv(name string, fallback time.Duration) (time.Duration, error) { + value := strings.TrimSpace(os.Getenv(name)) + if value == "" { + return fallback, nil + } + n, err := strconv.Atoi(value) + if err != nil || n < 0 { + return 0, fmt.Errorf("%s must be a non-negative integer number of hours", name) + } + return time.Duration(n) * time.Hour, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..441114f --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,100 @@ +package config + +import ( + "strings" + "testing" + "time" +) + +const ( + testAPISecret = "api-secret-that-is-long-enough" + testSMSSecret = "sms-secret-that-is-long-enough" +) + +func TestValidateServeRequiresCoreSecrets(t *testing.T) { + cfg := Config{PaymentTTL: 5 * time.Minute, AmountQuarantine: 24 * time.Hour} + err := cfg.ValidateServe() + if err == nil { + t.Fatal("ValidateServe accepted missing core configuration") + } + for _, field := range []string{"UPI_ID", "PAYGATE_API_KEY", "SMS_WEBHOOK_SECRET"} { + if !strings.Contains(err.Error(), field) { + t.Errorf("error %q does not mention %s", err, field) + } + } +} + +func TestValidateServeRejectsWeakPrimarySecrets(t *testing.T) { + base := Config{UPIID: "operator@bank", APIKey: "short", SMSWebhookSecret: testSMSSecret, PaymentTTL: time.Minute, AmountQuarantine: time.Hour} + if err := base.ValidateServe(); err == nil || !strings.Contains(err.Error(), "PAYGATE_API_KEY") { + t.Fatalf("weak API key error = %v", err) + } + base.APIKey = testAPISecret + base.SMSWebhookSecret = "short" + if err := base.ValidateServe(); err == nil || !strings.Contains(err.Error(), "SMS_WEBHOOK_SECRET") { + t.Fatalf("weak SMS secret error = %v", err) + } +} + +func TestValidateServeWebhookSecretIsConditional(t *testing.T) { + base := Config{UPIID: "operator@bank", APIKey: testAPISecret, SMSWebhookSecret: testSMSSecret, PaymentTTL: time.Minute, AmountQuarantine: time.Hour} + if err := base.ValidateServe(); err != nil { + t.Fatalf("base config invalid: %v", err) + } + base.OutgoingWebhookURL = "https://example.test/webhook" + if err := base.ValidateServe(); err == nil { + t.Fatal("webhook URL without signing secret was accepted") + } + base.OutgoingWebhookSecret = "signing-secret-that-is-long-enough" + if err := base.ValidateServe(); err != nil { + t.Fatalf("complete webhook config invalid: %v", err) + } + base.OutgoingWebhookURL = "not-a-url" + if err := base.ValidateServe(); err == nil || !strings.Contains(err.Error(), "OUTGOING_WEBHOOK_URL") { + t.Fatalf("malformed webhook URL error = %v", err) + } +} + +func TestLoadSupportsLegacyPrototypeVariablesWithoutPromotingLegacySecret(t *testing.T) { + t.Setenv("UPI_ID", "legacy@bank") + t.Setenv("UPI_NAME", "Legacy Name") + t.Setenv("PAYGATE_API_KEY", testAPISecret) + t.Setenv("WEBHOOK_SECRET", "legacy-webhook") + t.Setenv("TICKET_TTL_MINUTES", "7") + t.Setenv("AMOUNT_QUARANTINE_HOURS", "12") + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.UPIPayeeName != "Legacy Name" || cfg.LegacySMSWebhookSecret != "legacy-webhook" || cfg.SMSWebhookSecret != "" { + t.Fatalf("legacy mapping failed: %+v", cfg) + } + if cfg.PaymentTTL != 7*time.Minute || cfg.AmountQuarantine != 12*time.Hour { + t.Fatalf("legacy durations failed: ttl=%s quarantine=%s", cfg.PaymentTTL, cfg.AmountQuarantine) + } +} + +func TestValidateServeLegacyWebhookIsExplicit(t *testing.T) { + base := Config{UPIID: "operator@bank", APIKey: testAPISecret, SMSWebhookSecret: testSMSSecret, PaymentTTL: time.Minute, AmountQuarantine: time.Hour} + base.LegacySMSWebhookEnabled = true + if err := base.ValidateServe(); err == nil || !strings.Contains(err.Error(), "WEBHOOK_SECRET") { + t.Fatalf("legacy route without legacy secret error = %v", err) + } + base.LegacySMSWebhookSecret = "old-secret" + if err := base.ValidateServe(); err != nil { + t.Fatalf("explicit legacy route config rejected: %v", err) + } +} + +func TestLoadRejectsMalformedEnvironmentValues(t *testing.T) { + t.Setenv("PAYMENT_TTL", "five-minutes") + if _, err := Load(); err == nil || !strings.Contains(err.Error(), "PAYMENT_TTL") { + t.Fatalf("invalid duration error = %v", err) + } + + t.Setenv("PAYMENT_TTL", "5m") + t.Setenv("PAYGATE_RATE_LIMITS_ENABLED", "sometimes") + if _, err := Load(); err == nil || !strings.Contains(err.Error(), "PAYGATE_RATE_LIMITS_ENABLED") { + t.Fatalf("invalid bool error = %v", err) + } +} diff --git a/internal/domain/errors.go b/internal/domain/errors.go new file mode 100644 index 0000000..ba5a712 --- /dev/null +++ b/internal/domain/errors.go @@ -0,0 +1,58 @@ +package domain + +import "net/http" + +type Error struct { + Code string `json:"code"` + Message string `json:"message"` + Status int `json:"-"` + Details map[string]any `json:"details,omitempty"` +} + +func (e *Error) Error() string { return e.Message } + +func New(code, message string, status int) *Error { + return &Error{Code: code, Message: message, Status: status} +} + +func InvalidAmount() *Error { + return New("INVALID_AMOUNT", "amount must be a positive whole number of INR rupees", http.StatusBadRequest) +} + +func InvalidExternalID() *Error { + return New("INVALID_EXTERNAL_ID", "externalId must be at most 255 characters", http.StatusBadRequest) +} + +func InvalidIdempotencyKey() *Error { + return New("INVALID_IDEMPOTENCY_KEY", "Idempotency-Key must be at most 255 characters", http.StatusBadRequest) +} + +func InvalidMetadata() *Error { + return New("INVALID_METADATA", "metadata must be valid JSON no larger than 1 MiB", http.StatusBadRequest) +} + +func InvalidSMS(message string) *Error { + return New("INVALID_SMS", message, http.StatusBadRequest) +} + +func PaymentNotFound() *Error { + return New("PAYMENT_NOT_FOUND", "payment not found", http.StatusNotFound) +} + +func CapacityExhausted() *Error { + return New("AMOUNT_CAPACITY_EXHAUSTED", "all 99 paise fingerprints for this amount are temporarily unavailable", http.StatusConflict) +} + +func IdempotencyConflict() *Error { + return New("IDEMPOTENCY_CONFLICT", "the idempotency key was already used with different payment parameters", http.StatusConflict) +} + +func PaymentResolved(status string) *Error { + e := New("PAYMENT_ALREADY_RESOLVED", "payment is already resolved", http.StatusConflict) + e.Details = map[string]any{"status": status} + return e +} + +func AmbiguousMatch() *Error { + return New("AMBIGUOUS_PAYMENT_MATCH", "multiple active payments have the same payable amount; automatic confirmation was refused", http.StatusConflict) +} diff --git a/internal/domain/payment.go b/internal/domain/payment.go new file mode 100644 index 0000000..fae712c --- /dev/null +++ b/internal/domain/payment.go @@ -0,0 +1,36 @@ +package domain + +import "time" + +type PaymentStatus string + +const ( + StatusPending PaymentStatus = "pending" + StatusPaid PaymentStatus = "paid" + StatusExpired PaymentStatus = "expired" + StatusCancelled PaymentStatus = "cancelled" + StatusLate PaymentStatus = "late" +) + +type ParsedSMS struct { + AmountPaise int64 + RRN string + UPIId string + PayerName string + OccurredAt time.Time +} + +type Payment struct { + ID string + RequestedPaise int64 + PayablePaise int64 + Status PaymentStatus + ExpiresAt time.Time + ReuseAfter time.Time + RRN string + UPIId string + PayerName string + PaidAt time.Time + ExternalID string + IdempotencyKey string +} diff --git a/internal/gmessages/manager.go b/internal/gmessages/manager.go new file mode 100644 index 0000000..0757922 --- /dev/null +++ b/internal/gmessages/manager.go @@ -0,0 +1,601 @@ +package gmessages + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/Phloraxx/payment-api/internal/config" + "github.com/Phloraxx/payment-api/internal/sms" + "github.com/mdp/qrterminal/v3" + "github.com/rs/zerolog" + "go.mau.fi/mautrix-gmessages/pkg/libgm" + "go.mau.fi/mautrix-gmessages/pkg/libgm/events" + "rsc.io/qr" +) + +type Status struct { + Enabled bool `json:"enabled"` + State string `json:"state"` + Paired bool `json:"paired"` + Connected bool `json:"connected"` + PhoneResponsive bool `json:"phoneResponsive"` + LastConnectedAt *time.Time `json:"lastConnectedAt,omitempty"` + LastMessageAt *time.Time `json:"lastMessageAt,omitempty"` + LastError string `json:"lastError,omitempty"` +} + +type IngestFunc func(sms.Input) error + +type Manager struct { + cfg config.Config + ingest IngestFunc + logger zerolog.Logger + + mu sync.RWMutex + session *libgm.AuthData + client *libgm.Client + status Status + ctx context.Context + cancel context.CancelFunc + reconnecting bool +} + +func NewManager(cfg config.Config, logger zerolog.Logger, ingest IngestFunc) *Manager { + m := &Manager{cfg: cfg, ingest: ingest, logger: logger} + m.status = Status{Enabled: cfg.GMessagesEnabled, State: "disabled"} + if !cfg.GMessagesEnabled { + return m + } + + session, err := loadSession(cfg.GMessagesSessionPath) + if err != nil { + m.status = Status{Enabled: true, State: "unpaired", LastError: "stored session could not be loaded"} + logger.Warn().Err(err).Msg("ignoring invalid Google Messages session") + return m + } + if !validSession(session) { + m.status = Status{Enabled: true, State: "unpaired"} + return m + } + m.session = session + m.status = Status{Enabled: true, State: "disconnected", Paired: true} + return m +} + +func (m *Manager) Start(parent context.Context) { + if !m.cfg.GMessagesEnabled { + return + } + m.mu.Lock() + if m.ctx != nil { + m.mu.Unlock() + return + } + m.ctx, m.cancel = context.WithCancel(parent) + ctx := m.ctx + paired := validSession(m.session) + m.mu.Unlock() + if paired { + go m.connectWithBackoff(ctx) + } +} + +func (m *Manager) Stop() { + m.mu.Lock() + cancel := m.cancel + client := m.client + m.cancel = nil + m.ctx = nil + m.client = nil + m.status.Connected = false + if m.status.State == "connected" { + m.status.State = "disconnected" + } + m.mu.Unlock() + if cancel != nil { + cancel() + } + if client != nil { + client.Disconnect() + } +} + +func (m *Manager) Status() Status { + m.mu.RLock() + defer m.mu.RUnlock() + status := m.status + if m.client != nil && status.Paired { + status.Connected = m.client.IsConnected() + if status.Connected && status.State == "disconnected" { + status.State = "connected" + } + } + return status +} + +func (m *Manager) connectWithBackoff(ctx context.Context) { + backoff := time.Second + for { + if err := ctx.Err(); err != nil { + return + } + + m.mu.Lock() + if !validSession(m.session) { + m.status.State = "unpaired" + m.status.Paired = false + m.mu.Unlock() + return + } + if m.client == nil { + m.client = m.newClient(m.session) + } + client := m.client + m.status.State = "connecting" + m.mu.Unlock() + + if err := client.Connect(); err == nil { + m.markConnected() + return + } else { + m.setError("degraded", err) + } + + timer := time.NewTimer(backoff) + select { + case <-ctx.Done(): + timer.Stop() + return + case <-timer.C: + } + if backoff < time.Minute { + backoff *= 2 + if backoff > time.Minute { + backoff = time.Minute + } + } + } +} + +func (m *Manager) newClient(session *libgm.AuthData) *libgm.Client { + client := libgm.NewClient(session, nil, m.logger.With().Str("component", "libgm").Logger()) + client.SetEventHandler(m.handleEvent) + return client +} + +func (m *Manager) handleEvent(raw any) { + switch event := raw.(type) { + case *events.ClientReady: + m.markConnected() + case *events.PairSuccessful: + m.mu.Lock() + m.status.Paired = true + m.status.State = "connected" + m.status.Connected = true + m.status.PhoneResponsive = true + m.mu.Unlock() + if err := m.saveCurrentSession(); err != nil { + m.logger.Error().Err(err).Msg("failed to persist Google Messages session") + } + case *events.AuthTokenRefreshed: + if err := m.saveCurrentSession(); err != nil { + m.logger.Error().Err(err).Msg("failed to persist refreshed Google Messages auth") + } + case *events.PhoneNotResponding: + m.mu.Lock() + m.status.PhoneResponsive = false + m.status.State = "degraded" + m.mu.Unlock() + case *events.PhoneRespondingAgain: + m.mu.Lock() + m.status.PhoneResponsive = true + m.status.State = "connected" + m.status.Connected = true + m.mu.Unlock() + case *events.ListenTemporaryError: + m.setError("degraded", event.Error) + case *events.ListenRecovered: + m.markConnected() + case *events.ListenFatalError: + m.setError("degraded", event.Error) + m.scheduleReconnect() + case *events.PingFailed: + m.setError("degraded", event.Error) + case *events.GaiaLoggedOut: + m.markLoggedOut() + case *libgm.WrappedMessage: + m.handleMessage(event) + } +} + +func (m *Manager) handleMessage(wrapped *libgm.WrappedMessage) { + if wrapped == nil || wrapped.Message == nil { + return + } + message := wrapped.Message + if isFromMe(message.GetMessageStatus().GetStatus().String()) { + return + } + body := messageBody(wrapped) + if body == "" || !sms.LooksLikeBankCredit(body) { + // Privacy by default: don't copy unrelated personal messages into PayGate. + return + } + + timestampMS := normalizeTimestampMS(message.GetTimestamp()) + messageAt := time.Time{} + if timestampMS > 0 { + messageAt = time.UnixMilli(timestampMS).UTC() + } + sender := strings.TrimSpace(message.GetParticipantID()) + if participant := message.GetSenderParticipant(); participant != nil { + if participant.GetFormattedNumber() != "" { + sender = participant.GetFormattedNumber() + } else if participant.GetFullName() != "" { + sender = participant.GetFullName() + } + } + + now := time.Now().UTC() + m.mu.Lock() + m.status.LastMessageAt = &now + m.status.PhoneResponsive = true + m.mu.Unlock() + if m.ingest == nil { + return + } + if err := m.ingest(sms.Input{ + Source: "gmessages", + SourceEventID: message.GetMessageID(), + Sender: sender, + Body: body, + MessageTime: messageAt, + RawPayload: map[string]any{ + "messageId": message.GetMessageID(), + "conversationId": message.GetConversationID(), + "isOld": wrapped.IsOld, + }, + }); err != nil { + m.logger.Error().Err(err).Str("message_id", message.GetMessageID()).Msg("failed to ingest Google Messages bank SMS") + } +} + +func messageBody(wrapped *libgm.WrappedMessage) string { + var parts []string + for _, info := range wrapped.Message.GetMessageInfo() { + if content := info.GetMessageContent(); content != nil && strings.TrimSpace(content.GetContent()) != "" { + parts = append(parts, content.GetContent()) + } + } + return strings.TrimSpace(strings.Join(parts, "\n")) +} + +func isFromMe(status string) bool { + return strings.Contains(status, "OUTGOING") || strings.Contains(status, "SENT_BY_ME") +} + +func normalizeTimestampMS(timestamp int64) int64 { + if timestamp > 100_000_000_000_000 { + return timestamp / 1000 + } + return timestamp +} + +func (m *Manager) scheduleReconnect() { + m.mu.Lock() + if m.reconnecting || m.ctx == nil || !validSession(m.session) { + m.mu.Unlock() + return + } + m.reconnecting = true + ctx := m.ctx + client := m.client + m.mu.Unlock() + + go func() { + defer func() { + m.mu.Lock() + m.reconnecting = false + m.mu.Unlock() + }() + if client != nil { + client.Disconnect() + } + timer := time.NewTimer(2 * time.Second) + select { + case <-ctx.Done(): + timer.Stop() + return + case <-timer.C: + } + m.mu.Lock() + m.client = nil + m.mu.Unlock() + m.connectWithBackoff(ctx) + }() +} + +func (m *Manager) Reconnect() error { + if !m.cfg.GMessagesEnabled { + return errors.New("google messages connector is disabled") + } + m.mu.RLock() + client := m.client + paired := validSession(m.session) + m.mu.RUnlock() + if !paired { + return errors.New("google messages is not paired") + } + if client == nil { + m.scheduleReconnect() + return nil + } + if err := client.Reconnect(); err != nil { + m.setError("degraded", err) + return err + } + m.markConnected() + return m.saveCurrentSession() +} + +func (m *Manager) BeginPair() (string, error) { + if !m.cfg.GMessagesEnabled { + return "", errors.New("google messages connector is disabled") + } + m.mu.RLock() + alreadyPaired := validSession(m.session) + m.mu.RUnlock() + if alreadyPaired { + return "", errors.New("google messages is already paired; unpair it before starting a new pairing") + } + m.mu.Lock() + oldClient := m.client + m.session = libgm.NewAuthData() + m.client = m.newClient(m.session) + client := m.client + m.status = Status{Enabled: true, State: "pairing"} + m.mu.Unlock() + if oldClient != nil { + oldClient.Disconnect() + } + qrURL, err := client.StartLogin() + if err != nil { + m.setError("degraded", err) + return "", err + } + return qrURL, nil +} + +func (m *Manager) RefreshPair() (string, error) { + m.mu.RLock() + client := m.client + state := m.status.State + m.mu.RUnlock() + if client == nil || state != "pairing" { + return "", errors.New("pairing has not started") + } + return client.RefreshPhoneRelay() +} + +func (m *Manager) Unpair() error { + m.mu.Lock() + client := m.client + m.client = nil + m.session = nil + m.status = Status{Enabled: m.cfg.GMessagesEnabled, State: "unpaired"} + m.mu.Unlock() + if client != nil { + if _, err := client.UnpairBugle(); err != nil { + m.logger.Warn().Err(err).Msg("remote Google Messages unpair failed; deleting local session anyway") + } + client.Disconnect() + } + if err := os.Remove(m.cfg.GMessagesSessionPath); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return nil +} + +func (m *Manager) markConnected() { + now := time.Now().UTC() + m.mu.Lock() + m.status.State = "connected" + m.status.Paired = true + m.status.Connected = true + m.status.PhoneResponsive = true + m.status.LastConnectedAt = &now + m.status.LastError = "" + m.mu.Unlock() +} + +func (m *Manager) markLoggedOut() { + m.mu.Lock() + client := m.client + m.client = nil + m.session = nil + m.status = Status{Enabled: m.cfg.GMessagesEnabled, State: "unpaired", LastError: "Google Messages session was logged out"} + m.mu.Unlock() + if client != nil { + client.Disconnect() + } + _ = os.Remove(m.cfg.GMessagesSessionPath) +} + +func (m *Manager) setError(state string, err error) { + m.mu.Lock() + m.status.State = state + m.status.Connected = false + if err != nil { + m.status.LastError = err.Error() + } + m.mu.Unlock() +} + +func (m *Manager) saveCurrentSession() error { + // Keep the manager read lock for the whole atomic save so Unpair cannot + // delete the session and then have this goroutine resurrect it afterwards. + m.mu.RLock() + defer m.mu.RUnlock() + if m.session == nil { + return nil + } + return saveSession(m.cfg.GMessagesSessionPath, m.session) +} + +func validSession(session *libgm.AuthData) bool { + return session != nil && + session.Browser != nil && + session.Mobile != nil && + session.RequestCrypto != nil && + len(session.RequestCrypto.AESKey) == 32 && + len(session.RequestCrypto.HMACKey) > 0 && + session.RefreshKey != nil && + len(session.RefreshKey.D) > 0 && + len(session.RefreshKey.X) > 0 && + len(session.RefreshKey.Y) > 0 && + len(session.TachyonAuthToken) > 0 +} + +func loadSession(path string) (*libgm.AuthData, error) { + file, err := os.Open(path) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + defer file.Close() + var session libgm.AuthData + if err := json.NewDecoder(file).Decode(&session); err != nil { + return nil, err + } + return &session, nil +} + +func saveSession(path string, session *libgm.AuthData) error { + if session == nil { + return errors.New("cannot save empty Google Messages session") + } + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + if err := os.Chmod(dir, 0o700); err != nil { + return err + } + tmp := path + ".tmp" + file, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return err + } + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + // AuthData.Cookies is mutated by libgm HTTP handling and has its own lock. + session.CookiesLock.RLock() + encodeErr := encoder.Encode(session) + session.CookiesLock.RUnlock() + if encodeErr != nil { + _ = file.Close() + _ = os.Remove(tmp) + return encodeErr + } + if err := file.Sync(); err != nil { + _ = file.Close() + _ = os.Remove(tmp) + return err + } + if err := file.Close(); err != nil { + _ = os.Remove(tmp) + return err + } + if err := os.Chmod(tmp, 0o600); err != nil { + _ = os.Remove(tmp) + return err + } + return os.Rename(tmp, path) +} + +// PairConsole performs the explicit operator QR flow. QR tokens are refreshed +// every 25 seconds because the Messages Web pairing token is short-lived. +func PairConsole(ctx context.Context, sessionPath, qrPNG string, logger zerolog.Logger) error { + session := libgm.NewAuthData() + client := libgm.NewClient(session, nil, logger.With().Str("component", "libgm-pair").Logger()) + paired := make(chan struct{}, 1) + fatal := make(chan error, 1) + client.SetEventHandler(func(raw any) { + switch event := raw.(type) { + case *events.PairSuccessful: + if err := saveSession(sessionPath, session); err != nil { + select { + case fatal <- err: + default: + } + return + } + select { + case paired <- struct{}{}: + default: + } + case *events.ListenFatalError: + select { + case fatal <- event.Error: + default: + } + } + }) + defer client.Disconnect() + + render := func(qrURL string) error { + fmt.Fprint(os.Stderr, "\nScan this QR in Google Messages → Device pairing → Switch to QR pairing:\n\n") + qrterminal.GenerateHalfBlock(qrURL, qrterminal.L, os.Stderr) + if qrPNG != "" { + code, err := qr.Encode(qrURL, qr.L) + if err != nil { + return err + } + if err := os.WriteFile(qrPNG, code.PNG(), 0o600); err != nil { + return err + } + fmt.Fprintf(os.Stderr, "QR PNG updated: %s\n", qrPNG) + } + return nil + } + + qrURL, err := client.StartLogin() + if err != nil { + return fmt.Errorf("start Google Messages pairing: %w", err) + } + if err := render(qrURL); err != nil { + return err + } + + refresh := time.NewTicker(25 * time.Second) + defer refresh.Stop() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case err := <-fatal: + return fmt.Errorf("google messages pairing failed: %w", err) + case <-paired: + fmt.Fprintf(os.Stderr, "Paired. Session saved to %s\n", sessionPath) + return nil + case <-refresh.C: + fresh, err := client.RefreshPhoneRelay() + if err != nil { + logger.Warn().Err(err).Msg("failed to refresh Google Messages pairing QR") + continue + } + if err := render(fresh); err != nil { + return err + } + } + } +} diff --git a/internal/gmessages/manager_test.go b/internal/gmessages/manager_test.go new file mode 100644 index 0000000..00440a1 --- /dev/null +++ b/internal/gmessages/manager_test.go @@ -0,0 +1,101 @@ +package gmessages + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Phloraxx/payment-api/internal/config" + + "go.mau.fi/mautrix-gmessages/pkg/libgm" + "go.mau.fi/mautrix-gmessages/pkg/libgm/gmproto" +) + +func TestSessionPersistenceUsesRestrictedPermissions(t *testing.T) { + dir := filepath.Join(t.TempDir(), "nested", "gmessages") + path := filepath.Join(dir, "session.json") + session := libgm.NewAuthData() + if err := saveSession(path, session); err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("session mode = %o; want 600", got) + } + dirInfo, err := os.Stat(dir) + if err != nil { + t.Fatal(err) + } + if got := dirInfo.Mode().Perm(); got&0o077 != 0 { + t.Fatalf("session directory is group/world accessible: %o", got) + } + loaded, err := loadSession(path) + if err != nil || loaded == nil || loaded.RefreshKey == nil || loaded.RequestCrypto == nil { + t.Fatalf("loaded session = %#v, %v", loaded, err) + } +} + +func TestMessageBodyJoinsTextPartsOnly(t *testing.T) { + wrapped := &libgm.WrappedMessage{Message: &gmproto.Message{MessageInfo: []*gmproto.MessageInfo{ + {Data: &gmproto.MessageInfo_MessageContent{MessageContent: &gmproto.MessageContent{Content: "Received Rs.100.01"}}}, + {Data: &gmproto.MessageInfo_MediaContent{MediaContent: &gmproto.MediaContent{MediaName: "ignored.jpg"}}}, + {Data: &gmproto.MessageInfo_MessageContent{MessageContent: &gmproto.MessageContent{Content: "UPI Ref:123456789012"}}}, + }}} + got := messageBody(wrapped) + want := "Received Rs.100.01\nUPI Ref:123456789012" + if got != want { + t.Fatalf("messageBody() = %q; want %q", got, want) + } +} + +func TestNormalizeTimestampHandlesMicrosAndMillis(t *testing.T) { + if got := normalizeTimestampMS(1_700_000_000_123); got != 1_700_000_000_123 { + t.Fatalf("millis changed: %d", got) + } + if got := normalizeTimestampMS(1_700_000_000_123_000); got != 1_700_000_000_123 { + t.Fatalf("micros = %d", got) + } +} + +func TestOutgoingStatusDetectionIsConservative(t *testing.T) { + for _, status := range []string{"OUTGOING_COMPLETE", "SENT_BY_ME"} { + if !isFromMe(status) { + t.Errorf("%q should be outgoing", status) + } + } + if isFromMe("INCOMING_COMPLETE") { + t.Fatal("incoming message classified as outgoing") + } +} + +func TestBeginPairRefusesToReplaceExistingSession(t *testing.T) { + session := libgm.NewAuthData() + session.Browser = &gmproto.Device{} + session.Mobile = &gmproto.Device{} + session.TachyonAuthToken = []byte{1} + manager := &Manager{ + cfg: config.Config{GMessagesEnabled: true}, + session: session, + } + if _, err := manager.BeginPair(); err == nil || !strings.Contains(err.Error(), "already paired") { + t.Fatalf("BeginPair() error = %v; want already paired guard", err) + } +} + +func TestValidSessionRejectsPartialCredentialState(t *testing.T) { + partial := &libgm.AuthData{Browser: &gmproto.Device{}, TachyonAuthToken: []byte{1}} + if validSession(partial) { + t.Fatal("partial session without mobile/crypto/refresh key was accepted") + } + complete := libgm.NewAuthData() + complete.Browser = &gmproto.Device{} + complete.Mobile = &gmproto.Device{} + complete.TachyonAuthToken = []byte{1} + if !validSession(complete) { + t.Fatal("complete session was rejected") + } +} diff --git a/internal/money/money.go b/internal/money/money.go new file mode 100644 index 0000000..9dab8c8 --- /dev/null +++ b/internal/money/money.go @@ -0,0 +1,69 @@ +package money + +import ( + "encoding/json" + "errors" + "fmt" + "regexp" + "strconv" + "strings" +) + +var wholeRupees = regexp.MustCompile(`^[1-9][0-9]*$`) +var paisaAmount = regexp.MustCompile(`^[0-9]+(?:\.[0-9]{1,2})?$`) + +var ErrInvalidAmount = errors.New("amount must be a positive whole number of INR rupees") + +var maxRequestedRupees = (int64(^uint64(0)>>1) - 99) / 100 + +func ParseWholeRupees(raw json.RawMessage) (int64, error) { + value := strings.TrimSpace(string(raw)) + if len(value) >= 2 && value[0] == '"' { + var decoded string + if err := json.Unmarshal(raw, &decoded); err != nil { + return 0, ErrInvalidAmount + } + value = strings.TrimSpace(decoded) + } + if !wholeRupees.MatchString(value) { + return 0, ErrInvalidAmount + } + r, err := strconv.ParseInt(value, 10, 64) + if err != nil || r <= 0 || r > maxRequestedRupees { + return 0, ErrInvalidAmount + } + return r, nil +} + +func RupeesToPaise(rupees int64) (int64, error) { + if rupees <= 0 || rupees > maxRequestedRupees { + return 0, ErrInvalidAmount + } + return rupees * 100, nil +} + +func ParseAmount(text string) (int64, error) { + text = strings.ReplaceAll(strings.TrimSpace(text), ",", "") + if !paisaAmount.MatchString(text) { + return 0, fmt.Errorf("invalid INR amount %q", text) + } + parts := strings.SplitN(text, ".", 2) + r, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil || r < 0 || r > maxRequestedRupees { + return 0, fmt.Errorf("invalid INR amount %q", text) + } + p := int64(0) + if len(parts) == 2 { + fraction := parts[1] + if len(fraction) == 1 { + fraction += "0" + } + p, err = strconv.ParseInt(fraction, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid INR amount %q", text) + } + } + return r*100 + p, nil +} + +func FormatPaise(paise int64) string { return fmt.Sprintf("%d.%02d", paise/100, paise%100) } diff --git a/internal/money/money_test.go b/internal/money/money_test.go new file mode 100644 index 0000000..912f734 --- /dev/null +++ b/internal/money/money_test.go @@ -0,0 +1,38 @@ +package money + +import ( + "encoding/json" + "testing" +) + +func TestParseWholeRupeesRejectsPaiseAndNonIntegers(t *testing.T) { + for _, raw := range []string{`100.01`, `100.0`, `1e2`, `0`, `-1`, `"100.01"`, `"+100"`} { + if _, err := ParseWholeRupees(json.RawMessage(raw)); err == nil { + t.Errorf("ParseWholeRupees(%s) accepted fractional or invalid amount", raw) + } + } + for _, raw := range []string{`100`, `"100"`} { + if got, err := ParseWholeRupees(json.RawMessage(raw)); err != nil || got != 100 { + t.Errorf("ParseWholeRupees(%s) = %d, %v; want 100", raw, got, err) + } + } +} + +func TestParseAmountUsesIntegerPaise(t *testing.T) { + cases := map[string]int64{"1": 100, "1.5": 150, "1.05": 105, "1,234.50": 123450} + for input, want := range cases { + got, err := ParseAmount(input) + if err != nil || got != want { + t.Errorf("ParseAmount(%q) = %d, %v; want %d", input, got, err, want) + } + } +} + +func TestRequestedAmountReservesDDMSuffixHeadroom(t *testing.T) { + if _, err := RupeesToPaise(maxRequestedRupees); err != nil { + t.Fatalf("maximum safe requested rupees rejected: %v", err) + } + if _, err := RupeesToPaise(maxRequestedRupees + 1); err == nil { + t.Fatal("requested amount that can overflow when adding a DDM suffix was accepted") + } +} diff --git a/internal/payments/service.go b/internal/payments/service.go new file mode 100644 index 0000000..61d571f --- /dev/null +++ b/internal/payments/service.go @@ -0,0 +1,565 @@ +package payments + +import ( + "crypto/rand" + "database/sql" + "encoding/json" + "errors" + "fmt" + "math/big" + "net/http" + "net/url" + "reflect" + "strings" + "time" + + "github.com/Phloraxx/payment-api/internal/config" + "github.com/Phloraxx/payment-api/internal/domain" + "github.com/Phloraxx/payment-api/internal/money" + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/tools/types" +) + +type WebhookScheduler interface { + Schedule(app core.App, event string, payment *core.Record, at time.Time) error + Wake() +} + +type Service struct { + App core.App + Config config.Config + Webhooks WebhookScheduler + Now func() time.Time + + // SuffixStart is injectable for deterministic tests. Production uses crypto/rand. + SuffixStart func() (int64, error) +} + +type CreateInput struct { + AmountRupees int64 + ExternalID string + Metadata any + IdempotencyKey string +} + +type MatchResult struct { + Payment *domain.Payment + Action string +} + +func NewService(app core.App, cfg config.Config, webhooks WebhookScheduler) *Service { + return &Service{ + App: app, + Config: cfg, + Webhooks: webhooks, + Now: time.Now, + SuffixStart: randomSuffixStart, + } +} + +func (s *Service) Create(input CreateInput) (*domain.Payment, bool, error) { + requested, err := money.RupeesToPaise(input.AmountRupees) + if err != nil { + return nil, false, domain.InvalidAmount() + } + input.ExternalID = strings.TrimSpace(input.ExternalID) + input.IdempotencyKey = strings.TrimSpace(input.IdempotencyKey) + if len(input.ExternalID) > 255 { + return nil, false, domain.InvalidExternalID() + } + if len(input.IdempotencyKey) > 255 { + return nil, false, domain.InvalidIdempotencyKey() + } + metadata, err := validateAndNormalizeMetadata(input.Metadata) + if err != nil { + return nil, false, err + } + now := s.now() + var result *core.Record + var reused bool + var queued bool + + err = s.App.RunInTransaction(func(tx core.App) error { + expired, err := s.ExpireDueInApp(tx, now) + if err != nil { + return err + } + queued = expired > 0 + + if input.IdempotencyKey != "" { + existing, findErr := tx.FindFirstRecordByData("payments", "idempotency_key", input.IdempotencyKey) + if findErr == nil { + if int64(existing.GetInt("requested_amount")) != requested || + existing.GetString("external_id") != input.ExternalID || + !metadataEqual(existing.Get("metadata"), metadata) { + return domain.IdempotencyConflict() + } + result = existing.Clone() + reused = true + return nil + } + if !errors.Is(findErr, sql.ErrNoRows) { + return findErr + } + } + + start, err := s.SuffixStart() + if err != nil { + return fmt.Errorf("choose amount fingerprint: %w", err) + } + if start < 1 || start > 99 { + return fmt.Errorf("invalid amount fingerprint start %d", start) + } + collection, err := tx.FindCollectionByNameOrId("payments") + if err != nil { + return err + } + expiresAt := now.Add(s.Config.PaymentTTL) + reuseAfter := expiresAt.Add(s.Config.AmountQuarantine) + + for i := int64(0); i < 99; i++ { + suffix := ((start - 1 + i) % 99) + 1 + candidate := requested + suffix + blocked, err := tx.FindFirstRecordByFilter( + "payments", + "payable_amount = {:amount} && reuse_after > {:now}", + dbx.Params{"amount": candidate, "now": filterDate(now)}, + ) + if err == nil && blocked != nil { + continue + } + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + + record := core.NewRecord(collection) + record.Set("created_at", now) + record.Set("requested_amount", requested) + record.Set("payable_amount", candidate) + record.Set("status", string(domain.StatusPending)) + record.Set("expires_at", expiresAt) + record.Set("reuse_after", reuseAfter) + record.Set("external_id", input.ExternalID) + record.Set("idempotency_key", input.IdempotencyKey) + if metadata != nil { + record.Set("metadata", metadata) + } + if err := tx.Save(record); err != nil { + return err + } + result = record.Clone() + return nil + } + return domain.CapacityExhausted() + }) + if err != nil { + return nil, false, err + } + if queued { + s.WakeWebhooks() + } + return FromRecord(result), reused, nil +} + +func (s *Service) Get(id string) (*domain.Payment, error) { + now := s.now() + var result *core.Record + var queued bool + err := s.App.RunInTransaction(func(tx core.App) error { + expired, err := s.ExpireDueInApp(tx, now) + if err != nil { + return err + } + queued = expired > 0 + record, err := tx.FindRecordById("payments", id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return domain.PaymentNotFound() + } + return err + } + result = record.Clone() + return nil + }) + if err != nil { + return nil, err + } + if queued { + s.WakeWebhooks() + } + return FromRecord(result), nil +} + +func (s *Service) Cancel(id string) (*domain.Payment, error) { + now := s.now() + var result *core.Record + var queued bool + err := s.App.RunInTransaction(func(tx core.App) error { + expired, err := s.ExpireDueInApp(tx, now) + if err != nil { + return err + } + queued = expired > 0 + record, err := tx.FindRecordById("payments", id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return domain.PaymentNotFound() + } + return err + } + status := record.GetString("status") + if status == string(domain.StatusCancelled) { + result = record.Clone() + return nil + } + if status != string(domain.StatusPending) { + return domain.PaymentResolved(status) + } + record.Set("status", string(domain.StatusCancelled)) + extendReuseAfter(record, now.Add(s.Config.AmountQuarantine)) + if err := tx.Save(record); err != nil { + return err + } + if err := s.schedule(tx, "payment.cancelled", record, now); err != nil { + return err + } + queued = true + result = record.Clone() + return nil + }) + if err != nil { + return nil, err + } + if queued { + s.WakeWebhooks() + } + return FromRecord(result), nil +} + +func (s *Service) Match(parsed domain.ParsedSMS) (*MatchResult, error) { + if parsed.AmountPaise <= 0 || strings.TrimSpace(parsed.RRN) == "" { + return nil, domain.New("SMS_NOT_MATCHABLE", "bank SMS requires an exact amount and RRN", http.StatusUnprocessableEntity) + } + now := s.now() + var record *core.Record + var action string + var queued bool + err := s.App.RunInTransaction(func(tx core.App) error { + var err error + record, action, queued, err = s.MatchInApp(tx, parsed, now) + return err + }) + if err != nil { + return nil, err + } + if queued { + s.WakeWebhooks() + } + return &MatchResult{Payment: FromRecord(record), Action: action}, nil +} + +// MatchInApp applies exact-amount matching inside the caller's transaction. +// It returns whether outgoing webhook work was queued so the caller can wake the +// delivery loop only after the transaction commits. +func (s *Service) MatchInApp(tx core.App, parsed domain.ParsedSMS, now time.Time) (*core.Record, string, bool, error) { + now = now.UTC() + expired, err := s.ExpireDueInApp(tx, now) + if err != nil { + return nil, "error", false, err + } + queued := expired > 0 + rrn := strings.TrimSpace(parsed.RRN) + evidenceAt := parsed.OccurredAt.UTC() + if evidenceAt.IsZero() || evidenceAt.After(now) { + evidenceAt = now + } + if rrn == "" || parsed.AmountPaise <= 0 { + return nil, "not_matchable", queued, domain.New("SMS_NOT_MATCHABLE", "bank SMS requires an exact amount and RRN", http.StatusUnprocessableEntity) + } + + existing, err := tx.FindFirstRecordByData("payments", "rrn", rrn) + if err == nil { + if int64(existing.GetInt("payable_amount")) != parsed.AmountPaise { + return nil, "rrn_amount_mismatch", queued, domain.New("RRN_AMOUNT_MISMATCH", "the UPI reference was already recorded with a different amount", http.StatusConflict) + } + return existing, "duplicate_rrn", queued, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return nil, "error", queued, err + } + + pending, err := tx.FindRecordsByFilter( + "payments", + "payable_amount = {:amount} && status = 'pending' && expires_at > {:now} && created_at <= {:evidenceAt}", + "created", + 2, + 0, + dbx.Params{"amount": parsed.AmountPaise, "now": filterDate(now), "evidenceAt": filterDate(evidenceAt)}, + ) + if err != nil { + return nil, "error", queued, err + } + if len(pending) > 1 { + return nil, "ambiguous", queued, domain.AmbiguousMatch() + } + if len(pending) == 1 { + record := pending[0] + applyEvidence(record, parsed, domain.StatusPaid, now, s.Config.AmountQuarantine) + if err := tx.Save(record); err != nil { + return nil, "error", queued, err + } + if err := s.schedule(tx, "payment.paid", record, now); err != nil { + return nil, "error", queued, err + } + return record, "marked_paid", true, nil + } + + late, err := tx.FindRecordsByFilter( + "payments", + "payable_amount = {:amount} && (status = 'expired' || status = 'cancelled') && reuse_after > {:now} && created_at <= {:evidenceAt}", + "-created", + 2, + 0, + dbx.Params{"amount": parsed.AmountPaise, "now": filterDate(now), "evidenceAt": filterDate(evidenceAt)}, + ) + if err != nil { + return nil, "error", queued, err + } + if len(late) > 1 { + return nil, "ambiguous", queued, domain.AmbiguousMatch() + } + if len(late) == 1 { + record := late[0] + applyEvidence(record, parsed, domain.StatusLate, now, s.Config.AmountQuarantine) + if err := tx.Save(record); err != nil { + return nil, "error", queued, err + } + if err := s.schedule(tx, "payment.late", record, now); err != nil { + return nil, "error", queued, err + } + return record, "marked_late", true, nil + } + + return nil, "unmatched", queued, nil +} + +func applyEvidence(record *core.Record, parsed domain.ParsedSMS, status domain.PaymentStatus, now time.Time, quarantine time.Duration) { + paidAt := parsed.OccurredAt.UTC() + if paidAt.IsZero() || paidAt.After(now) { + paidAt = now.UTC() + } + record.Set("status", string(status)) + record.Set("rrn", strings.TrimSpace(parsed.RRN)) + record.Set("upi_id", strings.TrimSpace(parsed.UPIId)) + record.Set("payer_name", strings.TrimSpace(parsed.PayerName)) + record.Set("paid_at", paidAt) + extendReuseAfter(record, now.UTC().Add(quarantine)) +} + +func extendReuseAfter(record *core.Record, candidate time.Time) { + existing := record.GetDateTime("reuse_after").Time() + if existing.IsZero() || candidate.After(existing) { + record.Set("reuse_after", candidate.UTC()) + } +} + +func (s *Service) ExpireDue() (int, error) { + now := s.now() + count := 0 + err := s.App.RunInTransaction(func(tx core.App) error { + var err error + count, err = s.ExpireDueInApp(tx, now) + return err + }) + if err == nil && count > 0 { + s.WakeWebhooks() + } + return count, err +} + +// ExpireDueInApp changes only currently pending records whose persisted expiry +// timestamp is due. Existing reuse_after is retained because it was fixed at +// creation as expires_at + quarantine. +func (s *Service) ExpireDueInApp(tx core.App, now time.Time) (int, error) { + now = now.UTC() + records, err := tx.FindRecordsByFilter( + "payments", + "status = 'pending' && expires_at <= {:now}", + "expires_at", + 0, + 0, + dbx.Params{"now": filterDate(now)}, + ) + if err != nil { + return 0, err + } + for _, record := range records { + record.Set("status", string(domain.StatusExpired)) + if record.GetDateTime("reuse_after").IsZero() { + record.Set("reuse_after", now.Add(s.Config.AmountQuarantine)) + } + if err := tx.Save(record); err != nil { + return 0, err + } + if err := s.schedule(tx, "payment.expired", record, now); err != nil { + return 0, err + } + } + return len(records), nil +} + +func (s *Service) Stats() (map[string]int64, error) { + if _, err := s.ExpireDue(); err != nil { + return nil, err + } + result := map[string]int64{ + "total": 0, "pending": 0, "paid": 0, "expired": 0, "cancelled": 0, "late": 0, + } + records, err := s.App.FindAllRecords("payments") + if err != nil { + return nil, err + } + for _, record := range records { + result["total"]++ + status := record.GetString("status") + if _, ok := result[status]; ok { + result[status]++ + } + } + return result, nil +} + +func FromRecord(record *core.Record) *domain.Payment { + if record == nil { + return nil + } + return &domain.Payment{ + ID: record.Id, + RequestedPaise: int64(record.GetInt("requested_amount")), + PayablePaise: int64(record.GetInt("payable_amount")), + Status: domain.PaymentStatus(record.GetString("status")), + ExpiresAt: record.GetDateTime("expires_at").Time(), + ReuseAfter: record.GetDateTime("reuse_after").Time(), + RRN: record.GetString("rrn"), + UPIId: record.GetString("upi_id"), + PayerName: record.GetString("payer_name"), + PaidAt: record.GetDateTime("paid_at").Time(), + ExternalID: record.GetString("external_id"), + IdempotencyKey: record.GetString("idempotency_key"), + } +} + +func PublicPayment(payment *domain.Payment) map[string]any { + if payment == nil { + return nil + } + return map[string]any{ + "id": payment.ID, + "requestedAmount": payment.RequestedPaise / 100, + "requestedAmountPaise": payment.RequestedPaise, + "payableAmount": money.FormatPaise(payment.PayablePaise), + "payableAmountPaise": payment.PayablePaise, + "status": payment.Status, + "expiresAt": formatTime(payment.ExpiresAt), + "paidAt": formatOptionalTime(payment.PaidAt), + } +} + +func CreateResponse(payment *domain.Payment, cfg config.Config) map[string]any { + response := PublicPayment(payment) + response["externalId"] = payment.ExternalID + query := url.Values{} + query.Set("pa", cfg.UPIID) + query.Set("pn", cfg.UPIPayeeName) + query.Set("am", money.FormatPaise(payment.PayablePaise)) + query.Set("cu", "INR") + query.Set("tr", payment.ID) + query.Set("tn", payment.ID) + response["upiUri"] = "upi://pay?" + query.Encode() + return response +} + +func (s *Service) WakeWebhooks() { + if s.Webhooks != nil { + s.Webhooks.Wake() + } +} + +func (s *Service) schedule(tx core.App, event string, payment *core.Record, at time.Time) error { + if s.Webhooks == nil { + return nil + } + return s.Webhooks.Schedule(tx, event, payment, at) +} + +func (s *Service) now() time.Time { + if s.Now == nil { + return time.Now().UTC() + } + return s.Now().UTC() +} + +func randomSuffixStart() (int64, error) { + n, err := rand.Int(rand.Reader, big.NewInt(99)) + if err != nil { + return 0, err + } + return n.Int64() + 1, nil +} + +func validateAndNormalizeMetadata(value any) (any, error) { + if value == nil { + return nil, nil + } + raw, err := json.Marshal(value) + if err != nil || len(raw) > 1<<20 { + return nil, domain.InvalidMetadata() + } + var normalized any + if err := json.Unmarshal(raw, &normalized); err != nil { + return nil, domain.InvalidMetadata() + } + return normalized, nil +} + +func normalizeMetadata(value any) any { + if value == nil { + return nil + } + raw, err := json.Marshal(value) + if err != nil { + return value + } + var normalized any + if json.Unmarshal(raw, &normalized) != nil { + return value + } + return normalized +} + +func metadataEqual(a, b any) bool { + return reflect.DeepEqual(normalizeMetadata(a), normalizeMetadata(b)) +} + +func filterDate(t time.Time) string { + value, err := types.ParseDateTime(t.UTC()) + if err != nil { + return t.UTC().Format(time.RFC3339Nano) + } + return value.String() +} + +func formatTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.UTC().Format(time.RFC3339Nano) +} + +func formatOptionalTime(t time.Time) any { + if t.IsZero() { + return nil + } + return formatTime(t) +} diff --git a/internal/payments/service_test.go b/internal/payments/service_test.go new file mode 100644 index 0000000..bcb7308 --- /dev/null +++ b/internal/payments/service_test.go @@ -0,0 +1,311 @@ +package payments + +import ( + "errors" + "strings" + "sync" + "testing" + "time" + + "github.com/Phloraxx/payment-api/internal/config" + "github.com/Phloraxx/payment-api/internal/domain" + _ "github.com/Phloraxx/payment-api/migrations" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/tests" +) + +func paymentTestService(t *testing.T) (*Service, *tests.TestApp, *time.Time) { + t.Helper() + app, err := tests.NewTestApp() + if err != nil { + t.Fatalf("create PocketBase test app: %v", err) + } + t.Cleanup(app.Cleanup) + now := time.Date(2026, 7, 25, 12, 0, 0, 0, time.UTC) + cfg := config.Config{ + PaymentTTL: 5 * time.Minute, + AmountQuarantine: 24 * time.Hour, + UPIID: "operator@bank", + UPIPayeeName: "PayGate", + } + service := NewService(app, cfg, nil) + service.Now = func() time.Time { return now } + service.SuffixStart = func() (int64, error) { return 1, nil } + return service, app, &now +} + +func TestCreateAllocatesAllNinetyNineSlotsAndExhausts(t *testing.T) { + service, _, _ := paymentTestService(t) + seen := make(map[int64]bool) + for i := 0; i < 99; i++ { + payment, replayed, err := service.Create(CreateInput{AmountRupees: 100}) + if err != nil { + t.Fatalf("Create #%d: %v", i+1, err) + } + if replayed || payment.PayablePaise < 10001 || payment.PayablePaise > 10099 { + t.Fatalf("Create #%d returned %+v", i+1, payment) + } + seen[payment.PayablePaise] = true + } + if len(seen) != 99 { + t.Fatalf("allocated %d unique slots; want 99", len(seen)) + } + _, _, err := service.Create(CreateInput{AmountRupees: 100}) + var domainErr *domain.Error + if !errors.As(err, &domainErr) || domainErr.Code != "AMOUNT_CAPACITY_EXHAUSTED" { + t.Fatalf("exhausted Create() error = %v; want AMOUNT_CAPACITY_EXHAUSTED", err) + } +} + +func TestCreateIdempotencyAndExactPaymentMatching(t *testing.T) { + service, _, now := paymentTestService(t) + first, replayed, err := service.Create(CreateInput{ + AmountRupees: 100, + ExternalID: "order-1", + Metadata: map[string]any{"kind": "checkout"}, + IdempotencyKey: "idem-1", + }) + if err != nil || replayed { + t.Fatalf("first Create() = %+v, %v, %v", first, replayed, err) + } + replay, replayed, err := service.Create(CreateInput{ + AmountRupees: 100, + ExternalID: "order-1", + Metadata: map[string]any{"kind": "checkout"}, + IdempotencyKey: "idem-1", + }) + if err != nil || !replayed || replay.ID != first.ID { + t.Fatalf("replayed Create() = %+v, %v, %v", replay, replayed, err) + } + _, _, err = service.Create(CreateInput{AmountRupees: 101, IdempotencyKey: "idem-1"}) + var conflict *domain.Error + if !errors.As(err, &conflict) || conflict.Code != "IDEMPOTENCY_CONFLICT" { + t.Fatalf("conflicting Create() error = %v; want IDEMPOTENCY_CONFLICT", err) + } + + matched, err := service.Match(domain.ParsedSMS{AmountPaise: first.PayablePaise, RRN: "123456789012"}) + if err != nil || matched.Action != "marked_paid" || matched.Payment.Status != domain.StatusPaid { + t.Fatalf("exact Match() = %+v, %v", matched, err) + } + duplicate, err := service.Match(domain.ParsedSMS{AmountPaise: first.PayablePaise, RRN: "123456789012"}) + if err != nil || duplicate.Action != "duplicate_rrn" || duplicate.Payment.ID != first.ID { + t.Fatalf("duplicate Match() = %+v, %v", duplicate, err) + } + + // A freshly constructed service still reads the durable record state. + restarted := NewService(service.App, service.Config, nil) + restarted.Now = func() time.Time { return *now } + persisted, err := restarted.Get(first.ID) + if err != nil || persisted.Status != domain.StatusPaid || persisted.PayablePaise != first.PayablePaise { + t.Fatalf("persisted payment = %+v, %v", persisted, err) + } +} + +func TestExpiredPaymentReceivesLateExactCredit(t *testing.T) { + service, _, now := paymentTestService(t) + payment, _, err := service.Create(CreateInput{AmountRupees: 50}) + if err != nil { + t.Fatal(err) + } + *now = now.Add(6 * time.Minute) + result, err := service.Match(domain.ParsedSMS{AmountPaise: payment.PayablePaise, RRN: "998877665544"}) + if err != nil || result.Action != "marked_late" || result.Payment.Status != domain.StatusLate { + t.Fatalf("late Match() = %+v, %v", result, err) + } +} + +func TestConcurrentAllocationsRemainUnique(t *testing.T) { + service, app, _ := paymentTestService(t) + const workers = 20 + ids := make(chan string, workers) + errs := make(chan error, workers) + var wg sync.WaitGroup + for i := 0; i < workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + payment, _, err := service.Create(CreateInput{AmountRupees: 200}) + if err != nil { + errs <- err + return + } + ids <- payment.ID + }() + } + wg.Wait() + close(ids) + close(errs) + for err := range errs { + t.Fatalf("concurrent Create() error: %v", err) + } + seen := map[string]bool{} + for id := range ids { + seen[id] = true + } + if len(seen) != workers { + t.Fatalf("concurrent allocation returned %d unique IDs; want %d", len(seen), workers) + } + records, err := app.FindRecordsByFilter("payments", "requested_amount = 20000", "created", 0, 0) + if err != nil || len(records) != workers { + t.Fatalf("stored concurrent payments = %d, %v; want %d", len(records), err, workers) + } +} + +func TestPublicPaymentRedactsEvidence(t *testing.T) { + record := core.NewRecord(core.NewBaseCollection("payments")) + record.Id = "payment-id" + record.Set("requested_amount", 10000) + record.Set("payable_amount", 10001) + record.Set("status", "paid") + payment := FromRecord(record) + public := PublicPayment(payment) + for _, forbidden := range []string{"rrn", "upiId", "payerName", "rawSms"} { + if _, ok := public[forbidden]; ok { + t.Errorf("public response exposed %q", forbidden) + } + } + if public["payableAmount"] != "100.01" { + t.Errorf("public payableAmount = %v; want 100.01", public["payableAmount"]) + } +} + +func TestCreateRejectsOverflowFromServiceInput(t *testing.T) { + service, _, _ := paymentTestService(t) + _, _, err := service.Create(CreateInput{AmountRupees: int64(^uint64(0)>>1)/100 + 1}) + var domainErr *domain.Error + if !errors.As(err, &domainErr) || domainErr.Code != "INVALID_AMOUNT" { + t.Fatalf("overflow Create() error = %v", err) + } +} + +func TestAmountRemainsUnavailableUntilQuarantineEnds(t *testing.T) { + service, _, now := paymentTestService(t) + created := make([]*domain.Payment, 0, 99) + for i := 0; i < 99; i++ { + payment, _, err := service.Create(CreateInput{AmountRupees: 300}) + if err != nil { + t.Fatalf("Create #%d: %v", i+1, err) + } + created = append(created, payment) + } + for _, payment := range created { + if payment.PayablePaise%100 == 0 { + t.Fatalf("allocator used .00 fingerprint: %d", payment.PayablePaise) + } + if payment.PayablePaise/100 != 300 { + t.Fatalf("allocator spilled into another rupee: %d", payment.PayablePaise) + } + } + + *now = now.Add(6 * time.Minute) // all expired, but still quarantined + if _, _, err := service.Create(CreateInput{AmountRupees: 300}); err == nil { + t.Fatal("allocator reused an expired amount while it was still quarantined") + } + + *now = now.Add(24*time.Hour + time.Minute) // beyond expires_at + quarantine + payment, _, err := service.Create(CreateInput{AmountRupees: 300}) + if err != nil { + t.Fatalf("allocator did not reuse a released amount after quarantine: %v", err) + } + if payment.PayablePaise != 30001 { + t.Fatalf("reused amount = %d; deterministic allocator should reuse 30001", payment.PayablePaise) + } +} + +func TestPaidAmountUsesQuarantineBeforeReuse(t *testing.T) { + service, _, now := paymentTestService(t) + first, _, err := service.Create(CreateInput{AmountRupees: 400}) + if err != nil { + t.Fatal(err) + } + if first.PayablePaise != 40001 { + t.Fatalf("first amount = %d; want 40001", first.PayablePaise) + } + if _, err := service.Match(domain.ParsedSMS{AmountPaise: first.PayablePaise, RRN: "111122223333"}); err != nil { + t.Fatal(err) + } + second, _, err := service.Create(CreateInput{AmountRupees: 400}) + if err != nil { + t.Fatal(err) + } + if second.PayablePaise == first.PayablePaise { + t.Fatal("paid amount was reused before quarantine expired") + } + + *now = now.Add(24*time.Hour + 6*time.Minute) // beyond original expires_at + quarantine + third, _, err := service.Create(CreateInput{AmountRupees: 400}) + if err != nil { + t.Fatal(err) + } + if third.PayablePaise != first.PayablePaise { + t.Fatalf("released paid amount = %d; want deterministic reuse of %d", third.PayablePaise, first.PayablePaise) + } +} + +func TestDuplicateRRNWithDifferentAmountIsRejected(t *testing.T) { + service, _, _ := paymentTestService(t) + first, _, err := service.Create(CreateInput{AmountRupees: 500}) + if err != nil { + t.Fatal(err) + } + if _, err := service.Match(domain.ParsedSMS{AmountPaise: first.PayablePaise, RRN: "444455556666"}); err != nil { + t.Fatal(err) + } + _, err = service.Match(domain.ParsedSMS{AmountPaise: first.PayablePaise + 1, RRN: "444455556666"}) + var domainErr *domain.Error + if !errors.As(err, &domainErr) || domainErr.Code != "RRN_AMOUNT_MISMATCH" { + t.Fatalf("mismatched duplicate RRN error = %v; want RRN_AMOUNT_MISMATCH", err) + } +} + +func TestCreateRejectsOversizedIdentifiersAndMetadata(t *testing.T) { + service, _, _ := paymentTestService(t) + cases := []struct { + name string + input CreateInput + code string + }{ + {name: "external id", input: CreateInput{AmountRupees: 10, ExternalID: strings.Repeat("x", 256)}, code: "INVALID_EXTERNAL_ID"}, + {name: "idempotency key", input: CreateInput{AmountRupees: 10, IdempotencyKey: strings.Repeat("k", 256)}, code: "INVALID_IDEMPOTENCY_KEY"}, + {name: "metadata", input: CreateInput{AmountRupees: 10, Metadata: map[string]any{"blob": strings.Repeat("m", (1<<20)+1)}}, code: "INVALID_METADATA"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, _, err := service.Create(tc.input) + var domainErr *domain.Error + if !errors.As(err, &domainErr) || domainErr.Code != tc.code { + t.Fatalf("error = %v; want %s", err, tc.code) + } + }) + } +} + +func TestEarlyResolutionNeverShortensOriginalQuarantine(t *testing.T) { + service, _, now := paymentTestService(t) + payment, _, err := service.Create(CreateInput{AmountRupees: 321}) + if err != nil { + t.Fatal(err) + } + original := payment.ReuseAfter + + paid, err := service.Match(domain.ParsedSMS{AmountPaise: payment.PayablePaise, RRN: "321321321321", OccurredAt: *now}) + if err != nil { + t.Fatal(err) + } + if paid.Payment.ReuseAfter.Before(original) { + t.Fatalf("paid reuse_after shortened: original=%s paid=%s", original, paid.Payment.ReuseAfter) + } + + second, _, err := service.Create(CreateInput{AmountRupees: 322}) + if err != nil { + t.Fatal(err) + } + originalSecond := second.ReuseAfter + cancelled, err := service.Cancel(second.ID) + if err != nil { + t.Fatal(err) + } + if cancelled.ReuseAfter.Before(originalSecond) { + t.Fatalf("cancelled reuse_after shortened: original=%s cancelled=%s", originalSecond, cancelled.ReuseAfter) + } +} diff --git a/internal/sms/parser.go b/internal/sms/parser.go new file mode 100644 index 0000000..812def8 --- /dev/null +++ b/internal/sms/parser.go @@ -0,0 +1,67 @@ +package sms + +import ( + "errors" + "regexp" + "strings" + + "github.com/Phloraxx/payment-api/internal/domain" + "github.com/Phloraxx/payment-api/internal/money" +) + +var ( + // Intentionally anchored to credit language. We do not infer payments from + // arbitrary messages that merely contain a rupee amount. + bankCreditPattern = regexp.MustCompile(`(?i)(?:payment\s+for\s+)?received\s*(?:rs\.?|inr|₹)\s*([0-9][0-9,]*(?:\.[0-9]{1,2})?)`) + rrnPattern = regexp.MustCompile(`(?i)(?:upi\s*ref(?:erence)?|rrn|ref(?:erence)?)(?:\s*(?:no|number|id))?\s*[:.#\- ]*([0-9]{8,24})`) + upiPattern = regexp.MustCompile(`(?i)[a-z0-9][a-z0-9._-]{0,127}@[a-z0-9][a-z0-9._-]{0,127}`) + fromPattern = regexp.MustCompile(`(?i)\bfrom\s+(.+?)(?:\s+on\s+|\s+upi\s+ref|\.|$)`) +) + +var ErrUnrecognized = errors.New("SMS is not a recognized bank credit message") + +func LooksLikeBankCredit(body string) bool { + return bankCreditPattern.MatchString(body) +} + +func Parse(body string) (domain.ParsedSMS, error) { + match := bankCreditPattern.FindStringSubmatch(body) + if len(match) < 2 { + return domain.ParsedSMS{}, ErrUnrecognized + } + amount, err := money.ParseAmount(match[1]) + if err != nil || amount <= 0 { + if err != nil { + return domain.ParsedSMS{}, err + } + return domain.ParsedSMS{}, money.ErrInvalidAmount + } + + rrn := "" + if match := rrnPattern.FindStringSubmatch(body); len(match) > 1 { + rrn = strings.TrimSpace(match[1]) + } + upiID := strings.TrimSpace(upiPattern.FindString(body)) + payerName := "" + if from := fromPattern.FindStringSubmatch(body); len(from) > 1 { + payerName = strings.TrimSpace(from[1]) + if strings.EqualFold(payerName, upiID) { + payerName = "" + } + } + + return domain.ParsedSMS{ + AmountPaise: amount, + RRN: rrn, + UPIId: truncateRunes(upiID, 255), + PayerName: truncateRunes(payerName, 255), + }, nil +} + +func truncateRunes(value string, max int) string { + runes := []rune(value) + if len(runes) <= max { + return value + } + return string(runes[:max]) +} diff --git a/internal/sms/parser_test.go b/internal/sms/parser_test.go new file mode 100644 index 0000000..aa72205 --- /dev/null +++ b/internal/sms/parser_test.go @@ -0,0 +1,53 @@ +package sms + +import ( + "strings" + "testing" +) + +func TestParseKotakCreditVariants(t *testing.T) { + cases := []struct { + name string + body string + amt int64 + rrn string + }{ + {"received rs", "Kotak: Received Rs. 1,250.50 from Maya UPI Ref No. 123456789012", 125050, "123456789012"}, + {"payment for received inr", "payment for Received INR 75 from a@upi UPI Ref: 987654321", 7500, "987654321"}, + {"rupee symbol", "Payment for Received ₹99.25 from payer ref 12345678", 9925, "12345678"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + parsed, err := Parse(tc.body) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if parsed.AmountPaise != tc.amt || parsed.RRN != tc.rrn { + t.Fatalf("Parse() = amount %d rrn %q; want amount %d rrn %q", parsed.AmountPaise, parsed.RRN, tc.amt, tc.rrn) + } + }) + } +} + +func TestParseIgnoresUnrelatedMessages(t *testing.T) { + if LooksLikeBankCredit("Your OTP is 123456 for a Rs. 100 transaction") { + t.Fatal("OTP message looked like a bank credit") + } + if _, err := Parse("Your OTP is 123456"); err != ErrUnrecognized { + t.Fatalf("Parse() error = %v; want ErrUnrecognized", err) + } +} + +func TestParseBoundsDerivedIdentityFields(t *testing.T) { + local := "a" + strings.Repeat("b", 127) + host := "c" + strings.Repeat("d", 127) + upi := local + "@" + host + body := "Received Rs.100.01 from " + strings.Repeat("P", 300) + " UPI Ref:123456789012 payer " + upi + parsed, err := Parse(body) + if err != nil { + t.Fatal(err) + } + if len([]rune(parsed.UPIId)) > 255 || len([]rune(parsed.PayerName)) > 255 { + t.Fatalf("derived fields exceed storage bounds: upi=%d payer=%d", len([]rune(parsed.UPIId)), len([]rune(parsed.PayerName))) + } +} diff --git a/internal/sms/service.go b/internal/sms/service.go new file mode 100644 index 0000000..805f265 --- /dev/null +++ b/internal/sms/service.go @@ -0,0 +1,226 @@ +package sms + +import ( + "database/sql" + "errors" + "strings" + "time" + "unicode/utf8" + + "github.com/Phloraxx/payment-api/internal/domain" + "github.com/Phloraxx/payment-api/internal/payments" + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/core" +) + +type Input struct { + Source string + SourceEventID string + Sender string + Body string + MessageTime time.Time + RawPayload any +} + +type Result struct { + EventID string `json:"eventId"` + Status string `json:"status"` + Action string `json:"action"` + PaymentID string `json:"paymentId,omitempty"` + Duplicate bool `json:"duplicate,omitempty"` +} + +type Service struct { + App core.App + Payments *payments.Service + Now func() time.Time +} + +func NewService(app core.App, paymentService *payments.Service) *Service { + return &Service{App: app, Payments: paymentService, Now: time.Now} +} + +func (s *Service) Ingest(input Input) (Result, error) { + input.Source = strings.TrimSpace(input.Source) + if input.Source == "" { + input.Source = "manual" + } + if !validSource(input.Source) { + return Result{}, domain.New("INVALID_SMS_SOURCE", "source must be android_webhook, gmessages, or manual", 400) + } + input.SourceEventID = strings.TrimSpace(input.SourceEventID) + input.Sender = strings.TrimSpace(input.Sender) + input.Body = strings.TrimSpace(input.Body) + if utf8.RuneCountInString(input.SourceEventID) > 255 { + return Result{}, domain.InvalidSMS("sourceId must be at most 255 characters") + } + if utf8.RuneCountInString(input.Sender) > 255 { + return Result{}, domain.InvalidSMS("sender must be at most 255 characters") + } + if utf8.RuneCountInString(input.Body) > 64*1024 { + return Result{}, domain.InvalidSMS("sms body must be at most 65536 characters") + } + if input.Body == "" { + return Result{}, domain.InvalidSMS("sms body is required") + } + now := time.Now().UTC() + if s.Now != nil { + now = s.Now().UTC() + } + messageTime := input.MessageTime.UTC() + if messageTime.IsZero() || messageTime.After(now) { + messageTime = now + } + + var result Result + var domainErr error + var queued bool + err := s.App.RunInTransaction(func(tx core.App) error { + if input.SourceEventID != "" { + existing, err := tx.FindFirstRecordByFilter( + "sms_events", + "source = {:source} && source_event_id = {:id}", + dbx.Params{"source": input.Source, "id": input.SourceEventID}, + ) + if err == nil { + result = resultFromEvent(existing) + result.Action = "duplicate_event" + result.Duplicate = true + return nil + } + if !errors.Is(err, sql.ErrNoRows) { + return err + } + } + + collection, err := tx.FindCollectionByNameOrId("sms_events") + if err != nil { + return err + } + event := core.NewRecord(collection) + event.Set("source", input.Source) + event.Set("source_event_id", input.SourceEventID) + event.Set("sender", input.Sender) + event.Set("body", input.Body) + event.Set("processing_status", "received") + event.Set("message_time", messageTime) + if input.RawPayload != nil { + event.Set("raw_payload", input.RawPayload) + } + if err := tx.Save(event); err != nil { + return err + } + result.EventID = event.Id + + parsed, parseErr := Parse(input.Body) + parsed.OccurredAt = messageTime + if errors.Is(parseErr, ErrUnrecognized) { + event.Set("processing_status", "ignored") + event.Set("error", "not a recognized bank credit message") + if err := tx.Save(event); err != nil { + return err + } + result.Status = "ignored" + result.Action = "ignored_non_bank_sms" + return nil + } + if parseErr != nil { + event.Set("processing_status", "error") + event.Set("error", parseErr.Error()) + if err := tx.Save(event); err != nil { + return err + } + result.Status = "error" + result.Action = "parse_error" + domainErr = domain.New("SMS_PARSE_ERROR", parseErr.Error(), 422) + return nil + } + + event.Set("amount", parsed.AmountPaise) + event.Set("rrn", parsed.RRN) + event.Set("upi_id", parsed.UPIId) + event.Set("payer_name", parsed.PayerName) + event.Set("processing_status", "parsed") + if strings.TrimSpace(parsed.RRN) == "" { + event.Set("processing_status", "error") + event.Set("error", "bank credit has no usable UPI reference/RRN") + if err := tx.Save(event); err != nil { + return err + } + result.Status = "error" + result.Action = "missing_rrn" + domainErr = domain.New("SMS_MISSING_RRN", "bank credit has no usable UPI reference/RRN", 422) + return nil + } + + payment, action, matchQueued, matchErr := s.Payments.MatchInApp(tx, parsed, now) + queued = queued || matchQueued + if matchErr != nil { + var dErr *domain.Error + if errors.As(matchErr, &dErr) { + event.Set("processing_status", "error") + event.Set("error", dErr.Message) + if err := tx.Save(event); err != nil { + return err + } + result.Status = "error" + result.Action = "match_error" + domainErr = dErr + return nil + } + return matchErr + } + + switch action { + case "marked_paid", "marked_late": + event.Set("processing_status", "matched") + event.Set("matched_payment", payment.Id) + result.Status = "matched" + result.PaymentID = payment.Id + case "duplicate_rrn": + event.Set("processing_status", "duplicate") + event.Set("matched_payment", payment.Id) + result.Status = "duplicate" + result.PaymentID = payment.Id + result.Duplicate = true + case "unmatched": + event.Set("processing_status", "unmatched") + event.Set("error", "no eligible payment has this exact amount") + result.Status = "unmatched" + default: + event.Set("processing_status", "error") + event.Set("error", "unexpected matching action: "+action) + result.Status = "error" + domainErr = domain.New("INTERNAL_MATCH_STATE", "unexpected matching result", 500) + } + result.Action = action + return tx.Save(event) + }) + if err != nil { + return Result{}, err + } + if queued { + s.Payments.WakeWebhooks() + } + if domainErr != nil { + return result, domainErr + } + return result, nil +} + +func resultFromEvent(event *core.Record) Result { + return Result{ + EventID: event.Id, + Status: event.GetString("processing_status"), + PaymentID: event.GetString("matched_payment"), + } +} + +func validSource(source string) bool { + switch source { + case "android_webhook", "gmessages", "manual": + return true + default: + return false + } +} diff --git a/internal/sms/service_test.go b/internal/sms/service_test.go new file mode 100644 index 0000000..f63eb15 --- /dev/null +++ b/internal/sms/service_test.go @@ -0,0 +1,230 @@ +package sms + +import ( + "errors" + "strings" + "testing" + "time" + + "github.com/Phloraxx/payment-api/internal/config" + "github.com/Phloraxx/payment-api/internal/domain" + "github.com/Phloraxx/payment-api/internal/payments" + _ "github.com/Phloraxx/payment-api/migrations" + "github.com/pocketbase/pocketbase/tests" +) + +func smsTestService(t *testing.T) (*Service, *payments.Service, *tests.TestApp, *time.Time) { + t.Helper() + app, err := tests.NewTestApp() + if err != nil { + t.Fatalf("create PocketBase test app: %v", err) + } + t.Cleanup(app.Cleanup) + now := time.Date(2026, 7, 25, 12, 0, 0, 0, time.UTC) + cfg := config.Config{PaymentTTL: 5 * time.Minute, AmountQuarantine: 24 * time.Hour} + paymentService := payments.NewService(app, cfg, nil) + paymentService.Now = func() time.Time { return now } + paymentService.SuffixStart = func() (int64, error) { return 1, nil } + service := NewService(app, paymentService) + service.Now = func() time.Time { return now } + return service, paymentService, app, &now +} + +func TestIngestMatchesExactBankSMSAndPersistsEvidence(t *testing.T) { + service, paymentService, app, _ := smsTestService(t) + payment, _, err := paymentService.Create(payments.CreateInput{AmountRupees: 100}) + if err != nil { + t.Fatal(err) + } + + result, err := service.Ingest(Input{ + Source: "android_webhook", SourceEventID: "sms-1", Sender: "VK-KOTAKB", + Body: "Confirmed payment for Received Rs.100.01 in your Kotak Bank AC X4959 from user@oksbi on 08-03-26.UPI Ref:606703736479.", + MessageTime: time.Date(2026, 7, 25, 12, 1, 0, 0, time.UTC), + }) + if err != nil { + t.Fatalf("Ingest() error = %v", err) + } + if result.Status != "matched" || result.Action != "marked_paid" || result.PaymentID != payment.ID { + t.Fatalf("Ingest() = %+v", result) + } + stored, err := paymentService.Get(payment.ID) + if err != nil || stored.Status != domain.StatusPaid || stored.RRN != "606703736479" { + t.Fatalf("stored payment = %+v, %v", stored, err) + } + event, err := app.FindRecordById("sms_events", result.EventID) + if err != nil { + t.Fatal(err) + } + if event.GetString("processing_status") != "matched" || event.GetString("matched_payment") != payment.ID { + t.Fatalf("event state = status=%s payment=%s", event.GetString("processing_status"), event.GetString("matched_payment")) + } +} + +func TestIngestDedupesBySourceAndSourceEventID(t *testing.T) { + service, paymentService, app, _ := smsTestService(t) + payment, _, err := paymentService.Create(payments.CreateInput{AmountRupees: 100}) + if err != nil { + t.Fatal(err) + } + body := "Received Rs.100.01 from user@oksbi. UPI Ref:123456789012" + first, err := service.Ingest(Input{Source: "android_webhook", SourceEventID: "same", Body: body}) + if err != nil { + t.Fatal(err) + } + second, err := service.Ingest(Input{Source: "android_webhook", SourceEventID: "same", Body: body}) + if err != nil { + t.Fatal(err) + } + if !second.Duplicate || second.Action != "duplicate_event" || second.EventID != first.EventID { + t.Fatalf("duplicate result = %+v; first=%+v", second, first) + } + count, err := app.CountRecords("sms_events") + if err != nil || count != 1 { + t.Fatalf("sms event count = %d, %v; want 1", count, err) + } + stored, _ := paymentService.Get(payment.ID) + if stored.Status != domain.StatusPaid { + t.Fatalf("payment status = %s", stored.Status) + } + + // The same provider ID from another connector is a different source event, + // but the RRN makes the payment evidence itself idempotent. + third, err := service.Ingest(Input{Source: "gmessages", SourceEventID: "same", Body: body}) + if err != nil { + t.Fatal(err) + } + if !third.Duplicate || third.Action != "duplicate_rrn" { + t.Fatalf("cross-source duplicate = %+v", third) + } + count, _ = app.CountRecords("sms_events") + if count != 2 { + t.Fatalf("sms event count = %d; want 2", count) + } +} + +func TestIngestIgnoresUnrelatedMessagesButKeepsAuditRecord(t *testing.T) { + service, _, app, _ := smsTestService(t) + result, err := service.Ingest(Input{Source: "android_webhook", SourceEventID: "otp-1", Body: "Your OTP is 123456 for Rs.100"}) + if err != nil { + t.Fatal(err) + } + if result.Status != "ignored" || result.Action != "ignored_non_bank_sms" { + t.Fatalf("result = %+v", result) + } + event, err := app.FindRecordById("sms_events", result.EventID) + if err != nil { + t.Fatal(err) + } + if event.GetString("processing_status") != "ignored" { + t.Fatalf("event status = %s", event.GetString("processing_status")) + } +} + +func TestIngestPersistsMissingRRNFailure(t *testing.T) { + service, _, app, _ := smsTestService(t) + result, err := service.Ingest(Input{Source: "android_webhook", SourceEventID: "missing-rrn", Body: "Received Rs.100.01 from user@oksbi"}) + var domainErr *domain.Error + if !errors.As(err, &domainErr) || domainErr.Code != "SMS_MISSING_RRN" { + t.Fatalf("error = %v", err) + } + event, findErr := app.FindRecordById("sms_events", result.EventID) + if findErr != nil { + t.Fatal(findErr) + } + if event.GetString("processing_status") != "error" { + t.Fatalf("event status = %s", event.GetString("processing_status")) + } +} + +func TestIngestUnmatchedBankCreditDoesNotMutatePayments(t *testing.T) { + service, _, app, _ := smsTestService(t) + result, err := service.Ingest(Input{Source: "android_webhook", SourceEventID: "unmatched", Body: "Received Rs.777.77 from user@oksbi UPI Ref:777788889999"}) + if err != nil { + t.Fatal(err) + } + if result.Status != "unmatched" || result.PaymentID != "" { + t.Fatalf("result = %+v", result) + } + count, _ := app.CountRecords("payments") + if count != 0 { + t.Fatalf("payments count = %d; want 0", count) + } +} + +func TestDelayedOldSMSCannotConfirmReusedAmount(t *testing.T) { + service, paymentService, _, now := smsTestService(t) + first, _, err := paymentService.Create(payments.CreateInput{AmountRupees: 600}) + if err != nil { + t.Fatal(err) + } + oldMessageTime := now.Add(2 * time.Minute) + + // Move beyond expiry + quarantine so the deterministic .01 slot can be reused. + *now = now.Add(24*time.Hour + 6*time.Minute + time.Second) + second, _, err := paymentService.Create(payments.CreateInput{AmountRupees: 600}) + if err != nil { + t.Fatal(err) + } + if second.PayablePaise != first.PayablePaise { + t.Fatalf("test requires amount reuse: first=%d second=%d", first.PayablePaise, second.PayablePaise) + } + + result, err := service.Ingest(Input{ + Source: "gmessages", + SourceEventID: "old-catchup-message", + Body: "Received Rs.600.01 from oldpayer@oksbi UPI Ref:121212121212", + MessageTime: oldMessageTime, + }) + if err != nil { + t.Fatal(err) + } + if result.Status != "unmatched" || result.PaymentID != "" { + t.Fatalf("old catch-up SMS result = %+v; want unmatched", result) + } + storedSecond, err := paymentService.Get(second.ID) + if err != nil { + t.Fatal(err) + } + if storedSecond.Status != domain.StatusPending { + t.Fatalf("reused payment status = %s; old SMS must not confirm it", storedSecond.Status) + } +} + +func TestIngestRejectsUnknownSource(t *testing.T) { + service, _, app, _ := smsTestService(t) + _, err := service.Ingest(Input{Source: "unknown_connector", Body: "Received Rs.10.01 UPI Ref:123456789012"}) + var domainErr *domain.Error + if !errors.As(err, &domainErr) || domainErr.Code != "INVALID_SMS_SOURCE" { + t.Fatalf("error = %v; want INVALID_SMS_SOURCE", err) + } + count, countErr := app.CountRecords("sms_events") + if countErr != nil || count != 0 { + t.Fatalf("sms event count = %d, %v; invalid source must not persist", count, countErr) + } +} + +func TestIngestRejectsValuesThatCannotFitStorage(t *testing.T) { + service, _, app, _ := smsTestService(t) + cases := []struct { + name string + input Input + }{ + {name: "source event id", input: Input{Source: "android_webhook", SourceEventID: strings.Repeat("e", 256), Body: "Received Rs.10.01 UPI Ref:123456789012"}}, + {name: "sender", input: Input{Source: "android_webhook", Sender: strings.Repeat("s", 256), Body: "Received Rs.10.01 UPI Ref:123456789012"}}, + {name: "body", input: Input{Source: "android_webhook", Body: strings.Repeat("x", 64*1024+1)}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := service.Ingest(tc.input) + var domainErr *domain.Error + if !errors.As(err, &domainErr) || domainErr.Code != "INVALID_SMS" { + t.Fatalf("error = %v; want INVALID_SMS", err) + } + }) + } + count, err := app.CountRecords("sms_events") + if err != nil || count != 0 { + t.Fatalf("sms event count = %d, %v; invalid records must not persist", count, err) + } +} diff --git a/internal/web/embed.go b/internal/web/embed.go new file mode 100644 index 0000000..cd4067a --- /dev/null +++ b/internal/web/embed.go @@ -0,0 +1,21 @@ +package web + +import ( + "embed" + "io/fs" +) + +// dist is populated by the frontend build before the production Go binary is +// compiled. A placeholder index.html is committed so Go tests can compile +// without Node being installed. +// +//go:embed all:dist +var embedded embed.FS + +func Assets() fs.FS { + sub, err := fs.Sub(embedded, "dist") + if err != nil { + panic(err) + } + return sub +} diff --git a/internal/webhooks/service.go b/internal/webhooks/service.go new file mode 100644 index 0000000..aa2960e --- /dev/null +++ b/internal/webhooks/service.go @@ -0,0 +1,326 @@ +package webhooks + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "strconv" + "strings" + "time" + + "github.com/Phloraxx/payment-api/internal/config" + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/tools/security" + "github.com/pocketbase/pocketbase/tools/types" +) + +const maxAttempts = 8 + +type Service struct { + App core.App + Config config.Config + HTTPClient *http.Client + Logger *slog.Logger + Now func() time.Time + + wake chan struct{} +} + +func NewService(app core.App, cfg config.Config) *Service { + return &Service{ + App: app, + Config: cfg, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + Logger: slog.Default(), + Now: time.Now, + wake: make(chan struct{}, 1), + } +} + +func (s *Service) Enabled() bool { + return s != nil && strings.TrimSpace(s.Config.OutgoingWebhookURL) != "" && s.Config.OutgoingWebhookSecret != "" +} + +func (s *Service) Schedule(app core.App, event string, payment *core.Record, at time.Time) error { + if !s.Enabled() { + return nil + } + collection, err := app.FindCollectionByNameOrId("webhook_deliveries") + if err != nil { + return err + } + eventID := "evt_" + security.RandomString(24) + body, err := json.Marshal(map[string]any{ + "id": eventID, + "type": event, + "createdAt": at.UTC().Format(time.RFC3339Nano), + "data": map[string]any{ + "payment": map[string]any{ + "id": payment.Id, + "requestedAmountPaise": payment.GetInt("requested_amount"), + "payableAmountPaise": payment.GetInt("payable_amount"), + "status": payment.GetString("status"), + "rrn": payment.GetString("rrn"), + "upiId": payment.GetString("upi_id"), + "payerName": payment.GetString("payer_name"), + "paidAt": payment.GetDateTime("paid_at").String(), + "externalId": payment.GetString("external_id"), + }, + }, + }) + if err != nil { + return fmt.Errorf("marshal webhook payload: %w", err) + } + record := core.NewRecord(collection) + record.Set("event_id", eventID) + record.Set("event", event) + record.Set("payment", payment.Id) + record.Set("url", s.Config.OutgoingWebhookURL) + record.Set("body", string(body)) + record.Set("attempts", 0) + record.Set("status", "pending") + record.Set("next_attempt_at", at.UTC()) + return app.Save(record) +} + +func (s *Service) Wake() { + if !s.Enabled() { + return + } + select { + case s.wake <- struct{}{}: + default: + } +} + +func (s *Service) Run(ctx context.Context) { + if !s.Enabled() { + return + } + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + s.Wake() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + case <-s.wake: + } + if _, err := s.SendPending(ctx); err != nil && !errors.Is(err, context.Canceled) { + s.logger().Error("webhook delivery pass failed", "error", err) + } + } +} + +func (s *Service) SendPending(ctx context.Context) (int, error) { + if !s.Enabled() { + return 0, nil + } + now := s.now() + if err := s.recoverStale(now); err != nil { + return 0, err + } + records, err := s.App.FindRecordsByFilter( + "webhook_deliveries", + "(status = 'pending' || status = 'failed') && next_attempt_at <= {:now}", + "next_attempt_at,created", + 50, + 0, + dbx.Params{"now": filterDate(now)}, + ) + if err != nil { + return 0, err + } + processed := 0 + for _, record := range records { + if err := ctx.Err(); err != nil { + return processed, err + } + claimed, err := s.claim(record.Id, now) + if err != nil { + s.logger().Warn("failed to claim webhook delivery", "id", record.Id, "error", err) + continue + } + if claimed == nil { + continue + } + s.deliver(ctx, claimed) + processed++ + } + return processed, nil +} + +func (s *Service) claim(id string, now time.Time) (*core.Record, error) { + var claimed *core.Record + err := s.App.RunInTransaction(func(tx core.App) error { + record, err := tx.FindRecordById("webhook_deliveries", id) + if err != nil { + return err + } + status := record.GetString("status") + if status != "pending" && status != "failed" { + return nil + } + if next := record.GetDateTime("next_attempt_at").Time(); !next.IsZero() && next.After(now) { + return nil + } + record.Set("status", "sending") + record.Set("locked_at", now) + record.Set("last_attempt_at", now) + record.Set("attempts", record.GetInt("attempts")+1) + if err := tx.Save(record); err != nil { + return err + } + claimed = record.Clone() + return nil + }) + return claimed, err +} + +func (s *Service) deliver(ctx context.Context, record *core.Record) { + body := record.GetString("body") + timestamp := strconv.FormatInt(s.now().Unix(), 10) + signature := Sign(s.Config.OutgoingWebhookSecret, timestamp, []byte(body)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, record.GetString("url"), strings.NewReader(body)) + if err == nil { + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "PayGate/1.0") + req.Header.Set("X-PayGate-Event-Id", record.GetString("event_id")) + req.Header.Set("X-PayGate-Timestamp", timestamp) + req.Header.Set("X-PayGate-Signature", "v1="+signature) + } + + statusCode := 0 + if err == nil { + var response *http.Response + response, err = s.HTTPClient.Do(req) + if response != nil { + statusCode = response.StatusCode + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64<<10)) + _ = response.Body.Close() + if statusCode < 200 || statusCode >= 300 { + err = fmt.Errorf("webhook returned HTTP %d", statusCode) + } + } + } + if finishErr := s.finish(record.Id, statusCode, err); finishErr != nil { + s.logger().Error("failed to persist webhook result", "id", record.Id, "error", finishErr) + } +} + +func (s *Service) finish(id string, statusCode int, deliveryErr error) error { + now := s.now() + return s.App.RunInTransaction(func(tx core.App) error { + record, err := tx.FindRecordById("webhook_deliveries", id) + if err != nil { + return err + } + record.Set("locked_at", "") + record.Set("response_code", statusCode) + if deliveryErr == nil { + record.Set("status", "delivered") + record.Set("delivered_at", now) + record.Set("last_error", "") + return tx.Save(record) + } + + attempts := record.GetInt("attempts") + record.Set("last_error", truncate(deliveryErr.Error(), 4000)) + if attempts >= maxAttempts { + record.Set("status", "exhausted") + // Keep a valid date for the required field; exhausted records aren't queried. + record.Set("next_attempt_at", now.Add(365*24*time.Hour)) + } else { + record.Set("status", "failed") + record.Set("next_attempt_at", now.Add(retryDelay(attempts))) + } + return tx.Save(record) + }) +} + +func (s *Service) recoverStale(now time.Time) error { + stale := now.Add(-2 * time.Minute) + records, err := s.App.FindRecordsByFilter( + "webhook_deliveries", + "status = 'sending' && locked_at < {:stale}", + "locked_at", + 50, + 0, + dbx.Params{"stale": filterDate(stale)}, + ) + if err != nil { + return err + } + for _, record := range records { + record.Set("status", "failed") + record.Set("locked_at", "") + record.Set("next_attempt_at", now) + record.Set("last_error", "recovered stale delivery lease after restart") + if err := s.App.Save(record); err != nil { + return err + } + } + return nil +} + +func (s *Service) RetryCount() (int64, error) { + return s.App.CountRecords("webhook_deliveries", dbx.NewExp("status IN ('pending','failed','sending')")) +} + +func Sign(secret, timestamp string, body []byte) string { + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(timestamp)) + _, _ = mac.Write([]byte(".")) + _, _ = mac.Write(body) + return hex.EncodeToString(mac.Sum(nil)) +} + +func retryDelay(attempt int) time.Duration { + delays := []time.Duration{time.Minute, 5 * time.Minute, 30 * time.Minute, 2 * time.Hour, 6 * time.Hour, 12 * time.Hour, 24 * time.Hour} + index := attempt - 1 + if index < 0 { + index = 0 + } + if index >= len(delays) { + return delays[len(delays)-1] + } + return delays[index] +} + +func filterDate(t time.Time) string { + value, err := types.ParseDateTime(t.UTC()) + if err != nil { + return t.UTC().Format(time.RFC3339Nano) + } + return value.String() +} + +func (s *Service) now() time.Time { + if s.Now == nil { + return time.Now().UTC() + } + return s.Now().UTC() +} + +func (s *Service) logger() *slog.Logger { + if s.Logger == nil { + return slog.Default() + } + return s.Logger +} + +func truncate(value string, max int) string { + if len(value) <= max { + return value + } + return value[:max] +} diff --git a/internal/webhooks/service_test.go b/internal/webhooks/service_test.go new file mode 100644 index 0000000..1a66a6f --- /dev/null +++ b/internal/webhooks/service_test.go @@ -0,0 +1,154 @@ +package webhooks + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Phloraxx/payment-api/internal/config" + "github.com/Phloraxx/payment-api/internal/payments" + _ "github.com/Phloraxx/payment-api/migrations" + "github.com/pocketbase/pocketbase/tests" +) + +func webhookTestApp(t *testing.T) *tests.TestApp { + t.Helper() + app, err := tests.NewTestApp() + if err != nil { + t.Fatalf("create PocketBase test app: %v", err) + } + t.Cleanup(app.Cleanup) + return app +} + +func createWebhookTestPayment(t *testing.T, app *tests.TestApp, cfg config.Config, now time.Time) string { + t.Helper() + service := payments.NewService(app, cfg, nil) + service.Now = func() time.Time { return now } + service.SuffixStart = func() (int64, error) { return 1, nil } + payment, _, err := service.Create(payments.CreateInput{AmountRupees: 100}) + if err != nil { + t.Fatal(err) + } + return payment.ID +} + +func TestSignIsStableHMAC(t *testing.T) { + got := Sign("secret", "123", []byte(`{"ok":true}`)) + want := "12f14ade5e7e737164d9ae20ea4e070056a3045b2c8f42f5f216008eae4684dd" + if got != want { + t.Fatalf("Sign() = %s; want %s", got, want) + } +} + +func TestWebhookDeliveryPersistsSuccessAndSignature(t *testing.T) { + app := webhookTestApp(t) + now := time.Date(2026, 7, 25, 12, 0, 0, 0, time.UTC) + var seenSignature, seenTimestamp, seenEventID, seenBody string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenSignature = r.Header.Get("X-PayGate-Signature") + seenTimestamp = r.Header.Get("X-PayGate-Timestamp") + seenEventID = r.Header.Get("X-PayGate-Event-Id") + body, _ := io.ReadAll(r.Body) + seenBody = string(body) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + cfg := config.Config{PaymentTTL: time.Minute, AmountQuarantine: time.Hour, OutgoingWebhookURL: server.URL, OutgoingWebhookSecret: "secret"} + paymentID := createWebhookTestPayment(t, app, cfg, now) + payment, _ := app.FindRecordById("payments", paymentID) + service := NewService(app, cfg) + service.Now = func() time.Time { return now } + if err := service.Schedule(app, "payment.paid", payment, now); err != nil { + t.Fatal(err) + } + processed, err := service.SendPending(context.Background()) + if err != nil || processed != 1 { + t.Fatalf("SendPending() = %d, %v", processed, err) + } + if seenEventID == "" || seenTimestamp == "" || seenBody == "" { + t.Fatalf("missing webhook request fields") + } + if seenSignature != "v1="+Sign("secret", seenTimestamp, []byte(seenBody)) { + t.Fatalf("invalid signature %q", seenSignature) + } + records, _ := app.FindAllRecords("webhook_deliveries") + if len(records) != 1 || records[0].GetString("status") != "delivered" || records[0].GetInt("attempts") != 1 { + t.Fatalf("delivery record = %+v", records) + } +} + +func TestWebhookRetryIsDurableAndEventuallySucceeds(t *testing.T) { + app := webhookTestApp(t) + now := time.Date(2026, 7, 25, 12, 0, 0, 0, time.UTC) + var fail atomic.Bool + fail.Store(true) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if fail.Load() { + http.Error(w, "try later", http.StatusBadGateway) + return + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + cfg := config.Config{PaymentTTL: time.Minute, AmountQuarantine: time.Hour, OutgoingWebhookURL: server.URL, OutgoingWebhookSecret: "secret"} + paymentID := createWebhookTestPayment(t, app, cfg, now) + payment, _ := app.FindRecordById("payments", paymentID) + service := NewService(app, cfg) + service.Now = func() time.Time { return now } + if err := service.Schedule(app, "payment.paid", payment, now); err != nil { + t.Fatal(err) + } + if _, err := service.SendPending(context.Background()); err != nil { + t.Fatal(err) + } + records, _ := app.FindAllRecords("webhook_deliveries") + if records[0].GetString("status") != "failed" || records[0].GetInt("attempts") != 1 { + t.Fatalf("first attempt = status=%s attempts=%d", records[0].GetString("status"), records[0].GetInt("attempts")) + } + + fail.Store(false) + now = now.Add(2 * time.Minute) + if _, err := service.SendPending(context.Background()); err != nil { + t.Fatal(err) + } + records, _ = app.FindAllRecords("webhook_deliveries") + if records[0].GetString("status") != "delivered" || records[0].GetInt("attempts") != 2 { + t.Fatalf("retry = status=%s attempts=%d", records[0].GetString("status"), records[0].GetInt("attempts")) + } +} + +func TestConcurrentWebhookPassesClaimDeliveryOnce(t *testing.T) { + app := webhookTestApp(t) + now := time.Date(2026, 7, 25, 12, 0, 0, 0, time.UTC) + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + time.Sleep(30 * time.Millisecond) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + cfg := config.Config{PaymentTTL: time.Minute, AmountQuarantine: time.Hour, OutgoingWebhookURL: server.URL, OutgoingWebhookSecret: "secret"} + paymentID := createWebhookTestPayment(t, app, cfg, now) + payment, _ := app.FindRecordById("payments", paymentID) + service := NewService(app, cfg) + service.Now = func() time.Time { return now } + if err := service.Schedule(app, "payment.paid", payment, now); err != nil { + t.Fatal(err) + } + + var wg sync.WaitGroup + for range 2 { + wg.Add(1) + go func() { defer wg.Done(); _, _ = service.SendPending(context.Background()) }() + } + wg.Wait() + if got := requests.Load(); got != 1 { + t.Fatalf("HTTP requests = %d; want exactly 1", got) + } +} diff --git a/migrations/20260725000000_paygate.go b/migrations/20260725000000_paygate.go new file mode 100644 index 0000000..1c52fff --- /dev/null +++ b/migrations/20260725000000_paygate.go @@ -0,0 +1,171 @@ +package migrations + +import ( + "database/sql" + + "github.com/pocketbase/pocketbase/core" + pbmigrations "github.com/pocketbase/pocketbase/migrations" + "github.com/pocketbase/pocketbase/tools/types" +) + +func init() { + pbmigrations.Register(func(app core.App) error { + users, err := findOrCreateUsers(app) + if err != nil { + return err + } + _ = users + + payments, err := findOrCreatePayments(app) + if err != nil { + return err + } + if _, err := findOrCreateSMSEvents(app, payments.Id); err != nil { + return err + } + if _, err := findOrCreateWebhookDeliveries(app, payments.Id); err != nil { + return err + } + return nil + }, func(app core.App) error { + for _, name := range []string{"webhook_deliveries", "sms_events", "payments", "users"} { + collection, err := app.FindCollectionByNameOrId(name) + if err != nil { + if err == sql.ErrNoRows { + continue + } + continue + } + if err := app.Delete(collection); err != nil { + return err + } + } + return nil + }) +} + +func findOrCreateUsers(app core.App) (*core.Collection, error) { + if c, err := app.FindCollectionByNameOrId("users"); err == nil { + return c, nil + } + c := core.NewAuthCollection("users") + c.Fields.Add(&core.AutodateField{Name: "created", OnCreate: true}, &core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true}) + // Dashboard accounts are created by a superuser; public self-registration is disabled. + c.CreateRule = nil + c.UpdateRule = nil + c.DeleteRule = nil + c.ListRule = nil + c.ViewRule = nil + if err := app.Save(c); err != nil { + return nil, err + } + return c, nil +} + +func findOrCreatePayments(app core.App) (*core.Collection, error) { + if c, err := app.FindCollectionByNameOrId("payments"); err == nil { + return c, nil + } + c := core.NewBaseCollection("payments") + lockDomainWrites(c) + c.Fields.Add( + &core.DateField{Name: "created_at", Required: true}, + &core.NumberField{Name: "requested_amount", OnlyInt: true, Required: true}, + &core.NumberField{Name: "payable_amount", OnlyInt: true, Required: true}, + &core.SelectField{Name: "status", Values: []string{"pending", "paid", "expired", "cancelled", "late"}, Required: true}, + &core.DateField{Name: "expires_at", Required: true}, + &core.DateField{Name: "reuse_after", Required: true}, + &core.TextField{Name: "rrn", Max: 64}, + &core.TextField{Name: "upi_id", Max: 255}, + &core.TextField{Name: "payer_name", Max: 255}, + &core.DateField{Name: "paid_at"}, + &core.TextField{Name: "external_id", Max: 255}, + &core.TextField{Name: "idempotency_key", Max: 255}, + &core.JSONField{Name: "metadata", MaxSize: 1 << 20}, + &core.AutodateField{Name: "created", OnCreate: true}, + &core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true}, + ) + c.AddIndex("idx_payments_created_at", false, "created_at", "") + c.AddIndex("idx_payments_payable", false, "payable_amount", "") + c.AddIndex("idx_payments_allocation", false, "payable_amount,status,reuse_after", "") + c.AddIndex("idx_payments_expiry", false, "status,expires_at", "status = 'pending'") + c.AddIndex("idx_payments_rrn_nonempty", true, "rrn", "rrn != ''") + c.AddIndex("idx_payments_idempotency_nonempty", true, "idempotency_key", "idempotency_key != ''") + c.AddIndex("idx_payments_external_id", false, "external_id", "external_id != ''") + if err := app.Save(c); err != nil { + return nil, err + } + return c, nil +} + +func findOrCreateSMSEvents(app core.App, paymentsID string) (*core.Collection, error) { + if c, err := app.FindCollectionByNameOrId("sms_events"); err == nil { + return c, nil + } + c := core.NewBaseCollection("sms_events") + lockDomainWrites(c) + c.Fields.Add( + &core.SelectField{Name: "source", Values: []string{"android_webhook", "gmessages", "manual"}, Required: true}, + &core.TextField{Name: "source_event_id", Max: 255}, + &core.TextField{Name: "sender", Max: 255}, + &core.TextField{Name: "body", Max: 64 * 1024, Required: true}, + &core.DateField{Name: "message_time"}, + &core.NumberField{Name: "amount", OnlyInt: true}, + &core.TextField{Name: "rrn", Max: 64}, + &core.TextField{Name: "upi_id", Max: 255}, + &core.TextField{Name: "payer_name", Max: 255}, + &core.SelectField{Name: "processing_status", Values: []string{"received", "parsed", "matched", "duplicate", "unmatched", "ignored", "error"}, Required: true}, + &core.RelationField{Name: "matched_payment", CollectionId: paymentsID, MaxSelect: 1}, + &core.TextField{Name: "error", Max: 4096}, + &core.JSONField{Name: "raw_payload", MaxSize: 1 << 20}, + &core.AutodateField{Name: "created", OnCreate: true}, + &core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true}, + ) + c.AddIndex("idx_sms_source_event_nonempty", true, "source,source_event_id", "source_event_id != ''") + c.AddIndex("idx_sms_processing", false, "processing_status,created", "") + c.AddIndex("idx_sms_rrn", false, "rrn", "rrn != ''") + if err := app.Save(c); err != nil { + return nil, err + } + return c, nil +} + +func findOrCreateWebhookDeliveries(app core.App, paymentsID string) (*core.Collection, error) { + if c, err := app.FindCollectionByNameOrId("webhook_deliveries"); err == nil { + return c, nil + } + c := core.NewBaseCollection("webhook_deliveries") + lockDomainWrites(c) + c.Fields.Add( + &core.TextField{Name: "event_id", Max: 64, Required: true}, + &core.SelectField{Name: "event", Values: []string{"payment.paid", "payment.late", "payment.expired", "payment.cancelled"}, Required: true}, + &core.RelationField{Name: "payment", CollectionId: paymentsID, MaxSelect: 1, Required: true}, + &core.URLField{Name: "url", Required: true}, + &core.TextField{Name: "body", Max: 1 << 20, Required: true}, + &core.NumberField{Name: "attempts", OnlyInt: true}, + &core.SelectField{Name: "status", Values: []string{"pending", "sending", "delivered", "failed", "exhausted"}, Required: true}, + &core.DateField{Name: "next_attempt_at", Required: true}, + &core.DateField{Name: "locked_at"}, + &core.DateField{Name: "last_attempt_at"}, + &core.DateField{Name: "delivered_at"}, + &core.NumberField{Name: "response_code", OnlyInt: true}, + &core.TextField{Name: "last_error", Max: 4096}, + &core.AutodateField{Name: "created", OnCreate: true}, + &core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true}, + ) + c.AddIndex("idx_webhook_due", false, "status,next_attempt_at", "status = 'pending' OR status = 'failed'") + c.AddIndex("idx_webhook_event_id", true, "event_id", "") + if err := app.Save(c); err != nil { + return nil, err + } + return c, nil +} + +func lockDomainWrites(c *core.Collection) { + readRule := "@request.auth.id != '' && @request.auth.collectionName = 'users'" + c.ListRule = types.Pointer(readRule) + c.ViewRule = types.Pointer(readRule) + c.CreateRule = nil + c.UpdateRule = nil + c.DeleteRule = nil +} diff --git a/migrations/migration_test.go b/migrations/migration_test.go new file mode 100644 index 0000000..7b9b5af --- /dev/null +++ b/migrations/migration_test.go @@ -0,0 +1,31 @@ +package migrations + +import ( + "strings" + "testing" + + "github.com/pocketbase/pocketbase/tests" +) + +func TestDomainCollectionsOnlyExposeReadsToOperatorUsers(t *testing.T) { + app, err := tests.NewTestApp() + if err != nil { + t.Fatal(err) + } + defer app.Cleanup() + + for _, name := range []string{"payments", "sms_events", "webhook_deliveries"} { + collection, err := app.FindCollectionByNameOrId(name) + if err != nil { + t.Fatalf("find %s: %v", name, err) + } + for ruleName, rule := range map[string]*string{"list": collection.ListRule, "view": collection.ViewRule} { + if rule == nil || !strings.Contains(*rule, "@request.auth.collectionName = 'users'") { + t.Errorf("%s %s rule = %v; expected users-only auth restriction", name, ruleName, rule) + } + } + if collection.CreateRule != nil || collection.UpdateRule != nil || collection.DeleteRule != nil { + t.Errorf("%s direct write rules must remain locked", name) + } + } +} diff --git a/package-lock.json b/package-lock.json index 8a1a555..fb557a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,92 +1,99 @@ { - "name": "payment-gateway-v2", - "version": "0.1.0", + "name": "paygate-ui", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "payment-gateway-v2", - "version": "0.1.0", + "name": "paygate-ui", + "version": "1.0.0", "dependencies": { - "@fastify/helmet": "^13.0.1", - "@fastify/rate-limit": "^10.3.0", - "@sinclair/typebox": "^0.34.28", - "better-sqlite3": "^11.9.1", - "dotenv": "^16.4.7", - "fastify": "^5.2.1", - "pino": "^9.6.0" + "pocketbase": "0.27.0", + "qrcode": "1.5.4", + "react": "19.2.8", + "react-dom": "19.2.8" }, "devDependencies": { - "@eslint/js": "^9.22.0", - "@types/better-sqlite3": "^7.6.13", - "@types/node": "^22.13.10", - "eslint": "^9.22.0", - "tsx": "^4.19.3", - "typescript": "^5.8.2", - "typescript-eslint": "^8.26.1", - "vitest": "^3.0.8" + "@types/qrcode": "1.5.6", + "@types/react": "19.2.17", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "6.0.4", + "typescript": "7.0.2", + "vite": "8.1.5" }, "engines": { - "node": ">=22" + "node": ">=22.23.1" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", - "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", - "cpu": [ - "ppc64" - ], + "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, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", - "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", - "cpu": [ - "arm" - ], + "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, - "os": [ - "android" - ], - "engines": { - "node": ">=18" + "dependencies": { + "tslib": "^2.4.0" } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", - "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", - "cpu": [ - "arm64" - ], + "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, - "os": [ - "android" - ], - "engines": { - "node": ">=18" + "dependencies": { + "tslib": "^2.4.0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" } }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", - "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "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==", "cpu": [ - "x64" + "arm64" ], "dev": true, "license": "MIT", @@ -95,13 +102,13 @@ "android" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", - "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "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==", "cpu": [ "arm64" ], @@ -112,13 +119,13 @@ "darwin" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", - "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "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==", "cpu": [ "x64" ], @@ -129,15 +136,15 @@ "darwin" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", - "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "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==", "cpu": [ - "arm64" + "x64" ], "dev": true, "license": "MIT", @@ -146,32 +153,32 @@ "freebsd" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", - "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "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==", "cpu": [ - "x64" + "arm" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "freebsd" + "linux" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", - "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "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==", "cpu": [ - "arm" + "arm64" ], "dev": true, "license": "MIT", @@ -180,13 +187,13 @@ "linux" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", - "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "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==", "cpu": [ "arm64" ], @@ -197,15 +204,15 @@ "linux" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", - "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "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==", "cpu": [ - "ia32" + "ppc64" ], "dev": true, "license": "MIT", @@ -214,15 +221,15 @@ "linux" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", - "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "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==", "cpu": [ - "loong64" + "s390x" ], "dev": true, "license": "MIT", @@ -231,15 +238,15 @@ "linux" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", - "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "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==", "cpu": [ - "mips64el" + "x64" ], "dev": true, "license": "MIT", @@ -248,15 +255,15 @@ "linux" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", - "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "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==", "cpu": [ - "ppc64" + "x64" ], "dev": true, "license": "MIT", @@ -265,4427 +272,1391 @@ "linux" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", - "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "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==", "cpu": [ - "riscv64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "openharmony" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", - "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "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": [ - "s390x" + "wasm32" ], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", - "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "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==", "cpu": [ - "x64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", - "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "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==", "cpu": [ - "arm64" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "netbsd" + "win32" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "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/node": { + "version": "22.19.19", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" } }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", - "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "node_modules/@types/react-dom": { + "version": "19.2.3", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", "cpu": [ - "x64" + "ppc64" ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", "optional": true, "os": [ - "netbsd" + "aix" ], "engines": { - "node": ">=18" + "node": ">=16.20.0" } }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", - "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", "optional": true, "os": [ - "openbsd" + "darwin" ], "engines": { - "node": ">=18" + "node": ">=16.20.0" } }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", - "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", "optional": true, "os": [ - "openbsd" + "darwin" ], "engines": { - "node": ">=18" + "node": ">=16.20.0" } }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", - "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", "optional": true, "os": [ - "openharmony" + "freebsd" ], "engines": { - "node": ">=18" + "node": ">=16.20.0" } }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", - "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", "optional": true, "os": [ - "sunos" + "freebsd" ], "engines": { - "node": ">=18" + "node": ">=16.20.0" } }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", - "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", "cpu": [ - "arm64" + "arm" ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", "optional": true, "os": [ - "win32" + "linux" ], "engines": { - "node": ">=18" + "node": ">=16.20.0" } }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", - "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", "cpu": [ - "ia32" + "arm64" ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", "optional": true, "os": [ - "win32" + "linux" ], "engines": { - "node": ">=18" + "node": ">=16.20.0" } }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", - "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", "cpu": [ - "x64" + "loong64" ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", "optional": true, "os": [ - "win32" + "linux" ], "engines": { - "node": ">=18" + "node": ">=16.20.0" } }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + "node": ">=16.20.0" } }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=16.20.0" } }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + "node": ">=16.20.0" } }, - "node_modules/@eslint/config-array": { - "version": "0.21.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", - "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], "dev": true, "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.5" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=16.20.0" } }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], "dev": true, "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=16.20.0" } }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], "dev": true, "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=16.20.0" } }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", - "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.5", - "strip-json-comments": "^3.1.1" - }, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=16.20.0" } }, - "node_modules/@eslint/js": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", - "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" + "node": ">=16.20.0" } }, - "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], "dev": true, "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=16.20.0" } }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@fastify/ajv-compiler": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", - "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "ajv": "^8.12.0", - "ajv-formats": "^3.0.1", - "fast-uri": "^3.0.0" - } - }, - "node_modules/@fastify/ajv-compiler/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@fastify/ajv-compiler/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/@fastify/error": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", - "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/@fastify/fast-json-stringify-compiler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", - "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "fast-json-stringify": "^6.0.0" - } - }, - "node_modules/@fastify/forwarded": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", - "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/@fastify/helmet": { - "version": "13.0.2", - "resolved": "https://registry.npmjs.org/@fastify/helmet/-/helmet-13.0.2.tgz", - "integrity": "sha512-tO1QMkOfNeCt9l4sG/FiWErH4QMm+RjHzbMTrgew1DYOQ2vb/6M1G2iNABBrD7Xq6dUk+HLzWW8u+rmmhQHifA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "fastify-plugin": "^5.0.0", - "helmet": "^8.0.0" - } - }, - "node_modules/@fastify/merge-json-schemas": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", - "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/@fastify/proxy-addr": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", - "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@fastify/forwarded": "^3.0.0", - "ipaddr.js": "^2.1.0" - } - }, - "node_modules/@fastify/rate-limit": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-10.3.0.tgz", - "integrity": "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@lukeed/ms": "^2.0.2", - "fastify-plugin": "^5.0.0", - "toad-cache": "^3.7.0" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", - "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/types": "^0.15.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.8", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", - "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.2", - "@humanfs/types": "^0.15.0", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/types": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", - "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@lukeed/ms": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", - "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@pinojs/redact": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", - "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", - "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", - "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", - "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", - "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", - "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", - "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", - "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", - "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", - "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", - "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", - "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", - "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", - "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", - "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", - "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", - "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", - "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", - "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", - "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", - "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", - "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", - "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", - "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", - "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", - "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@sinclair/typebox": { - "version": "0.34.49", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", - "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", - "license": "MIT" - }, - "node_modules/@types/better-sqlite3": { - "version": "7.6.13", - "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", - "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "22.19.19", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", - "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz", - "integrity": "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.60.0", - "@typescript-eslint/type-utils": "8.60.0", - "@typescript-eslint/utils": "8.60.0", - "@typescript-eslint/visitor-keys": "8.60.0", - "ignore": "^7.0.5", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.60.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==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.0.tgz", - "integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.60.0", - "@typescript-eslint/types": "8.60.0", - "@typescript-eslint/typescript-estree": "8.60.0", - "@typescript-eslint/visitor-keys": "8.60.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.0.tgz", - "integrity": "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.60.0", - "@typescript-eslint/types": "^8.60.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz", - "integrity": "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.60.0", - "@typescript-eslint/visitor-keys": "8.60.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz", - "integrity": "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.0.tgz", - "integrity": "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.60.0", - "@typescript-eslint/typescript-estree": "8.60.0", - "@typescript-eslint/utils": "8.60.0", - "debug": "^4.4.3", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.0.tgz", - "integrity": "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz", - "integrity": "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.60.0", - "@typescript-eslint/tsconfig-utils": "8.60.0", - "@typescript-eslint/types": "8.60.0", - "@typescript-eslint/visitor-keys": "8.60.0", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 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==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.0.tgz", - "integrity": "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.60.0", - "@typescript-eslint/types": "8.60.0", - "@typescript-eslint/typescript-estree": "8.60.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz", - "integrity": "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.60.0", - "eslint-visitor-keys": "^5.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@vitest/expect": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", - "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "3.2.4", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "3.2.4", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.4", - "magic-string": "^0.30.17", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", - "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.4", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/abstract-logging": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", - "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", - "license": "MIT" - }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/atomic-sleep": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", - "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/avvio": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz", - "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@fastify/error": "^4.0.0", - "fastq": "^1.17.1" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/better-sqlite3": { - "version": "11.10.0", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", - "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "bindings": "^1.5.0", - "prebuild-install": "^7.1.1" - } - }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "license": "MIT", - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "license": "MIT", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", - "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.0", - "@esbuild/android-arm": "0.28.0", - "@esbuild/android-arm64": "0.28.0", - "@esbuild/android-x64": "0.28.0", - "@esbuild/darwin-arm64": "0.28.0", - "@esbuild/darwin-x64": "0.28.0", - "@esbuild/freebsd-arm64": "0.28.0", - "@esbuild/freebsd-x64": "0.28.0", - "@esbuild/linux-arm": "0.28.0", - "@esbuild/linux-arm64": "0.28.0", - "@esbuild/linux-ia32": "0.28.0", - "@esbuild/linux-loong64": "0.28.0", - "@esbuild/linux-mips64el": "0.28.0", - "@esbuild/linux-ppc64": "0.28.0", - "@esbuild/linux-riscv64": "0.28.0", - "@esbuild/linux-s390x": "0.28.0", - "@esbuild/linux-x64": "0.28.0", - "@esbuild/netbsd-arm64": "0.28.0", - "@esbuild/netbsd-x64": "0.28.0", - "@esbuild/openbsd-arm64": "0.28.0", - "@esbuild/openbsd-x64": "0.28.0", - "@esbuild/openharmony-arm64": "0.28.0", - "@esbuild/sunos-x64": "0.28.0", - "@esbuild/win32-arm64": "0.28.0", - "@esbuild/win32-ia32": "0.28.0", - "@esbuild/win32-x64": "0.28.0" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", - "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.2", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.5", - "@eslint/js": "9.39.4", - "@eslint/plugin-kit": "^0.4.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.14.0", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.5", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "license": "(MIT OR WTFPL)", - "engines": { - "node": ">=6" - } - }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/fast-decode-uri-component": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", - "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", - "license": "MIT" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-json-stringify": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.4.0.tgz", - "integrity": "sha512-ibRCQ0GZKJIQ+P3Et1h0LhPgp3PMTYk0MH8O+kW3lNYsvmaQww5Nn3f1jf73Q0jR1Yz3a1CDP4/NZD3vOajWJQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@fastify/merge-json-schemas": "^0.2.0", - "ajv": "^8.12.0", - "ajv-formats": "^3.0.1", - "fast-uri": "^3.0.0", - "json-schema-ref-resolver": "^3.0.0", - "rfdc": "^1.2.0" - } - }, - "node_modules/fast-json-stringify/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/fast-json-stringify/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-querystring": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", - "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", - "license": "MIT", - "dependencies": { - "fast-decode-uri-component": "^1.0.1" - } - }, - "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fastify": { - "version": "5.8.5", - "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.5.tgz", - "integrity": "sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@fastify/ajv-compiler": "^4.0.5", - "@fastify/error": "^4.0.0", - "@fastify/fast-json-stringify-compiler": "^5.0.0", - "@fastify/proxy-addr": "^5.0.0", - "abstract-logging": "^2.0.1", - "avvio": "^9.0.0", - "fast-json-stringify": "^6.0.0", - "find-my-way": "^9.0.0", - "light-my-request": "^6.0.0", - "pino": "^9.14.0 || ^10.1.0", - "process-warning": "^5.0.0", - "rfdc": "^1.3.1", - "secure-json-parse": "^4.0.0", - "semver": "^7.6.0", - "toad-cache": "^3.7.0" - } - }, - "node_modules/fastify-plugin": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", - "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "license": "MIT" - }, - "node_modules/find-my-way": { - "version": "9.6.0", - "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz", - "integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-querystring": "^1.0.0", - "safe-regex2": "^5.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", - "dev": true, - "license": "ISC" - }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "license": "MIT" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT" - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/helmet": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.2.0.tgz", - "integrity": "sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/EvanHahn" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" - }, - "node_modules/ipaddr.js": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", - "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-ref-resolver": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", - "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/light-my-request": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", - "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause", - "dependencies": { - "cookie": "^1.0.1", - "process-warning": "^4.0.0", - "set-cookie-parser": "^2.6.0" - } - }, - "node_modules/light-my-request/node_modules/process-warning": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", - "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT" - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "license": "MIT" - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-abi": { - "version": "3.92.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", - "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", - "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/on-exit-leak-free": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", - "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pino": { - "version": "9.14.0", - "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", - "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", - "license": "MIT", - "dependencies": { - "@pinojs/redact": "^0.4.0", - "atomic-sleep": "^1.0.0", - "on-exit-leak-free": "^2.1.0", - "pino-abstract-transport": "^2.0.0", - "pino-std-serializers": "^7.0.0", - "process-warning": "^5.0.0", - "quick-format-unescaped": "^4.0.3", - "real-require": "^0.2.0", - "safe-stable-stringify": "^2.3.1", - "sonic-boom": "^4.0.1", - "thread-stream": "^3.0.0" - }, - "bin": { - "pino": "bin.js" - } - }, - "node_modules/pino-abstract-transport": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", - "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", - "license": "MIT", - "dependencies": { - "split2": "^4.0.0" - } - }, - "node_modules/pino-std-serializers": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", - "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", - "license": "MIT" - }, - "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.12", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", - "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/process-warning": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", - "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/quick-format-unescaped": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", - "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", - "license": "MIT" - }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/rc/node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/real-require": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", - "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", - "license": "MIT", - "engines": { - "node": ">= 12.13.0" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/ret": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", - "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "license": "MIT" - }, - "node_modules/rollup": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", - "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.4", - "@rollup/rollup-android-arm64": "4.60.4", - "@rollup/rollup-darwin-arm64": "4.60.4", - "@rollup/rollup-darwin-x64": "4.60.4", - "@rollup/rollup-freebsd-arm64": "4.60.4", - "@rollup/rollup-freebsd-x64": "4.60.4", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", - "@rollup/rollup-linux-arm-musleabihf": "4.60.4", - "@rollup/rollup-linux-arm64-gnu": "4.60.4", - "@rollup/rollup-linux-arm64-musl": "4.60.4", - "@rollup/rollup-linux-loong64-gnu": "4.60.4", - "@rollup/rollup-linux-loong64-musl": "4.60.4", - "@rollup/rollup-linux-ppc64-gnu": "4.60.4", - "@rollup/rollup-linux-ppc64-musl": "4.60.4", - "@rollup/rollup-linux-riscv64-gnu": "4.60.4", - "@rollup/rollup-linux-riscv64-musl": "4.60.4", - "@rollup/rollup-linux-s390x-gnu": "4.60.4", - "@rollup/rollup-linux-x64-gnu": "4.60.4", - "@rollup/rollup-linux-x64-musl": "4.60.4", - "@rollup/rollup-openbsd-x64": "4.60.4", - "@rollup/rollup-openharmony-arm64": "4.60.4", - "@rollup/rollup-win32-arm64-msvc": "4.60.4", - "@rollup/rollup-win32-ia32-msvc": "4.60.4", - "@rollup/rollup-win32-x64-gnu": "4.60.4", - "@rollup/rollup-win32-x64-msvc": "4.60.4", - "fsevents": "~2.3.2" + "node": ">=16.20.0" } }, - "node_modules/rollup/node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" ], - "license": "MIT" - }, - "node_modules/safe-regex2": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", - "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" ], - "license": "MIT", - "dependencies": { - "ret": "~0.5.0" - }, - "bin": { - "safe-regex2": "bin/safe-regex2.js" - } - }, - "node_modules/safe-stable-stringify": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", - "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", - "license": "MIT", "engines": { - "node": ">=10" + "node": ">=16.20.0" } }, - "node_modules/secure-json-parse": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", - "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" ], - "license": "BSD-3-Clause" - }, - "node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, "engines": { - "node": ">=10" + "node": ">=16.20.0" } }, - "node_modules/set-cookie-parser": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", - "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", - "license": "MIT" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "node_modules/@vitejs/plugin-react": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.4.tgz", + "integrity": "sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg==", "dev": true, "license": "MIT", "dependencies": { - "shebang-regex": "^3.0.0" + "@rolldown/pluginutils": "^1.0.1" }, "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true }, - { - "type": "consulting", - "url": "https://feross.org/support" + "babel-plugin-react-compiler": { + "optional": true } - ], - "license": "MIT", - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" } }, - "node_modules/sonic-boom": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", - "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "license": "MIT", - "dependencies": { - "atomic-sleep": "^1.0.0" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "license": "ISC", "engines": { - "node": ">= 10.x" + "node": ">=8" } }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "license": "MIT", "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", + "color-convert": "^2.0.1" + }, "engines": { "node": ">=8" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, "engines": { - "node": ">=8" + "node": ">=6" } }, - "node_modules/tar-fs": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", - "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", - "license": "MIT", + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" } }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "license": "MIT", "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" + "color-name": "~1.1.4" }, "engines": { - "node": ">=6" - } - }, - "node_modules/thread-stream": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", - "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", - "license": "MIT", - "dependencies": { - "real-require": "^0.2.0" + "node": ">=7.0.0" } }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, - "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "node_modules/csstype": { + "version": "3.2.3", "dev": true, "license": "MIT" }, - "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/toad-cache": { - "version": "3.7.1", - "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.1.tgz", - "integrity": "sha512-5DXWzE4Vz7xNHsv+xQ+MGfJYyC78Aok3tEr0MNwHoRf7vZnga1mQXZ4/Nsodld4VR6Wd+VhfmqnNrsRJyYPfrQ==", - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/ts-api-utils": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", - "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, - "node_modules/tsx": { - "version": "4.22.3", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz", - "integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.28.0" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, "engines": { - "node": ">= 0.8.0" + "node": ">=0.10.0" } }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/typescript-eslint": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.0.tgz", - "integrity": "sha512-9f65qWLZdAW9m1JaxBDUHcqRUfL8bkxxXL7XxEfI+F09q56PkBvIfCjLF3yInsDM/BBmwkqmCQdCZe/RYlIWEw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.60.0", - "@typescript-eslint/parser": "8.60.0", - "@typescript-eslint/typescript-estree": "8.60.0", - "@typescript-eslint/utils": "8.60.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "engines": { + "node": ">=8" } }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", "license": "MIT" }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, - "node_modules/vite": { - "version": "7.3.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", - "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", - "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" + "node": ">=12.0.0" }, "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" + "picomatch": "^3 || ^4" }, "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { + "picomatch": { "optional": true } } }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", "license": "MIT", "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "node": ">=8" } }, - "node_modules/vite/node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", - "cpu": [ - "ppc64" - ], + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, + "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ - "aix" + "darwin" ], "engines": { - "node": ">=18" + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/vite/node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", - "cpu": [ - "arm" - ], - "dev": true, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "license": "MIT", - "optional": true, - "os": [ - "android" - ], "engines": { - "node": ">=18" + "node": ">=8" } }, - "node_modules/vite/node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "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/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": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ "android" ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/vite/node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "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": [ - "x64" + "arm64" ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ - "android" + "darwin" ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/vite/node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "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": [ - "arm64" + "x64" ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ "darwin" ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/vite/node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "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": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ - "darwin" + "freebsd" ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "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": [ - "arm64" + "arm" ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ - "freebsd" + "linux" ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/vite/node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "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": [ - "x64" + "arm64" ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ - "freebsd" + "linux" ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/vite/node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "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": [ - "arm" + "arm64" ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/vite/node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "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": [ - "arm64" + "x64" ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/vite/node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "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": [ - "ia32" + "x64" ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/vite/node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "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": [ - "loong64" + "arm64" ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ - "linux" + "win32" ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/vite/node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "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": [ - "mips64el" + "x64" ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ - "linux" + "win32" ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/vite/node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", - "cpu": [ - "ppc64" + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/vite/node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", - "cpu": [ - "riscv64" - ], - "dev": true, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": ">=10.13.0" } }, - "node_modules/vite/node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", - "cpu": [ - "s390x" - ], + "node_modules/pocketbase": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/pocketbase/-/pocketbase-0.27.0.tgz", + "integrity": "sha512-K5N6d93UP/BNMbMnlZ6BUfy9VPCIvLyqhJFOsNI8OsZwzvKWEAfyD36boi5K4ECIOl5HMlo0TzuaeGdKpMwizQ==", + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, "engines": { - "node": ">=18" + "node": "^10 || ^12 || >=14" } }, - "node_modules/vite/node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, "engines": { - "node": ">=18" + "node": ">=10.13.0" } }, - "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", - "cpu": [ - "arm64" - ], - "dev": true, + "node_modules/react": { + "version": "19.2.8", "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], "engines": { - "node": ">=18" + "node": ">=0.10.0" } }, - "node_modules/vite/node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/react-dom": { + "version": "19.2.8", "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" } }, - "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", - "cpu": [ - "arm64" - ], - "dev": true, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], "engines": { - "node": ">=18" + "node": ">=0.10.0" } }, - "node_modules/vite/node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", - "cpu": [ - "x64" - ], + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, "engines": { - "node": ">=18" - } + "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" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "license": "MIT" }, - "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", - "cpu": [ - "arm64" - ], + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], + "license": "BSD-3-Clause", "engines": { - "node": ">=18" + "node": ">=0.10.0" } }, - "node_modules/vite/node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, "engines": { - "node": ">=18" + "node": ">=8" } }, - "node_modules/vite/node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", - "cpu": [ - "arm64" - ], - "dev": true, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "ansi-regex": "^5.0.1" + }, "engines": { - "node": ">=18" + "node": ">=8" } }, - "node_modules/vite/node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", - "cpu": [ - "ia32" - ], + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, "engines": { - "node": ">=18" + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/vite/node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", - "cpu": [ - "x64" - ], + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } + "license": "0BSD", + "optional": true }, - "node_modules/vite/node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", "dev": true, - "hasInstallScript": true, - "license": "MIT", + "license": "Apache-2.0", "bin": { - "esbuild": "bin/esbuild" + "tsc": "bin/tsc" }, "engines": { - "node": ">=18" + "node": ">=16.20.0" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" } }, - "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "node_modules/undici-types": { + "version": "6.21.0", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", "dev": true, "license": "MIT", "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", - "@vitest/pretty-format": "^3.2.4", - "@vitest/runner": "3.2.4", - "@vitest/snapshot": "3.2.4", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", - "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", - "why-is-node-running": "^2.3.0" + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" }, "bin": { - "vitest": "vitest.mjs" + "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { - "url": "https://opencollective.com/vitest" + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" }, "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.4", - "@vitest/ui": "3.2.4", - "happy-dom": "*", - "jsdom": "*" + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { - "@edge-runtime/vm": { + "@types/node": { "optional": true }, - "@types/debug": { + "@vitejs/devtools": { "optional": true }, - "@types/node": { + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { "optional": true }, - "@vitest/browser": { + "stylus": { + "optional": true + }, + "sugarss": { "optional": true }, - "@vitest/ui": { + "terser": { "optional": true }, - "happy-dom": { + "tsx": { "optional": true }, - "jsdom": { + "yaml": { "optional": true } } }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", "license": "MIT", "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" }, "engines": { "node": ">=8" } }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", "license": "ISC" }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, "engines": { - "node": ">=10" + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=6" } } } diff --git a/package.json b/package.json index 003ae7e..ac1f073 100644 --- a/package.json +++ b/package.json @@ -1,37 +1,29 @@ { - "name": "payment-gateway-v2", - "version": "0.1.0", + "name": "paygate-ui", + "version": "1.0.0", "private": true, "type": "module", "scripts": { - "dev": "tsx watch src/server/index.ts", - "build": "tsc -p tsconfig.json", - "start": "node dist/server/index.js", - "test": "vitest run", - "test:watch": "vitest", - "lint": "eslint .", - "typecheck": "tsc -p tsconfig.json --noEmit" + "dev": "vite --config web/vite.config.ts", + "build": "vite build --config web/vite.config.ts", + "typecheck": "tsc -p web/tsconfig.json --noEmit", + "test": "npm run typecheck && npm run build" }, "dependencies": { - "@fastify/helmet": "^13.0.1", - "@fastify/rate-limit": "^10.3.0", - "@sinclair/typebox": "^0.34.28", - "better-sqlite3": "^11.9.1", - "dotenv": "^16.4.7", - "fastify": "^5.2.1", - "pino": "^9.6.0" + "pocketbase": "0.27.0", + "qrcode": "1.5.4", + "react": "19.2.8", + "react-dom": "19.2.8" }, "devDependencies": { - "@eslint/js": "^9.22.0", - "@types/better-sqlite3": "^7.6.13", - "@types/node": "^22.13.10", - "eslint": "^9.22.0", - "tsx": "^4.19.3", - "typescript": "^5.8.2", - "typescript-eslint": "^8.26.1", - "vitest": "^3.0.8" + "@types/qrcode": "1.5.6", + "@types/react": "19.2.17", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "6.0.4", + "typescript": "7.0.2", + "vite": "8.1.5" }, "engines": { - "node": ">=22" + "node": ">=22.23.1" } } diff --git a/src/server/app.ts b/src/server/app.ts deleted file mode 100644 index 258b4c5..0000000 --- a/src/server/app.ts +++ /dev/null @@ -1,46 +0,0 @@ -import helmet from "@fastify/helmet"; -import rateLimit from "@fastify/rate-limit"; -import Fastify from "fastify"; -import type { Logger } from "pino"; -import type Database from "better-sqlite3"; -import type { Config } from "./config.js"; -import type { DecimalPoolService } from "./services/decimal.service.js"; -import type { TicketService } from "./services/ticket.service.js"; -import type { PaymentService } from "./services/payment.service.js"; -import { errorHandler } from "./middleware/error-handler.js"; -import { registerRequestLogger } from "./middleware/request-logger.js"; -import { registerHealthRoute } from "./routes/health.js"; -import { registerTicketRoutes } from "./routes/ticket.js"; -import { registerWebhookRoute } from "./routes/webhook.js"; - -export interface AppServices { - db: Database.Database; - logger: Logger; - decimalPool: DecimalPoolService; - tickets: TicketService; - payments: PaymentService; -} - -export async function buildApp(config: Config, services: AppServices) { - const app = Fastify({ logger: false, trustProxy: true, bodyLimit: 64 * 1024 }); - app.setErrorHandler(errorHandler); - await app.register(helmet, { - contentSecurityPolicy: { - directives: { - defaultSrc: ["'self'"], - scriptSrc: ["'self'"], - styleSrc: ["'self'", "'unsafe-inline'"], - imgSrc: ["'self'", "data:"], - connectSrc: ["'self'"], - frameAncestors: ["'none'"], - }, - }, - }); - await app.register(rateLimit, { max: 100, timeWindow: "1 minute" }); - - registerRequestLogger(app, services.logger); - await registerHealthRoute(app); - await registerTicketRoutes(app, services); - await registerWebhookRoute(app, config, services); - return app; -} diff --git a/src/server/config.ts b/src/server/config.ts deleted file mode 100644 index 974a20d..0000000 --- a/src/server/config.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { randomBytes } from "node:crypto"; -import { mkdirSync } from "node:fs"; -import { resolve } from "node:path"; -import "dotenv/config"; - -export interface Config { - port: number; - host: string; - dataDir: string; - ticketTtlMinutes: number; - webhookSecret: string; - upiId: string; - upiPayeeName: string; -} - -function env(name: string, fallback = ""): string { - return process.env[name] ?? fallback; -} - -export function loadConfig(): Config { - const dataDir = resolve(env("DATA_DIR", "data")); - mkdirSync(dataDir, { recursive: true }); - - const webhookSecret = env("WEBHOOK_SECRET"); - if (!webhookSecret || webhookSecret.startsWith("change-me")) { - process.stderr.write("WARN: WEBHOOK_SECRET is missing or weak. Set a strong random value before production.\n"); - } - if (!env("UPI_ID")) { - process.stderr.write("WARN: UPI_ID is not set. UPI payment references will not be available.\n"); - } - - return { - port: Number.parseInt(env("PORT", "3000"), 10), - host: env("HOST", "0.0.0.0"), - dataDir, - ticketTtlMinutes: Number.parseInt(env("TICKET_TTL_MINUTES", "2"), 10), - webhookSecret: webhookSecret || randomBytes(24).toString("hex"), - upiId: env("UPI_ID"), - upiPayeeName: env("UPI_PAYEE_NAME"), - }; -} diff --git a/src/server/db/connection.ts b/src/server/db/connection.ts deleted file mode 100644 index 3a5ea4b..0000000 --- a/src/server/db/connection.ts +++ /dev/null @@ -1,22 +0,0 @@ -import Database from "better-sqlite3"; -import { join } from "node:path"; -import type { Config } from "../config.js"; -import { schema } from "./schema.js"; - -export function openDatabase(config: Config): Database.Database { - const db = new Database(join(config.dataDir, "app.db")); - db.pragma("journal_mode = WAL"); - db.pragma("foreign_keys = ON"); - db.pragma("busy_timeout = 5000"); - db.exec(schema); - const integrity = db.pragma("integrity_check", { simple: true }); - if (integrity !== "ok") { - throw new Error(`database integrity_check failed: ${integrity}`); - } - return db; -} - -export function closeDatabase(db: Database.Database): void { - db.pragma("wal_checkpoint(TRUNCATE)"); - db.close(); -} diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts deleted file mode 100644 index cfb8a62..0000000 --- a/src/server/db/schema.ts +++ /dev/null @@ -1,22 +0,0 @@ -export const schema = ` -CREATE TABLE IF NOT EXISTS tickets ( - id TEXT PRIMARY KEY, - amount INTEGER NOT NULL, - status TEXT NOT NULL DEFAULT 'pending' - CHECK(status IN ('pending','paid','cancelled','expired')), - base_amount INTEGER NOT NULL, - decimal_val INTEGER NOT NULL, - sender_name TEXT, - rrn TEXT UNIQUE, - upi_id TEXT, - paid_at TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status); -CREATE INDEX IF NOT EXISTS idx_tickets_amount ON tickets(amount); -CREATE INDEX IF NOT EXISTS idx_tickets_rrn ON tickets(rrn); -CREATE INDEX IF NOT EXISTS idx_tickets_decimal ON tickets(base_amount, decimal_val, status); -CREATE INDEX IF NOT EXISTS idx_tickets_created ON tickets(created_at); -`; diff --git a/src/server/errors.ts b/src/server/errors.ts deleted file mode 100644 index e677ee4..0000000 --- a/src/server/errors.ts +++ /dev/null @@ -1,39 +0,0 @@ -export type ErrorCode = - | "INVALID_AMOUNT" - | "TICKET_NOT_FOUND" - | "POOL_EXHAUSTED" - | "RRN_DUPLICATE" - | "AMOUNT_MISMATCH" - | "TICKET_ALREADY_RESOLVED" - | "WEBHOOK_UNAUTHORIZED" - | "RATE_LIMITED" - | "INTERNAL_ERROR"; - -const statusByCode: Record = { - INVALID_AMOUNT: 400, - TICKET_NOT_FOUND: 404, - POOL_EXHAUSTED: 503, - RRN_DUPLICATE: 409, - AMOUNT_MISMATCH: 400, - TICKET_ALREADY_RESOLVED: 409, - WEBHOOK_UNAUTHORIZED: 401, - RATE_LIMITED: 429, - INTERNAL_ERROR: 500, -}; - -export class AppError extends Error { - public readonly code: ErrorCode; - public readonly statusCode: number; - public readonly details: Record | undefined; - - constructor(code: ErrorCode, message: string, details?: Record) { - super(message); - this.code = code; - this.statusCode = statusByCode[code]; - this.details = details; - } -} - -export function isSqliteUniqueError(error: unknown): boolean { - return error instanceof Error && error.message.includes("UNIQUE constraint failed"); -} diff --git a/src/server/index.ts b/src/server/index.ts deleted file mode 100644 index d6cfd55..0000000 --- a/src/server/index.ts +++ /dev/null @@ -1,41 +0,0 @@ -import pino from "pino"; -import { buildApp } from "./app.js"; -import { loadConfig } from "./config.js"; -import { openDatabase, closeDatabase } from "./db/connection.js"; -import { DecimalPoolService } from "./services/decimal.service.js"; -import { TicketService } from "./services/ticket.service.js"; -import { PaymentService } from "./services/payment.service.js"; -import type { Ticket } from "../types/index.js"; - -const logger = pino({ level: process.env.LOG_LEVEL ?? "info" }); -const config = loadConfig(); -const db = openDatabase(config); -const decimalPool = new DecimalPoolService(db); -const tickets = new TicketService(db, config, decimalPool, logger); -const payments = new PaymentService(db, tickets); - -const expired = db.prepare("UPDATE tickets SET status = 'expired', updated_at = datetime('now') WHERE status = 'pending'").run(); -if (expired.changes > 0) logger.warn({ count: expired.changes }, "Expired stale pending tickets on startup"); - -const rows = db.prepare("SELECT base_amount, decimal_val, status FROM tickets").all() as Array>; -decimalPool.rebuild(rows); - -const app = await buildApp(config, { db, decimalPool, tickets, payments, logger }); - -const close = async () => { - try { - logger.warn("Graceful shutdown started"); - await app.close(); - closeDatabase(db); - } catch (err) { - logger.error({ error: String(err) }, "Shutdown error"); - process.exit(1); - } - process.exit(0); -}; - -process.on("SIGTERM", () => void close()); -process.on("SIGINT", () => void close()); - -await app.listen({ port: config.port, host: config.host }); -logger.info({ port: config.port, host: config.host }, "Server listening"); diff --git a/src/server/middleware/error-handler.ts b/src/server/middleware/error-handler.ts deleted file mode 100644 index 669ef86..0000000 --- a/src/server/middleware/error-handler.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { FastifyError, FastifyReply, FastifyRequest } from "fastify"; -import { AppError } from "../errors.js"; - -export function errorHandler(error: FastifyError | AppError, _request: FastifyRequest, reply: FastifyReply): void { - if (error instanceof AppError) { - reply.status(error.statusCode).send({ - error: { - code: error.code, - message: error.message, - details: error.details, - }, - }); - return; - } - - if ((error as FastifyError).statusCode === 429) { - reply.status(429).send({ - error: { - code: "RATE_LIMITED", - message: "Rate limit exceeded.", - }, - }); - return; - } - - const message = error instanceof Error ? error.stack ?? error.message : String(error); - process.stderr.write(`UNHANDLED ERROR: ${message}\n`); - reply.status(500).send({ - error: { - code: "INTERNAL_ERROR", - message: "Unexpected server error.", - }, - }); -} diff --git a/src/server/middleware/request-logger.ts b/src/server/middleware/request-logger.ts deleted file mode 100644 index f7dbda3..0000000 --- a/src/server/middleware/request-logger.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { FastifyInstance } from "fastify"; -import type { Logger } from "pino"; - -export function registerRequestLogger(app: FastifyInstance, logger: Logger): void { - app.addHook("onResponse", (request, reply, done) => { - logger.info({ - method: request.method, - path: request.url, - status: reply.statusCode, - duration_ms: Math.round(reply.elapsedTime), - }, "HTTP request"); - done(); - }); -} diff --git a/src/server/money.ts b/src/server/money.ts deleted file mode 100644 index 9a419d9..0000000 --- a/src/server/money.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { AppError } from "./errors.js"; - -export function toPaisa(value: number | string): number { - const raw = String(value).trim(); - if (!/^\d+(\.\d{1,2})?$/.test(raw)) { - throw new AppError("INVALID_AMOUNT", "Amount must be a positive number with up to two decimals."); - } - const [rupeesRaw, paisaRaw = ""] = raw.split("."); - const rupees = Number.parseInt(rupeesRaw ?? "0", 10); - const paisa = Number.parseInt(paisaRaw.padEnd(2, "0") || "0", 10); - const total = rupees * 100 + paisa; - if (!Number.isSafeInteger(total) || total <= 0) { - throw new AppError("INVALID_AMOUNT", "Amount must be greater than zero."); - } - return total; -} - -export function fromPaisa(value: number): number { - return Number((value / 100).toFixed(2)); -} - -export function baseAmountFromPaisa(value: number): number { - return Math.floor(value / 100) * 100; -} - -export function decimalFromPaisa(value: number): number { - return value % 100; -} diff --git a/src/server/routes/health.ts b/src/server/routes/health.ts deleted file mode 100644 index 78555c8..0000000 --- a/src/server/routes/health.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { FastifyInstance } from "fastify"; - -export async function registerHealthRoute(app: FastifyInstance): Promise { - app.get("/health", { config: { rateLimit: false } }, async () => ({ - status: "healthy", - uptime: Math.round(process.uptime()), - db: "ok", - })); -} diff --git a/src/server/routes/ticket.ts b/src/server/routes/ticket.ts deleted file mode 100644 index 7484496..0000000 --- a/src/server/routes/ticket.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { FastifyInstance } from "fastify"; -import { Type } from "@sinclair/typebox"; -import type { AppServices } from "../app.js"; -import { toTicketResponse } from "../services/ticket.service.js"; - -export async function registerTicketRoutes(app: FastifyInstance, services: AppServices): Promise { - app.post<{ Body: { amount: number | string } }>( - "/api/ticket", - { - config: { rateLimit: { max: 5, timeWindow: "1 minute" } }, - schema: { - body: Type.Object({ amount: Type.Union([Type.Number(), Type.String()]) }), - }, - }, - async (request) => { - const ticket = services.tickets.createTicket(request.body.amount); - return toTicketResponse(ticket); - }, - ); - - app.get<{ Params: { id: string } }>( - "/api/status/:id", - { config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, - async (request) => { - return toTicketResponse(services.tickets.getTicket(request.params.id)); - }, - ); -} diff --git a/src/server/routes/webhook.ts b/src/server/routes/webhook.ts deleted file mode 100644 index 05d691e..0000000 --- a/src/server/routes/webhook.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { timingSafeEqual } from "node:crypto"; -import type { FastifyInstance } from "fastify"; -import { Type } from "@sinclair/typebox"; -import type { Config } from "../config.js"; -import type { AppServices } from "../app.js"; -import { AppError } from "../errors.js"; -import { toTicketResponse } from "../services/ticket.service.js"; - -function safeEqual(expected: string, actual: string | undefined): boolean { - if (!actual) return false; - const a = Buffer.from(expected); - const b = Buffer.from(actual); - return a.length === b.length && timingSafeEqual(a, b); -} - -export async function registerWebhookRoute(app: FastifyInstance, config: Config, services: AppServices): Promise { - app.post( - "/api/webhook", - { - config: { rateLimit: { max: 30, timeWindow: "1 minute" } }, - schema: { body: Type.Object({ sms: Type.String({ minLength: 1 }) }) }, - }, - async (request) => { - const header = request.headers["x-webhook-secret"]; - const secret = Array.isArray(header) ? header[0] : header; - if (!secret || !safeEqual(config.webhookSecret, secret)) { - services.logger.warn({ ip: request.ip, reason: "bad_secret" }, "Webhook auth failure"); - throw new AppError("WEBHOOK_UNAUTHORIZED", "Invalid webhook secret."); - } - const { sms } = request.body as { sms: string }; - const parsed = services.payments.parseSms(sms); - if (parsed.method === "bank") { - const result = services.payments.confirmFromBankSms(sms); - return { status: "ok", ticketId: result.ticket.id, action: result.action, ticket: toTicketResponse(result.ticket) }; - } - const result = services.payments.fillFromGenericSms(sms); - return { status: "ok", ticketId: result.ticket.id, action: result.action, ticket: toTicketResponse(result.ticket) }; - }, - ); -} - diff --git a/src/server/services/decimal.service.ts b/src/server/services/decimal.service.ts deleted file mode 100644 index ade3f86..0000000 --- a/src/server/services/decimal.service.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type Database from "better-sqlite3"; -import type { Ticket } from "../../types/index.js"; -import { AppError } from "../errors.js"; - -function baseAmountFromPaisa(value: number): number { - return Math.floor(value / 100) * 100; -} - -export class DecimalPoolService { - private readonly pools = new Map>(); - - constructor(private readonly db: Database.Database) {} - - rebuild(tickets: Pick[]): void { - this.pools.clear(); - for (const t of tickets) { - if (t.status !== "pending" && t.status !== "paid") continue; - let set = this.pools.get(t.base_amount); - if (!set) { - set = new Set(); - this.pools.set(t.base_amount, set); - } - set.add(t.decimal_val); - } - } - - allocate(requestedPaisa: number): { amount: number; baseAmount: number; decimalVal: number } { - const base = baseAmountFromPaisa(requestedPaisa); - let set = this.pools.get(base); - if (!set) { - set = new Set(); - this.pools.set(base, set); - } - for (let i = 0; i < 100; i++) { - if (!set.has(i)) { - set.add(i); - return { amount: base + i, baseAmount: base, decimalVal: i }; - } - } - let block = base + 100; - let attempts = 0; - while (attempts < 10_000) { - set = this.pools.get(block); - if (!set) { - set = new Set(); - this.pools.set(block, set); - } - for (let i = 0; i < 100; i++) { - if (!set.has(i)) { - set.add(i); - return { amount: block + i, baseAmount: block, decimalVal: i }; - } - } - block += 100; - attempts++; - } - throw new AppError("POOL_EXHAUSTED", "No decimal slots available for this amount."); - } - - release(baseAmount: number, decimalVal: number): void { - const set = this.pools.get(baseAmount); - if (set) { - set.delete(decimalVal); - if (set.size === 0) this.pools.delete(baseAmount); - } - } -} diff --git a/src/server/services/payment.service.ts b/src/server/services/payment.service.ts deleted file mode 100644 index e6f387f..0000000 --- a/src/server/services/payment.service.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type Database from "better-sqlite3"; -import type { ParsedSms, Ticket } from "../../types/index.js"; -import { AppError } from "../errors.js"; -import { baseAmountFromPaisa, toPaisa } from "../money.js"; -import type { TicketService } from "./ticket.service.js"; - -export class PaymentService { - constructor( - private readonly db: Database.Database, - private readonly tickets: TicketService, - ) {} - - parseSms(sms: string): ParsedSms { - const generic = sms.match(/(TICKET\d+).*?(?:₹|Rs\.?|INR)\s?(\d+(?:\.\d{1,2})?)/i); - if (generic?.[1] && generic[2]) { - const sender = sms.match(/TICKET\d+\s+([A-Za-z][A-Za-z .'-]{1,60}?)\s+paid/i)?.[1]?.trim(); - return { - ticketId: generic[1], - amount: toPaisa(generic[2]), - senderName: sender, - rrn: this.extractRrn(sms), - upiId: this.extractUpi(sms), - method: "generic", - }; - } - - const bank = sms.match(/(?:Received|payment for Received)\s+(?:Rs\.?|₹)\s?(\d+(?:\.\d{1,2})?)/i); - if (bank?.[1]) { - return { - amount: toPaisa(bank[1]), - rrn: this.extractRrn(sms), - upiId: this.extractUpi(sms), - method: "bank", - }; - } - - throw new AppError("INVALID_AMOUNT", 'Unrecognized SMS format. Expected: "TICKET123 Name paid ₹500" or "Received Rs. 500 from Name".'); - } - - confirmFromBankSms(sms: string): { ticket: Ticket; action: string; parsed: ParsedSms } { - const parsed = this.parseSms(sms); - if (parsed.method !== "bank") { - throw new AppError("INVALID_AMOUNT", "Expected bank SMS format."); - } - const baseAmount = baseAmountFromPaisa(parsed.amount); - const matches = this.db - .prepare("SELECT * FROM tickets WHERE base_amount = ? AND status = 'pending' ORDER BY created_at ASC LIMIT 2") - .all(baseAmount) as Ticket[]; - if (matches.length === 0) throw new AppError("TICKET_NOT_FOUND", "No pending ticket matches this payment amount."); - if (matches.length > 1) throw new AppError("AMOUNT_MISMATCH", "Multiple pending tickets match this base amount."); - const ticket = matches[0]!; - const paid = this.tickets.markPaid(ticket.id, { - rrn: parsed.rrn, - upiId: parsed.upiId, - paidAt: new Date().toISOString(), - matchMethod: "bank", - }); - return { ticket: paid, action: "marked_paid", parsed }; - } - - fillFromGenericSms(sms: string): { ticket: Ticket; action: string; parsed: ParsedSms } { - const parsed = this.parseSms(sms); - if (parsed.method !== "generic") { - throw new AppError("INVALID_AMOUNT", "Expected generic SMS format."); - } - if (!parsed.ticketId) throw new AppError("TICKET_NOT_FOUND", "No ticket ID in SMS."); - const ticket = this.tickets.fillSenderName(parsed.ticketId, parsed.senderName); - return { ticket, action: "name_filled", parsed }; - } - - private extractRrn(sms: string): string | undefined { - return sms.match(/(?:UPI\s*Ref|RRN|Ref(?:erence)?)[\s:.#-]*(\d{8,20})/i)?.[1]; - } - - private extractUpi(sms: string): string | undefined { - return sms.match(/[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+/)?.[0]; - } -} diff --git a/src/server/services/ticket.service.ts b/src/server/services/ticket.service.ts deleted file mode 100644 index 6b7374f..0000000 --- a/src/server/services/ticket.service.ts +++ /dev/null @@ -1,209 +0,0 @@ -import type Database from "better-sqlite3"; -import type { Ticket, TicketResponse, TicketStatus } from "../../types/index.js"; -import { AppError, isSqliteUniqueError } from "../errors.js"; -import type { Logger } from "pino"; -import { fromPaisa, toPaisa } from "../money.js"; -import type { Config } from "../config.js"; -import type { DecimalPoolService } from "./decimal.service.js"; - -export class TicketService { - private idCounter = 0; - private readonly createStmt; - private readonly getStmt; - private readonly listStmt; - private readonly updateStatusStmt; - private readonly markPaidStmt; - private readonly updateTicketStmt; - private readonly expiryTimers = new Map(); - private readonly graceTimers = new Map(); - private readonly releaseTimers = new Map(); - - constructor( - private readonly db: Database.Database, - private readonly config: Config, - private readonly decimalPool: DecimalPoolService, - private readonly logger: Logger, - ) { - this.createStmt = db.prepare(` - INSERT INTO tickets (id, amount, status, base_amount, decimal_val) - VALUES (?, ?, 'pending', ?, ?) - `); - this.getStmt = db.prepare("SELECT * FROM tickets WHERE id = ?"); - this.listStmt = db.prepare(` - SELECT * FROM tickets - WHERE (? IS NULL OR status = ?) - AND (? IS NULL OR id LIKE '%' || ? || '%' OR sender_name LIKE '%' || ? || '%' OR rrn LIKE '%' || ? || '%') - ORDER BY created_at DESC - LIMIT ? OFFSET ? - `); - this.updateStatusStmt = db.prepare(` - UPDATE tickets - SET status = ?, updated_at = datetime('now') - WHERE id = ? AND status = 'pending' - `); - this.markPaidStmt = db.prepare(` - UPDATE tickets - SET status = 'paid', - sender_name = COALESCE(?, sender_name), - rrn = COALESCE(?, rrn), - upi_id = COALESCE(?, upi_id), - paid_at = COALESCE(?, datetime('now')), - updated_at = datetime('now') - WHERE id = ? AND status = 'pending' - `); - this.updateTicketStmt = db.prepare(` - UPDATE tickets - SET sender_name = COALESCE(?, sender_name), - rrn = COALESCE(?, rrn), - upi_id = COALESCE(?, upi_id), - updated_at = datetime('now') - WHERE id = ? - `); - } - - createTicket(rawAmount: number | string): Ticket { - const requested = toPaisa(rawAmount); - const allocation = this.decimalPool.allocate(requested); - const id = `TICKET${Date.now()}${(this.idCounter++ % 10000).toString().padStart(4, "0")}`; - this.createStmt.run(id, allocation.amount, allocation.baseAmount, allocation.decimalVal); - const ticket = this.getTicket(id); - const ms = this.config.ticketTtlMinutes * 60_000; - const handle = setTimeout(() => this.onTtlReached(ticket), ms); - this.expiryTimers.set(ticket.id, handle); - this.logger.info({ ticketId: ticket.id, amount: ticket.amount, decimal: ticket.decimal_val }, "Ticket created"); - return ticket; - } - - private onTtlReached(ticket: Ticket): void { - this.expiryTimers.delete(ticket.id); - const handle = setTimeout(() => { - this.graceTimers.delete(ticket.id); - const existing = this.getTicket(ticket.id); - if (existing.status !== "pending") return; - this.updateStatusStmt.run("expired", existing.id); - this.logger.info({ ticketId: existing.id, amount: existing.amount }, "Ticket expired"); - const releaseHandle = setTimeout(() => { - this.releaseTimers.delete(existing.id); - this.decimalPool.release(existing.base_amount, existing.decimal_val); - }, 30_000).unref(); - this.releaseTimers.set(existing.id, releaseHandle); - }, 30_000).unref(); - this.graceTimers.set(ticket.id, handle); - } - - getTicket(id: string): Ticket { - const ticket = this.getStmt.get(id) as Ticket | undefined; - if (!ticket) throw new AppError("TICKET_NOT_FOUND", "Ticket does not exist."); - return ticket; - } - - list(params: { status?: string | undefined; q?: string | undefined; limit?: number | undefined; offset?: number | undefined }): Ticket[] { - const status = params.status || null; - const q = params.q || null; - const limit = Math.min(params.limit ?? 100, 500); - const offset = params.offset ?? 0; - return this.listStmt.all(status, status, q, q, q, q, limit, offset) as Ticket[]; - } - - updateTicket(id: string, fields: { senderName?: string | undefined; rrn?: string | undefined; upiId?: string | undefined }): Ticket { - const existing = this.getTicket(id); - try { - this.updateTicketStmt.run(fields.senderName ?? null, fields.rrn ?? null, fields.upiId ?? null, id); - } catch (error) { - if (isSqliteUniqueError(error)) throw new AppError("RRN_DUPLICATE", "RRN has already been processed."); - throw error; - } - const ticket = this.getTicket(existing.id); - return ticket; - } - - markPaid( - id: string, - fields: { - senderName?: string | undefined; - rrn?: string | undefined; - upiId?: string | undefined; - paidAt?: string | undefined; - matchMethod?: string | undefined; - } = {}, - ): Ticket { - const existing = this.getTicket(id); - if (existing.status !== "pending") { - throw new AppError("TICKET_ALREADY_RESOLVED", "Ticket is already resolved."); - } - try { - this.markPaidStmt.run(fields.senderName ?? null, fields.rrn ?? null, fields.upiId ?? null, fields.paidAt ?? null, id); - } catch (error) { - if (isSqliteUniqueError(error)) throw new AppError("RRN_DUPLICATE", "RRN has already been processed."); - throw error; - } - this.clearTimers(id); - const ticket = this.getTicket(id); - this.decimalPool.release(ticket.base_amount, ticket.decimal_val); - this.logger.info({ - ticketId: ticket.id, - amount: ticket.amount, - sender: ticket.sender_name, - rrn: ticket.rrn, - match_method: fields.matchMethod ?? "manual", - }, "Payment confirmed"); - return ticket; - } - - cancelTicket(id: string): Ticket { - const existing = this.getTicket(id); - if (existing.status !== "pending") throw new AppError("TICKET_ALREADY_RESOLVED", "Ticket is already resolved."); - this.updateStatusStmt.run("cancelled", id); - this.clearTimers(id); - const ticket = this.getTicket(id); - const handle = setTimeout(() => { - this.releaseTimers.delete(id); - this.decimalPool.release(ticket.base_amount, ticket.decimal_val); - }, 30_000).unref(); - this.releaseTimers.set(id, handle); - this.logger.info({ ticketId: ticket.id, amount: ticket.amount }, "Ticket cancelled"); - return ticket; - } - - fillSenderName(id: string, senderName: string | undefined): Ticket { - if (!senderName) return this.getTicket(id); - this.updateTicketStmt.run(senderName, null, null, id); - return this.getTicket(id); - } - - private clearTimers(id: string): void { - const e = this.expiryTimers.get(id); - if (e) { clearTimeout(e); this.expiryTimers.delete(id); } - const g = this.graceTimers.get(id); - if (g) { clearTimeout(g); this.graceTimers.delete(id); } - const r = this.releaseTimers.get(id); - if (r) { clearTimeout(r); this.releaseTimers.delete(id); } - } - - stats(): Record { - const rows = this.db - .prepare("SELECT status, COUNT(*) as count, COALESCE(SUM(amount), 0) as total FROM tickets GROUP BY status") - .all() as Array<{ status: TicketStatus; count: number; total: number }>; - const stats: Record = { total: 0, pending: 0, paid: 0, cancelled: 0, expired: 0, revenue: 0 }; - for (const row of rows) { - stats[row.status] = row.count; - stats.total = (stats.total ?? 0) + row.count; - if (row.status === "paid") stats.revenue = row.total; - } - return stats; - } -} - -export function toTicketResponse(ticket: Ticket): TicketResponse { - return { - ticketId: ticket.id, - amount: fromPaisa(ticket.amount), - amountPaisa: ticket.amount, - status: ticket.status, - createdAt: ticket.created_at, - paidAt: ticket.paid_at, - senderName: ticket.sender_name, - rrn: ticket.rrn, - upiId: ticket.upi_id, - }; -} diff --git a/src/types/index.ts b/src/types/index.ts deleted file mode 100644 index c14f80b..0000000 --- a/src/types/index.ts +++ /dev/null @@ -1,36 +0,0 @@ -export type TicketStatus = "pending" | "paid" | "cancelled" | "expired"; - -export interface Ticket { - id: string; - amount: number; - status: TicketStatus; - base_amount: number; - decimal_val: number; - sender_name: string | null; - rrn: string | null; - upi_id: string | null; - paid_at: string | null; - created_at: string; - updated_at: string; -} - -export interface TicketResponse { - ticketId: string; - amount: number; - amountPaisa: number; - status: TicketStatus; - createdAt: string; - paidAt?: string | null; - senderName?: string | null; - rrn?: string | null; - upiId?: string | null; -} - -export interface ParsedSms { - ticketId?: string | undefined; - amount: number; - senderName?: string | undefined; - rrn?: string | undefined; - upiId?: string | undefined; - method: "generic" | "bank"; -} diff --git a/test/decimal.test.ts b/test/decimal.test.ts deleted file mode 100644 index 59d7d60..0000000 --- a/test/decimal.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { afterEach, describe, expect, it } from "vitest"; -import { withServices } from "./helpers.js"; - -let ctx: ReturnType | undefined; - -afterEach(() => { - ctx?.cleanup(); - ctx = undefined; -}); - -describe("decimal allocation", () => { - it("allocates 100 unique slots then moves into the next integer block", () => { - ctx = withServices(); - const tickets = Array.from({ length: 101 }, () => ctx!.services.tickets.createTicket(100)); - expect(new Set(tickets.map((ticket) => ticket.amount)).size).toBe(101); - expect(tickets[0]!.amount).toBe(10000); - expect(tickets[99]!.amount).toBe(10099); - expect(tickets[100]!.amount).toBe(10100); - }); - - it("expires pending tickets on recovery", () => { - ctx = withServices(); - ctx.services.tickets.createTicket(100); - ctx.db.prepare("UPDATE tickets SET status = 'expired', updated_at = datetime('now') WHERE status = 'pending'").run(); - expect(ctx.services.tickets.list({ status: "expired" })).toHaveLength(1); - }); -}); diff --git a/test/helpers.ts b/test/helpers.ts deleted file mode 100644 index 51d3bea..0000000 --- a/test/helpers.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import pino from "pino"; -import type { Config } from "../src/server/config.js"; -import { closeDatabase, openDatabase } from "../src/server/db/connection.js"; -import { DecimalPoolService } from "../src/server/services/decimal.service.js"; -import { TicketService } from "../src/server/services/ticket.service.js"; -import { PaymentService } from "../src/server/services/payment.service.js"; - -export function withServices() { - const dir = mkdtempSync(join(tmpdir(), "pg-v2-")); - const config: Config = { - port: 0, - host: "127.0.0.1", - dataDir: dir, - ticketTtlMinutes: 2, - webhookSecret: "test-webhook-secret", - upiId: "test@upi", - upiPayeeName: "Test", - }; - const logger = pino({ level: "silent" }); - const db = openDatabase(config); - const decimalPool = new DecimalPoolService(db); - const tickets = new TicketService(db, config, decimalPool, logger); - const payments = new PaymentService(db, tickets); - const rows = db.prepare("SELECT base_amount, decimal_val, status FROM tickets").all() as Array<{ base_amount: number; decimal_val: number; status: string }>; - decimalPool.rebuild(rows); - return { - config, - db, - services: { db, decimalPool, tickets, payments, logger }, - cleanup: () => { - closeDatabase(db); - rmSync(dir, { recursive: true, force: true }); - }, - }; -} - -export async function withApp() { - const context = withServices(); - const { buildApp } = await import("../src/server/app.js"); - const app = await buildApp(context.config, context.services); - await app.ready(); - return { - ...context, - app, - cleanup: async () => { - await app.close(); - context.cleanup(); - }, - }; -} diff --git a/test/money.test.ts b/test/money.test.ts deleted file mode 100644 index df2a19c..0000000 --- a/test/money.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { fromPaisa, toPaisa } from "../src/server/money.js"; - -describe("money helpers", () => { - it("converts decimal rupees to integer paisa", () => { - expect(toPaisa(100)).toBe(10000); - expect(toPaisa("100.03")).toBe(10003); - expect(toPaisa("1.5")).toBe(150); - }); - - it("rejects invalid amounts", () => { - expect(() => toPaisa("0")).toThrow(); - expect(() => toPaisa("10.999")).toThrow(); - expect(() => toPaisa("abc")).toThrow(); - }); - - it("converts paisa to display amount", () => { - expect(fromPaisa(10003)).toBe(100.03); - }); -}); diff --git a/test/payment.test.ts b/test/payment.test.ts deleted file mode 100644 index daf4522..0000000 --- a/test/payment.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { afterEach, describe, expect, it } from "vitest"; -import { withServices } from "./helpers.js"; - -let ctx: ReturnType | undefined; - -afterEach(() => { - ctx?.cleanup(); - ctx = undefined; -}); - -describe("payment matching", () => { - it("parses generic SMS and fills sender name", () => { - ctx = withServices(); - const ticket = ctx.services.tickets.createTicket(100); - const result = ctx.services.payments.fillFromGenericSms(`${ticket.id} SOURAV paid you ₹100.00 UPI Ref:606703736479`); - expect(result.action).toBe("name_filled"); - expect(result.ticket.sender_name).toBe("SOURAV"); - expect(result.ticket.status).toBe("pending"); - }); - - it("parses bank SMS and marks ticket paid", () => { - ctx = withServices(); - const ticket = ctx.services.tickets.createTicket(100); - const result = ctx.services.payments.confirmFromBankSms( - "Confirmed payment for Received Rs.100.00 in your Kotak Bank AC X4959 from user@oksbi on 08-03-26.UPI Ref:606703736480.", - ); - expect(result.ticket.id).toBe(ticket.id); - expect(result.ticket.status).toBe("paid"); - expect(result.ticket.upi_id).toBe("user@oksbi"); - }); - - it("rejects duplicate RRNs", () => { - ctx = withServices(); - const one = ctx.services.tickets.createTicket(100); - const two = ctx.services.tickets.createTicket(100); - ctx.services.tickets.markPaid(one.id, { rrn: "606703736481" }); - expect(() => ctx!.services.tickets.markPaid(two.id, { rrn: "606703736481" })).toThrow(); - }); - - it("reuses paid decimals immediately", () => { - ctx = withServices(); - const first = ctx.services.tickets.createTicket(100); - ctx.services.tickets.markPaid(first.id, { rrn: "111122223333" }); - const second = ctx.services.tickets.createTicket(100); - expect(second.decimal_val).toBe(first.decimal_val); - expect(second.amount).toBe(first.amount); - }); -}); diff --git a/test/routes.test.ts b/test/routes.test.ts deleted file mode 100644 index 28e2ea8..0000000 --- a/test/routes.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { afterEach, describe, expect, it } from "vitest"; -import { withApp } from "./helpers.js"; - -let ctx: Awaited> | undefined; - -afterEach(async () => { - await ctx?.cleanup(); - ctx = undefined; -}); - -describe("HTTP routes", () => { - it("creates a ticket, reads status, and confirms via webhook", async () => { - ctx = await withApp(); - const create = await ctx.app.inject({ - method: "POST", - url: "/api/ticket", - payload: { amount: 100 }, - }); - expect(create.statusCode).toBe(200); - const ticket = create.json<{ ticketId: string; amount: number }>(); - expect(ticket.amount).toBe(100); - - const status = await ctx.app.inject({ method: "GET", url: `/api/status/${ticket.ticketId}` }); - expect(status.statusCode).toBe(200); - expect(status.json<{ status: string }>().status).toBe("pending"); - - const webhook = await ctx.app.inject({ - method: "POST", - url: "/api/webhook", - headers: { "x-webhook-secret": ctx.config.webhookSecret }, - payload: { sms: `Confirmed payment for Received Rs.100.00 in your Kotak Bank AC X4959 from user@paytm on 01-01-26.UPI Ref:606703736499.` }, - }); - expect(webhook.statusCode).toBe(200); - expect(webhook.json<{ ticketId: string }>().ticketId).toBe(ticket.ticketId); - }); - - it("rejects webhook calls without the shared secret", async () => { - ctx = await withApp(); - const response = await ctx.app.inject({ - method: "POST", - url: "/api/webhook", - payload: { sms: "Confirmed payment for Received Rs.100.00 UPI Ref:606703736500." }, - }); - expect(response.statusCode).toBe(401); - }); -}); diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index ac5c2b2..0000000 --- a/tsconfig.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "lib": ["ES2022", "DOM"], - "module": "NodeNext", - "moduleResolution": "NodeNext", - "rootDir": "src", - "outDir": "dist", - "strict": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "skipLibCheck": true, - "resolveJsonModule": true, - "noUncheckedIndexedAccess": true, - "exactOptionalPropertyTypes": true - }, - "include": ["src/**/*.ts"], - "exclude": ["src/admin", "dist", "node_modules"] -} diff --git a/vitest.config.ts b/vitest.config.ts deleted file mode 100644 index 0f1eb0d..0000000 --- a/vitest.config.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - environment: "node", - include: ["test/**/*.test.ts"], - fileParallelism: false, - coverage: { - reporter: ["text", "html"], - }, - }, -}); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..e2421dd --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + PayGate + + +
+ + + diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..0b5088e --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,76 @@ +import { useEffect, useMemo, useState } from "react"; +import { pb } from "./pb"; +import type { Page } from "./types"; +import { Dashboard } from "./pages/Dashboard"; +import { Login } from "./pages/Login"; +import { Payments } from "./pages/Payments"; +import { SMSEvents, WebhookDeliveries } from "./pages/Records"; +import { Settings } from "./pages/Settings"; + +const pages: Page[] = ["dashboard", "payments", "sms", "webhooks", "settings"]; + +function pageFromHash(): Page { + const value = window.location.hash.replace(/^#\/?/, "") as Page; + return pages.includes(value) ? value : "dashboard"; +} + +export function App() { + const [loggedIn, setLoggedIn] = useState(pb.authStore.isValid); + const [page, setPage] = useState(pageFromHash()); + const [notice, setNotice] = useState(""); + + useEffect(() => pb.authStore.onChange(() => setLoggedIn(pb.authStore.isValid)), []); + useEffect(() => { + if (!pb.authStore.token) return; + const refreshAuth = async () => { + try { await pb.collection("users").authRefresh(); } catch { pb.authStore.clear(); } + }; + void refreshAuth(); + const timer = window.setInterval(() => void refreshAuth(), 10 * 60_000); + return () => window.clearInterval(timer); + }, [loggedIn]); + useEffect(() => { + const handler = () => setPage(pageFromHash()); + window.addEventListener("hashchange", handler); + return () => window.removeEventListener("hashchange", handler); + }, []); + useEffect(() => { + if (!notice) return; + const timer = window.setTimeout(() => setNotice(""), 5000); + return () => window.clearTimeout(timer); + }, [notice]); + + const title = useMemo(() => label(page), [page]); + if (!loggedIn) return ; + + function navigate(next: Page) { + window.location.hash = `/${next}`; + setPage(next); + } + + return
+ +
+

PAYMENT OPERATIONS

{title}

+ {notice &&
setNotice("")}>{notice}
} + {page === "dashboard" && } + {page === "payments" && } + {page === "sms" && } + {page === "webhooks" && } + {page === "settings" && } +
+
; +} + +function label(value: string) { + if (value === "sms") return "SMS Events"; + return value.charAt(0).toUpperCase() + value.slice(1); +} diff --git a/web/src/components/common.tsx b/web/src/components/common.tsx new file mode 100644 index 0000000..7fe1635 --- /dev/null +++ b/web/src/components/common.tsx @@ -0,0 +1,25 @@ +import type { ReactNode } from "react"; + +export function Badge({ status }: { status?: string }) { + const value = status || "unknown"; + return {value}; +} + +export function Stat({ label, value, tone = "" }: { label: string; value: number; tone?: string }) { + return
{label}{value}
; +} + +export function Modal({ title, onClose, children }: { title: string; onClose: () => void; children: ReactNode }) { + return
+
event.stopPropagation()}> +

{title}

+ {children} +
+
; +} + +export function formatDate(value?: string) { + if (!value) return "—"; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..c4b26d6 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,8 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App"; +import "./styles.css"; + +const root = document.getElementById("root"); +if (!root) throw new Error("missing #root element"); +createRoot(root).render(); diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx new file mode 100644 index 0000000..e721019 --- /dev/null +++ b/web/src/pages/Dashboard.tsx @@ -0,0 +1,54 @@ +import { useCallback, useEffect, useState } from "react"; +import { Badge, Stat } from "../components/common"; +import { api, pb } from "../pb"; +import type { DashboardData } from "../types"; +import { PaymentTable } from "./Payments"; + +export function Dashboard() { + const [data, setData] = useState(null); + const [error, setError] = useState(""); + + const load = useCallback(async () => { + try { + setData(await api("/api/dashboard")); + setError(""); + } catch (err) { + setError(err instanceof Error ? err.message : "Could not load dashboard"); + } + }, []); + + useEffect(() => { + void load(); + let disposed = false; + let unsubscribe: (() => void) | undefined; + void pb.collection("payments").subscribe("*", () => void load()).then((fn) => { + if (disposed) void fn(); else unsubscribe = fn; + }); + const timer = window.setInterval(() => void load(), 30_000); + return () => { disposed = true; unsubscribe?.(); window.clearInterval(timer); }; + }, [load]); + + const stats = data?.stats ?? {}; + const connector = data?.connector; + return <> + {error &&

{error}

} +
+ + + + +
+
+
+

GOOGLE MESSAGES

+

{connector?.enabled ? connector.state : "disabled"}

+

{connector?.lastError || (connector?.phoneResponsive ? "Phone responding" : "The legacy SMS webhook remains available")}

+
+ +
+
+

Recent payments

Realtime updates
+ +
+ ; +} diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx new file mode 100644 index 0000000..80ebdcb --- /dev/null +++ b/web/src/pages/Login.tsx @@ -0,0 +1,34 @@ +import { useState, type FormEvent } from "react"; +import { pb } from "../pb"; + +export function Login() { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + + async function submit(event: FormEvent) { + event.preventDefault(); + setBusy(true); + setError(""); + try { + await pb.collection("users").authWithPassword(email.trim(), password); + } catch { + setError("Login failed. Check the operator credentials."); + } finally { + setBusy(false); + } + } + + return
+
+
PAYGATE
+

Operator sign in

+

Payment verification, SMS evidence and connector health.

+ + + {error &&

{error}

} + +
+
; +} diff --git a/web/src/pages/Payments.tsx b/web/src/pages/Payments.tsx new file mode 100644 index 0000000..217df03 --- /dev/null +++ b/web/src/pages/Payments.tsx @@ -0,0 +1,129 @@ +import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from "react"; +import { Badge, formatDate, Modal } from "../components/common"; +import { api, pb } from "../pb"; +import type { Payment, PaymentCreateResponse } from "../types"; + +export function Payments({ notify }: { notify: (value: string) => void }) { + const [amount, setAmount] = useState("100"); + const [externalId, setExternalId] = useState(""); + const [creating, setCreating] = useState(false); + const [created, setCreated] = useState(null); + const retryIdempotencyKey = useRef(null); + + async function create(event: FormEvent) { + event.preventDefault(); + if (!/^\d+$/.test(amount) || Number(amount) <= 0) { + notify("Requested amount must be a positive whole number of rupees."); + return; + } + setCreating(true); + try { + const idempotencyKey = retryIdempotencyKey.current ?? crypto.randomUUID(); + retryIdempotencyKey.current = idempotencyKey; + const result = await api("/api/payments", { + method: "POST", + headers: { "Idempotency-Key": idempotencyKey }, + body: JSON.stringify({ amount, externalId: externalId.trim() || undefined }), + }); + setCreated(result); + retryIdempotencyKey.current = null; + notify("Payment created."); + } catch (err) { + notify(err instanceof Error ? err.message : "Payment creation failed."); + } finally { + setCreating(false); + } + } + + return <> +
+

CREATE PAYMENT

Generate a DDM payable amount

+
+ + + +
+ {created &&
+ ₹{created.payableAmount} + Requested ₹{created.requestedAmount} · expires {formatDate(created.expiresAt)} + {created.upiUri} +
} +
+
+

Payments

Select a row for evidence and actions
+ +
+ ; +} + +export function PaymentTable({ limit, notify }: { limit: number; notify?: (value: string) => void }) { + const [records, setRecords] = useState([]); + const [selected, setSelected] = useState(null); + const [error, setError] = useState(""); + + const load = useCallback(async () => { + try { + const result = await pb.collection("payments").getList(1, limit, { sort: "-created" }); + setRecords(result.items); + if (selected) { + const refreshed = result.items.find((item) => item.id === selected.id); + if (refreshed) setSelected(refreshed); + } + setError(""); + } catch (err) { + setError(err instanceof Error ? err.message : "Could not load payments"); + } + }, [limit, selected?.id]); + + useEffect(() => { + void load(); + let disposed = false; + let unsubscribe: (() => void) | undefined; + void pb.collection("payments").subscribe("*", () => void load()).then((fn) => { + if (disposed) void fn(); else unsubscribe = fn; + }); + return () => { disposed = true; unsubscribe?.(); }; + }, [load]); + + async function cancel(payment: Payment) { + try { + await api(`/api/payments/${payment.id}/cancel`, { method: "POST" }); + notify?.("Payment cancelled."); + await load(); + } catch (err) { + notify?.(err instanceof Error ? err.message : "Cancel failed."); + } + } + + if (error) return

{error}

; + if (!records.length) return

No payments yet.

; + + return <> +
+ {records.map((record) => setSelected(record)}> + + + + )}
PaymentRequestedPayableStatusExpires
{record.id}{record.external_id || "—"}₹{record.requested_amount / 100}₹{(record.payable_amount / 100).toFixed(2)}{formatDate(record.expires_at)}
+ {selected && setSelected(null)}> +
+ + ₹{selected.requested_amount / 100} + ₹{(selected.payable_amount / 100).toFixed(2)} + {formatDate(selected.created)} + {formatDate(selected.expires_at)} + {formatDate(selected.reuse_after)} + {selected.rrn || "—"} + {selected.upi_id || "—"} + {selected.payer_name || "—"} + {formatDate(selected.paid_at)} + {selected.external_id || "—"} +
+ {selected.status === "pending" &&
} +
} + ; +} + +function Detail({ label, children }: { label: string; children: ReactNode }) { + return
{label}
{children}
; +} diff --git a/web/src/pages/Records.tsx b/web/src/pages/Records.tsx new file mode 100644 index 0000000..161855a --- /dev/null +++ b/web/src/pages/Records.tsx @@ -0,0 +1,59 @@ +import { useCallback, useEffect, useState } from "react"; +import type { RecordModel } from "pocketbase"; +import { formatDate } from "../components/common"; +import { pb } from "../pb"; + +const smsFields = ["source", "source_event_id", "message_time", "sender", "body", "amount", "rrn", "upi_id", "payer_name", "processing_status", "matched_payment", "error"]; +const webhookFields = ["event_id", "event", "payment", "status", "attempts", "response_code", "next_attempt_at", "last_attempt_at", "delivered_at", "last_error"]; + +export function SMSEvents() { + return ; +} + +export function WebhookDeliveries() { + return ; +} + +function Records({ collection, title, fields }: { collection: string; title: string; fields: string[] }) { + const [records, setRecords] = useState([]); + const [error, setError] = useState(""); + + const load = useCallback(async () => { + try { + const result = await pb.collection(collection).getList(1, 100, { sort: "-created" }); + setRecords(result.items); + setError(""); + } catch (err) { + setError(err instanceof Error ? err.message : `Could not load ${collection}`); + } + }, [collection]); + + useEffect(() => { + void load(); + let disposed = false; + let unsubscribe: (() => void) | undefined; + void pb.collection(collection).subscribe("*", () => void load()).then((fn) => { + if (disposed) void fn(); else unsubscribe = fn; + }); + return () => { disposed = true; unsubscribe?.(); }; + }, [collection, load]); + + return
+

{title}

+ {error &&

{error}

} + {!error && !records.length &&

No records yet.

} +
{records.map((record) =>
+
{record.id}{formatDate(String(record.created || ""))}
+ {fields.map((field) => renderField(record, field))} +
)}
+
; +} + +function renderField(record: RecordModel, field: string) { + const value = record[field]; + if (value === undefined || value === null || value === "") return null; + const display = field === "amount" && typeof value === "number" + ? `₹${(value / 100).toFixed(2)} (${value} paise)` + : typeof value === "object" ? JSON.stringify(value) : String(value); + return
{field}{display}
; +} diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx new file mode 100644 index 0000000..3098571 --- /dev/null +++ b/web/src/pages/Settings.tsx @@ -0,0 +1,140 @@ +import QRCode from "qrcode"; +import { useCallback, useEffect, useState } from "react"; +import { Badge, formatDate } from "../components/common"; +import { api } from "../pb"; +import type { Connector } from "../types"; + +type SafeConfig = { + upiId: string; + upiPayeeName: string; + paymentTtlSeconds: number; + quarantineSeconds: number; + webhookConfigured: boolean; + rateLimitsEnabled: boolean; + legacySMSWebhookEnabled: boolean; + connector: Connector; +}; + +type PairResponse = { qrUrl: string; status: Connector }; + +export function Settings({ notify }: { notify: (value: string) => void }) { + const [config, setConfig] = useState(null); + const [connector, setConnector] = useState(null); + const [qrUrl, setQrUrl] = useState(""); + const [qrImage, setQrImage] = useState(""); + const [busy, setBusy] = useState(false); + + const refresh = useCallback(async () => { + try { + const [cfg, status] = await Promise.all([ + api("/api/config"), + api("/api/connector/gmessages/status"), + ]); + setConfig(cfg); + setConnector(status); + if (status.state !== "pairing" && status.paired) { + setQrUrl(""); + setQrImage(""); + } + } catch (err) { + notify(err instanceof Error ? err.message : "Could not load settings."); + } + }, [notify]); + + useEffect(() => { + void refresh(); + const timer = window.setInterval(() => void refresh(), 10_000); + return () => window.clearInterval(timer); + }, [refresh]); + + useEffect(() => { + if (!qrUrl) { setQrImage(""); return; } + void QRCode.toDataURL(qrUrl, { width: 420, margin: 2, errorCorrectionLevel: "L" }).then(setQrImage).catch(() => setQrImage("")); + }, [qrUrl]); + + useEffect(() => { + if (!qrUrl || connector?.state !== "pairing") return; + const timer = window.setInterval(async () => { + try { + const result = await api("/api/connector/gmessages/pair/refresh", { method: "POST" }); + setQrUrl(result.qrUrl); + setConnector(result.status); + } catch (err) { + notify(err instanceof Error ? err.message : "Could not refresh pairing QR."); + } + }, 20_000); + return () => window.clearInterval(timer); + }, [qrUrl, connector?.state, notify]); + + async function startPairing() { + setBusy(true); + try { + const result = await api("/api/connector/gmessages/pair", { method: "POST" }); + setQrUrl(result.qrUrl); + setConnector(result.status); + notify("Pairing started. Scan the QR from Google Messages."); + } catch (err) { + notify(err instanceof Error ? err.message : "Pairing could not start."); + } finally { + setBusy(false); + } + } + + async function reconnect() { + setBusy(true); + try { + setConnector(await api("/api/connector/gmessages/reconnect", { method: "POST" })); + notify("Reconnect requested."); + } catch (err) { + notify(err instanceof Error ? err.message : "Reconnect failed."); + } finally { setBusy(false); } + } + + async function unpair() { + if (!window.confirm("Remove the stored Google Messages pairing from PayGate?")) return; + setBusy(true); + try { + setConnector(await api("/api/connector/gmessages/pair", { method: "DELETE" })); + setQrUrl(""); setQrImage(""); + notify("Google Messages unpaired."); + } catch (err) { + notify(err instanceof Error ? err.message : "Unpair failed."); + } finally { setBusy(false); } + } + + const enabled = connector?.enabled ?? false; + return <> +
+
+

GOOGLE MESSAGES

{enabled ? connector?.state ?? "loading" : "disabled"}

+ +
+

{connector?.lastError || "Read-only SMS connector. The authenticated Android SMS webhook remains available as fallback."}

+
+
Paired
{connector?.paired ? "Yes" : "No"}
+
Phone responsive
{connector?.phoneResponsive ? "Yes" : "No / unknown"}
+
Last connected
{formatDate(connector?.lastConnectedAt)}
+
Last bank SMS
{formatDate(connector?.lastMessageAt)}
+
+
+ + + +
+ {qrImage &&
Google Messages pairing QR

Google Messages → Device pairing → Switch to QR pairing → scan this code.

The QR refreshes automatically before the token expires.

} +
+ +
+

SAFE CONFIGURATION

+ {config ?
+
UPI ID
{config.upiId}
+
Payee name
{config.upiPayeeName}
+
Payment TTL
{config.paymentTtlSeconds}s
+
Amount quarantine
{Math.round(config.quarantineSeconds / 3600)}h
+
Outgoing webhook
{config.webhookConfigured ? "Configured" : "Disabled"}
+
API rate limits
{config.rateLimitsEnabled ? "Enabled" : "Disabled"}
+
Legacy /api/webhook
{config.legacySMSWebhookEnabled ? "Enabled (migration only)" : "Disabled"}
+
:

Loading configuration…

} +
+ ; +} diff --git a/web/src/pb.ts b/web/src/pb.ts new file mode 100644 index 0000000..22adb44 --- /dev/null +++ b/web/src/pb.ts @@ -0,0 +1,27 @@ +import PocketBase from "pocketbase"; + +export const pb = new PocketBase(window.location.origin); +pb.autoCancellation(false); + +type ErrorEnvelope = { + message?: string; + error?: { code?: string; message?: string }; +}; + +export async function api(path: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers); + if (init.body && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + if (pb.authStore.token) { + headers.set("Authorization", `Bearer ${pb.authStore.token}`); + } + const response = await fetch(path, { ...init, headers }); + const body = (await response.json().catch(() => ({}))) as ErrorEnvelope & T; + if (!response.ok) { + if (response.status === 401 && pb.authStore.token) pb.authStore.clear(); + const message = body.error?.message ?? body.message ?? `Request failed with HTTP ${response.status}`; + throw new Error(message); + } + return body as T; +} diff --git a/web/src/styles.css b/web/src/styles.css new file mode 100644 index 0000000..5c97f52 --- /dev/null +++ b/web/src/styles.css @@ -0,0 +1,90 @@ +:root { + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: #e9edf2; + background: #10141a; + font-synthesis: none; + color-scheme: dark; +} +* { box-sizing: border-box; } +body { margin: 0; min-width: 320px; background: #10141a; } +button, input { font: inherit; } +button { border: 1px solid #35404c; border-radius: 8px; background: #1a212a; color: #e9edf2; padding: 10px 14px; cursor: pointer; } +button:hover:not(:disabled) { border-color: #6c7b8a; } +button:disabled { opacity: .55; cursor: not-allowed; } +.primary { background: #d8f36a; color: #11151b; border-color: #d8f36a; font-weight: 750; } +.danger { color: #ff9ca5; border-color: #75414a; background: #2b1b20; } +.ghost { background: transparent; } +.shell { display: flex; min-height: 100vh; } +aside { width: 238px; flex: 0 0 238px; background: #161b22; border-right: 1px solid #29313b; padding: 28px 18px; display: flex; flex-direction: column; position: sticky; top: 0; height: 100vh; } +.brand { font-size: 20px; font-weight: 900; letter-spacing: .08em; color: #fff; } +.brand span { color: #d8f36a; } +.muted { color: #8b97a5; font-size: 13px; line-height: 1.55; } +.eyebrow { color: #9aaaaf; font-size: 11px; letter-spacing: .14em; font-weight: 800; margin: 0 0 8px; } +nav { margin-top: 16px; } +.nav { display: block; width: 100%; text-align: left; background: transparent; border-color: transparent; color: #a5b0bd; margin: 4px 0; } +.nav.active { background: #29323d; border-color: #35404c; color: #fff; } +.sidebar-bottom { margin-top: auto; display: grid; gap: 10px; } +.operator { color: #7f8b98; font-size: 12px; overflow-wrap: anywhere; } +.signout { width: 100%; } +main { max-width: 1320px; width: 100%; padding: 36px clamp(20px, 5vw, 72px); } +header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 28px; } +h1 { font-size: clamp(28px, 4vw, 36px); margin: 0; letter-spacing: -.02em; } +h2 { font-size: 19px; margin: 0 0 8px; } +.grid { display: grid; gap: 16px; } +.four { grid-template-columns: repeat(4, minmax(0, 1fr)); } +.card { background: #181e26; border: 1px solid #29313b; border-radius: 12px; padding: 22px; margin-bottom: 16px; box-shadow: 0 10px 35px rgba(0,0,0,.08); } +.stat { display: flex; flex-direction: column; gap: 12px; } +.stat span { color: #8995a4; font-size: 13px; } +.stat strong { font-size: 30px; } +.stat.good strong { color: #d8f36a; } +.stat.warn strong { color: #ffc56a; } +.split { display: flex; justify-content: space-between; align-items: center; gap: 24px; } +.section-title { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 18px; } +.inline-form { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 14px; } +.inline-form label, .login-card label { display: flex; flex-direction: column; gap: 7px; color: #aeb8c3; font-size: 13px; } +input { background: #11151b; border: 1px solid #36404c; border-radius: 7px; color: #fff; padding: 11px 12px; min-width: 220px; outline: none; } +input:focus { border-color: #82944d; box-shadow: 0 0 0 3px rgba(216,243,106,.08); } +.table-wrap { overflow: auto; } +table { width: 100%; border-collapse: collapse; font-size: 13px; min-width: 660px; } +th { text-align: left; color: #7f8b99; font-weight: 650; padding: 10px; border-bottom: 1px solid #303a46; } +td { padding: 13px 10px; border-bottom: 1px solid #252d36; color: #c5ced8; } +td strong, td small { display: block; } +td small { color: #73808c; margin-top: 4px; } +tr.clickable { cursor: pointer; } +tr.clickable:hover { background: #1d252f; } +.badge { display: inline-block; border-radius: 999px; padding: 5px 9px; font-size: 11px; background: #303944; color: #b7c2ce; white-space: nowrap; } +.badge.paid, .badge.connected, .badge.delivered { background: #334f36; color: #d8f36a; } +.badge.late, .badge.failed, .badge.degraded, .badge.exhausted { background: #5b4228; color: #ffc56a; } +.badge.pending, .badge.sending, .badge.connecting, .badge.pairing { background: #34445d; color: #a9ccff; } +.badge.expired, .badge.cancelled, .badge.unpaired, .badge.disabled { background: #303944; color: #9da8b4; } +.empty { color: #778492; font-size: 14px; } +.created { margin-top: 20px; border: 1px solid #3d4d38; background: #202b22; padding: 15px; border-radius: 8px; display: flex; flex-direction: column; gap: 6px; } +.created strong { font-size: 28px; color: #d8f36a; } +.created code, .pair-panel code { overflow-wrap: anywhere; color: #b7d2ff; font-size: 12px; } +.notice { background: #33445b; border: 1px solid #6681a3; border-radius: 8px; padding: 12px 15px; margin: -10px 0 20px; color: #d6e5f6; cursor: pointer; } +.error { color: #ff9ca5; font-size: 13px; } +.error.banner { background: #331e24; border: 1px solid #75414a; padding: 12px; border-radius: 8px; } +.record-list { display: grid; gap: 10px; } +.record-list article { border: 1px solid #2c3540; border-radius: 8px; padding: 14px; } +.record-head, .record-field { display: flex; justify-content: space-between; gap: 20px; } +.record-head { margin-bottom: 10px; } +.record-head span { color: #7d8996; font-size: 12px; } +.record-field { padding: 7px 0; border-top: 1px solid #242c35; font-size: 12px; } +.record-field span { color: #7d8996; flex: 0 0 145px; } +.record-field code { max-width: 76%; text-align: right; white-space: pre-wrap; overflow-wrap: anywhere; color: #c4d0dd; } +.actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 18px; } +.settings, .detail-list { margin: 0; } +.settings div, .detail-list div { display: flex; justify-content: space-between; gap: 24px; border-top: 1px solid #2b333e; padding: 11px 0; } +.settings dt, .detail-list dt { color: #84909d; } +.settings dd, .detail-list dd { margin: 0; color: #d7e0e8; text-align: right; overflow-wrap: anywhere; } +.settings.compact { margin-top: 20px; } +.login { min-height: 100vh; display: grid; place-items: center; padding: 24px; background: radial-gradient(circle at 20% 20%, #273234, #10141a 45%); } +.login-card { width: min(400px, 100%); background: #181e26; border: 1px solid #39434d; border-radius: 14px; padding: 30px; display: flex; flex-direction: column; gap: 16px; } +.login-card h1 { font-size: 27px; margin: 6px 0 0; } +.login-card input { min-width: 0; width: 100%; } +.pair-panel { margin-top: 20px; padding: 18px; background: #10151b; border: 1px solid #303944; border-radius: 10px; text-align: center; } +.pair-panel img { width: min(100%, 420px); height: auto; display: block; margin: 0 auto 14px; background: #fff; padding: 8px; border-radius: 8px; } +.modal-backdrop { position: fixed; inset: 0; z-index: 50; background: rgba(0,0,0,.64); display: grid; place-items: center; padding: 22px; } +.modal { width: min(680px, 100%); max-height: 88vh; overflow: auto; background: #181e26; border: 1px solid #3a4552; border-radius: 14px; padding: 24px; box-shadow: 0 28px 90px rgba(0,0,0,.45); } +@media (max-width: 900px) { .four { grid-template-columns: repeat(2, 1fr); } aside { width: 190px; flex-basis: 190px; } } +@media (max-width: 640px) { .shell { display: block; } aside { width: 100%; height: auto; position: static; padding: 16px; } nav { display: flex; gap: 4px; overflow-x: auto; margin-top: 10px; } .nav { width: auto; white-space: nowrap; } .sidebar-bottom { margin-top: 12px; display: flex; justify-content: space-between; align-items: center; } main { padding: 24px 16px; } .four { grid-template-columns: repeat(2, 1fr); } .split, .section-title { align-items: flex-start; } .record-field { display: block; } .record-field code { display: block; text-align: left; max-width: 100%; margin-top: 5px; } .settings div, .detail-list div { display: block; } .settings dd, .detail-list dd { text-align: left; margin-top: 4px; } } diff --git a/web/src/types.ts b/web/src/types.ts new file mode 100644 index 0000000..571fc8e --- /dev/null +++ b/web/src/types.ts @@ -0,0 +1,46 @@ +import type { RecordModel } from "pocketbase"; + +export type Page = "dashboard" | "payments" | "sms" | "webhooks" | "settings"; + +export type Payment = RecordModel & { + requested_amount: number; + payable_amount: number; + status: "pending" | "paid" | "expired" | "cancelled" | "late"; + expires_at: string; + reuse_after: string; + rrn: string; + upi_id: string; + payer_name: string; + paid_at: string; + external_id: string; + metadata?: unknown; +}; + +export type Connector = { + enabled: boolean; + state: string; + paired: boolean; + connected: boolean; + phoneResponsive: boolean; + lastConnectedAt?: string; + lastMessageAt?: string; + lastError?: string; +}; + +export type DashboardData = { + stats: Record; + connector: Connector; +}; + +export type PaymentCreateResponse = { + id: string; + requestedAmount: number; + requestedAmountPaise: number; + payableAmount: string; + payableAmountPaise: number; + status: string; + expiresAt: string; + paidAt: string | null; + externalId: string; + upiUri: string; +}; diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..2cdee19 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src", "vite.config.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..950fc44 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,19 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + root: "web", + plugins: [react()], + server: { + port: 5173, + proxy: { + "/api": "http://127.0.0.1:3000", + "/_": "http://127.0.0.1:3000", + }, + }, + build: { + outDir: "../internal/web/dist", + emptyOutDir: true, + sourcemap: false, + }, +});