From 30c9d24bc906c02618e72d4256908d5716489a6a Mon Sep 17 00:00:00 2001 From: nedson202 Date: Sun, 1 Nov 2026 17:35:26 +0000 Subject: [PATCH 01/11] feat(api): NestJS feature modules, Enable Banking sync, Cloudflare Access auth Replace the Hono API with NestJS, organised as business feature modules: access, bank-connections, merchant-rules, settings, sheet-export, health, plus shared (database, crypto, http). Behaviour and response shapes of the existing endpoints are unchanged (POSTs still return 200, errors still {error}). Bank sync (Enable Banking, with a built-in test bank for local use): - off by default; misconfiguration fails closed (503), test bank refused in production - Cloudflare Access JWT verified server-side, admin allow-list for the back office - CSRF header on mutating requests, single-use state + HttpOnly cookie on the callback - session ids encrypted at rest (AES-256-GCM); transactions are never stored server-side - sync throttle, audit log, log redaction, no-store responses - reconnecting an already connected bank replaces the old connection - each synced row carries bank, account label and a stable account reference Tooling and housekeeping: - oxlint + oxfmt configs; vitest + supertest tests (60) - load api/.env on local runs (real environment variables still win) - api/.env.example and a refreshed root example.env - remove the AI categorization feature and the OpenAI dependency - Dockerfile: PORT=3000, node dist/main.js - README rewritten for the self-hosted, privacy-first setup Co-Authored-By: Claude Sonnet 5.5 --- README-v2.md | 135 - README.md | 150 +- api/.env.example | 35 + api/.oxfmtrc.json | 9 + api/.oxlintrc.json | 30 + api/Dockerfile | 2 +- api/nest-cli.json | 6 + api/package-lock.json | 5541 +++++++++++++---- api/package.json | 39 +- api/scripts/install-better-sqlite3.cjs | 133 +- api/src/access/access.guard.ts | 54 + api/src/access/access.module.ts | 30 + api/src/access/access.settings.ts | 37 + api/src/access/admin.guard.ts | 12 + api/src/access/cloudflare-verifier.ts | 14 + api/src/access/csrf.guard.ts | 22 + api/src/app.module.ts | 26 + api/src/app.setup.ts | 21 + api/src/app.test.ts | 193 + .../bank-connections/bank-admin.controller.ts | 24 + .../bank-callback.controller.ts | 42 + .../bank-connections.controller.ts | 83 + .../bank-connections.module.ts | 74 + .../bank-connections.schemas.ts | 12 + .../bank-connections.service.ts | 355 ++ .../bank-connections.store.ts | 232 + .../bank-connections/bank-connections.test.ts | 420 ++ .../bank-connections.tokens.ts | 10 + api/src/bank-connections/bank-provider.ts | 65 + .../banking-unavailable.controller.ts | 19 + .../bank-connections/banking.config.test.ts | 74 + api/src/bank-connections/banking.config.ts | 107 + .../providers/enable-banking.provider.test.ts | 188 + .../providers/enable-banking.provider.ts | 217 + .../providers/test-bank.provider.ts | 159 + api/src/bank-connections/rate-limiter.ts | 18 + .../bank-connections/test-bank.controller.ts | 53 + api/src/health/health.controller.ts | 14 + api/src/health/health.module.ts | 5 + api/src/index.ts | 57 - api/src/lib/anonymizer.ts | 72 - api/src/load-env.ts | 5 + api/src/main.ts | 17 + .../merchant-rules.controller.ts | 40 + .../merchant-rules/merchant-rules.module.ts | 11 + .../merchant-rules.repository.ts | 95 + .../merchant-rules/merchant-rules.schemas.ts | 6 + .../merchant-rules/merchant-rules.service.ts | 27 + api/src/routes/categorize.ts | 53 - api/src/routes/export.ts | 93 - api/src/routes/learn.ts | 113 - api/src/routes/settings.ts | 138 - api/src/services/database.ts | 239 - api/src/services/openai.ts | 99 - api/src/settings/settings.controller.ts | 47 + api/src/settings/settings.module.ts | 11 + api/src/settings/settings.repository.ts | 52 + api/src/settings/settings.service.ts | 33 + api/src/settings/sheet-config.schema.ts | 49 + api/src/shared/crypto/encryption.test.ts | 20 + api/src/shared/crypto/encryption.ts | 26 + api/src/shared/database/database.module.ts | 15 + api/src/shared/database/database.service.ts | 29 + api/src/shared/http/http-error.filter.ts | 37 + api/src/shared/http/no-store.interceptor.ts | 12 + .../shared/http/request-logger.middleware.ts | 18 + api/src/shared/http/request-logger.test.ts | 13 + api/src/shared/http/zod-validation.pipe.ts | 28 + .../google-sheets.service.ts} | 200 +- .../sheet-export/sheet-export.controller.ts | 62 + api/src/sheet-export/sheet-export.module.ts | 6 + api/src/sheet-export/sheet-export.schemas.ts | 17 + api/src/testing/test-app.ts | 69 + api/tsconfig.build.json | 4 + api/tsconfig.json | 14 +- api/vitest.config.mts | 9 + docker-compose.yml | 1 - example.env | 24 +- 78 files changed, 8142 insertions(+), 2379 deletions(-) delete mode 100644 README-v2.md create mode 100644 api/.env.example create mode 100644 api/.oxfmtrc.json create mode 100644 api/.oxlintrc.json create mode 100644 api/nest-cli.json create mode 100644 api/src/access/access.guard.ts create mode 100644 api/src/access/access.module.ts create mode 100644 api/src/access/access.settings.ts create mode 100644 api/src/access/admin.guard.ts create mode 100644 api/src/access/cloudflare-verifier.ts create mode 100644 api/src/access/csrf.guard.ts create mode 100644 api/src/app.module.ts create mode 100644 api/src/app.setup.ts create mode 100644 api/src/app.test.ts create mode 100644 api/src/bank-connections/bank-admin.controller.ts create mode 100644 api/src/bank-connections/bank-callback.controller.ts create mode 100644 api/src/bank-connections/bank-connections.controller.ts create mode 100644 api/src/bank-connections/bank-connections.module.ts create mode 100644 api/src/bank-connections/bank-connections.schemas.ts create mode 100644 api/src/bank-connections/bank-connections.service.ts create mode 100644 api/src/bank-connections/bank-connections.store.ts create mode 100644 api/src/bank-connections/bank-connections.test.ts create mode 100644 api/src/bank-connections/bank-connections.tokens.ts create mode 100644 api/src/bank-connections/bank-provider.ts create mode 100644 api/src/bank-connections/banking-unavailable.controller.ts create mode 100644 api/src/bank-connections/banking.config.test.ts create mode 100644 api/src/bank-connections/banking.config.ts create mode 100644 api/src/bank-connections/providers/enable-banking.provider.test.ts create mode 100644 api/src/bank-connections/providers/enable-banking.provider.ts create mode 100644 api/src/bank-connections/providers/test-bank.provider.ts create mode 100644 api/src/bank-connections/rate-limiter.ts create mode 100644 api/src/bank-connections/test-bank.controller.ts create mode 100644 api/src/health/health.controller.ts create mode 100644 api/src/health/health.module.ts delete mode 100644 api/src/index.ts delete mode 100644 api/src/lib/anonymizer.ts create mode 100644 api/src/load-env.ts create mode 100644 api/src/main.ts create mode 100644 api/src/merchant-rules/merchant-rules.controller.ts create mode 100644 api/src/merchant-rules/merchant-rules.module.ts create mode 100644 api/src/merchant-rules/merchant-rules.repository.ts create mode 100644 api/src/merchant-rules/merchant-rules.schemas.ts create mode 100644 api/src/merchant-rules/merchant-rules.service.ts delete mode 100644 api/src/routes/categorize.ts delete mode 100644 api/src/routes/export.ts delete mode 100644 api/src/routes/learn.ts delete mode 100644 api/src/routes/settings.ts delete mode 100644 api/src/services/database.ts delete mode 100644 api/src/services/openai.ts create mode 100644 api/src/settings/settings.controller.ts create mode 100644 api/src/settings/settings.module.ts create mode 100644 api/src/settings/settings.repository.ts create mode 100644 api/src/settings/settings.service.ts create mode 100644 api/src/settings/sheet-config.schema.ts create mode 100644 api/src/shared/crypto/encryption.test.ts create mode 100644 api/src/shared/crypto/encryption.ts create mode 100644 api/src/shared/database/database.module.ts create mode 100644 api/src/shared/database/database.service.ts create mode 100644 api/src/shared/http/http-error.filter.ts create mode 100644 api/src/shared/http/no-store.interceptor.ts create mode 100644 api/src/shared/http/request-logger.middleware.ts create mode 100644 api/src/shared/http/request-logger.test.ts create mode 100644 api/src/shared/http/zod-validation.pipe.ts rename api/src/{services/sheets.ts => sheet-export/google-sheets.service.ts} (59%) create mode 100644 api/src/sheet-export/sheet-export.controller.ts create mode 100644 api/src/sheet-export/sheet-export.module.ts create mode 100644 api/src/sheet-export/sheet-export.schemas.ts create mode 100644 api/src/testing/test-app.ts create mode 100644 api/tsconfig.build.json create mode 100644 api/vitest.config.mts diff --git a/README-v2.md b/README-v2.md deleted file mode 100644 index f49068f..0000000 --- a/README-v2.md +++ /dev/null @@ -1,135 +0,0 @@ -# FinKit - -A privacy-first transaction analysis tool that anonymizes your bank data before processing and exports to your Google Sheets yearly breakdown. - -## Features - -- **Privacy-First**: All personal data is anonymized before any processing -- **Smart Categorization**: Rule-based categorization with AI fallback -- **Recurring Detection**: Automatically identifies recurring transactions -- **Inline Editing**: Click any category to change it -- **Bulk Operations**: Select multiple transactions and recategorize at once -- **Advanced Analytics**: Charts, trends, and spending breakdowns -- **Google Sheets Export**: One-click export to your existing yearly breakdown - -## Quick Start - -### 1. Install Dependencies - -```bash -# Install web dependencies -cd web-v2 -npm install - -# Install API dependencies -cd ../api-v2 -npm install -``` - -### 2. Configure Environment - -Create `.env` file in `api-v2/`: - -```env -OPENAI_API_KEY=your-openai-api-key -GOOGLE_SERVICE_ACCOUNT_KEY_PATH=/path/to/service-account.json -``` - -### 3. Run Development Servers - -In separate terminals: - -```bash -# Terminal 1: Run API (runs on port 3003) -cd api-v2 -npm run dev - -# Terminal 2: Run Web (runs on port 5173, proxies /api to 3003) -cd web-v2 -npm run dev -``` - -Open http://localhost:5173 - -## Project Structure - -``` -├── web-v2/ # React + Vite frontend -│ ├── src/ -│ │ ├── components/ # UI components -│ │ │ ├── Dashboard.tsx -│ │ │ ├── TransactionTable.tsx -│ │ │ ├── SettingsPanel.tsx -│ │ │ ├── ExportPreview.tsx -│ │ │ └── charts/ -│ │ ├── lib/ # Core logic -│ │ │ ├── anonymizer.ts -│ │ │ ├── parser.ts -│ │ │ ├── categorizer.ts -│ │ │ └── recurring.ts -│ │ └── types.ts -│ └── package.json -│ -├── api-v2/ # Hono API server -│ ├── src/ -│ │ ├── index.ts -│ │ ├── routes/ -│ │ │ ├── categorize.ts -│ │ │ └── export.ts -│ │ ├── services/ -│ │ │ ├── openai.ts -│ │ │ └── sheets.ts -│ │ └── lib/ -│ │ └── anonymizer.ts -│ └── package.json -``` - -## Google Sheets Setup - -### 1. Create Service Account - -1. Go to [Google Cloud Console](https://console.cloud.google.com/) -2. Create a new project or select existing -3. Enable Google Sheets API -4. Create a Service Account -5. Download the JSON key file -6. Share your Google Sheet with the service account email - -### 2. Configure Export Settings - -In the app: -1. Click "Settings" -2. Paste your Google Sheet URL or ID -3. Set the tab name (e.g., "2026") -4. Map your categories to sheet columns - -## Your Sheet Structure - -The app is designed for this column layout: - -| A | B | C | D | E | ... | R | S | T | U | V | W | -|---|---|---|---|---|-----|---|---|---|---|---|---| -| Month | Rent | Eating Out | Personal Entertainment | Subscriptions | ... | Total Expenditure | Income | Income After Expenditure | Gross Savings | Gross Investment | Net Income | - -Configure mappings in Settings to match your exact layout. - -## Anonymization - -The following data is anonymized: - -| Data Type | Anonymization | -|-----------|--------------| -| Names | `Person_ABC` | -| IBANs | `DE**********5515` | -| Account Numbers | `Acc_XYZ123` | -| Emails | `j***@gmail.com` | -| Phone Numbers | `+*********1234` | - -Mappings are kept in memory only and never persisted. - -## Tech Stack - -- **Frontend**: React 18, Vite, TailwindCSS, Recharts -- **Backend**: Hono (lightweight Express alternative) -- **AI**: OpenAI GPT-4o-mini -- **Export**: Google Sheets API diff --git a/README.md b/README.md index 0edc4a6..728d285 100644 --- a/README.md +++ b/README.md @@ -1,124 +1,98 @@ # FinKit -A simple tool to parse and visualize transaction records exported from FinanzGuru. +**Self-hosted** personal finance tooling: you run the web app and API on your own machine or server. Import transactions (CSV/XLSX or optional bank sync), categorize them, explore spending, and optionally push summary rows to **your** Google Sheet. -## Features +There is no FinKit cloud, no vendor account, and **no built-in analytics, telemetry, or data collection**. Nothing is sent to the authors of this software unless **you** configure an optional integration below. -- **CSV/XLSX Import**: Parse transaction exports from FinanzGuru -- **Privacy-First**: Anonymizes personal data before processing -- **Smart Categorization**: Rule-based categorization with AI fallback -- **Recurring Detection**: Identifies recurring transactions automatically -- **Interactive Dashboard**: View spending trends, category breakdowns, and top expenses -- **Bulk Editing**: Select and recategorize multiple transactions at once -- **Google Sheets Export**: Export to your existing yearly budget spreadsheet +## Privacy & data (short version) -## Quick Start +| Where (all on **your** deployment) | What is kept | +|--------|----------------| +| **Your browser** | Parsed transactions while you work; optional local session (IndexedDB) if you leave “remember session” on. Optional in-memory anonymization mappings (never persisted). | +| **Your self-hosted API** (SQLite file on **your** server or laptop) | Merchant→category rules, sheet/settings preferences, encrypted bank connection tokens (if bank sync is on). **Transaction history is not written to this database**—only connection metadata and rules. | +| **Nowhere else by default** | No FinKit-operated cloud, no background uploads, crash reports, usage stats, or third-party trackers in the app. | -### Prerequisites +**Optional outbound connections (you enable these):** -- Node.js 18+ -- OpenAI API key (for AI categorization) -- Google Service Account (for Sheets export, optional) +- **Google Sheets** — only when you configure a service account and click export; sends the summary row you choose to a spreadsheet you control. +- **Open Banking (Enable Banking)** — only when `BANKING_PROVIDER=enablebanking`; **your** self-hosted API calls Enable Banking and your bank; transactions land in **your** browser. FinKit never sees your bank password. +- **Cloudflare Access** — optional gate in front of a production deploy; Cloudflare’s privacy terms apply to that layer. -### 1. Setup Environment +See [web/public/privacy.html](./web/public/privacy.html) and [web/public/terms.html](./web/public/terms.html) for the full privacy and terms copy shown on the landing site. -Create `.env` file in the `api/` directory: +## Features -```env -OPENAI_API_KEY=your-openai-api-key -GOOGLE_SERVICE_ACCOUNT_KEY_PATH=/path/to/service-account.json -``` +- **Import**: FinanzGuru CSV/XLSX; optional Open Banking sync when configured +- **Categorization**: Client-side rules, recurring detection, and server-stored merchant rules (learned when you recategorize) +- **Review & edit**: Inline categories, bulk actions, income and review queues +- **Analytics**: Trends, category breakdowns, top expenses (computed in your browser from your data) +- **Export**: Optional push of monthly totals to your Google Sheet +- **Privacy**: Optional in-browser anonymization of names, IBANs, emails, and similar fields -### 2. Install & Run with Docker +## Prerequisites -```bash -# Development -docker-compose up +- Node.js 20+ (matches Docker images) +- For Sheets export: Google Cloud service account with Sheets API enabled +- For production deploy: see [example.env](./example.env) (tunnel, registry, Google credentials) +- For bank sync: see [api/.env.example](./api/.env.example) (`BANKING_PROVIDER`, Enable Banking keys, Cloudflare Access) -# Production -docker-compose -f docker-compose.prod.yml up -d -``` +## Local development -The web app will be available at `http://localhost:5173` (dev) or `http://localhost` (production). +```bash +cd api && npm install && npm run dev # http://localhost:3003 +cd web && npm install && npm run dev # http://localhost:5173 — proxies /api → :3003 +``` -### 3. Manual Setup (without Docker) +Open **http://localhost:5173/app/** for the app (landing page is at `/`). -```bash -# Install API dependencies -cd api -npm install +Copy [api/.env.example](./api/.env.example) to `api/.env` and adjust optional features. Defaults are enough for import, categorization, and merchant rules (SQLite under `api/data` on your machine). -# Install Web dependencies -cd ../web -npm install +### Tests -# Run both (in separate terminals) -cd api && npm run dev # API runs on :3003 -cd web && npm run dev # Web runs on :5173 +```bash +cd api && npm test +cd web && npm test ``` -## Usage - -1. **Export from FinanzGuru**: Export your transactions as CSV or XLSX -2. **Upload**: Drag and drop the file into the web interface -3. **Review**: Check auto-categorized transactions, edit as needed -4. **Analyze**: View charts and spending breakdowns -5. **Export**: (Optional) Export to Google Sheets +## Production (Docker) -## Project Structure +You host the containers; data stays in volumes you control: -``` -├── api/ # Backend API (Hono) -│ ├── src/ -│ │ ├── routes/ # API endpoints -│ │ ├── services/ # OpenAI, Sheets integration -│ │ └── lib/ # Anonymization logic -│ └── Dockerfile -│ -├── web/ # Frontend (React + Vite) -│ ├── src/ -│ │ ├── components/ # UI components -│ │ ├── lib/ # Parsers, categorization -│ │ └── types.ts -│ └── Dockerfile -│ -└── docker-compose.yml # Development setup +```bash +cp example.env .env # fill in values +docker compose -f docker-compose.prod.yml pull +docker compose -f docker-compose.prod.yml up -d ``` -## Data Privacy +API health: `GET /health`. Persistent SQLite data uses the `finkit-api-data` volume on **your** host. -All sensitive data is anonymized before processing: +Root [docker-compose.yml](./docker-compose.yml) builds the same web and API images for local or CI use; wire ports or a reverse proxy so the browser can reach the web container and `/api` can reach the API. -- **Names**: `Person_ABC` -- **IBANs**: `DE**********5515` -- **Account Numbers**: `Acc_XYZ123` -- **Emails**: `j***@gmail.com` -- **Phone Numbers**: `+*********1234` +## Google Sheets (optional) -Anonymization mappings are kept in memory only and never persisted. +Only runs when you set credentials and export from the app: -## Google Sheets Setup (Optional) +1. In [Google Cloud Console](https://console.cloud.google.com/), enable the Sheets API and create a service account. +2. Share your spreadsheet with the service account email (Editor). +3. **Local API**: set `GOOGLE_SERVICE_ACCOUNT_KEY_PATH` in `api/.env` to the JSON key file. +4. **Docker production**: set `GOOGLE_SERVICE_ACCOUNT_EMAIL` and `GOOGLE_PRIVATE_KEY` in root `.env` (see [example.env](./example.env)). +5. In the app **Settings**, set sheet URL/ID, tab name (e.g. `2026`), and map categories to columns. -### 1. Create Service Account +The export expects a monthly summary layout (month row plus category columns and totals). Use Settings to align mappings with your sheet. -1. Go to [Google Cloud Console](https://console.cloud.google.com/) -2. Enable Google Sheets API -3. Create a Service Account and download JSON key -4. Share your Google Sheet with the service account email +## Project layout -### 2. Configure in App - -1. Open Settings panel -2. Enter your Google Sheet URL or ID -3. Set the target tab name (e.g., "2026") -4. Map categories to sheet columns +``` +api/ NestJS API — merchant rules, settings, Sheets export, optional banking +web/ React + Vite — parsing, categorization, UI (/ and /app/) +example.env Production compose secrets template +``` -## Tech Stack +## Tech stack -- **Frontend**: React 18, Vite, TailwindCSS, Recharts -- **Backend**: Hono (Node.js) -- **AI**: OpenAI GPT-4o-mini -- **Export**: Google Sheets API +- **Web**: React 18, Vite, TanStack Router, TailwindCSS, Recharts +- **API**: NestJS, SQLite (better-sqlite3), Zod +- **Export**: Google Sheets API (optional) ## License diff --git a/api/.env.example b/api/.env.example new file mode 100644 index 0000000..8b2368d --- /dev/null +++ b/api/.env.example @@ -0,0 +1,35 @@ +# FinKit API environment. Copy to .env for local runs; in production these go in the root .env (see ../example.env). +# Never commit real values. + +# --- Server --- +# PORT=3000 # default 3000 +# HOST=127.0.0.1 # default 0.0.0.0 (needed in Docker); 127.0.0.1 keeps a local run private +# DATA_DIR=./data # where the SQLite database lives (/data in Docker) +# NODE_ENV=development # production enables the stricter banking and Cloudflare checks below +# ALLOWED_ORIGIN=https://finkit.example.com # lock CORS to your site in production + +# --- Optional features --- +# GOOGLE_SERVICE_ACCOUNT_KEY_PATH=/run/secrets/google-sa.json # Google Sheets sync + +# --- Cloudflare Access (required in production when banking is on) --- +# The API verifies the Access JWT itself, so it does not trust the network alone. +# CF_ACCESS_TEAM_DOMAIN=yourteam.cloudflareaccess.com +# CF_ACCESS_AUD= +# BANKING_ADMIN_EMAILS=you@example.com # who may open Settings > Back office (comma separated) + +# --- Bank sync (off by default; the API answers "disabled" and 404s the routes) --- +# off | mock | enablebanking +BANKING_PROVIDER=off +# mock = a fake test bank for local development. Refused in production unless BANKING_ALLOW_MOCK_IN_PROD=1. + +# 32 random bytes, base64. Encrypts bank session ids at rest. Required in production. +# openssl rand -base64 32 +# BANKING_ENCRYPTION_KEY= + +# Where the bank sends people back after approving. Must match the redirect URL registered with Enable Banking. +# BANKING_REDIRECT_URL=https://finkit.example.com/api/banking/callback + +# Enable Banking application (BANKING_PROVIDER=enablebanking). Generate the key pair with ../scripts/gen-banking-keys.sh. +# The private key stays on the server as a mounted secret. Never paste it into chat or commit it. +# EB_APP_ID= +# EB_PRIVATE_KEY_PATH=/run/secrets/enablebanking.pem diff --git a/api/.oxfmtrc.json b/api/.oxfmtrc.json new file mode 100644 index 0000000..bbe877c --- /dev/null +++ b/api/.oxfmtrc.json @@ -0,0 +1,9 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "printWidth": 120, + "semi": false, + "singleQuote": true, + "trailingComma": "all", + "arrowParens": "avoid", + "ignorePatterns": ["dist", "node_modules", "data", "package-lock.json"] +} diff --git a/api/.oxlintrc.json b/api/.oxlintrc.json new file mode 100644 index 0000000..89c97b9 --- /dev/null +++ b/api/.oxlintrc.json @@ -0,0 +1,30 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["typescript", "unicorn", "oxc", "import", "node", "promise", "vitest"], + "categories": { + "correctness": "error", + "suspicious": "warn" + }, + "rules": { + "typescript/no-extraneous-class": "off", + "vitest/expect-expect": [ + "error", + { + "assertFunctionNames": ["expect", "request.**.expect"] + } + ] + }, + "env": { + "builtin": true, + "node": true + }, + "ignorePatterns": ["dist", "node_modules", "data"], + "overrides": [ + { + "files": ["**/*.test.ts"], + "rules": { + "unicorn/consistent-function-scoping": "off" + } + } + ] +} diff --git a/api/Dockerfile b/api/Dockerfile index 90494ba..4bd70e3 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -59,4 +59,4 @@ EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1 -CMD ["node", "dist/index.js"] +CMD ["node", "dist/main.js"] diff --git a/api/nest-cli.json b/api/nest-cli.json new file mode 100644 index 0000000..096ccb8 --- /dev/null +++ b/api/nest-cli.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { "tsConfigPath": "tsconfig.build.json", "deleteOutDir": true } +} diff --git a/api/package-lock.json b/api/package-lock.json index 2e8bd45..57eb880 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -9,473 +9,503 @@ "version": "2.0.0", "hasInstallScript": true, "dependencies": { - "@hono/node-server": "^2.1.5", + "@nestjs/common": "^12.3.2", + "@nestjs/core": "^12.3.2", + "@nestjs/platform-express": "^12.3.2", "better-sqlite3": "12.8.0", "googleapis": "^189.0.0", - "hono": "^4.13.16", - "openai": "^7.47.0", + "helmet": "^8.3.0", + "jose": "^5.10.0", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.2", "zod": "^4.6.7" }, "devDependencies": { + "@nestjs/cli": "^12.0.11", + "@nestjs/testing": "^12.3.2", + "@swc/core": "^1.16.21", "@types/better-sqlite3": "^7.6.13", + "@types/express": "^5.0.6", "@types/node": "^22.10.0", - "tsup": "^8.5.1", - "tsx": "^4.23.15", - "typescript": "~5.6.2" + "@types/supertest": "^7.2.1", + "oxfmt": "^0.75.0", + "oxlint": "^1.90.0", + "supertest": "^7.3.1", + "typescript": "~5.6.2", + "unplugin-swc": "^2.0.0", + "vitest": "^2.1.9" } }, - "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/@angular-devkit/core": { + "version": "22.2.3", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-22.2.3.tgz", + "integrity": "sha512-bi1oBjJeg1XCgRrBYSuJikaJV4sO/mcnyFE/0a1H646yHhQpVYoU/M2Vh69Qv8h+YoMfC7RuSxT4LZHaubhhLw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "aix" - ], + "dependencies": { + "ajv": "8.20.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.7", + "rxjs": "7.8.2", + "source-map": "0.8.0" + }, "engines": { - "node": ">=18" + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^5.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } } }, - "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" - ], + "node_modules/@angular-devkit/schematics": { + "version": "22.2.3", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-22.2.3.tgz", + "integrity": "sha512-+XfBViIGqiFQK8KJXA6PgANX9tLuMO/x8GFjlzEmA4LIe9ikAtpGZw+Ezo547BIJQoJBIKCJYR0knE4BmbFeSQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "dependencies": { + "@angular-devkit/core": "22.2.3", + "jsonc-parser": "3.3.1", + "magic-string": "1.4.1", + "ora": "9.4.1", + "rxjs": "7.8.2" + }, "engines": { - "node": ">=18" + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" } }, - "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==", - "cpu": [ - "arm64" - ], + "node_modules/@angular-devkit/schematics-cli": { + "version": "22.2.3", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics-cli/-/schematics-cli-22.2.3.tgz", + "integrity": "sha512-GebRkurUplPBwJOz/LT40ZZ0Hxv0EzpllJD1Oj7Bjkk/9A8PKGPDSRgEGfWcVk2h35V4JNNC0g6pSMj9s0dWxg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "dependencies": { + "@angular-devkit/core": "22.2.3", + "@angular-devkit/schematics": "22.2.3", + "@inquirer/prompts": "8.7.2" + }, + "bin": { + "schematics": "bin/schematics.js" + }, "engines": { - "node": ">=18" + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" } }, - "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==", - "cpu": [ - "x64" - ], + "node_modules/@angular-devkit/schematics-cli/node_modules/@inquirer/prompts": { + "version": "8.7.2", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.7.2.tgz", + "integrity": "sha512-QoRB4wFIjgH5iOhSjoIKMkTvSHDuV+O3OITlIqAYO0oK5x364GJILXiMBvlPiE+klg7Xx9tq5XVqQHcGUDYYPA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "dependencies": { + "@inquirer/checkbox": "^5.2.5", + "@inquirer/confirm": "^6.3.2", + "@inquirer/editor": "^5.3.3", + "@inquirer/expand": "^5.1.5", + "@inquirer/input": "^5.1.6", + "@inquirer/number": "^4.2.3", + "@inquirer/password": "^5.2.2", + "@inquirer/rawlist": "^5.3.5", + "@inquirer/search": "^4.3.3", + "@inquirer/select": "^5.2.5" + }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "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==", - "cpu": [ - "arm64" - ], + "node_modules/@angular-devkit/schematics/node_modules/magic-string": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz", + "integrity": "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" } }, - "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==", - "cpu": [ - "x64" - ], + "node_modules/@borewit/text-codec": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.3.tgz", + "integrity": "sha512-49CycTz1aAZmvFcwkeFyhud1HUmRIB5Tabf7ALV+VA4Iu85MTfz2cUwzPagMFAdVBvrWdkCxG67I30tUwwpKsw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "darwin" - ], "engines": { - "node": ">=18" + "node": ">=0.1.90" } }, - "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==", - "cpu": [ - "arm64" - ], + "node_modules/@inquirer/ansi": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.10.tgz", + "integrity": "sha512-dwrc+1aqVZpHNkNDswXGtn1typ+Pxpo5FHZRyBw1le7onEJUkPUkYOY1FIFC1LvFMYUqqLuWBwElhAmencL7MQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" } }, - "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==", - "cpu": [ - "x64" - ], + "node_modules/@inquirer/checkbox": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.2.8.tgz", + "integrity": "sha512-NY/S1EalQhlonqhwwsarrcnkH7s9ByQuvjWYdk1jB2Gb9EPQJjKkhCTWjM3yQ0UpHaLm1XbIL89b+32G8rhO5w==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "dependencies": { + "@inquirer/ansi": "^2.0.10", + "@inquirer/core": "^12.0.6", + "@inquirer/figures": "^2.0.10", + "@inquirer/type": "4.2.1" + }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "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==", - "cpu": [ - "arm" - ], + "node_modules/@inquirer/confirm": { + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.3.5.tgz", + "integrity": "sha512-BJ59BOyURiZiAxdr9AuRu38/k+rV+DcTIM2fUF/c/BXrab6H8zMZTTOl3LgkSJH96Xob2vOJxDiBkHiz32Xx2w==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@inquirer/core": "^12.0.6", + "@inquirer/type": "4.2.1" + }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "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==", - "cpu": [ - "arm64" - ], + "node_modules/@inquirer/core": { + "version": "12.0.6", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.6.tgz", + "integrity": "sha512-5yxn2LsJ8Tu4ei2a+yNkmFRhM/cb+JMEsEs/AW6CCOnBnpMy4IjYiPMFyHtYq8weOmlTyqqCx40y0DsD7OsEPw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@inquirer/ansi": "^2.0.10", + "@inquirer/figures": "^2.0.10", + "@inquirer/type": "4.2.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "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==", - "cpu": [ - "ia32" - ], + "node_modules/@inquirer/editor": { + "version": "5.3.6", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.3.6.tgz", + "integrity": "sha512-Z5SOeRC5VNkINfKN4rY8BSaYKb3GYi8iSEqZZRqogsE2GhBBToQ0Xw25SbI+4bcatLzU009mZ5d9EuJNiJuUCg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@inquirer/core": "^12.0.6", + "@inquirer/external-editor": "^3.0.7", + "@inquirer/type": "4.2.1" + }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "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==", - "cpu": [ - "loong64" - ], + "node_modules/@inquirer/expand": { + "version": "5.1.8", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.1.8.tgz", + "integrity": "sha512-dMEnTkpLqzM/sbILwL7QJgx9iX5hoEM8+bh6shgwd9mYJamdOnth7y2mMaFd08Glj9AXEFKllWcGYyBqnJsHrA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@inquirer/core": "^12.0.6", + "@inquirer/type": "4.2.1" + }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "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==", - "cpu": [ - "mips64el" - ], + "node_modules/@inquirer/external-editor": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.7.tgz", + "integrity": "sha512-enNqTJpojgISCG+a5acjnfJ02hcRTFUBVm8uXiCY40PRPZtSzFnNLskOTU+kDUUEODYma90syxuea1rRtUFh0Q==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.2" + }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "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/@inquirer/figures": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.10.tgz", + "integrity": "sha512-51elrUJV3jZpvD0ygz8+U10Kc6+Lzqln+/tYmnbhy6jo5oXpE6hMCcv+OTBC0enwJXsb3JIuT3VtEinRNCSK2A==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" } }, - "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" - ], + "node_modules/@inquirer/input": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.1.9.tgz", + "integrity": "sha512-2Vd5T7lTxfgJMpAd3aOu6bQpbUnCU7tBgvTQIZkP/7juJzmRmTM2t5XfOCuirFduWLTSJI/WZp32Cq1E01TVeg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@inquirer/core": "^12.0.6", + "@inquirer/type": "4.2.1" + }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "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/@inquirer/number": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.2.6.tgz", + "integrity": "sha512-22LnGGCDm1M/pKXxxF8n6ppr/1IztdsxqeqeBRUMTTNnJTkjIBiAkewiDM4u/gjtazj/FvZS+nk9AbO/vEcBFQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@inquirer/core": "^12.0.6", + "@inquirer/type": "4.2.1" + }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "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" - ], + "node_modules/@inquirer/password": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.2.5.tgz", + "integrity": "sha512-0v0wLQA4uhWbybmdH6oPl7ooxCiymNylkvi2gCSc+mA5ZQB6QoJLVhoHq12T82lhozpp9fpMJnJkC1nJrU/A+A==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@inquirer/ansi": "^2.0.10", + "@inquirer/core": "^12.0.6", + "@inquirer/type": "4.2.1" + }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "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" - ], + "node_modules/@inquirer/prompts": { + "version": "8.7.3", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.7.3.tgz", + "integrity": "sha512-DHK20kO+H5k/XcJQpHQUpVQbMYHHhZchlGWhZVMUV4WIuRuBW5744hipOtriE1vMqPLgnIyXFMHWKkIbTepa4Q==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], + "dependencies": { + "@inquirer/checkbox": "^5.2.6", + "@inquirer/confirm": "^6.3.3", + "@inquirer/editor": "^5.3.4", + "@inquirer/expand": "^5.1.6", + "@inquirer/input": "^5.1.7", + "@inquirer/number": "^4.2.4", + "@inquirer/password": "^5.2.3", + "@inquirer/rawlist": "^5.3.6", + "@inquirer/search": "^4.3.4", + "@inquirer/select": "^5.2.6" + }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "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" - ], + "node_modules/@inquirer/rawlist": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.3.8.tgz", + "integrity": "sha512-h+6Y4pPvYvuRbksn1kIBdD9tCYD+E468W5sIcVfLmoLP5IasnY0r33N+r+46ej/P8b2PCREjyKR+MnnfPGdTzg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "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, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "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, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], + "dependencies": { + "@inquirer/core": "^12.0.6", + "@inquirer/type": "4.2.1" + }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "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" - ], + "node_modules/@inquirer/search": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.3.6.tgz", + "integrity": "sha512-JZj9ttkIvuaFQIc2oxsyqC1ad05WL02ku6W/INVKTCzNaVq1KkhP3AfTRC0m7OWlKZyw+EWwPBFZfYiG9jF6Yw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "@inquirer/core": "^12.0.6", + "@inquirer/figures": "^2.0.10", + "@inquirer/type": "4.2.1" + }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "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/@inquirer/select": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.2.8.tgz", + "integrity": "sha512-BklkkAAOpjdCfuQw5fVGcDuhKxA7T1iBpn6b2JUvDDdkIsO3Ftlg+WnWUCVpkUBFagL2o036OFW+xL4lC9wIPw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "@inquirer/ansi": "^2.0.10", + "@inquirer/core": "^12.0.6", + "@inquirer/figures": "^2.0.10", + "@inquirer/type": "4.2.1" + }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "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/@inquirer/type": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.2.1.tgz", + "integrity": "sha512-lDkPsnv5M5CcYB2T1qTc3dobNTPkVr4DU3Ic2Q6SW8/qVRHgvzdRdmVZ6YqG9Ag1aJTeMsLU0s0WPRh3CZWDQQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@hono/node-server": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.5.tgz", - "integrity": "sha512-MgP/I06GdvsilDspMk/qmRzpk8LOUStP6jFAbSRi40W09DhoyUn+Mj7eyKICPqeYKybOjVFLVIAQzvu1bfIa7Q==", - "license": "MIT", "engines": { - "node": ">=20" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { - "hono": "^4 || ^5.0.0-0" + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/@jridgewell/gen-mapping": { @@ -489,6 +519,17 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -517,6 +558,15 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lukeed/csprng": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", + "integrity": "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@napi-rs/lzma-linux-x64-gnu": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", @@ -534,234 +584,499 @@ "node": "^22.20 || ^24.12 || >=25" } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.67.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.67.4.tgz", - "integrity": "sha512-/v/pUAPOcwp+r1Hjjdg0cBV1TE89CzGKrKh2FgkIQw2QBcIMmVPOCSfuOZStCn4Xh/98HXfo/ht6ztcssctPNg==", - "cpu": [ - "arm" - ], + "node_modules/@nestjs/cli": { + "version": "12.0.11", + "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-12.0.11.tgz", + "integrity": "sha512-ExNgbKfhwMNXCV6FbQQnQOcxuubkDPcpYXUh+jkFn5Xm+WkdP+wjYS0KqyZgFSfpY24mST6y50UOEHVsvQME1A==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "dependencies": { + "@angular-devkit/core": "22.2.3", + "@angular-devkit/schematics": "22.2.3", + "@angular-devkit/schematics-cli": "22.2.3", + "@inquirer/prompts": "8.7.3", + "@nestjs/schematics": "^12.0.0", + "ansis": "4.4.0", + "chokidar": "5.0.0", + "cli-table3": "0.6.5", + "commander": "15.0.0", + "jsonc-parser": "3.3.1", + "minimatch": "10.2.6", + "node-emoji": "2.2.0", + "ora": "9.4.1", + "tsconfig-paths": "4.2.0", + "typescript": "~6.0.2" + }, + "bin": { + "nest": "bin/nest.js" + }, + "engines": { + "node": ">= 20.11" + }, + "peerDependencies": { + "@rspack/core": "^1.7.7 || ^2.1.10", + "@swc/cli": "^0.8.0", + "@swc/core": "^1.15.18", + "fork-ts-checker-webpack-plugin": "^9.1.0", + "ts-loader": "^9.5.4", + "tsconfig-paths-webpack-plugin": "^4.2.0", + "webpack": "^5.105.4", + "webpack-node-externals": "^3.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "@swc/cli": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "fork-ts-checker-webpack-plugin": { + "optional": true + }, + "ts-loader": { + "optional": true + }, + "tsconfig-paths-webpack-plugin": { + "optional": true + }, + "webpack": { + "optional": true + }, + "webpack-node-externals": { + "optional": true + } + } }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.67.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.67.4.tgz", - "integrity": "sha512-6tmmB/P52Vt167FXXEw5auiEUgTtZz7aqUgWHqYXToIUE0HFDY6fTdAyeekjjsNk6O/8dtpSKtClxSj1mMneKQ==", - "cpu": [ - "arm64" - ], + "node_modules/@nestjs/cli/node_modules/@nestjs/schematics": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-12.0.0.tgz", + "integrity": "sha512-ilsxam7i+SWd2psr12faLVSk86FYKnxRUGO6jAGNi1P9+O0kRaYVOScyrKeIj97W9T9UJoJF38fg+7KgjY/nOg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "dependencies": { + "@angular-devkit/core": "22.1.5", + "@angular-devkit/schematics": "22.1.5", + "comment-json": "5.0.0", + "jsonc-parser": "3.3.1", + "pluralize": "8.0.0" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "prettier": "^3.0.0", + "typescript": ">=6.0.0" + }, + "peerDependenciesMeta": { + "prettier": { + "optional": true + } + } }, - "node_modules/@rollup/rollup-android-x64": { - "version": "4.67.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-x64/-/rollup-android-x64-4.67.4.tgz", - "integrity": "sha512-8Z9Q4RmotVmuL4llOvqX814JNiOK1oqoBv+glG5KbpRfUiypqzz2/fxIzK4Pq129l4dT5a8Gszmpc0/XPYoFuQ==", - "cpu": [ - "x64" - ], + "node_modules/@nestjs/cli/node_modules/@nestjs/schematics/node_modules/@angular-devkit/core": { + "version": "22.1.5", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-22.1.5.tgz", + "integrity": "sha512-HiY6d5dkIdJs5grP9OHvgkf14QOcDIo+hbuT7YKuLItQ++ZxCwUabJMLCCJ5R0KrstE5NqED0dNcyk5WD01t5Q==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "dependencies": { + "ajv": "8.20.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.5", + "rxjs": "7.8.2", + "source-map": "0.7.6" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^5.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.67.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.67.4.tgz", - "integrity": "sha512-pnq6hssxTtwuRAl6Kdi/+IDY+b1IhkUetjhUkdLAAJCssjfqtL2Oqy/FXJVJFtAWOu1oRlArIvf0Yk1O92x2HQ==", - "cpu": [ - "arm64" - ], + "node_modules/@nestjs/cli/node_modules/@nestjs/schematics/node_modules/@angular-devkit/schematics": { + "version": "22.1.5", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-22.1.5.tgz", + "integrity": "sha512-HTmo9y8wjXKtJGVTYomTjZsjWzhirpu1pPRAzsVob3eiYOuxv1qDRAgWiHXNGp5CpnbHunZweXY/WffNGOFRyA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] + "dependencies": { + "@angular-devkit/core": "22.1.5", + "jsonc-parser": "3.3.1", + "magic-string": "1.0.0", + "ora": "9.4.1", + "rxjs": "7.8.2" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.67.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.67.4.tgz", - "integrity": "sha512-BeIrGZvHMVdJg/N+ohZfFywG/gF41684YhDj2wBvouj7m9ziP1tZDUNwzXRiDY+3WIKngaC4hiUBqVtBPGP5og==", - "cpu": [ - "x64" - ], + "node_modules/@nestjs/cli/node_modules/magic-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.0.0.tgz", + "integrity": "sha512-CGvjzMN08iv6w1mm4/x3Gh1hLb4VnyRUA15FFpl6CsCIGGoe36k7kY5KNz9QDbSBN5I/fWHM6ZlIkUTa5xdUEA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.67.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.67.4.tgz", - "integrity": "sha512-fRFX/oqNg/g780DKoS8RIfYcZD59Ru5O/bSeK1N7gQljBFrTxJY/q5EX4eMs5SrJf32VgVBVVYCdfPpzAwPPnQ==", - "cpu": [ - "arm64" - ], + "node_modules/@nestjs/cli/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": [ - "freebsd" - ] + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.67.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.67.4.tgz", - "integrity": "sha512-bSrBRopG1q+0RuOsW0NtYTtIODlvNA3T3ebx5WnQVdSxWIoXFmj+rZdHUHdSLgFdZ5M6VGmekYE7r4RMGQXjfg==", + "node_modules/@nestjs/cli/node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/@nestjs/cli/node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/@nestjs/common": { + "version": "12.3.2", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-12.3.2.tgz", + "integrity": "sha512-XPNu75rXiDBuQ/EECiA0urFJ7bAycA1/xTrKnKct5vEF7A5FE0LyOem/EhRZI0Cq1xAGPDuTB89sFMqeSPWjzQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "1.1.0", + "file-type": "22.2.0", + "iterare": "1.2.1", + "load-esm": "1.0.3", + "tslib": "2.8.1", + "uid": "2.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "class-transformer": ">=0.4.1", + "class-validator": ">=0.13.2", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, + "node_modules/@nestjs/core": { + "version": "12.3.2", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-12.3.2.tgz", + "integrity": "sha512-lCDN5iC4BoG9k5Sk/tG2EF4VAtoZABFjN4e0nk9xKK3ytD4eSMc9ma1Y092t/navenlf3roTtCDUZ/RIHzYjCw==", + "license": "MIT", + "dependencies": { + "fast-safe-stringify": "2.1.1", + "iterare": "1.2.1", + "path-to-regexp": "8.4.2", + "tslib": "2.8.1", + "uid": "2.0.2" + }, + "engines": { + "node": ">= 20" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^12.0.0", + "@nestjs/microservices": "^12.0.0", + "@nestjs/platform-express": "^12.0.0", + "@nestjs/websockets": "^12.0.0", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "@nestjs/microservices": { + "optional": true + }, + "@nestjs/platform-express": { + "optional": true + }, + "@nestjs/websockets": { + "optional": true + } + } + }, + "node_modules/@nestjs/platform-express": { + "version": "12.3.2", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-12.3.2.tgz", + "integrity": "sha512-wbe1zd6xWLW8qI6t3RP3SqqQnp32pVAfXKqttcp/geL5ys6xw6c/HkASXLNvwlf7IzMLxnpqANtHHwcF4waqRA==", + "license": "MIT", + "dependencies": { + "cors": "2.8.6", + "express": "5.3.0", + "multer": "2.5.0", + "path-to-regexp": "8.4.2", + "tslib": "2.8.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^12.0.0", + "@nestjs/core": "^12.0.0" + } + }, + "node_modules/@nestjs/testing": { + "version": "12.3.2", + "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-12.3.2.tgz", + "integrity": "sha512-l1m+uHTp4hJ1K+Gw8ENrLSj2TxZlKVre7aoluUdxMHCodK2kZnpjvD8H8Oh460hj/Z6PDJ/m9r6t8NIK6OyDxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^12.0.0", + "@nestjs/core": "^12.0.0", + "@nestjs/microservices": "^12.0.0", + "@nestjs/platform-express": "^12.0.0" + }, + "peerDependenciesMeta": { + "@nestjs/microservices": { + "optional": true + }, + "@nestjs/platform-express": { + "optional": true + } + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@oxfmt/binding-android-arm-eabi": { + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.75.0.tgz", + "integrity": "sha512-zHJ7LcTN46/5mtoLxsUu14pdF9ixFwDURtmxZ0CeNwqom5qB5NRVqU6o2GRZ7QPdNBW8G6j9UTJzQ+EDevMH0w==", "cpu": [ - "x64" + "arm" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "freebsd" - ] + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.67.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.67.4.tgz", - "integrity": "sha512-L8IvdrJVtl65g1ozwDSyjGPG+FmibhCdFUwmqTItJLw0S3+Oj4QzFs7rfgTUiK7rsPjkCLdG1/GApFxoynXFJQ==", + "node_modules/@oxfmt/binding-android-arm64": { + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.75.0.tgz", + "integrity": "sha512-IdxsKZJvHZUvI7q6kLmzXdEFpFiBEDQw/WJtSp1QlzNXBw1JlLN3tY50WwWd4doPnq86XW4P+7BsQwu6meCW+w==", "cpu": [ - "arm" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" - ] + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.67.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.67.4.tgz", - "integrity": "sha512-h7PcoQOzC31jshNpQ48J0akB9tsghjkSjSl5AZ6XsVGTxVsln7t7C4SQyw62tNVSaz2SeHgk4VT2ELbL17BSHQ==", + "node_modules/@oxfmt/binding-darwin-arm64": { + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.75.0.tgz", + "integrity": "sha512-vyiqZilO5jal/nXZ0143NyKcP6TkzjaQdVEWU/kwgyWqugAgIRgAbrnO3MOF+e6LkQu3CrtrkPoc3mrsZZY6cA==", "cpu": [ - "arm" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" - ] + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.67.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.67.4.tgz", - "integrity": "sha512-KSPuOBNHoOl7AsVjeY1KZci8+0up0U4TFAHU5IzBYN8P+Z6cB9DZq28+JQycZW7f6WLLa/tSzhV7Rdy95bVi+g==", + "node_modules/@oxfmt/binding-darwin-x64": { + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.75.0.tgz", + "integrity": "sha512-oua7eVqVe769mDam6ORcavygxTzG3ovo5QRQ1kJCPaEbnWXPYhtAiYDj8pajVmFjO7j8Elf3gInnWpTX0LLYtg==", "cpu": [ - "arm64" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" - ] + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.67.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.67.4.tgz", - "integrity": "sha512-0E/mKmVk1PlQeQYygIwQm0jlwjANTzGSiNBMPrFx+rdFGFj/2Axh4p6XwcrystQIqXZVudTaGsdbDpzYgJ4wSA==", + "node_modules/@oxfmt/binding-freebsd-x64": { + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.75.0.tgz", + "integrity": "sha512-NWkbezhg7mx2Ep94g/TysR3c1bsISsU4lQ6t5xI4841PAk0Kqcn1+UEe+BoFH2/UK2YegPXq+OF+0bUcSvUalA==", "cpu": [ - "arm64" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" - ] + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.67.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.67.4.tgz", - "integrity": "sha512-Fvw7dOSc2Sa9UOipRZVI0zOgdLCzsjVyD/6JuHXzUHTXFu7mMgmeX/0/bCVBIut1dz+nWz3pV4WpS/Qg0XVnxw==", + "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.75.0.tgz", + "integrity": "sha512-CAHX87kJplTq2E0OWP7opG8gEVlHc7CeLEi4nMJOyx3psuqBKf84HZ3UIkX2guWEJ5UljU2zJF0o46XylywVVA==", "cpu": [ - "loong64" + "arm" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.67.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.67.4.tgz", - "integrity": "sha512-jzxbVHaE7V3F+P0wEK7UydQtvv7Twfdtx58jI5/3VRx1jJxyrn+qFwfXmqjVFxPhBl+zjaRs+ZJMFJw7vdHaQA==", + "node_modules/@oxfmt/binding-linux-arm-musleabihf": { + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.75.0.tgz", + "integrity": "sha512-jmxYOiQzx2fp+OwQb7717K1zI8AMKEykw04sfr95/mU8SIL/oKgQq8nXH42sEznEquBL2RU7nLY36eBnYFT+vg==", "cpu": [ - "loong64" + "arm" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.67.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.67.4.tgz", - "integrity": "sha512-fhtrzp9p4GjEJVaigaap9g6X9nP6xgJtKiLLXSDgpE18HXufA5aqfltGn+KAqMtVZReJZfOrXm//y5YSA9BWbA==", + "node_modules/@oxfmt/binding-linux-arm64-gnu": { + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.75.0.tgz", + "integrity": "sha512-eimaiAhx08dbOg12Fj3PYhymXY8vRbaiJB505vDXMy6ohne3Y1qTS0e5KEwFMq3LmDjEO+16BX38c/oXFI/SAQ==", "cpu": [ - "ppc64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.67.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.67.4.tgz", - "integrity": "sha512-uynaF7IUHwAckV0S+yC1f5esy9gB+aqCBsPH5BOck32TYdCuAn2lVG5tvUcFnEsrviGEmRZxE1NHSOieUPYJEg==", + "node_modules/@oxfmt/binding-linux-arm64-musl": { + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.75.0.tgz", + "integrity": "sha512-bjSONIG1ba2xugr9oHQau0E6oPFYOMjPaa5hK/wpVC/Q1pcgf2BZMKwbuA8/cWzhWX5QV/UdPzD+rw1kQHpikQ==", "cpu": [ - "ppc64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.67.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.67.4.tgz", - "integrity": "sha512-uaqRFdfb8eIa2bVHN/FE3ndCvR0zV2xv0AwjCfxrqSu6l6fp/54VhjfhslKlf2RIik9L1jwlt4E56PeVX6tQtQ==", + "node_modules/@oxfmt/binding-linux-ppc64-gnu": { + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.75.0.tgz", + "integrity": "sha512-wDksWQXC/KZucISyqlhBtvpW+pyHh8z0Cjm6Qxiv22XlllM2Bx5gJGDnPYjV+H0lL+o1iqLlEli8jmBuRCLcjw==", "cpu": [ - "riscv64" + "ppc64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.67.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.67.4.tgz", - "integrity": "sha512-JZRow7kmxUDEASnxHuVG5itTm+nTXi9BQUABcVUnUEngC+OPwMrpx1uJ9hIA/v+IcAEBL52ktCSPXk5OJn9Jqg==", + "node_modules/@oxfmt/binding-linux-riscv64-gnu": { + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.75.0.tgz", + "integrity": "sha512-5JkNwViqaVuk4I+9+zgVrEYiHOAPXFxgDgP6iDnpdOBjm7XAHAOjSvolHZZAokWPx7CGOw8tKteXmn4WPIInaQ==", "cpu": [ "riscv64" ], @@ -770,40 +1085,49 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.67.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.67.4.tgz", - "integrity": "sha512-rdrh4e+Lq9fCNKoDPaCqeHXbQCe7yl77Ukl45tlSClwYdZ1gpFGZz1XLJdYtY++FRlfL5aHvb6/0n3GDW/Nxsw==", + "node_modules/@oxfmt/binding-linux-riscv64-musl": { + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.75.0.tgz", + "integrity": "sha512-1EVqRIE0eZLB08j3ZCVN47Ors7LCXGmOI+3xJYgyoFUKUerD3Glxb+Q1Bp3lK8NeT5R3ZdS9uhX/wobNBYZ6jw==", "cpu": [ - "s390x" + "riscv64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.67.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.67.4.tgz", - "integrity": "sha512-wL7MS4r0fYcxluF3ne2plofHZiB/LsqNCnyaUlyiKpAJLb6agPSFz7DEFCfkonWAZXMrLwCmC08b2ZEnEdJkog==", + "node_modules/@oxfmt/binding-linux-s390x-gnu": { + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.75.0.tgz", + "integrity": "sha512-UJWRVNT6Yuw4ssLv2BwaBUWU1GWtByE+EnoHu8I+P1t8Kn+WozYU5WYOqb5aj8gbXJJ8gGZt+w40Hi/7L6iVLw==", "cpu": [ - "x64" + "s390x" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.67.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.67.4.tgz", - "integrity": "sha512-4H0XHfd2VufGjX7NjUyeue78sD0bRIoKSGjGqO+F5nSvL6jN48NAU7esdmtFEYxZXwObLtt3MJ/kHS0jASMW3g==", + "node_modules/@oxfmt/binding-linux-x64-gnu": { + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.75.0.tgz", + "integrity": "sha512-FHdvHhyz8ChBctBUvVGti49kSEwANd1PZQwlXUEoF9Ed4plxXxk/wWc0mfZmrze2lRgmME7ytDmVIOixYauQhg==", "cpu": [ "x64" ], @@ -812,12 +1136,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.67.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.67.4.tgz", - "integrity": "sha512-osRHQbE55pZpCLetpMsFmhkapyZnAUH73qRKMh+npi3boUom6u6hdaTTPSxlInXxwJHsn9r2uDhdz0V7Yoxx2A==", + "node_modules/@oxfmt/binding-linux-x64-musl": { + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.75.0.tgz", + "integrity": "sha512-6MjtsF96s9mSBV0GEwUe4pOFPP2Mz7ahcHPAit0DSKL5fM6oMM7RG1zvYxwB7+SBkIxsWBRhhxuqmy7pibNwsA==", "cpu": [ "x64" ], @@ -825,13 +1152,16 @@ "license": "MIT", "optional": true, "os": [ - "openbsd" - ] + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.67.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.67.4.tgz", - "integrity": "sha512-95+exYEyOwrZePsE0czi4q7X5lxKPa21+lmyAZlwbYENITQ0Unk4HAQAFp/p+KhXBHBx9mUABXRYjOC5A1wY3Q==", + "node_modules/@oxfmt/binding-openharmony-arm64": { + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.75.0.tgz", + "integrity": "sha512-1D6T6aL1Pj+U5GLxqwV1fKjUJ5+FFm+bn7UrqNDMveZmWs0jo/3vc+dzzuV/jF/FdVo12gowODk3SnAO2YOwLg==", "cpu": [ "arm64" ], @@ -840,12 +1170,15 @@ "optional": true, "os": [ "openharmony" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.67.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.67.4.tgz", - "integrity": "sha512-FOXOcKhNQpO4Po+Ynan6IbtM67SrXZbgpRJWVYwz53CT5veI7sVzDwVBmWS65N5Imv+wK3SVhIPAl+C5DXXNCg==", + "node_modules/@oxfmt/binding-win32-arm64-msvc": { + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.75.0.tgz", + "integrity": "sha512-RX2eUmy7J/nM8+tnM3nzR8t1eqkwj1q8Kgo7beA+4DVgKYYHHyrEa3qEmthxw7Z+HqpCCEOWuoqa+jelo9/w2Q==", "cpu": [ "arm64" ], @@ -854,49 +1187,1083 @@ "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { + "node_modules/@oxfmt/binding-win32-ia32-msvc": { + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.75.0.tgz", + "integrity": "sha512-UpKta0iSZmPLII0dYXYdbzdBTsGREUbSHLqgLdtKcqBvs40qjmUz+cgUcaU1smPx13DfbEG4XqnaSn5MoFN0og==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-x64-msvc": { + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.75.0.tgz", + "integrity": "sha512-pjiLiCk0QMCWxs0n5XiNU85YLAKA+wJxPjxlDVygmr4bP4glcfTU2nOpso5nR4Rdi2Pxv5YwakD4UE+eh7M0BQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.90.0.tgz", + "integrity": "sha512-sgfalb1HuxYjiAa4wESZ626z1FGusqNcXYHyB5oIOEprS2sbPg0UYEfo83uF1GNG70DIfZxHkXuE6KFrlSdn8Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.90.0.tgz", + "integrity": "sha512-RcKhz1ETMq+fazE9TKS2+5NIRGwigm/nW8egqF0LI+A8Yw2iW5r0E/C3XAPRiwIm/vcPiutIRIa6IQCIKhZsTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.90.0.tgz", + "integrity": "sha512-PSrxMvP6mx+p1karZcUxLdy9lpaP+JWQPti4al9FgTaPhxE+pT/BjhGExloVVSWgZgIfOlLOjVyyq8BXlodhLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.90.0.tgz", + "integrity": "sha512-OLbU9qsrV2Q8QxT2OEzOPdO8zCCpyI8qB+hDI8yr6RsYELtkf0FAZhJdfjM/RnLHKzt44HWwAPnk92vBtKofvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.90.0.tgz", + "integrity": "sha512-fBA4pWyVDARmNucQqpyFuPC49xYf1r+sX7dxPL5fNPQrSMLum39Sn56tZ9JPleWC7FzLPwSQ7plYYfirYBHF8A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.90.0.tgz", + "integrity": "sha512-zhxH78eP2nR6/pGdhBc7MIfjhhcgATeu8fkmkFJbuzfk6Cj0FzBxyEhL85yDqrCybjx+G2YDzZGsxIO9kvO4Iw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.90.0.tgz", + "integrity": "sha512-MvYmpHyJqUJjEvkZbJHdwfdL45cGn8EVBkYmoXFiaYFW9suoD3dbJl5mBdpy0DVgdjlyddmldbRtk3cCgETadQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.90.0.tgz", + "integrity": "sha512-L13Sy+Kg0nZhwTsToniqjUFPA2TuM6962qCZ5Wp5KeB517Tw7ApdLTTtlb+bF86fcZZa2EtvCVvAJxk4Dya/MQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.90.0.tgz", + "integrity": "sha512-wFDdZWGpzIPFdJFjSLWnKB0TGzFriTKUIrl04iMqMyM9BBIJTuerfCHYa0WpDSjr5x3lcW/CHLyacSt886WpGg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.90.0.tgz", + "integrity": "sha512-FRywfn+MoKn8xpBSzgNwNVliSKRI2QXkxi/iF7o6FEQs8Bh8z2RpVTaFAcO3pchXjvB2eGNFIoLLQABIQ+u7VA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.90.0.tgz", + "integrity": "sha512-IS2PaUgpc/qeLADTyhUNmdx3x4ijs9pjJdPKBYHaT+mAE7k3FsjnHPTjhpsak1b++h6CIPhv6WX9Z+y7Pw0voQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.90.0.tgz", + "integrity": "sha512-s7JFKRkGS8O7i2ZVODfuO++NC7sH6HDqpMn8lYxw91w0SXJOxnAVjQ8utvmDwLMMT0jaSkuOKuUYJDGDtu4sHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.90.0.tgz", + "integrity": "sha512-b0GxrOQr5VONCkd7KLSqmP6b6nqZuTpuS8QPQHzltmqdXP5bY0Jl0CZ0QmlCTrS3ExaLYbioFYOARzNbYvcApQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.90.0.tgz", + "integrity": "sha512-iaJeShJtgUp8rZbt9YUdTAGOpmX5KogCLba17Jp1tWdJ3F4M3D2g2TKUyI1FMTHxKH9NNHuQcuC2kZRg6IV7yQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.90.0.tgz", + "integrity": "sha512-/aZoXnL5WBLCXuzfHqvEFXcm4iwYmr/cYByHDTXzLFzJdE5DC7bm6phpWqHv6BeqpVgY9IpBAxGDv2WEkl5JCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.90.0.tgz", + "integrity": "sha512-SMhk7ozwoxYDKfYqGZCQEfULOKDneF9uhaoIf2neoDLWvNtjY9yVeL7YHmE5L9WdKvMpWNwwZxhenxBu6AgfOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.90.0.tgz", + "integrity": "sha512-KBvR3A9fO7fCi2vCP8ifWvlia7tGEQV9NaYh+IGwa/7Groy48Mt/UaYqMK9/zOtAWl20BUCf6RPqzJ32fRw33Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.90.0.tgz", + "integrity": "sha512-+x42rDHQGbTCuT3EzpWAzhP3NoVPE/cJsG4gnrakOSg0olV+GCdMpPU23Nvrl3zZMnXxFZdTtDv4D3SuaA5ZeQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.90.0.tgz", + "integrity": "sha512-30JiaFEBUB9peEOcvVM8ggo88SoN/IdSr9DW505JdBji/R6dM66gKEiG65hw6qcaF2f4mO0cEJ+RwRZprmun9g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "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/@rollup/pluginutils": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.5.1.tgz", + "integrity": "sha512-P9ZvnfouRu4MSC+ilERaw6FmZs8zqKhpHxEs4aGDGWQAMzQGXnhvCSvoNwokfNvdt7t3Y5Y4p6gStQgezeIqug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.67.4.tgz", + "integrity": "sha512-/v/pUAPOcwp+r1Hjjdg0cBV1TE89CzGKrKh2FgkIQw2QBcIMmVPOCSfuOZStCn4Xh/98HXfo/ht6ztcssctPNg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.67.4.tgz", + "integrity": "sha512-6tmmB/P52Vt167FXXEw5auiEUgTtZz7aqUgWHqYXToIUE0HFDY6fTdAyeekjjsNk6O/8dtpSKtClxSj1mMneKQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-x64": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-x64/-/rollup-android-x64-4.67.4.tgz", + "integrity": "sha512-8Z9Q4RmotVmuL4llOvqX814JNiOK1oqoBv+glG5KbpRfUiypqzz2/fxIzK4Pq129l4dT5a8Gszmpc0/XPYoFuQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.67.4.tgz", + "integrity": "sha512-pnq6hssxTtwuRAl6Kdi/+IDY+b1IhkUetjhUkdLAAJCssjfqtL2Oqy/FXJVJFtAWOu1oRlArIvf0Yk1O92x2HQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.67.4.tgz", + "integrity": "sha512-BeIrGZvHMVdJg/N+ohZfFywG/gF41684YhDj2wBvouj7m9ziP1tZDUNwzXRiDY+3WIKngaC4hiUBqVtBPGP5og==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.67.4.tgz", + "integrity": "sha512-fRFX/oqNg/g780DKoS8RIfYcZD59Ru5O/bSeK1N7gQljBFrTxJY/q5EX4eMs5SrJf32VgVBVVYCdfPpzAwPPnQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.67.4.tgz", + "integrity": "sha512-bSrBRopG1q+0RuOsW0NtYTtIODlvNA3T3ebx5WnQVdSxWIoXFmj+rZdHUHdSLgFdZ5M6VGmekYE7r4RMGQXjfg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.67.4.tgz", + "integrity": "sha512-L8IvdrJVtl65g1ozwDSyjGPG+FmibhCdFUwmqTItJLw0S3+Oj4QzFs7rfgTUiK7rsPjkCLdG1/GApFxoynXFJQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.67.4.tgz", + "integrity": "sha512-h7PcoQOzC31jshNpQ48J0akB9tsghjkSjSl5AZ6XsVGTxVsln7t7C4SQyw62tNVSaz2SeHgk4VT2ELbL17BSHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.67.4.tgz", + "integrity": "sha512-KSPuOBNHoOl7AsVjeY1KZci8+0up0U4TFAHU5IzBYN8P+Z6cB9DZq28+JQycZW7f6WLLa/tSzhV7Rdy95bVi+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.67.4.tgz", + "integrity": "sha512-0E/mKmVk1PlQeQYygIwQm0jlwjANTzGSiNBMPrFx+rdFGFj/2Axh4p6XwcrystQIqXZVudTaGsdbDpzYgJ4wSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.67.4.tgz", + "integrity": "sha512-Fvw7dOSc2Sa9UOipRZVI0zOgdLCzsjVyD/6JuHXzUHTXFu7mMgmeX/0/bCVBIut1dz+nWz3pV4WpS/Qg0XVnxw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.67.4.tgz", + "integrity": "sha512-jzxbVHaE7V3F+P0wEK7UydQtvv7Twfdtx58jI5/3VRx1jJxyrn+qFwfXmqjVFxPhBl+zjaRs+ZJMFJw7vdHaQA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.67.4.tgz", + "integrity": "sha512-fhtrzp9p4GjEJVaigaap9g6X9nP6xgJtKiLLXSDgpE18HXufA5aqfltGn+KAqMtVZReJZfOrXm//y5YSA9BWbA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.67.4.tgz", + "integrity": "sha512-uynaF7IUHwAckV0S+yC1f5esy9gB+aqCBsPH5BOck32TYdCuAn2lVG5tvUcFnEsrviGEmRZxE1NHSOieUPYJEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.67.4.tgz", + "integrity": "sha512-uaqRFdfb8eIa2bVHN/FE3ndCvR0zV2xv0AwjCfxrqSu6l6fp/54VhjfhslKlf2RIik9L1jwlt4E56PeVX6tQtQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.67.4.tgz", + "integrity": "sha512-JZRow7kmxUDEASnxHuVG5itTm+nTXi9BQUABcVUnUEngC+OPwMrpx1uJ9hIA/v+IcAEBL52ktCSPXk5OJn9Jqg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.67.4.tgz", + "integrity": "sha512-rdrh4e+Lq9fCNKoDPaCqeHXbQCe7yl77Ukl45tlSClwYdZ1gpFGZz1XLJdYtY++FRlfL5aHvb6/0n3GDW/Nxsw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.67.4.tgz", + "integrity": "sha512-wL7MS4r0fYcxluF3ne2plofHZiB/LsqNCnyaUlyiKpAJLb6agPSFz7DEFCfkonWAZXMrLwCmC08b2ZEnEdJkog==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.67.4.tgz", + "integrity": "sha512-4H0XHfd2VufGjX7NjUyeue78sD0bRIoKSGjGqO+F5nSvL6jN48NAU7esdmtFEYxZXwObLtt3MJ/kHS0jASMW3g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.67.4.tgz", + "integrity": "sha512-osRHQbE55pZpCLetpMsFmhkapyZnAUH73qRKMh+npi3boUom6u6hdaTTPSxlInXxwJHsn9r2uDhdz0V7Yoxx2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.67.4.tgz", + "integrity": "sha512-95+exYEyOwrZePsE0czi4q7X5lxKPa21+lmyAZlwbYENITQ0Unk4HAQAFp/p+KhXBHBx9mUABXRYjOC5A1wY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.67.4.tgz", + "integrity": "sha512-FOXOcKhNQpO4Po+Ynan6IbtM67SrXZbgpRJWVYwz53CT5veI7sVzDwVBmWS65N5Imv+wK3SVhIPAl+C5DXXNCg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.67.4", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.67.4.tgz", "integrity": "sha512-cklKh0gJOkAZQcWziQa3hrjxZKN+PCHZ7SPTyGJDAiBVr05ZXlEjGFCG7zFJMaVibxsmzAe1FimN4u/HBns9+Q==", "cpu": [ - "ia32" + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.67.4.tgz", + "integrity": "sha512-AegwxJPc9bvUp8fQtlsUHkFMOMkGylWU7glyL+YD4vwG+QaYoGXYLSsQoeTD7kvoLZrVlMWNVhTg1dedY5C1VQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.67.4.tgz", + "integrity": "sha512-RJZnrwTWj7Iq2ooBwoGbDdC65tDwYawneGcAfFMJqVUrRZMcskka6nTkBpXR/47G7kzn3EW2qupExnaBy11vwg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@swc/core": { + "version": "1.16.21", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.16.21.tgz", + "integrity": "sha512-sE2asan0US6gcp11oBDFJb5+s+ltaAPBcagVQGub1+qo+8TsQET2TmXfJCw/Oy0w1eEPp1rV1wsR/Umx2KXGcw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.28" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.16.21", + "@swc/core-darwin-x64": "1.16.21", + "@swc/core-linux-arm-gnueabihf": "1.16.21", + "@swc/core-linux-arm64-gnu": "1.16.21", + "@swc/core-linux-arm64-musl": "1.16.21", + "@swc/core-linux-ppc64-gnu": "1.16.21", + "@swc/core-linux-s390x-gnu": "1.16.21", + "@swc/core-linux-x64-gnu": "1.16.21", + "@swc/core-linux-x64-musl": "1.16.21", + "@swc/core-win32-arm64-msvc": "1.16.21", + "@swc/core-win32-ia32-msvc": "1.16.21", + "@swc/core-win32-x64-msvc": "1.16.21" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.16.21", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.16.21.tgz", + "integrity": "sha512-VRreJpbRygpSD1hj3HBEOIA1xjXYnhB+IfcKsxZRhCIBGRP4UkqxZH4/ipFh/heIRp7EKQ56E8ygwEftLNCWXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.16.21", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.16.21.tgz", + "integrity": "sha512-KqCJ04pMYDBUYpAOFGUFR8TEjqo4h0Vtwe/za9xNRDKsxSDnjgM7HVhfO0uNNpMMlM2ao2E6d0iLpApIRvNGnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.16.21", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.16.21.tgz", + "integrity": "sha512-tLkw3uWxQj69qBRX5Xm8vbq+5iX5e/ktoPXhrDeFFa2jNMc+AWcxa0bqwatvVGK40v5mcmz6Ulu5Nk9jF8ciMA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.16.21", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.16.21.tgz", + "integrity": "sha512-lFHVlHB0B5C7FwyprWJQp7b2iF/ohj2RqnMiv8uCnFJc+rYP0Py2I7ftZ+rOX4Zu7Ra9eMk7GnmuxAywCZYUJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.16.21", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.16.21.tgz", + "integrity": "sha512-gXDgrMMDsWT7r5gZsml10snBS9yaoVYwU9vGffa+DAts4ctEMrMHv+G8AH+ZxCV9gr5MfdgtGGQKjXjLay4mXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-ppc64-gnu": { + "version": "1.16.21", + "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.16.21.tgz", + "integrity": "sha512-hZF7J2YzXiADZ4eQxTFOWJcSSy3fbUR8/DcCXqiBHh0bVyLl2Y+O5pVWxk/r/XV5tA9n7DnnLlvkPPsHoajE9w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-s390x-gnu": { + "version": "1.16.21", + "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.16.21.tgz", + "integrity": "sha512-rmtGwkAlbl+fQMGrGUMklUmByueOmt1ju9sKsE5YWVA0KfFYM0W87WVx4lFYQXt+E4P3zkLjeqo/AUa5mFbF1A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.16.21", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.16.21.tgz", + "integrity": "sha512-EUJsyrFw3aNE64iQzk3bDqjADgl4l0vpxfwVMjMuZIooNg35yKqvkw3bYNEhQyrBDY0+5kKoiirGLpth3Wf3lA==", + "cpu": [ + "x64" ], "dev": true, - "license": "MIT", + "license": "Apache-2.0 AND MIT", "optional": true, "os": [ - "win32" - ] + "linux" + ], + "engines": { + "node": ">=10" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.67.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.67.4.tgz", - "integrity": "sha512-AegwxJPc9bvUp8fQtlsUHkFMOMkGylWU7glyL+YD4vwG+QaYoGXYLSsQoeTD7kvoLZrVlMWNVhTg1dedY5C1VQ==", + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.16.21", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.16.21.tgz", + "integrity": "sha512-ZXUnLjxTHxvNhVmQmYlb8XNKArfi/MiGoW5DmfuME8ttCLH9OJTqJVFy7g1yYxUCgw3tU6dF1uU59N8P/Yr5Pw==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.16.21", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.16.21.tgz", + "integrity": "sha512-jnnQpAHiWZgEobVfOPvJJzm1Jh4ZEIKh2IqcDqHv6zJcjvaEkHdkt+qdRsJ9AqvK7g18WBRHTnvKm5IxdbkagA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": ">=10" + } }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.67.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.67.4.tgz", - "integrity": "sha512-RJZnrwTWj7Iq2ooBwoGbDdC65tDwYawneGcAfFMJqVUrRZMcskka6nTkBpXR/47G7kzn3EW2qupExnaBy11vwg==", + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.16.21", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.16.21.tgz", + "integrity": "sha512-TaVTnNm9c1I+VG3hJUt11uspwbTrTQ9+fUPzODlvx4tj/ChYqUm4cKTIHw3O97hILxpmhBR0hbvLEs9seOV59A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.16.21", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.16.21.tgz", + "integrity": "sha512-aPv58Clf6SdAQtouzDYKbEtcCETY2hOXlrHgSFnFIJQOlMWINHSGazaM1Iv63/9wOUDPq6+0ZFytMqnPtER9OQ==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", + "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@swc/types": { + "version": "0.1.28", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.28.tgz", + "integrity": "sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, + "node_modules/@tokenizer/inflate": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.5.2.tgz", + "integrity": "sha512-3AZIuReWQ7i916s/UWAzy4V4k2wrkAZLW2sTi448W3w7Hz5uoc+Y7h+cnwzr0cwrwf3PsC5xKsH8MOl0NRltlg==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "strtok3": "^10.6.2", + "token-types": "^6.1.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" }, "node_modules/@types/better-sqlite3": { "version": "7.6.13", @@ -908,6 +2275,34 @@ "@types/node": "*" } }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.10.tgz", @@ -915,6 +2310,45 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz", + "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.20.5", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", @@ -925,17 +2359,203 @@ "undici-types": "~6.21.0" } }, - "node_modules/acorn": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", - "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", "dev": true, "license": "MIT", - "bin": { - "acorn": "bin/acorn" + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/superagent": { + "version": "8.1.11", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.11.tgz", + "integrity": "sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.1.tgz", + "integrity": "sha512-4CbBvoYVLHL7+yhbYrZET0vsvuyXTC05aRe7dNQkwMzm56auceoy6Yu3K50uZmwfHna1os3CMSgM/3QVkUtPTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" }, "engines": { - "node": ">=0.4.0" + "node": ">= 0.6" } }, "node_modules/agent-base": { @@ -947,13 +2567,108 @@ "node": ">= 14" } }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "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==", + "dev": true, + "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": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.4.0.tgz", + "integrity": "sha512-9k3v7xcHwgdO/DruxGIg4HtjvlAZlcnsX/mzqUb1t3NkYnl9kK2UJ+Gq0io+vQf7iT//BD/HB/NBkUR1LWxoeA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/array-timsort": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", + "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "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/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "dev": true, "license": "MIT" }, + "node_modules/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/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -1017,6 +2732,43 @@ "readable-stream": "^3.4.0" } }, + "node_modules/body-parser": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz", + "integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.1.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.3", + "on-finished": "^2.4.1", + "qs": "^6.16.1", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -1047,20 +2799,24 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, - "node_modules/bundle-require": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", - "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", - "dev": true, - "license": "MIT", + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", "dependencies": { - "load-tsconfig": "^0.2.3" + "streamsearch": "^1.1.0" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "peerDependencies": { - "esbuild": ">=0.18" + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" } }, "node_modules/cac": { @@ -1102,17 +2858,74 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/chardet": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", + "dev": true, + "license": "MIT" + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", "dev": true, "license": "MIT", "dependencies": { - "readdirp": "^4.0.1" + "readdirp": "^5.0.0" }, "engines": { - "node": ">= 14.16.0" + "node": ">= 20.19.0" }, "funding": { "url": "https://paulmillr.com/funding/" @@ -1124,31 +2937,174 @@ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "license": "ISC" }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz", + "integrity": "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-table3": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", + "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/comment-json": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-5.0.0.tgz", + "integrity": "sha512-uiqLcOiVDJtBP8WGkZHEP+FZIhTzP1dxvn59EfoYUi9gqupjrBWVQkO2atDrbnKPwLeotFYDsuNb26uBMqB+hw==", "dev": true, "license": "MIT", + "dependencies": { + "array-timsort": "^1.0.3", + "esprima": "^4.0.1" + }, "engines": { "node": ">= 6" } }, - "node_modules/confbox": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", - "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/content-disposition": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", + "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } }, - "node_modules/consola": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", - "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", "dev": true, + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, "engines": { - "node": "^14.18.0 || >=16.10.0" + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/data-uri-to-buffer": { @@ -1192,6 +3148,16 @@ "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", @@ -1201,6 +3167,25 @@ "node": ">=4.0.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1210,6 +3195,17 @@ "node": ">=8" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1233,6 +3229,39 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/emojilib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/emojilib/-/emojilib-2.4.0.tgz", + "integrity": "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.1.tgz", + "integrity": "sha512-Ev/hIyePLZtpL98x562lvPtenrUfGr0lfaf44HFZ+hDEwso7ccV9QAZrEVLg0hxNAzygaAFxy2dYaAKKM0G06A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -1260,6 +3289,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -1272,46 +3308,59 @@ "node": ">= 0.4" } }, - "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" }, "engines": { - "node": ">=18" + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" }, - "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" + "engines": { + "node": ">=4" + } + }, + "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/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" } }, "node_modules/expand-template": { @@ -1323,28 +3372,120 @@ "node": ">=6" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.3.0.tgz", + "integrity": "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.3.0", + "content-disposition": "^2.0.1", + "content-type": "^2.0.0", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.8", + "qs": "^6.16.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.1.0", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "license": "MIT" + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-uri": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" } }, "node_modules/fetch-blob": { @@ -1370,22 +3511,89 @@ "node": "^12.20 || >= 14.13" } }, + "node_modules/file-type": { + "version": "22.2.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-22.2.0.tgz", + "integrity": "sha512-XmEVJsRpQMVYC+sWFW+FKAVo2P7/lPctDdWH4UQ1r6oxhdrTobnowdM3z+ZJ93a4J8D3yDpxX0bEvRuFev0vUQ==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.5.1", + "strtok3": "^10.5.0", + "token-types": "^6.1.2", + "uint8array-extras": "^1.6.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, "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/fix-dts-default-cjs-exports": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", - "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "dev": true, "license": "MIT", "dependencies": { - "magic-string": "^0.30.17", - "mlly": "^1.7.4", - "rollup": "^4.34.8" + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" } }, "node_modules/formdata-polyfill": { @@ -1400,6 +3608,42 @@ "node": ">=12.20.0" } }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -1458,6 +3702,19 @@ "node": ">=22" } }, + "node_modules/get-east-asian-width": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz", + "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -1581,6 +3838,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -1593,13 +3866,36 @@ "node": ">= 0.4" } }, - "node_modules/hono": { - "version": "4.13.16", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.16.tgz", - "integrity": "sha512-AOLOEhvrV+iH92xXCnFr/5+3KjPKcI3xP63XJtX5spAnkznPdHijlIsUTpi7/9EhWqrTCekw5c5zh1K+YsVw7A==", + "node_modules/helmet": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz", + "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, "engines": { - "node": ">=16.9.0" + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/https-proxy-agent": { @@ -1615,6 +3911,22 @@ "node": ">= 14" } }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -1647,14 +3959,73 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, - "node_modules/joycon": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", - "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.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==", "dev": true, "license": "MIT", "engines": { - "node": ">=10" + "node": ">=8" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/iterare": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iterare/-/iterare-1.2.1.tgz", + "integrity": "sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==", + "license": "ISC", + "engines": { + "node": ">=6" + } + }, + "node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" } }, "node_modules/json-bigint": { @@ -1666,6 +4037,33 @@ "bignumber.js": "^9.0.0" } }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jwa": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", @@ -1687,26 +4085,25 @@ "safe-buffer": "^5.0.1" } }, - "node_modules/lilconfig": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.4.tgz", - "integrity": "sha512-zJZyBmQhArDRefuWTypZxwDpFDIYmrj8ypSBvwGD5P13Y95s6DjzKgu4DVdGbeQa5PDEsAOCAPNk4X+QOR006w==", - "dev": true, + "node_modules/load-esm": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/load-esm/-/load-esm-1.0.3.tgz", + "integrity": "sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + }, + { + "type": "buymeacoffee", + "url": "https://buymeacoffee.com/borewit" + } + ], "license": "MIT", "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" + "node": ">=13.2.0" } }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, - "license": "MIT" - }, "node_modules/load-tsconfig": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", @@ -1717,6 +4114,30 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/log-symbols": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", + "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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", @@ -1736,6 +4157,92 @@ "node": ">= 0.4" } }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mimic-response": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", @@ -1748,6 +4255,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -1763,35 +4286,100 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "license": "MIT" }, - "node_modules/mlly": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", - "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.16.0", - "pathe": "^2.0.3", - "pkg-types": "^1.3.1", - "ufo": "^1.6.3" - } - }, "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==", "license": "MIT" }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, + "node_modules/multer": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.5.0.tgz", + "integrity": "sha512-BsqOzYGVq49JcW2IzoSAxeKYRevb+dGGPPWleOR8Lrlk4igwxu1/9pwSXlByhCb7MVZDrNJFC0IBP5/O5nLYRw==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "license": "MIT", "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "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": { @@ -1800,6 +4388,22 @@ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", "license": "MIT" }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/node-abi": { "version": "3.101.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.101.0.tgz", @@ -1832,6 +4436,22 @@ "node": ">=10.5.0" } }, + "node_modules/node-emoji": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-2.2.0.tgz", + "integrity": "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.6.0", + "char-regex": "^1.0.2", + "emojilib": "^2.4.0", + "skin-tone": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/node-fetch": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", @@ -1854,7 +4474,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -1872,6 +4491,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-finished": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz", + "integrity": "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -1881,53 +4516,234 @@ "wrappy": "1" } }, - "node_modules/openai": { - "version": "7.47.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-7.47.0.tgz", - "integrity": "sha512-BYa2RYgocAv9WjPUJmNu8fICuA1fFhkTBEpiR3sC5L1K6v8Hnhc8pcnQEhu8l/+W1XKA+JUaBa7HpT+AK1s77w==", - "license": "Apache-2.0", + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, "engines": { - "node": ">=22.0.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-9.4.1.tgz", + "integrity": "sha512-6VlU9MLXbjVQD04AZCMX28hVtA5bUoadvUqO76MUCVA0ilwJbMiHsITRPfyVm6p/BC0Av/BXMujx39WCe1LEqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.6.2", + "cli-cursor": "^5.0.0", + "cli-spinners": "^3.2.0", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.1.0", + "log-symbols": "^7.0.1", + "stdin-discarder": "^0.3.2", + "string-width": "^8.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/ansi-regex": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.5.0.tgz", + "integrity": "sha512-zhvbbbdt1kbw7EJ9OQoxmQcuaNR9P5kTj7e/YdmOSU10aHWbycofJxA6jKfoEy3vlLdctOF9ZHaxLtmSm998wQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ora/node_modules/string-width": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz", + "integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/oxfmt": { + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.75.0.tgz", + "integrity": "sha512-/ZKvY5asfsVi9hdkOmRIumqOPu6kxlTgiJSZf54uq29kKew+Gu1FFAnEkGePyvLVW+MAYu41D6XggnzoElQzPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinypool": "2.2.0" + }, + "bin": { + "oxfmt": "bin/oxfmt" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxfmt/binding-android-arm-eabi": "0.75.0", + "@oxfmt/binding-android-arm64": "0.75.0", + "@oxfmt/binding-darwin-arm64": "0.75.0", + "@oxfmt/binding-darwin-x64": "0.75.0", + "@oxfmt/binding-freebsd-x64": "0.75.0", + "@oxfmt/binding-linux-arm-gnueabihf": "0.75.0", + "@oxfmt/binding-linux-arm-musleabihf": "0.75.0", + "@oxfmt/binding-linux-arm64-gnu": "0.75.0", + "@oxfmt/binding-linux-arm64-musl": "0.75.0", + "@oxfmt/binding-linux-ppc64-gnu": "0.75.0", + "@oxfmt/binding-linux-riscv64-gnu": "0.75.0", + "@oxfmt/binding-linux-riscv64-musl": "0.75.0", + "@oxfmt/binding-linux-s390x-gnu": "0.75.0", + "@oxfmt/binding-linux-x64-gnu": "0.75.0", + "@oxfmt/binding-linux-x64-musl": "0.75.0", + "@oxfmt/binding-openharmony-arm64": "0.75.0", + "@oxfmt/binding-win32-arm64-msvc": "0.75.0", + "@oxfmt/binding-win32-ia32-msvc": "0.75.0", + "@oxfmt/binding-win32-x64-msvc": "0.75.0" }, "peerDependencies": { - "@aws-sdk/credential-provider-node": ">=3.972.0 <4", - "@smithy/hash-node": ">=4.3.0 <5", - "@smithy/signature-v4": ">=5.4.0 <6", - "google-auth-library": ">=9.15.1 <12", - "undici": ">=5 <9", - "ws": "^8.21.0", - "zod": "^3.25 || ^4.0" + "@astrojs/compiler-rs": "^0.5.0", + "svelte": "^5.0.0", + "vite-plus": "*" }, "peerDependenciesMeta": { - "@aws-sdk/credential-provider-node": { + "@astrojs/compiler-rs": { "optional": true }, - "@smithy/hash-node": { + "svelte": { "optional": true }, - "@smithy/signature-v4": { + "vite-plus": { "optional": true - }, - "google-auth-library": { - "optional": true - }, - "undici": { - "optional": true - }, - "ws": { + } + } + }, + "node_modules/oxfmt/node_modules/tinypool": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.2.0.tgz", + "integrity": "sha512-jBrmx4lYmaC9k/mgPbylxs7kBUxHtD8256up+HjLaDFfXScKJQyil+SWXSvhAtT7XHo+yTVlpB81PGmHP8oLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, + "node_modules/oxlint": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.90.0.tgz", + "integrity": "sha512-RkTN9EmgIUpLuhNoLhO3OliMGJlvb3Dk7XVjBZnfoMCHrVpuz5ESd97klfiyYeV7XfA2lNLnDCPMkUI+Qw5How==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.90.0", + "@oxlint/binding-android-arm64": "1.90.0", + "@oxlint/binding-darwin-arm64": "1.90.0", + "@oxlint/binding-darwin-x64": "1.90.0", + "@oxlint/binding-freebsd-x64": "1.90.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.90.0", + "@oxlint/binding-linux-arm-musleabihf": "1.90.0", + "@oxlint/binding-linux-arm64-gnu": "1.90.0", + "@oxlint/binding-linux-arm64-musl": "1.90.0", + "@oxlint/binding-linux-ppc64-gnu": "1.90.0", + "@oxlint/binding-linux-riscv64-gnu": "1.90.0", + "@oxlint/binding-linux-riscv64-musl": "1.90.0", + "@oxlint/binding-linux-s390x-gnu": "1.90.0", + "@oxlint/binding-linux-x64-gnu": "1.90.0", + "@oxlint/binding-linux-x64-musl": "1.90.0", + "@oxlint/binding-openharmony-arm64": "1.90.0", + "@oxlint/binding-win32-arm64-msvc": "1.90.0", + "@oxlint/binding-win32-ia32-msvc": "1.90.0", + "@oxlint/binding-win32-x64-msvc": "1.90.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2005", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { "optional": true }, - "zod": { + "vite-plus": { "optional": true } } }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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" + "license": "MIT", + "engines": { + "node": ">= 14.16" + } }, "node_modules/picocolors": { "version": "1.1.1", @@ -1949,38 +4765,30 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", "dev": true, "license": "MIT", "engines": { - "node": ">= 6" - } - }, - "node_modules/pkg-types": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", - "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "confbox": "^0.1.8", - "mlly": "^1.7.4", - "pathe": "^2.0.1" + "node": ">=4" } }, - "node_modules/postcss-load-config": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", - "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "node_modules/postcss": { + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", "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" @@ -1988,30 +4796,12 @@ ], "license": "MIT", "dependencies": { - "lilconfig": "^3.1.1" + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" }, "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "jiti": ">=1.21.0", - "postcss": ">=8.0.9", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - }, - "postcss": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } + "node": "^10 || ^12 || >=14" } }, "node_modules/prebuild-install": { @@ -2041,6 +4831,23 @@ "node": ">=10" } }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -2067,6 +4874,34 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -2097,27 +4932,50 @@ } }, "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", "dev": true, "license": "MIT", "engines": { - "node": ">= 14.18.0" + "node": ">= 20.19.0" }, "funding": { "type": "individual", "url": "https://paulmillr.com/funding/" } }, - "node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", "dev": true, "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/rollup": { @@ -2167,6 +5025,31 @@ "fsevents": "~2.3.2" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -2187,6 +5070,12 @@ ], "license": "MIT" }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -2199,6 +5088,57 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/side-channel": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", @@ -2271,6 +5211,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/simple-concat": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", @@ -2316,16 +5276,83 @@ "simple-concat": "^1.0.0" } }, + "node_modules/skin-tone": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/skin-tone/-/skin-tone-2.0.0.tgz", + "integrity": "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "unicode-emoji-modifier-base": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/source-map": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", - "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0.tgz", + "integrity": "sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ==", "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">= 12" } }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "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/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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/stdin-discarder": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz", + "integrity": "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -2335,6 +5362,44 @@ "safe-buffer": "~5.2.0" } }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", @@ -2344,27 +5409,56 @@ "node": ">=0.10.0" } }, - "node_modules/sucrase": { - "version": "3.35.1", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", - "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "node_modules/strtok3": { + "version": "10.6.3", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.6.3.tgz", + "integrity": "sha512-MD8mENgyXMr2mDaDfzP3RWYDSyYQxIIou7HhCiCJiBekrsoDtUG0id3vYnjDfY9ndOEbazFXe13Qw6u/1AB8ew==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/superagent": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.4.1.tgz", + "integrity": "sha512-PVMkMrhKrSTtFhUU8jiWuGh/gyRMKiyCTCmm0+qVzRNxlfntmRKJkDhlEXN62x3HIb33sQ4kmL8lcSdccWAchQ==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "tinyglobby": "^0.2.11", - "ts-interface-checker": "^0.1.9" + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.3.1.tgz", + "integrity": "sha512-vNaNCFoCR/edDbErQZp5KLNT8R4dSPHX7rlDLoE8hWLAm/878j6ShMbe8HK/E6RwwuhlSXVelXAIg9AxR5WWPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=14.18.0" } }, "node_modules/tar-fs": { @@ -2395,146 +5489,385 @@ "node": ">=6" } }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "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==", + "license": "0BSD" + }, + "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-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uid": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/uid/-/uid-2.0.2.tgz", + "integrity": "sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==", + "license": "MIT", + "dependencies": { + "@lukeed/csprng": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/uint8array-extras": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.6.0.tgz", + "integrity": "sha512-8iAasVS4wUx0gPLjH8Xtz2PeDzTSiy8QiLGu2DT3X5GU+egFEQhpnbtkehbAwjvDcxIERmCYcysiODFmE3Ud0Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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, + "license": "MIT" + }, + "node_modules/unicode-emoji-modifier-base": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unicode-emoji-modifier-base/-/unicode-emoji-modifier-base-1.0.0.tgz", + "integrity": "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==", "dev": true, "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0" + "engines": { + "node": ">=4" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" } }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "node_modules/unplugin": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.7.0.tgz", + "integrity": "sha512-GrM2uq3DQo3hvLU2FjeYhooJ8BY9N6j1Fbzgx7Pta3VVJevsD3VP4dPjZkZOS0mLvN8w9zzr9OlP+31bSMyuLw==", "dev": true, "license": "MIT", "dependencies": { - "thenify": ">= 3.1.0 < 4" + "@jridgewell/remapping": "^2.3.5", + "@rolldown/pluginutils": "^1.0.1", + "picomatch": "^4.0.7", + "webpack-virtual-modules": "^0.6.2" }, "engines": { - "node": ">=0.8" + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@farmfe/core": "*", + "@rsbuild/core": "*", + "@rspack/core": "*", + "bun-types-no-globals": "*", + "esbuild": "*", + "rolldown": "*", + "rollup": "*", + "unloader": "*", + "vite": "*", + "webpack": "*" + }, + "peerDependenciesMeta": { + "@farmfe/core": { + "optional": true + }, + "@rsbuild/core": { + "optional": true + }, + "@rspack/core": { + "optional": true + }, + "bun-types-no-globals": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "rolldown": { + "optional": true + }, + "rollup": { + "optional": true + }, + "unloader": { + "optional": true + }, + "vite": { + "optional": true + }, + "webpack": { + "optional": true + } } }, - "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "node_modules/unplugin-swc": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unplugin-swc/-/unplugin-swc-2.0.0.tgz", + "integrity": "sha512-hMdGJEtkwiJhzuXXx4Nb1XYDeD9KG4+P7KZOaErJTpZNYhTo1sbWIsXb+u410zXyaqH00veNX4UxOr6TpUwNiQ==", "dev": true, "license": "MIT", "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" + "@rollup/pluginutils": "^5.4.0", + "load-tsconfig": "^0.2.5", + "unplugin": "^3.3.0" }, "engines": { - "node": ">=12.0.0" + "node": "^20.19.0 || >=22.12.0" }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" + "peerDependencies": { + "@swc/core": "^1.2.108" } }, - "node_modules/tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", - "dev": true, + "node_modules/url-template": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", + "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", + "license": "BSD" + }, + "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==", + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", "license": "MIT", - "bin": { - "tree-kill": "cli.js" + "engines": { + "node": ">= 0.8" } }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/tsup": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", - "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", "dependencies": { - "bundle-require": "^5.1.0", - "cac": "^6.7.14", - "chokidar": "^4.0.3", - "consola": "^3.4.0", - "debug": "^4.4.0", - "esbuild": "^0.27.0", - "fix-dts-default-cjs-exports": "^1.0.0", - "joycon": "^3.1.1", - "picocolors": "^1.1.1", - "postcss-load-config": "^6.0.1", - "resolve-from": "^5.0.0", - "rollup": "^4.34.8", - "source-map": "^0.7.6", - "sucrase": "^3.35.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.11", - "tree-kill": "^1.2.2" + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" }, "bin": { - "tsup": "dist/cli-default.js", - "tsup-node": "dist/cli-node.js" + "vite": "bin/vite.js" }, "engines": { - "node": ">=18" + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" }, "peerDependencies": { - "@microsoft/api-extractor": "^7.36.0", - "@swc/core": "^1", - "postcss": "^8.4.12", - "typescript": ">=4.5.0" + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" }, "peerDependenciesMeta": { - "@microsoft/api-extractor": { + "@types/node": { "optional": true }, - "@swc/core": { + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { "optional": true }, - "postcss": { + "sass-embedded": { "optional": true }, - "typescript": { + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { "optional": true } } }, - "node_modules/tsx": { - "version": "4.23.15", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", - "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "~0.28.0" + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" }, "bin": { - "tsx": "dist/cli.mjs" + "vite-node": "vite-node.mjs" }, "engines": { - "node": ">=18.0.0" + "node": "^18.0.0 || >=20.0.0" }, - "optionalDependencies": { - "fsevents": "~2.3.3" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", - "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "node_modules/vite-node/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", "cpu": [ "ppc64" ], @@ -2545,13 +5878,13 @@ "aix" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/tsx/node_modules/@esbuild/android-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", - "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", "cpu": [ "arm" ], @@ -2562,13 +5895,13 @@ "android" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/tsx/node_modules/@esbuild/android-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", - "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", "cpu": [ "arm64" ], @@ -2579,13 +5912,13 @@ "android" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/tsx/node_modules/@esbuild/android-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", - "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", "cpu": [ "x64" ], @@ -2596,13 +5929,13 @@ "android" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", - "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", "cpu": [ "arm64" ], @@ -2613,13 +5946,13 @@ "darwin" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/tsx/node_modules/@esbuild/darwin-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", - "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", "cpu": [ "x64" ], @@ -2630,13 +5963,13 @@ "darwin" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", - "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", "cpu": [ "arm64" ], @@ -2647,13 +5980,13 @@ "freebsd" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", - "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", "cpu": [ "x64" ], @@ -2664,13 +5997,13 @@ "freebsd" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/tsx/node_modules/@esbuild/linux-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", - "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", "cpu": [ "arm" ], @@ -2681,13 +6014,13 @@ "linux" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/tsx/node_modules/@esbuild/linux-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", - "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", "cpu": [ "arm64" ], @@ -2698,13 +6031,13 @@ "linux" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/tsx/node_modules/@esbuild/linux-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", - "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", "cpu": [ "ia32" ], @@ -2715,13 +6048,13 @@ "linux" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/tsx/node_modules/@esbuild/linux-loong64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", - "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", "cpu": [ "loong64" ], @@ -2732,13 +6065,13 @@ "linux" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", - "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", "cpu": [ "mips64el" ], @@ -2749,13 +6082,13 @@ "linux" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", - "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", "cpu": [ "ppc64" ], @@ -2766,13 +6099,13 @@ "linux" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", - "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", "cpu": [ "riscv64" ], @@ -2783,13 +6116,13 @@ "linux" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/tsx/node_modules/@esbuild/linux-s390x": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", - "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", "cpu": [ "s390x" ], @@ -2800,13 +6133,13 @@ "linux" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/tsx/node_modules/@esbuild/linux-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", - "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", "cpu": [ "x64" ], @@ -2817,30 +6150,13 @@ "linux" ], "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", - "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", - "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", "cpu": [ "x64" ], @@ -2851,30 +6167,13 @@ "netbsd" ], "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", - "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", - "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", "cpu": [ "x64" ], @@ -2885,30 +6184,13 @@ "openbsd" ], "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", - "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/tsx/node_modules/@esbuild/sunos-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", - "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", "cpu": [ "x64" ], @@ -2919,13 +6201,13 @@ "sunos" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/tsx/node_modules/@esbuild/win32-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", - "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", "cpu": [ "arm64" ], @@ -2936,13 +6218,13 @@ "win32" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/tsx/node_modules/@esbuild/win32-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", - "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", "cpu": [ "ia32" ], @@ -2953,13 +6235,13 @@ "win32" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/tsx/node_modules/@esbuild/win32-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", - "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", "cpu": [ "x64" ], @@ -2970,13 +6252,13 @@ "win32" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/tsx/node_modules/esbuild": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", - "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2984,89 +6266,107 @@ "esbuild": "bin/esbuild" }, "engines": { - "node": ">=18" + "node": ">=12" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.2", - "@esbuild/android-arm": "0.28.2", - "@esbuild/android-arm64": "0.28.2", - "@esbuild/android-x64": "0.28.2", - "@esbuild/darwin-arm64": "0.28.2", - "@esbuild/darwin-x64": "0.28.2", - "@esbuild/freebsd-arm64": "0.28.2", - "@esbuild/freebsd-x64": "0.28.2", - "@esbuild/linux-arm": "0.28.2", - "@esbuild/linux-arm64": "0.28.2", - "@esbuild/linux-ia32": "0.28.2", - "@esbuild/linux-loong64": "0.28.2", - "@esbuild/linux-mips64el": "0.28.2", - "@esbuild/linux-ppc64": "0.28.2", - "@esbuild/linux-riscv64": "0.28.2", - "@esbuild/linux-s390x": "0.28.2", - "@esbuild/linux-x64": "0.28.2", - "@esbuild/netbsd-arm64": "0.28.2", - "@esbuild/netbsd-x64": "0.28.2", - "@esbuild/openbsd-arm64": "0.28.2", - "@esbuild/openbsd-x64": "0.28.2", - "@esbuild/openharmony-arm64": "0.28.2", - "@esbuild/sunos-x64": "0.28.2", - "@esbuild/win32-arm64": "0.28.2", - "@esbuild/win32-ia32": "0.28.2", - "@esbuild/win32-x64": "0.28.2" - } - }, - "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", + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", "dependencies": { - "safe-buffer": "^5.0.1" + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" }, - "engines": { - "node": "*" - } - }, - "node_modules/typescript": { - "version": "5.6.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", - "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", - "dev": true, - "license": "Apache-2.0", "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "vitest": "vitest.mjs" }, "engines": { - "node": ">=14.17" + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } } }, - "node_modules/ufo": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", - "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", - "dev": true, - "license": "MIT" - }, - "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==", + "node_modules/vitest/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", "dev": true, "license": "MIT" }, - "node_modules/url-template": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", - "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", - "license": "BSD" - }, - "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==", - "license": "MIT" - }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -3076,12 +6376,49 @@ "node": ">= 8" } }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/yoctocolors": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz", + "integrity": "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zod": { "version": "4.6.7", "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", diff --git a/api/package.json b/api/package.json index 15a315b..b567f62 100644 --- a/api/package.json +++ b/api/package.json @@ -1,26 +1,43 @@ { "name": "finkit-api", "version": "2.0.0", - "type": "module", "scripts": { - "dev": "tsx watch src/index.ts", - "build": "tsup src/index.ts --format esm --dts --clean", - "start": "node dist/index.js", - "postinstall": "node scripts/install-better-sqlite3.cjs || npm rebuild better-sqlite3" + "dev": "nest start --watch", + "build": "nest build", + "start": "node dist/main.js", + "test": "vitest run", + "postinstall": "node scripts/install-better-sqlite3.cjs || npm rebuild better-sqlite3", + "lint": "oxlint", + "lint:fix": "oxlint --fix", + "format": "oxfmt", + "format:check": "oxfmt --check", + "check": "oxlint && oxfmt --check" }, "dependencies": { - "@hono/node-server": "^2.1.5", + "@nestjs/common": "^12.3.2", + "@nestjs/core": "^12.3.2", + "@nestjs/platform-express": "^12.3.2", "better-sqlite3": "12.8.0", "googleapis": "^189.0.0", - "hono": "^4.13.16", - "openai": "^7.47.0", + "helmet": "^8.3.0", + "jose": "^5.10.0", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.2", "zod": "^4.6.7" }, "devDependencies": { + "@nestjs/cli": "^12.0.11", + "@nestjs/testing": "^12.3.2", + "@swc/core": "^1.16.21", "@types/better-sqlite3": "^7.6.13", + "@types/express": "^5.0.6", "@types/node": "^22.10.0", - "tsup": "^8.5.1", - "tsx": "^4.23.15", - "typescript": "~5.6.2" + "@types/supertest": "^7.2.1", + "oxfmt": "^0.75.0", + "oxlint": "^1.90.0", + "supertest": "^7.3.1", + "typescript": "~5.6.2", + "unplugin-swc": "^2.0.0", + "vitest": "^2.1.9" } } diff --git a/api/scripts/install-better-sqlite3.cjs b/api/scripts/install-better-sqlite3.cjs index d3a86d2..7fd9678 100755 --- a/api/scripts/install-better-sqlite3.cjs +++ b/api/scripts/install-better-sqlite3.cjs @@ -6,80 +6,75 @@ * Falls back to npm rebuild if download fails */ -const https = require('https'); -const fs = require('fs'); -const path = require('path'); -const { execSync } = require('child_process'); +const https = require('https') +const fs = require('fs') +const path = require('path') +const { execSync } = require('child_process') // Extract better-sqlite3 version from package.json -const packageJson = require('../package.json'); -const BETTER_SQLITE3_VERSION = packageJson.dependencies['better-sqlite3'].replace(/[\^~]/, ''); -const NODE_ABI_VERSION = process.versions.modules; -const PLATFORM = process.platform; -const ARCH = process.arch; +const packageJson = require('../package.json') +const BETTER_SQLITE3_VERSION = packageJson.dependencies['better-sqlite3'].replace(/[\^~]/, '') +const NODE_ABI_VERSION = process.versions.modules +const PLATFORM = process.platform +const ARCH = process.arch -const BINARY_NAME = 'better_sqlite3.node'; -const BINARY_URL = `https://github.com/WiseLibs/better-sqlite3/releases/download/v${BETTER_SQLITE3_VERSION}/better-sqlite3-v${BETTER_SQLITE3_VERSION}-node-v${NODE_ABI_VERSION}-${PLATFORM}-${ARCH}.tar.gz`; +const BINARY_NAME = 'better_sqlite3.node' +const BINARY_URL = `https://github.com/WiseLibs/better-sqlite3/releases/download/v${BETTER_SQLITE3_VERSION}/better-sqlite3-v${BETTER_SQLITE3_VERSION}-node-v${NODE_ABI_VERSION}-${PLATFORM}-${ARCH}.tar.gz` -const BETTER_SQLITE3_PATH = path.join( - __dirname, - '..', - 'node_modules', - 'better-sqlite3' -); +const BETTER_SQLITE3_PATH = path.join(__dirname, '..', 'node_modules', 'better-sqlite3') -const BUILD_DIR = path.join(BETTER_SQLITE3_PATH, 'build', 'Release'); -const BINARY_PATH = path.join(BUILD_DIR, BINARY_NAME); +const BUILD_DIR = path.join(BETTER_SQLITE3_PATH, 'build', 'Release') +const BINARY_PATH = path.join(BUILD_DIR, BINARY_NAME) // Check if binary already exists if (fs.existsSync(BINARY_PATH)) { - console.log(`✓ better-sqlite3 binary already exists at ${BINARY_PATH}`); - process.exit(0); + console.log(`✓ better-sqlite3 binary already exists at ${BINARY_PATH}`) + process.exit(0) } -console.log(`Downloading better-sqlite3 pre-built binary...`); -console.log(` Version: ${BETTER_SQLITE3_VERSION}`); -console.log(` Platform: ${PLATFORM}-${ARCH}`); -console.log(` Node ABI: v${NODE_ABI_VERSION}`); -console.log(` URL: ${BINARY_URL}`); +console.log(`Downloading better-sqlite3 pre-built binary...`) +console.log(` Version: ${BETTER_SQLITE3_VERSION}`) +console.log(` Platform: ${PLATFORM}-${ARCH}`) +console.log(` Node ABI: v${NODE_ABI_VERSION}`) +console.log(` URL: ${BINARY_URL}`) // Ensure build directory exists -fs.mkdirSync(BUILD_DIR, { recursive: true }); +fs.mkdirSync(BUILD_DIR, { recursive: true }) -const tempTarball = path.join('/tmp', `better-sqlite3-${Date.now()}.tar.gz`); -const file = fs.createWriteStream(tempTarball); +const tempTarball = path.join('/tmp', `better-sqlite3-${Date.now()}.tar.gz`) +const file = fs.createWriteStream(tempTarball) /** * Extract the tarball and move binary to correct location */ function extractBinary() { try { - console.log(`✓ Downloaded to ${tempTarball}`); - console.log(`Extracting...`); - + console.log(`✓ Downloaded to ${tempTarball}`) + console.log(`Extracting...`) + // Extract tarball - execSync(`tar -xzf ${tempTarball} -C ${BUILD_DIR}`, { stdio: 'inherit' }); - + execSync(`tar -xzf ${tempTarball} -C ${BUILD_DIR}`, { stdio: 'inherit' }) + // Handle nested build directory structure - const extractedBuildDir = path.join(BUILD_DIR, 'build', 'Release', BINARY_NAME); + const extractedBuildDir = path.join(BUILD_DIR, 'build', 'Release', BINARY_NAME) if (fs.existsSync(extractedBuildDir)) { - fs.renameSync(extractedBuildDir, BINARY_PATH); - fs.rmSync(path.join(BUILD_DIR, 'build'), { recursive: true }); + fs.renameSync(extractedBuildDir, BINARY_PATH) + fs.rmSync(path.join(BUILD_DIR, 'build'), { recursive: true }) } - + // Cleanup temp file - fs.unlinkSync(tempTarball); - + fs.unlinkSync(tempTarball) + if (fs.existsSync(BINARY_PATH)) { - console.log(`✓ better-sqlite3 binary installed at ${BINARY_PATH}`); - process.exit(0); + console.log(`✓ better-sqlite3 binary installed at ${BINARY_PATH}`) + process.exit(0) } else { - console.error(`✗ Binary not found after extraction`); - fallbackToBuild(); + console.error(`✗ Binary not found after extraction`) + fallbackToBuild() } } catch (err) { - console.error(`✗ Extraction failed:`, err.message); - fallbackToBuild(); + console.error(`✗ Extraction failed:`, err.message) + fallbackToBuild() } } @@ -87,17 +82,17 @@ function extractBinary() { * Fallback to building from source */ function fallbackToBuild() { - console.log(`\nFalling back to build from source...`); + console.log(`\nFalling back to build from source...`) try { - execSync('npm rebuild better-sqlite3', { + execSync('npm rebuild better-sqlite3', { stdio: 'inherit', - cwd: path.join(__dirname, '..') - }); - console.log(`✓ better-sqlite3 built from source successfully`); - process.exit(0); + cwd: path.join(__dirname, '..'), + }) + console.log(`✓ better-sqlite3 built from source successfully`) + process.exit(0) } catch (err) { - console.error(`✗ Build from source failed:`, err.message); - process.exit(1); + console.error(`✗ Build from source failed:`, err.message) + process.exit(1) } } @@ -107,21 +102,21 @@ function fallbackToBuild() { function handleResponse(response) { if (response.statusCode === 302 || response.statusCode === 301) { // Follow redirect - https.get(response.headers.location, handleResponse).on('error', handleError); + https.get(response.headers.location, handleResponse).on('error', handleError) } else if (response.statusCode === 200) { - response.pipe(file); + response.pipe(file) file.on('finish', () => { - file.close(extractBinary); - }); + file.close(extractBinary) + }) } else if (response.statusCode === 404) { - console.error(`✗ Pre-built binary not available for this platform`); - console.error(` HTTP ${response.statusCode}: ${BINARY_URL}`); - fs.unlinkSync(tempTarball); - fallbackToBuild(); + console.error(`✗ Pre-built binary not available for this platform`) + console.error(` HTTP ${response.statusCode}: ${BINARY_URL}`) + fs.unlinkSync(tempTarball) + fallbackToBuild() } else { - console.error(`✗ Failed to download: HTTP ${response.statusCode}`); - fs.unlinkSync(tempTarball); - fallbackToBuild(); + console.error(`✗ Failed to download: HTTP ${response.statusCode}`) + fs.unlinkSync(tempTarball) + fallbackToBuild() } } @@ -130,11 +125,11 @@ function handleResponse(response) { */ function handleError(err) { if (fs.existsSync(tempTarball)) { - fs.unlinkSync(tempTarball); + fs.unlinkSync(tempTarball) } - console.error(`✗ Download failed:`, err.message); - fallbackToBuild(); + console.error(`✗ Download failed:`, err.message) + fallbackToBuild() } // Start download -https.get(BINARY_URL, handleResponse).on('error', handleError); +https.get(BINARY_URL, handleResponse).on('error', handleError) diff --git a/api/src/access/access.guard.ts b/api/src/access/access.guard.ts new file mode 100644 index 0000000..342d47b --- /dev/null +++ b/api/src/access/access.guard.ts @@ -0,0 +1,54 @@ +import { + CanActivate, + ExecutionContext, + Inject, + Injectable, + Optional, + UnauthorizedException, + createParamDecorator, +} from '@nestjs/common' +import type { Request } from 'express' +import { ACCESS_SETTINGS, ACCESS_VERIFIER, AccessSettings, AccessVerifier, Actor } from './access.settings' + +export type ActorRequest = Request & { actor?: Actor } + +/** Callback for features that want to record refused requests (the bank audit log does). */ +export const ACCESS_REFUSED = Symbol('ACCESS_REFUSED') +type AccessRefusedHandler = (path: string, reason: string) => void + +/** Only signed-in users get through. */ +@Injectable() +export class AccessGuard implements CanActivate { + constructor( + @Inject(ACCESS_SETTINGS) private readonly settings: AccessSettings, + @Optional() @Inject(ACCESS_VERIFIER) private readonly verify: AccessVerifier | null, + @Optional() @Inject(ACCESS_REFUSED) private readonly onRefused?: AccessRefusedHandler, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const req = context.switchToHttp().getRequest() + if (this.settings.mode === 'dev') { + req.actor = { email: 'dev@localhost', admin: true } + return true + } + const token = req.header('cf-access-jwt-assertion') + if (!token || !this.verify) return this.refuse(req, 'no token') + try { + const { email } = await this.verify(token) + req.actor = { email, admin: this.settings.adminEmails.includes(email) } + return true + } catch { + return this.refuse(req, 'invalid token') + } + } + + private refuse(req: Request, reason: string): never { + this.onRefused?.(req.path, reason) + throw new UnauthorizedException({ error: 'Unauthorized' }) + } +} + +/** The signed-in user (set by AccessGuard). */ +export const CurrentActor = createParamDecorator( + (_: unknown, ctx: ExecutionContext): Actor => ctx.switchToHttp().getRequest().actor!, +) diff --git a/api/src/access/access.module.ts b/api/src/access/access.module.ts new file mode 100644 index 0000000..0137198 --- /dev/null +++ b/api/src/access/access.module.ts @@ -0,0 +1,30 @@ +import { DynamicModule, Global, Module } from '@nestjs/common' +import { AccessGuard } from './access.guard' +import { AdminGuard } from './admin.guard' +import { CsrfGuard } from './csrf.guard' +import { ACCESS_SETTINGS, ACCESS_VERIFIER, loadAccessSettings } from './access.settings' +import { cloudflareVerifier } from './cloudflare-verifier' + +@Global() +@Module({}) +export class AccessModule { + /** Reads the sign-in settings from the environment once, at start-up. */ + static forRoot(env: NodeJS.ProcessEnv = process.env): DynamicModule { + const settings = loadAccessSettings(env) + return { + module: AccessModule, + providers: [ + { provide: ACCESS_SETTINGS, useValue: settings }, + { + provide: ACCESS_VERIFIER, + useValue: + settings.mode === 'cloudflare' ? cloudflareVerifier(settings.teamDomain!, settings.audience!) : null, + }, + AccessGuard, + AdminGuard, + CsrfGuard, + ], + exports: [ACCESS_SETTINGS, ACCESS_VERIFIER, AccessGuard, AdminGuard, CsrfGuard], + } + } +} diff --git a/api/src/access/access.settings.ts b/api/src/access/access.settings.ts new file mode 100644 index 0000000..7e55ac3 --- /dev/null +++ b/api/src/access/access.settings.ts @@ -0,0 +1,37 @@ +export interface AccessSettings { + isProd: boolean + mode: 'cloudflare' | 'dev' + teamDomain?: string + audience?: string + adminEmails: string[] + allowedOrigin?: string +} + +export const ACCESS_SETTINGS = Symbol('ACCESS_SETTINGS') +export const ACCESS_VERIFIER = Symbol('ACCESS_VERIFIER') +export type AccessVerifier = (token: string) => Promise<{ email: string }> + +export interface Actor { + email: string + admin: boolean +} + +/** + * In production, requests must carry a Cloudflare Access token that the server verifies itself. + * Locally there is no Access, so the developer is treated as the admin. + */ +export function loadAccessSettings(env: NodeJS.ProcessEnv = process.env): AccessSettings { + const teamDomain = env.CF_ACCESS_TEAM_DOMAIN + const audience = env.CF_ACCESS_AUD + return { + isProd: env.NODE_ENV === 'production', + mode: teamDomain && audience ? 'cloudflare' : 'dev', + teamDomain, + audience, + adminEmails: (env.BANKING_ADMIN_EMAILS || env.ADMIN_EMAILS || '') + .split(',') + .map(s => s.trim().toLowerCase()) + .filter(Boolean), + allowedOrigin: env.ALLOWED_ORIGIN, + } +} diff --git a/api/src/access/admin.guard.ts b/api/src/access/admin.guard.ts new file mode 100644 index 0000000..fddca7c --- /dev/null +++ b/api/src/access/admin.guard.ts @@ -0,0 +1,12 @@ +import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common' +import { ActorRequest } from './access.guard' + +/** Only the listed admin emails get through. Use after AccessGuard. */ +@Injectable() +export class AdminGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + if (!context.switchToHttp().getRequest().actor?.admin) + throw new ForbiddenException({ error: 'Forbidden' }) + return true + } +} diff --git a/api/src/access/cloudflare-verifier.ts b/api/src/access/cloudflare-verifier.ts new file mode 100644 index 0000000..d35998d --- /dev/null +++ b/api/src/access/cloudflare-verifier.ts @@ -0,0 +1,14 @@ +import { createRemoteJWKSet, jwtVerify } from 'jose' +import { AccessVerifier } from './access.settings' + +/** Verifies the token Cloudflare Access attaches to every request it lets through. */ +export function cloudflareVerifier(teamDomain: string, audience: string): AccessVerifier { + const issuer = `https://${teamDomain}.cloudflareaccess.com` + const jwks = createRemoteJWKSet(new URL(`${issuer}/cdn-cgi/access/certs`)) + return async token => { + const { payload } = await jwtVerify(token, jwks, { issuer, audience }) + const email = typeof payload.email === 'string' ? payload.email.toLowerCase() : '' + if (!email) throw new Error('Token has no email') + return { email } + } +} diff --git a/api/src/access/csrf.guard.ts b/api/src/access/csrf.guard.ts new file mode 100644 index 0000000..560bcf0 --- /dev/null +++ b/api/src/access/csrf.guard.ts @@ -0,0 +1,22 @@ +import { CanActivate, ExecutionContext, ForbiddenException, Inject, Injectable } from '@nestjs/common' +import type { Request } from 'express' +import { ACCESS_SETTINGS, AccessSettings } from './access.settings' + +/** + * Changing requests need a header a plain cross-site form or image tag can't send, and must + * come from the allowed origin when one is configured. + */ +@Injectable() +export class CsrfGuard implements CanActivate { + constructor(@Inject(ACCESS_SETTINGS) private readonly settings: AccessSettings) {} + + canActivate(context: ExecutionContext): boolean { + const req = context.switchToHttp().getRequest() + if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') return true + if (req.header('x-finkit') !== '1') throw new ForbiddenException({ error: 'Forbidden' }) + const origin = req.header('origin') + if (this.settings.allowedOrigin && origin && origin !== this.settings.allowedOrigin) + throw new ForbiddenException({ error: 'Forbidden' }) + return true + } +} diff --git a/api/src/app.module.ts b/api/src/app.module.ts new file mode 100644 index 0000000..e8b166b --- /dev/null +++ b/api/src/app.module.ts @@ -0,0 +1,26 @@ +import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common' +import { AccessModule } from './access/access.module' +import { BankConnectionsModule } from './bank-connections/bank-connections.module' +import { HealthModule } from './health/health.module' +import { MerchantRulesModule } from './merchant-rules/merchant-rules.module' +import { SettingsModule } from './settings/settings.module' +import { SheetExportModule } from './sheet-export/sheet-export.module' +import { DatabaseModule } from './shared/database/database.module' +import { RequestLoggerMiddleware } from './shared/http/request-logger.middleware' + +@Module({ + imports: [ + DatabaseModule.forRoot(), + AccessModule.forRoot(), + HealthModule, + MerchantRulesModule, + SettingsModule, + SheetExportModule, + BankConnectionsModule.forRoot(), + ], +}) +export class AppModule implements NestModule { + configure(consumer: MiddlewareConsumer) { + consumer.apply(RequestLoggerMiddleware).forRoutes('{*path}') + } +} diff --git a/api/src/app.setup.ts b/api/src/app.setup.ts new file mode 100644 index 0000000..3be156f --- /dev/null +++ b/api/src/app.setup.ts @@ -0,0 +1,21 @@ +import { INestApplication } from '@nestjs/common' +import { NestExpressApplication } from '@nestjs/platform-express' +import helmet from 'helmet' +import { HttpErrorFilter } from './shared/http/http-error.filter' + +/** HTTP behaviour shared by the real server and the tests. */ +export function configureApp(app: INestApplication, env: NodeJS.ProcessEnv = process.env) { + const express = app as NestExpressApplication + express.useBodyParser('json', { limit: '5mb' }) // large batch learn/export payloads + app.use(helmet()) + app.enableCors({ + // In production, set ALLOWED_ORIGIN (e.g. https://finkit.example.com) to lock this down. + // Unset keeps the old behaviour (any origin), which is fine behind Cloudflare Access. + origin: + env.NODE_ENV === 'production' ? env.ALLOWED_ORIGIN || '*' : ['http://localhost:5173', 'http://localhost:3000'], + methods: ['GET', 'POST', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'X-FinKit'], + }) + app.useGlobalFilters(new HttpErrorFilter()) + return app +} diff --git a/api/src/app.test.ts b/api/src/app.test.ts new file mode 100644 index 0000000..49b6c7e --- /dev/null +++ b/api/src/app.test.ts @@ -0,0 +1,193 @@ +import { afterEach, describe, expect, it } from 'vitest' +import request from 'supertest' +import { createTestApp } from './testing/test-app' + +type TestApp = Awaited> +const open: TestApp[] = [] +async function make(env?: NodeJS.ProcessEnv) { + const t = await createTestApp({ env }) + open.push(t) + return t +} +afterEach(async () => { + while (open.length) await open.pop()!.close() +}) +const json = { 'Content-Type': 'application/json' } + +describe('health', () => { + it('answers on /health and /api/health', async () => { + const t = await make() + for (const path of ['/health', '/api/health']) + expect((await request(t.server).get(path).expect(200)).body.status).toBe('ok') + }) +}) + +describe('merchant rules (/api/learn)', () => { + it('learns, lists, updates, and forgets a rule', async () => { + const t = await make() + expect( + ( + await request(t.server) + .post('/api/learn/mapping') + .set(json) + .send({ merchant: 'DeepSeek', category: 'Subscriptions' }) + .expect(200) + ).body, + ).toEqual({ success: true, message: 'Learned: "DeepSeek" → "Subscriptions"' }) + await request(t.server) + .post('/api/learn/mapping') + .set(json) + .send({ merchant: 'DeepSeek', category: 'Software' }) + .expect(200) + const list = (await request(t.server).get('/api/learn/mappings').expect(200)).body + expect(list).toMatchObject({ + success: true, + data: [{ merchant: 'deepseek', merchantDisplay: 'DeepSeek', category: 'Software', confidence: 2 }], + }) + expect((await request(t.server).delete('/api/learn/mapping/DeepSeek').expect(200)).body.message).toBe( + 'Deleted mapping for "DeepSeek"', + ) + expect((await request(t.server).get('/api/learn/mappings')).body.data).toEqual([]) + }) + it('saves a batch and reports stats', async () => { + const t = await make() + await request(t.server) + .post('/api/learn/mappings/batch') + .set(json) + .send({ + mappings: [ + { merchant: 'REWE', category: 'Groceries' }, + { merchant: 'Lidl', category: 'Groceries' }, + ], + }) + .expect(200) + const stats = (await request(t.server).get('/api/learn/stats').expect(200)).body.data + expect(stats.total).toBe(2) + expect(stats.topCategories[0]).toEqual({ category: 'Groceries', count: 2 }) + }) + it('decodes merchant names with special characters once', async () => { + const t = await make() + await request(t.server).post('/api/learn/mapping').set(json).send({ merchant: 'Café 100%', category: 'Eating Out' }) + await request(t.server) + .delete(`/api/learn/mapping/${encodeURIComponent('Café 100%')}`) + .expect(200) + expect((await request(t.server).get('/api/learn/mappings')).body.data).toEqual([]) + }) + it('rejects invalid bodies in the established shape', async () => { + const t = await make() + const r = await request(t.server).post('/api/learn/mapping').set(json).send({ merchant: '' }).expect(400) + expect(r.body.error).toBe('Invalid request') + expect(Array.isArray(r.body.details)).toBe(true) + await request(t.server).post('/api/learn/mappings/batch').set(json).send({ mappings: 'x' }).expect(400) + }) +}) + +describe('settings (/api/settings)', () => { + it('serves defaults, saves, and resets the sheet configuration', async () => { + const t = await make() + const defaults = (await request(t.server).get('/api/settings/sheet').expect(200)).body.data + expect(defaults).toMatchObject({ + sheetId: '', + tabName: '2026', + monthFormat: 'short', + constantColumns: ['S', 'U', 'X'], + }) + expect(defaults.categoryMappings).toHaveLength(17) + await request(t.server) + .post('/api/settings/sheet') + .set(json) + .send({ sheetId: 'abc', tabName: '2027', layout: [{ column: 'A', name: 'Month', kind: 'month' }], headerRow: 2 }) + .expect(200) + const saved = (await request(t.server).get('/api/settings/sheet')).body.data + expect(saved).toMatchObject({ + sheetId: 'abc', + tabName: '2027', + headerRow: 2, + layout: [{ column: 'A', name: 'Month', kind: 'month' }], + }) + await request(t.server).delete('/api/settings/sheet').expect(200) + expect((await request(t.server).get('/api/settings/sheet')).body.data.sheetId).toBe('') + }) + it('rejects an invalid column kind', async () => { + const t = await make() + const r = await request(t.server) + .post('/api/settings/sheet') + .set(json) + .send({ layout: [{ column: 'A', name: 'x', kind: 'nope' }] }) + .expect(400) + expect(r.body.success).toBe(false) + expect(Array.isArray(r.body.error)).toBe(true) + }) + it('keeps generic key-value settings, with the fixed routes taking priority', async () => { + const t = await make() + await request(t.server).get('/api/settings/theme').expect(404) + await request(t.server).post('/api/settings/theme').set(json).send({ value: 'dark' }).expect(200) + expect((await request(t.server).get('/api/settings/theme')).body).toEqual({ success: true, data: 'dark' }) + const all = (await request(t.server).get('/api/settings')).body.data + expect(all.theme).toBe('dark') + expect(all.sheetConfig.tabName).toBe('2026') + }) +}) + +describe('sheet export (/api/export)', () => { + it('validates requests before touching Google', async () => { + const t = await make() + expect((await request(t.server).post('/api/export/sheets').set(json).send({}).expect(400)).body.error).toBe( + 'Invalid request body', + ) + expect((await request(t.server).post('/api/export/sheets/layout').set(json).send({}).expect(400)).body).toEqual({ + error: 'Missing sheetId or tabName', + }) + expect((await request(t.server).post('/api/export/sheets/metadata').set(json).send({}).expect(400)).body).toEqual({ + error: 'Missing sheetId', + }) + }) + it('reports a missing Google configuration as an error message', async () => { + delete process.env.GOOGLE_SERVICE_ACCOUNT_KEY_PATH + const t = await make() + const r = await request(t.server).post('/api/export/sheets/metadata').set(json).send({ sheetId: 'x' }).expect(500) + expect(r.body.error).toMatch(/GOOGLE_SERVICE_ACCOUNT_KEY_PATH/) + }) +}) + +describe('cross-cutting behaviour', () => { + it('answers unknown routes with the established 404 body', async () => { + const t = await make() + expect((await request(t.server).get('/api/nope').expect(404)).body).toEqual({ error: 'Not Found' }) + }) + it('answers malformed JSON with a clean 400', async () => { + const t = await make() + const r = await request(t.server).post('/api/learn/mapping').set(json).send('{not json').expect(400) + expect(r.body).toEqual({ error: 'Bad Request' }) + }) + it('sets security headers', async () => { + const t = await make() + const h = (await request(t.server).get('/health')).headers + expect(h['x-content-type-options']).toBe('nosniff') + expect(h['x-frame-options']).toBeDefined() + expect(h['x-powered-by']).toBeUndefined() + }) + it('allows the custom header in CORS preflights', async () => { + const t = await make() + const r = await request(t.server) + .options('/api/banking/connect') + .set('Origin', 'http://localhost:5173') + .set('Access-Control-Request-Method', 'POST') + .set('Access-Control-Request-Headers', 'x-finkit,content-type') + expect(String(r.headers['access-control-allow-headers']).toLowerCase()).toContain('x-finkit') + }) + it('accepts large bodies (batch learn mappings)', async () => { + const t = await make() + const big = { + mappings: Array.from({ length: 15000 }, (_, i) => ({ merchant: `Merchant number ${i}`, category: 'Other' })), + } + expect(JSON.stringify(big).length).toBeGreaterThan(500_000) // well above the default 100 kB limit + await request(t.server).post('/api/learn/mappings/batch').set(json).send(big).expect(200) + }) + it('never leaks internals on unexpected errors', async () => { + const t = await make() + t.db.exec('DROP TABLE merchant_categories') // makes the next query throw + const r = await request(t.server).get('/api/learn/mappings').expect(500) + expect(r.body).toEqual({ error: 'Internal Server Error' }) + }) +}) diff --git a/api/src/bank-connections/bank-admin.controller.ts b/api/src/bank-connections/bank-admin.controller.ts new file mode 100644 index 0000000..52f9679 --- /dev/null +++ b/api/src/bank-connections/bank-admin.controller.ts @@ -0,0 +1,24 @@ +import { Controller, Get, Query, UseGuards, UseInterceptors } from '@nestjs/common' +import { AccessGuard } from '../access/access.guard' +import { AdminGuard } from '../access/admin.guard' +import { CsrfGuard } from '../access/csrf.guard' +import { NoStoreInterceptor } from '../shared/http/no-store.interceptor' +import { BankConnectionsService } from './bank-connections.service' + +/** Back office: is it set up correctly, what ran, who did what. Admin emails only. */ +@Controller('api/banking/admin') +@UseGuards(AccessGuard, AdminGuard, CsrfGuard) +@UseInterceptors(NoStoreInterceptor) +export class BankAdminController { + constructor(private readonly connections: BankConnectionsService) {} + + @Get('overview') overview() { + return this.connections.overview() + } + @Get('sync-log') syncLog(@Query('limit') limit?: string) { + return this.connections.syncLog(Number(limit)) + } + @Get('audit') audit(@Query('limit') limit?: string) { + return this.connections.auditLog(Number(limit)) + } +} diff --git a/api/src/bank-connections/bank-callback.controller.ts b/api/src/bank-connections/bank-callback.controller.ts new file mode 100644 index 0000000..d8d8f55 --- /dev/null +++ b/api/src/bank-connections/bank-callback.controller.ts @@ -0,0 +1,42 @@ +import { Controller, Get, Query, Req, Res, UseInterceptors } from '@nestjs/common' +import type { Request, Response } from 'express' +import { NoStoreInterceptor } from '../shared/http/no-store.interceptor' +import { BankConnectionsService } from './bank-connections.service' +import { STATE_COOKIE } from './bank-connections.controller' + +function cookieValue(req: Request, name: string): string | undefined { + for (const part of (req.headers.cookie || '').split(';')) { + const [k, ...v] = part.trim().split('=') + if (k === name) return decodeURIComponent(v.join('=')) + } + return undefined +} + +/** + * Where the bank sends the user back. This is the only public banking route: it is protected by a + * single-use request we started plus a cookie from the same browser, not by sign-in. + */ +@Controller('api/banking') +@UseInterceptors(NoStoreInterceptor) +export class BankCallbackController { + constructor(private readonly connections: BankConnectionsService) {} + + @Get('callback') + async callback( + @Query('state') state: string, + @Query('code') code: string | undefined, + @Query('error') error: string | undefined, + @Req() req: Request, + @Res() res: Response, + ) { + const cookieState = cookieValue(req, STATE_COOKIE) + res.clearCookie(STATE_COOKIE, { path: '/api/banking' }) + const outcome = await this.connections.completeConnection({ + state, + code, + error, + cookieState, + }) + return res.redirect(302, BankConnectionsService.returnUrl(outcome)) + } +} diff --git a/api/src/bank-connections/bank-connections.controller.ts b/api/src/bank-connections/bank-connections.controller.ts new file mode 100644 index 0000000..f27dac7 --- /dev/null +++ b/api/src/bank-connections/bank-connections.controller.ts @@ -0,0 +1,83 @@ +import { + HttpCode, + Body, + Controller, + Delete, + Get, + Param, + Post, + Query, + Res, + UseGuards, + UseInterceptors, +} from '@nestjs/common' +import type { Response } from 'express' +import { AccessGuard, CurrentActor } from '../access/access.guard' +import { Actor } from '../access/access.settings' +import { CsrfGuard } from '../access/csrf.guard' +import { NoStoreInterceptor } from '../shared/http/no-store.interceptor' +import { ZodValidationPipe } from '../shared/http/zod-validation.pipe' +import { BankConnectionsService } from './bank-connections.service' +import { ConnectInput, ConnectSchema, SyncInput, SyncSchema } from './bank-connections.schemas' +import { BadRequestException } from '@nestjs/common' + +export const STATE_COOKIE = 'finkit_bank_state' + +/** What a signed-in user does with their bank connections. */ +@Controller('api/banking') +@UseGuards(AccessGuard, CsrfGuard) +@UseInterceptors(NoStoreInterceptor) +export class BankConnectionsController { + constructor(private readonly connections: BankConnectionsService) {} + + @Get('status') + status(@CurrentActor() actor: Actor) { + return this.connections.status(actor) + } + + @Get('banks') + async banks(@Query('country') country = 'DE') { + const code = country.toUpperCase() + if (!/^[A-Z]{2}$/.test(code)) throw new BadRequestException({ error: 'Invalid country' }) + return { banks: await this.connections.banks(code) } + } + + @Post('connect') + @HttpCode(200) // the previous API answered 200, not 201 + async connect( + @CurrentActor() actor: Actor, + @Body(new ZodValidationPipe(ConnectSchema, { message: 'Invalid request' })) body: ConnectInput, + @Res({ passthrough: true }) res: Response, + ) { + const { url, state } = await this.connections.startConnection(actor, body) + // Ties the bank's return to this browser: the callback must present the same value + res.cookie(STATE_COOKIE, state, { + httpOnly: true, + sameSite: 'lax', + secure: this.connections.config.isProd, + path: '/api/banking', + maxAge: 600_000, + }) + return { url } + } + + @Get('connections') + list() { + return { connections: this.connections.list() } + } + + @Post('connections/:id/sync') + @HttpCode(200) // the previous API answered 200, not 201 + sync( + @CurrentActor() actor: Actor, + @Param('id') id: string, + @Body(new ZodValidationPipe(SyncSchema, { message: 'Invalid dates' })) body: SyncInput, + ) { + return this.connections.sync(actor, id, body ?? {}) + } + + @Delete('connections/:id') + disconnect(@CurrentActor() actor: Actor, @Param('id') id: string) { + return this.connections.disconnect(actor, id) + } +} diff --git a/api/src/bank-connections/bank-connections.module.ts b/api/src/bank-connections/bank-connections.module.ts new file mode 100644 index 0000000..ca0bf68 --- /dev/null +++ b/api/src/bank-connections/bank-connections.module.ts @@ -0,0 +1,74 @@ +import { DynamicModule, Logger, Module } from '@nestjs/common' +import { ACCESS_REFUSED } from '../access/access.guard' +import { loadAccessSettings } from '../access/access.settings' +import { loadBankingConfig } from './banking.config' +import { BankAdminController } from './bank-admin.controller' +import { BankCallbackController } from './bank-callback.controller' +import { BankConnectionsController } from './bank-connections.controller' +import { BankConnectionsService } from './bank-connections.service' +import { BankConnectionsStore } from './bank-connections.store' +import { BANKING_CONFIG, BANKING_STATE, CLOCK } from './bank-connections.tokens' +import { BANK_PROVIDER } from './bank-provider' +import { BankingUnavailableController } from './banking-unavailable.controller' +import { EnableBankingProvider } from './providers/enable-banking.provider' +import { TestBankProvider } from './providers/test-bank.provider' +import { TestBankController } from './test-bank.controller' + +@Module({}) +export class BankConnectionsModule { + /** + * Decides once, at start-up, what to expose. Banking fails closed: when it is off, or set up + * incorrectly (for example in production without sign-in), only a "disabled" status is served. + */ + static forRoot(env: NodeJS.ProcessEnv = process.env): DynamicModule { + const logger = new Logger('BankConnections') + const access = loadAccessSettings(env) + const { config, errors } = loadBankingConfig(env) + const problems = [ + ...errors, + ...(config && access.isProd && access.mode !== 'cloudflare' + ? ['CF_ACCESS_TEAM_DOMAIN and CF_ACCESS_AUD are required in production'] + : []), + ] + + if (!config || problems.length) { + if (problems.length) logger.error(`Banking is disabled: ${problems.join('; ')}`) + return { + module: BankConnectionsModule, + controllers: [BankingUnavailableController], + providers: [{ provide: BANKING_STATE, useValue: problems.length ? 'misconfigured' : 'off' }], + } + } + + logger.log(`Banking enabled (${config.provider}, access: ${access.mode})`) + const useTestBank = config.provider === 'mock' + return { + module: BankConnectionsModule, + controllers: [ + BankConnectionsController, + BankCallbackController, + BankAdminController, + ...(useTestBank && !config.isProd ? [TestBankController] : []), + ], + providers: [ + { provide: BANKING_CONFIG, useValue: config }, + { provide: CLOCK, useValue: { now: () => new Date() } }, + { + provide: BANK_PROVIDER, + useValue: useTestBank + ? new TestBankProvider() + : new EnableBankingProvider({ appId: config.ebAppId!, privateKey: config.ebPrivateKey! }), + }, + BankConnectionsStore, + BankConnectionsService, + // Refused sign-ins are recorded in the bank audit log + { + provide: ACCESS_REFUSED, + inject: [BankConnectionsStore], + useFactory: (store: BankConnectionsStore) => (path: string, reason: string) => + store.audit('unknown', 'access.refused', path, { reason }), + }, + ], + } + } +} diff --git a/api/src/bank-connections/bank-connections.schemas.ts b/api/src/bank-connections/bank-connections.schemas.ts new file mode 100644 index 0000000..0b376e8 --- /dev/null +++ b/api/src/bank-connections/bank-connections.schemas.ts @@ -0,0 +1,12 @@ +import { z } from 'zod' + +export const ConnectSchema = z.object({ + bank: z.string().min(1).max(100), + country: z.string().length(2).default('DE'), + reconnect: z.string().uuid().optional(), +}) +export type ConnectInput = z.infer + +const day = z.string().regex(/^\d{4}-\d{2}-\d{2}$/) +export const SyncSchema = z.object({ from: day.optional(), to: day.optional() }) +export type SyncInput = z.infer diff --git a/api/src/bank-connections/bank-connections.service.ts b/api/src/bank-connections/bank-connections.service.ts new file mode 100644 index 0000000..cf2dbfd --- /dev/null +++ b/api/src/bank-connections/bank-connections.service.ts @@ -0,0 +1,355 @@ +import { + BadGatewayException, + BadRequestException, + ConflictException, + HttpException, + Inject, + Injectable, + NotFoundException, + OnModuleInit, +} from '@nestjs/common' +import { ACCESS_SETTINGS, AccessSettings, Actor } from '../access/access.settings' +import { accountRef, decrypt, encrypt, randomToken } from '../shared/crypto/encryption' +import { BANK_PROVIDER, BankProvider, BankTransaction, ProviderError, ProviderSession } from './bank-provider' +import { BANKING_CONFIG, BankingConfig, CLOCK, Clock } from './bank-connections.tokens' +import { BankConnectionsStore, ConnectionRow } from './bank-connections.store' +import { ConnectInput, SyncInput } from './bank-connections.schemas' +import { RateLimiter } from './rate-limiter' + +const BANKS_URL = '/app/banks' // where the bank sends the user back to +const EXPIRING_DAYS = 14 +const MIN_SYNC_INTERVAL_MS = 60_000 +const MAX_RANGE_DAYS = 366 +const OVERLAP_DAYS = 3 // re-fetch a few days already seen; repeats are skipped by the bank's own ids +const DEFAULT_HISTORY_DAYS = 90 + +const day = (d: Date) => d.toISOString().slice(0, 10) +const addDays = (d: Date, n: number) => new Date(d.getTime() + n * 86400_000) + +/** Why the bank's return didn't produce a connection (shown to the user as a friendly message). */ +type CallbackOutcome = + | { ok: true; connectionId: string } + | { ok: false; reason: 'invalid' | 'expired' | 'denied' | 'failed' } + +@Injectable() +export class BankConnectionsService implements OnModuleInit { + private readonly limiter: RateLimiter + private readonly bankCache = new Map> }>() + + constructor( + private readonly store: BankConnectionsStore, + @Inject(BANK_PROVIDER) readonly provider: BankProvider, + @Inject(BANKING_CONFIG) readonly config: BankingConfig, + @Inject(ACCESS_SETTINGS) private readonly access: AccessSettings, + @Inject(CLOCK) private readonly clock: Clock, + ) { + this.limiter = new RateLimiter(() => this.clock.now().getTime()) + } + + onModuleInit() { + this.store.prune() + } + + private now() { + return this.clock.now() + } + + // ---------------- views ---------------- + view(row: ConnectionRow) { + const left = Math.ceil((new Date(row.valid_until).getTime() - this.now().getTime()) / 86400_000) + const status = row.status === 'expired' || left <= 0 ? 'expired' : left <= EXPIRING_DAYS ? 'expiring' : 'active' + return { + id: row.id, + bank: row.aspsp_name, + country: row.aspsp_country, + status, + daysLeft: Math.max(left, 0), + validUntil: row.valid_until, + accounts: JSON.parse(row.account_labels) as Array<{ id: string; label: string; currency?: string }>, + lastSyncAt: row.last_sync_at, + lastSyncTo: row.last_sync_to, + createdAt: row.created_at, + } + } + + status(actor: Actor) { + return { + enabled: true, + provider: this.provider.name, + mock: this.provider.name === 'mock', + canAdmin: actor.admin, + expiringDays: EXPIRING_DAYS, + } + } + + async banks(country: string) { + const hit = this.bankCache.get(country) + if (hit && this.now().getTime() - hit.at < 3600_000) return hit.banks + try { + const banks = await this.provider.listBanks(country) + this.bankCache.set(country, { at: this.now().getTime(), banks }) + return banks + } catch { + throw new BadGatewayException({ error: 'Could not load banks' }) + } + } + + list() { + return this.store.listConnections().map(r => this.view(r)) + } + + // ---------------- connect ---------------- + async startConnection(actor: Actor, input: ConnectInput): Promise<{ url: string; state: string }> { + if (!this.limiter.allow(`connect:${actor.email}`, 600_000, 10)) + throw new HttpException({ error: 'Too many attempts. Try again later.' }, 429) + if (input.reconnect && !this.store.getConnection(input.reconnect)) + throw new NotFoundException({ error: 'Unknown connection' }) + + const bank = (await this.banks(input.country.toUpperCase()).catch(() => [])).find(b => b.name === input.bank) + if (!bank) throw new BadRequestException({ error: 'Unknown bank' }) + + // One login covers all of a bank's accounts, so connecting a bank you already have replaces + // the old link instead of adding a duplicate card. + const existing = this.store + .listConnections() + .find(c => c.aspsp_name === bank.name && c.aspsp_country === bank.country) + const replaces = input.reconnect ?? existing?.id + + const state = randomToken(32) + const validUntil = addDays(this.now(), Math.max(1, Math.min(bank.maxConsentDays, 180)) - 0.05) + try { + const { url } = await this.provider.startAuth({ bank, redirectUrl: this.config.redirectUrl, state, validUntil }) + this.store.createState({ + state, + bankName: bank.name, + country: bank.country, + replaceConnectionId: replaces, + }) + this.store.audit(actor.email, 'connect.start', bank.name, replaces ? { replaces } : undefined) + return { url, state } + } catch { + throw new BadGatewayException({ error: 'Could not start the connection' }) + } + } + + /** The bank sent the user back. Valid only for a request we started, from the same browser, once. */ + async completeConnection(p: { + state: string + code?: string + error?: string + cookieState?: string + }): Promise { + if (!p.state || !p.cookieState || p.cookieState !== p.state) { + this.store.audit('callback', 'connect.failed', undefined, { reason: 'state mismatch' }) + return { ok: false, reason: 'invalid' } + } + const pending = this.store.consumeState(p.state) + if (!pending) { + this.store.audit('callback', 'connect.failed', undefined, { reason: 'expired or reused' }) + return { ok: false, reason: 'expired' } + } + if (p.error || !p.code) { + this.store.audit('callback', 'connect.denied', pending.aspsp_name, { error: p.error || 'no code' }) + return { ok: false, reason: 'denied' } + } + try { + const session = await this.provider.completeAuth(p.code) + const old = pending.replace_connection_id ? this.store.getConnection(pending.replace_connection_id) : undefined + const id = this.store.createConnection({ + provider: this.provider.name, + bankName: pending.aspsp_name, + country: pending.aspsp_country, + secretBlob: encrypt( + JSON.stringify({ sessionId: session.sessionId, accounts: session.accounts }), + this.config.encryptionKey, + ), + accountLabels: session.accounts.map(a => ({ + id: accountRef(a.stableId), + label: a.label, + currency: a.currency, + })), + validUntil: session.validUntil, + lastSyncTo: old?.last_sync_to ?? null, // keep syncing from where the old connection stopped + }) + if (old && old.status !== 'revoked') { + await this.revokeAtBank(old) + this.store.retire(old.id, 'replaced') + } + this.store.audit('callback', 'connect.complete', pending.aspsp_name, { + accounts: session.accounts.length, + connection: id, + }) + return { ok: true, connectionId: id } + } catch (e) { + this.store.audit('callback', 'connect.failed', pending.aspsp_name, { + reason: e instanceof ProviderError ? e.kind : 'error', + }) + return { ok: false, reason: 'failed' } + } + } + + static returnUrl(outcome: CallbackOutcome): string { + return outcome.ok + ? `${BANKS_URL}?bank=connected&c=${outcome.connectionId}` + : `${BANKS_URL}?bank=error&reason=${outcome.reason}` + } + + // ---------------- sync ---------------- + async sync(actor: Actor, id: string, input: SyncInput) { + const row = this.store.getConnection(id) + if (!row || row.status === 'revoked' || row.status === 'replaced') + throw new NotFoundException({ error: 'Not found' }) + if (this.view(row).status === 'expired') throw new ConflictException({ error: 'expired' }) + + const today = day(this.now()) + const to = input.to && input.to < today ? input.to : today + const from = + input.from ?? + (row.last_sync_to + ? day(addDays(new Date(row.last_sync_to), -OVERLAP_DAYS)) + : day(addDays(this.now(), -DEFAULT_HISTORY_DAYS))) + if (from > to || (new Date(to).getTime() - new Date(from).getTime()) / 86400_000 > MAX_RANGE_DAYS) + throw new BadRequestException({ error: 'Invalid date range' }) + + // Banks limit how often access without a user present is allowed; stay well inside that + if ( + row.last_sync_started_at && + this.now().getTime() - new Date(row.last_sync_started_at).getTime() < MIN_SYNC_INTERVAL_MS + ) { + throw new HttpException({ error: 'Please wait a minute before syncing again.' }, 429) + } + this.store.markSyncStarted(row.id, this.now().toISOString()) + const logId = this.store.startSyncLog(row.id, from, to) + try { + const secret = JSON.parse(decrypt(row.secret_blob, this.config.encryptionKey)) as Pick< + ProviderSession, + 'sessionId' | 'accounts' + > + const session: ProviderSession = { ...secret, validUntil: row.valid_until } + const all: BankTransaction[] = [] + for (const account of session.accounts) { + const fetched = await this.provider.fetchTransactions({ session, account, from, to }) + // Stamp where each one came from, so the app can browse bank → account → transactions + for (const tx of fetched) { + tx.bankName = row.aspsp_name + tx.accountLabel = account.label + tx.accountRef = accountRef(account.stableId) + } + all.push(...fetched) + } + all.sort((a, b) => b.date.localeCompare(a.date)) + this.store.markSyncDone(row.id, to, this.now().toISOString()) + this.store.finishSyncLog(logId, 'ok', all.length) + this.store.audit(actor.email, 'sync', row.aspsp_name, { fetched: all.length }) + return { transactions: all, from, to, count: all.length } + } catch (e) { + const kind = e instanceof ProviderError ? e.kind : 'unavailable' + if (kind === 'consent_expired') this.store.setStatus(row.id, 'expired') + this.store.finishSyncLog(logId, kind === 'rate_limited' ? 'rate_limited' : 'failed', 0, kind) + this.store.audit(actor.email, 'sync.failed', row.aspsp_name, { kind }) + const status = kind === 'consent_expired' ? 409 : kind === 'rate_limited' ? 429 : 502 + throw new HttpException({ error: kind === 'consent_expired' ? 'expired' : kind }, status) + } + } + + async disconnect(actor: Actor, id: string) { + const row = this.store.getConnection(id) + if (!row) throw new NotFoundException({ error: 'Not found' }) + await this.revokeAtBank(row) + this.store.retire(row.id, 'revoked') + this.store.audit(actor.email, 'disconnect', row.aspsp_name) + return { ok: true } + } + + private async revokeAtBank(row: ConnectionRow) { + try { + const s = JSON.parse(decrypt(row.secret_blob, this.config.encryptionKey)) as { sessionId: string } + await this.provider.revoke(s.sessionId) + } catch { + /* already gone or nothing stored */ + } + } + + // ---------------- back office ---------------- + overview() { + const conns = this.list() + const last = this.store.lastSuccessfulSync() + const { access, config, provider } = this + return { + provider: provider.name, + connections: { + total: conns.length, + active: conns.filter(c => c.status === 'active').length, + expiring: conns.filter(c => c.status === 'expiring').length, + expired: conns.filter(c => c.status === 'expired').length, + }, + syncs30d: this.store.syncStats(30), + lastSuccessfulSync: last ? { at: last.finished_at, bank: last.bank } : null, + checks: [ + { + id: 'provider', + ok: true, + label: 'Bank provider', + detail: provider.name === 'mock' ? 'Test bank (development)' : 'Enable Banking', + }, + { + id: 'signing', + ok: provider.name === 'mock' || !!config.ebPrivateKey, + label: 'Request signing key loaded', + detail: + provider.name === 'mock' + ? 'not needed for the test bank' + : `RS256 · app id …${(config.ebAppId || '').slice(-4)}`, + }, + { + id: 'encryption', + ok: !config.usesDevKey, + label: 'Stored links are encrypted with your own key', + detail: config.usesDevKey ? 'using the development key' : 'AES-256-GCM', + }, + { + id: 'access', + ok: access.mode === 'cloudflare', + label: 'Sign-in required (Cloudflare Access)', + detail: access.mode === 'cloudflare' ? 'token verified on every request' : 'development mode: no sign-in', + }, + { + id: 'https', + ok: config.redirectUrl.startsWith('https://'), + label: 'Return address uses https', + detail: config.redirectUrl, + }, + { + id: 'mock', + ok: !(config.isProd && provider.name === 'mock'), + label: 'Test bank is blocked in production', + detail: provider.name === 'mock' ? 'test bank is active (development)' : 'refused at startup', + }, + { + id: 'admins', + ok: access.adminEmails.length > 1 || access.mode === 'dev', + warn: access.adminEmails.length === 1, + label: `Admin list has ${access.adminEmails.length || 'no'} address${access.adminEmails.length === 1 ? '' : 'es'}`, + detail: + access.adminEmails.join(', ') || (access.mode === 'dev' ? 'development: everyone is admin' : 'none set'), + }, + ], + } + } + syncLog(limit: number) { + return { entries: this.store.syncLog(Math.min(limit || 100, 500)) } + } + auditLog(limit: number) { + return { entries: this.store.auditLog(Math.min(limit || 100, 500)) } + } + + /** Test bank only: ages a connection so the expiring and expired states can be seen. */ + ageConnection(actor: Actor, id: string, mode: 'expiring' | 'expired') { + const row = this.store.getConnection(id) + if (!row) throw new NotFoundException({ error: 'Not found' }) + this.store.setValidUntil(row.id, addDays(this.now(), mode === 'expired' ? -1 : 9).toISOString()) + if (mode === 'expired') this.store.setStatus(row.id, 'expired') + this.store.audit(actor.email, 'admin.expire', row.aspsp_name, { mode }) + return { ok: true } + } +} diff --git a/api/src/bank-connections/bank-connections.store.ts b/api/src/bank-connections/bank-connections.store.ts new file mode 100644 index 0000000..3e54b44 --- /dev/null +++ b/api/src/bank-connections/bank-connections.store.ts @@ -0,0 +1,232 @@ +import { Injectable } from '@nestjs/common' +import { randomUUID } from 'node:crypto' +import { DatabaseService } from '../shared/database/database.service' + +export type ConnectionStatus = 'active' | 'expired' | 'revoked' | 'replaced' + +export interface ConnectionRow { + id: string + provider: string + aspsp_name: string + aspsp_country: string + status: ConnectionStatus + secret_blob: string // encrypted {sessionId, accounts} + account_labels: string // JSON: [{label, currency}] (masked, safe to show) + valid_until: string + created_at: string + last_sync_at: string | null + last_sync_started_at: string | null + last_sync_to: string | null +} + +interface AuthState { + state: string + aspsp_name: string + aspsp_country: string + expires_at: string + used: number + replace_connection_id: string | null +} + +const iso = () => new Date().toISOString() +const daysAgo = (days: number) => new Date(Date.now() - days * 86400_000).toISOString() + +/** All persistence for bank connections. Connection metadata only: transactions are never stored. */ +@Injectable() +export class BankConnectionsStore { + private readonly q + + constructor(database: DatabaseService) { + const { db } = database + db.exec(` + CREATE TABLE IF NOT EXISTS bank_connections ( + id TEXT PRIMARY KEY, + provider TEXT NOT NULL, + aspsp_name TEXT NOT NULL, + aspsp_country TEXT NOT NULL, + status TEXT NOT NULL, + secret_blob TEXT NOT NULL, + account_labels TEXT NOT NULL, + valid_until TEXT NOT NULL, + created_at TEXT NOT NULL, + last_sync_at TEXT, + last_sync_started_at TEXT, + last_sync_to TEXT + ); + CREATE TABLE IF NOT EXISTS bank_auth_states ( + state TEXT PRIMARY KEY, + aspsp_name TEXT NOT NULL, + aspsp_country TEXT NOT NULL, + expires_at TEXT NOT NULL, + used INTEGER NOT NULL DEFAULT 0, + replace_connection_id TEXT + ); + CREATE TABLE IF NOT EXISTS bank_sync_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + connection_id TEXT NOT NULL, + started_at TEXT NOT NULL, + finished_at TEXT, + date_from TEXT, + date_to TEXT, + status TEXT NOT NULL, + fetched INTEGER NOT NULL DEFAULT 0, + error TEXT + ); + CREATE TABLE IF NOT EXISTS bank_audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts TEXT NOT NULL, + actor TEXT NOT NULL, + action TEXT NOT NULL, + target TEXT, + detail TEXT + ); + CREATE INDEX IF NOT EXISTS idx_bank_sync_log_started ON bank_sync_log(started_at); + CREATE INDEX IF NOT EXISTS idx_bank_audit_ts ON bank_audit_log(ts); + `) + this.q = { + insertState: db.prepare( + 'INSERT INTO bank_auth_states (state, aspsp_name, aspsp_country, expires_at, replace_connection_id) VALUES (?,?,?,?,?)', + ), + getState: db.prepare('SELECT * FROM bank_auth_states WHERE state = ?'), + // Single-use: only one caller can flip used from 0 to 1 + useState: db.prepare('UPDATE bank_auth_states SET used = 1 WHERE state = ? AND used = 0'), + purgeStates: db.prepare('DELETE FROM bank_auth_states WHERE expires_at < ?'), + insertConn: db.prepare(`INSERT INTO bank_connections + (id, provider, aspsp_name, aspsp_country, status, secret_blob, account_labels, valid_until, created_at, last_sync_to) + VALUES (@id, @provider, @aspsp_name, @aspsp_country, 'active', @secret_blob, @account_labels, @valid_until, @created_at, @last_sync_to)`), + getConn: db.prepare('SELECT * FROM bank_connections WHERE id = ?'), + listConns: db.prepare( + "SELECT * FROM bank_connections WHERE status NOT IN ('revoked','replaced') ORDER BY created_at DESC", + ), + setStatus: db.prepare('UPDATE bank_connections SET status = ? WHERE id = ?'), + wipeSecret: db.prepare("UPDATE bank_connections SET status = ?, secret_blob = '' WHERE id = ?"), + syncStarted: db.prepare('UPDATE bank_connections SET last_sync_started_at = ? WHERE id = ?'), + syncDone: db.prepare('UPDATE bank_connections SET last_sync_at = ?, last_sync_to = ? WHERE id = ?'), + setValidUntil: db.prepare('UPDATE bank_connections SET valid_until = ? WHERE id = ?'), + insertSync: db.prepare( + 'INSERT INTO bank_sync_log (connection_id, started_at, date_from, date_to, status) VALUES (?,?,?,?,?)', + ), + finishSync: db.prepare( + 'UPDATE bank_sync_log SET finished_at = ?, status = ?, fetched = ?, error = ? WHERE id = ?', + ), + syncLog: + db.prepare(`SELECT l.id, l.connection_id, c.aspsp_name AS bank, l.started_at, l.finished_at, l.date_from, l.date_to, l.status, l.fetched, l.error + FROM bank_sync_log l LEFT JOIN bank_connections c ON c.id = l.connection_id ORDER BY l.id DESC LIMIT ?`), + syncStats: db.prepare('SELECT status, COUNT(*) AS n FROM bank_sync_log WHERE started_at >= ? GROUP BY status'), + lastOk: + db.prepare(`SELECT l.finished_at, c.aspsp_name AS bank FROM bank_sync_log l LEFT JOIN bank_connections c ON c.id = l.connection_id + WHERE l.status = 'ok' ORDER BY l.id DESC LIMIT 1`), + insertAudit: db.prepare('INSERT INTO bank_audit_log (ts, actor, action, target, detail) VALUES (?,?,?,?,?)'), + audit: db.prepare('SELECT ts, actor, action, target, detail FROM bank_audit_log ORDER BY id DESC LIMIT ?'), + pruneSync: db.prepare('DELETE FROM bank_sync_log WHERE started_at < ?'), + pruneAudit: db.prepare('DELETE FROM bank_audit_log WHERE ts < ?'), + } + } + + // ---- sign-in requests started by the user (single use, ten minutes) ---- + createState(p: { state: string; bankName: string; country: string; replaceConnectionId?: string; ttlMs?: number }) { + this.q.purgeStates.run(iso()) + this.q.insertState.run( + p.state, + p.bankName, + p.country, + new Date(Date.now() + (p.ttlMs ?? 600_000)).toISOString(), + p.replaceConnectionId ?? null, + ) + } + getState(state: string) { + return this.q.getState.get(state) as AuthState | undefined + } + /** Returns the request only for the first caller within its validity window. */ + consumeState(state: string): AuthState | null { + const row = this.getState(state) + if (!row || row.used || row.expires_at < iso()) return null + return this.q.useState.run(state).changes === 1 ? row : null + } + + // ---- connections ---- + createConnection(p: { + provider: string + bankName: string + country: string + secretBlob: string + accountLabels: unknown + validUntil: string + lastSyncTo?: string | null + }) { + const id = randomUUID() + this.q.insertConn.run({ + id, + provider: p.provider, + aspsp_name: p.bankName, + aspsp_country: p.country, + secret_blob: p.secretBlob, + account_labels: JSON.stringify(p.accountLabels), + valid_until: p.validUntil, + created_at: iso(), + last_sync_to: p.lastSyncTo ?? null, + }) + return id + } + getConnection(id: string) { + return this.q.getConn.get(id) as ConnectionRow | undefined + } + listConnections() { + return this.q.listConns.all() as ConnectionRow[] + } + setStatus(id: string, status: ConnectionStatus) { + this.q.setStatus.run(status, id) + } + retire(id: string, status: 'revoked' | 'replaced') { + this.q.wipeSecret.run(status, id) + } // also erases the stored link + markSyncStarted(id: string, at: string = iso()) { + this.q.syncStarted.run(at, id) + } + markSyncDone(id: string, to: string, at: string = iso()) { + this.q.syncDone.run(at, to, id) + } + setValidUntil(id: string, validUntil: string) { + this.q.setValidUntil.run(validUntil, id) + } + + // ---- sync log and audit trail ---- + startSyncLog(connectionId: string, from: string, to: string) { + return Number(this.q.insertSync.run(connectionId, iso(), from, to, 'running').lastInsertRowid) + } + finishSyncLog(id: number, status: 'ok' | 'failed' | 'rate_limited', fetched: number, error?: string) { + this.q.finishSync.run(iso(), status, fetched, error ? error.slice(0, 200) : null, id) + } + syncLog(limit = 100) { + return this.q.syncLog.all(limit) as Array> + } + syncStats(days = 30) { + const rows = this.q.syncStats.all(daysAgo(days)) as Array<{ status: string; n: number }> + const by = Object.fromEntries(rows.map(r => [r.status, r.n])) + return { + ok: by.ok ?? 0, + failed: (by.failed ?? 0) + (by.rate_limited ?? 0), + total: rows.reduce((sum, r) => sum + r.n, 0), + } + } + lastSuccessfulSync() { + return this.q.lastOk.get() as { finished_at: string; bank: string } | undefined + } + audit(actor: string, action: string, target?: string, detail?: unknown) { + this.q.insertAudit.run( + iso(), + actor, + action, + target ?? null, + detail === undefined ? null : JSON.stringify(detail).slice(0, 500), + ) + } + auditLog(limit = 100) { + return this.q.audit.all(limit) as Array> + } + prune() { + this.q.pruneSync.run(daysAgo(90)) + this.q.pruneAudit.run(daysAgo(365)) + this.q.purgeStates.run(iso()) + } +} diff --git a/api/src/bank-connections/bank-connections.test.ts b/api/src/bank-connections/bank-connections.test.ts new file mode 100644 index 0000000..ed0784f --- /dev/null +++ b/api/src/bank-connections/bank-connections.test.ts @@ -0,0 +1,420 @@ +import { afterEach, describe, expect, it } from 'vitest' +import request from 'supertest' +import { randomBytes } from 'node:crypto' +import { SIGNED_IN_HEADERS as H, TEST_KEY, TestAppOptions, createTestApp } from '../testing/test-app' +import { BankProvider, ProviderError } from './bank-provider' + +type TestApp = Awaited> +const open: TestApp[] = [] +async function make(options?: TestAppOptions) { + const t = await createTestApp(options) + open.push(t) + return t +} +afterEach(async () => { + while (open.length) await open.pop()!.close() +}) + +const cookieOf = (res: request.Response) => String(res.headers['set-cookie']?.[0] ?? '').split(';')[0] +const loc = (res: request.Response) => String(res.headers.location ?? '') +const stateOf = (url: string) => new URL(url, 'http://x').searchParams.get('state')! +const idOf = (res: request.Response) => new URL(loc(res), 'http://x').searchParams.get('c')! + +/** connect → test bank page → approve or deny → the bank's return. Resolves to the return response. */ +async function connectBank(t: TestApp, bank = 'N26', extra: Record = {}, approve = true) { + const start = await request(t.server) + .post('/api/banking/connect') + .set(H) + .send({ bank, ...extra }) + .expect(200) + const decide = await request(t.server) + .get( + `/api/banking/mock-bank/decide?state=${encodeURIComponent(stateOf(start.body.url))}&d=${approve ? 'approve' : 'deny'}`, + ) + .expect(302) + return request(t.server).get(loc(decide)).set('Cookie', cookieOf(start)) +} + +describe('access control (production-style)', () => { + const verify = async (t: string) => { + if (t === 'me') return { email: 'me@x.com' } + if (t === 'other') return { email: 'other@x.com' } + throw new Error('bad') + } + const cloudflare = { CF_ACCESS_TEAM_DOMAIN: 't', CF_ACCESS_AUD: 'a', BANKING_ADMIN_EMAILS: 'me@x.com' } + const as = (token?: string) => (token ? { ...H, 'cf-access-jwt-assertion': token } : H) + + it('refuses every protected route without a valid token, and logs it', async () => { + const t = await make({ env: cloudflare, verify }) + for (const [method, path] of [ + ['get', '/status'], + ['get', '/banks'], + ['post', '/connect'], + ['get', '/connections'], + ['post', '/connections/x/sync'], + ['delete', '/connections/x'], + ['get', '/admin/overview'], + ['get', '/admin/audit'], + ] as const) { + expect((await request(t.server)[method](`/api/banking${path}`).set(as())).status, `${method} ${path}`).toBe(401) + expect( + (await request(t.server)[method](`/api/banking${path}`).set(as('garbage'))).status, + `${method} ${path} (bad token)`, + ).toBe(401) + } + expect(t.store!.auditLog().some(a => a.action === 'access.refused')).toBe(true) + }) + + it('lets a signed-in user in, but only listed admins into the back office', async () => { + const t = await make({ env: cloudflare, verify }) + const status = await request(t.server).get('/api/banking/status').set(as('other')).expect(200) + expect(status.body.canAdmin).toBe(false) + await request(t.server).get('/api/banking/admin/overview').set(as('other')).expect(403) + await request(t.server).get('/api/banking/admin/overview').set(as('me')).expect(200) + }) + + it('keeps the bank return page public: it is guarded by its request, not by sign-in', async () => { + const t = await make({ env: cloudflare, verify }) + const res = await request(t.server).get('/api/banking/callback?state=nope&code=x') + expect(res.status).toBe(302) + expect(loc(res)).toContain('bank=error') + }) + + it('does not expose the test bank page in production-style setups', async () => { + const t = await make({ + env: { + ...cloudflare, + NODE_ENV: 'production', + BANKING_PROVIDER: 'enablebanking', + EB_APP_ID: 'x', + EB_PRIVATE_KEY: 'k', + BANKING_REDIRECT_URL: 'https://a.b/cb', + }, + verify, + }) + await request(t.server).get('/api/banking/mock-bank?state=x').expect(404) + }) +}) + +describe('banking fails closed', () => { + it('is hidden when switched off', async () => { + const t = await make({ env: { BANKING_PROVIDER: 'off' } }) + expect((await request(t.server).get('/api/banking/status').expect(200)).body).toEqual({ enabled: false }) + await request(t.server).post('/api/banking/connect').set(H).send({ bank: 'N26' }).expect(404) + await request(t.server).get('/api/banking/connections').expect(404) + }) + it('refuses the test bank in production', async () => { + const t = await make({ env: { NODE_ENV: 'production', BANKING_PROVIDER: 'mock' } }) + expect((await request(t.server).get('/api/banking/status').expect(200)).body).toEqual({ enabled: false }) + await request(t.server).get('/api/banking/connections').expect(503) + }) + it('refuses to run in production without sign-in settings', async () => { + const t = await make({ + env: { + NODE_ENV: 'production', + BANKING_PROVIDER: 'enablebanking', + EB_APP_ID: 'x', + EB_PRIVATE_KEY: 'k', + BANKING_REDIRECT_URL: 'https://a.b/cb', + }, + }) + await request(t.server).get('/api/banking/connections').expect(503) + }) +}) + +describe('changing requests', () => { + it('need the custom header, and the right origin when one is set', async () => { + const t = await make({ env: { ALLOWED_ORIGIN: 'https://finkit.example' } }) + const body = { bank: 'N26' } + await request(t.server).post('/api/banking/connect').set('Content-Type', 'application/json').send(body).expect(403) + await request(t.server) + .post('/api/banking/connect') + .set(H) + .set('Origin', 'https://evil.example') + .send(body) + .expect(403) + await request(t.server) + .post('/api/banking/connect') + .set(H) + .set('Origin', 'https://finkit.example') + .send(body) + .expect(200) + }) +}) + +describe('connect flow with the test bank', () => { + it('connects, lists safely, syncs, throttles and disconnects', async () => { + const t = await make() + const cb = await connectBank(t) + expect(cb.status).toBe(302) + expect(loc(cb)).toMatch(/^\/app\/banks\?bank=connected&c=[0-9a-f-]{36}$/) + + const list = (await request(t.server).get('/api/banking/connections').expect(200)).body + expect(list.connections).toHaveLength(1) + const c = list.connections[0] + expect(c).toMatchObject({ bank: 'N26', status: 'active', accounts: [{ label: '…4821', currency: 'EUR' }] }) + expect(JSON.stringify(list)).not.toMatch(/sessionId|secret|mock-/) // nothing credential-like reaches the browser + + const sync = (await request(t.server).post(`/api/banking/connections/${c.id}/sync`).set(H).send({}).expect(200)) + .body + expect(sync.count).toBeGreaterThan(10) + expect(sync.transactions[0]).toHaveProperty('externalId') + // every transaction says where it came from, and the account id matches the one on the card + expect(sync.transactions[0]).toMatchObject({ bankName: 'N26', accountLabel: '…4821' }) + expect(sync.transactions[0].accountRef).toBe(c.accounts[0].id) + expect(c.accounts[0].id).toMatch(/^[0-9a-f]{16}$/) + const again = await request(t.server) + .post(`/api/banking/connections/${c.id}/sync`) + .set(H) + .send({ from: sync.from, to: sync.to }) + expect(again.status).toBe(429) + expect(again.body.error).toMatch(/wait/i) + + await request(t.server).delete(`/api/banking/connections/${c.id}`).set(H).expect(200) + expect((await request(t.server).get('/api/banking/connections')).body.connections).toHaveLength(0) + }) + + it('returns the same transaction ids on every fetch and continues where the last sync stopped', async () => { + const clock = { now: () => current } + let current = new Date('2026-10-20T12:00:00Z') + const t = await make({ clock }) + const id = idOf(await connectBank(t)) + const first = (await request(t.server).post(`/api/banking/connections/${id}/sync`).set(H).send({})).body + current = new Date('2026-10-22T12:00:00Z') + const second = (await request(t.server).post(`/api/banking/connections/${id}/sync`).set(H).send({})).body + const firstAmounts = new Map(first.transactions.map((x: any) => [x.externalId, x.amount])) + const overlap = second.transactions.filter((x: any) => firstAmounts.has(x.externalId)) + expect(overlap.length).toBeGreaterThan(0) + for (const x of overlap) expect(firstAmounts.get(x.externalId)).toBe(x.amount) + expect(second.from > first.from).toBe(true) // continued, not restarted + }) + + it('stores no transactions and no readable secrets', async () => { + const t = await make() + const id = idOf(await connectBank(t)) + await request(t.server).post(`/api/banking/connections/${id}/sync`).set(H).send({}) + const dump = JSON.stringify( + ['bank_connections', 'bank_sync_log', 'bank_audit_log', 'bank_auth_states'].map(n => + t.db.prepare(`SELECT * FROM ${n}`).all(), + ), + ) + expect(dump).not.toMatch(/REWE|Netflix|Salary|Acme|Amazon|Shell/) + expect(dump).not.toMatch(/mock-[0-9a-f]{16}/) // the session id only exists encrypted + expect( + ( + t.db.prepare('SELECT secret_blob FROM bank_connections').get() as { secret_blob: string } + ).secret_blob.startsWith('v1.'), + ).toBe(true) + }) + + it('handles a denied approval without creating a connection', async () => { + const t = await make() + expect(loc(await connectBank(t, 'N26', {}, false))).toBe('/app/banks?bank=error&reason=denied') + expect((await request(t.server).get('/api/banking/connections')).body.connections).toHaveLength(0) + }) + it('rejects unknown banks and malformed requests', async () => { + const t = await make() + await request(t.server).post('/api/banking/connect').set(H).send({ bank: 'Nope Bank' }).expect(400) + await request(t.server).post('/api/banking/connect').set(H).send({}).expect(400) + await request(t.server).post('/api/banking/connections/x/sync').set(H).send({ from: 'tomorrow' }).expect(400) + }) + + it('reconnecting replaces the old link and keeps the sync position', async () => { + const t = await make() + const first = idOf(await connectBank(t)) + await request(t.server).post(`/api/banking/connections/${first}/sync`).set(H).send({}) + const lastTo = t.store!.getConnection(first)!.last_sync_to + const second = idOf(await connectBank(t, 'N26', { reconnect: first })) + expect(t.store!.getConnection(first)!.status).toBe('replaced') + expect(t.store!.getConnection(first)!.secret_blob).toBe('') + expect(t.store!.getConnection(second)!.last_sync_to).toBe(lastTo) + expect((await request(t.server).get('/api/banking/connections')).body.connections.map((c: any) => c.id)).toEqual([ + second, + ]) + }) +}) + +describe('connecting the same bank twice', () => { + it('replaces the existing link instead of adding a duplicate', async () => { + const t = await make() + const first = idOf(await connectBank(t)) + const second = idOf(await connectBank(t)) // no reconnect flag: the server notices the bank is already connected + expect(second).not.toBe(first) + expect(t.store!.getConnection(first)!.status).toBe('replaced') + expect((await request(t.server).get('/api/banking/connections')).body.connections.map((c: any) => c.id)).toEqual([ + second, + ]) + }) + it('keeps different banks side by side', async () => { + const t = await make() + await connectBank(t, 'N26') + await connectBank(t, 'Wise') + expect( + (await request(t.server).get('/api/banking/connections')).body.connections.map((c: any) => c.bank).sort(), + ).toEqual(['N26', 'Wise']) + }) +}) + +describe('the return page cannot be forged', () => { + async function start(t: TestApp) { + const r = await request(t.server).post('/api/banking/connect').set(H).send({ bank: 'N26' }).expect(200) + return { state: stateOf(r.body.url), cookie: cookieOf(r), res: r } + } + const count = (t: TestApp) => (t.db.prepare('SELECT COUNT(*) n FROM bank_connections').get() as { n: number }).n + + it('needs the cookie from the browser that started the connection', async () => { + const t = await make() + const { state } = await start(t) + const code = new URL( + loc(await request(t.server).get(`/api/banking/mock-bank/decide?state=${state}&d=approve`)), + 'http://x', + ).searchParams.get('code')! + expect(loc(await request(t.server).get(`/api/banking/callback?code=${code}&state=${state}`))).toContain( + 'reason=invalid', + ) // no cookie + expect( + loc( + await request(t.server) + .get(`/api/banking/callback?code=${code}&state=${state}`) + .set('Cookie', 'finkit_bank_state=other'), + ), + ).toContain('reason=invalid') + expect(count(t)).toBe(0) + }) + it('works once only', async () => { + const t = await make() + const { state, cookie } = await start(t) + const decide = loc(await request(t.server).get(`/api/banking/mock-bank/decide?state=${state}&d=approve`)) + expect(loc(await request(t.server).get(decide).set('Cookie', cookie))).toContain('bank=connected') + expect(loc(await request(t.server).get(decide).set('Cookie', cookie))).toMatch(/reason=(expired|invalid)/) + expect(count(t)).toBe(1) + }) + it('expires after ten minutes', async () => { + const t = await make() + const state = randomBytes(8).toString('hex') + t.store!.createState({ state, bankName: 'N26', country: 'DE', ttlMs: -1000 }) + expect( + loc( + await request(t.server) + .get(`/api/banking/callback?code=c&state=${state}`) + .set('Cookie', `finkit_bank_state=${state}`), + ), + ).toContain('reason=expired') + }) + it('sets an HttpOnly, SameSite cookie scoped to the banking path, with no caching', async () => { + const t = await make() + const { res } = await start(t) + const c = String(res.headers['set-cookie']) + expect(c).toMatch(/HttpOnly/i) + expect(c).toMatch(/SameSite=Lax/i) + expect(c).toMatch(/Path=\/api\/banking/) + expect(c).toMatch(/Max-Age=600/) + expect(res.headers['cache-control']).toBe('no-store') + }) + it('is Secure in production', async () => { + const stub: BankProvider = { + name: 'mock', + listBanks: async () => [{ name: 'B', country: 'DE', maxConsentDays: 90 }], + startAuth: async () => ({ url: 'https://bank' }), + completeAuth: async () => { + throw new Error('x') + }, + fetchTransactions: async () => [], + revoke: async () => {}, + } + const t = await make({ + env: { + NODE_ENV: 'production', + BANKING_PROVIDER: 'enablebanking', + EB_APP_ID: 'x', + EB_PRIVATE_KEY: 'k', + BANKING_REDIRECT_URL: 'https://a.b/cb', + CF_ACCESS_TEAM_DOMAIN: 't', + CF_ACCESS_AUD: 'a', + BANKING_ADMIN_EMAILS: 'a@b.c', + }, + provider: stub, + verify: async () => ({ email: 'a@b.c' }), + }) + const r = await request(t.server) + .post('/api/banking/connect') + .set(H) + .set('cf-access-jwt-assertion', 't') + .send({ bank: 'B' }) + .expect(200) + expect(String(r.headers['set-cookie'])).toMatch(/Secure/i) + }) +}) + +describe('sync failures', () => { + function failing(kind: 'consent_expired' | 'rate_limited' | 'unavailable'): BankProvider { + return { + name: 'mock', + listBanks: async () => [{ name: 'B', country: 'DE', maxConsentDays: 180 }], + startAuth: async p => ({ url: `/x?state=${p.state}` }), + completeAuth: async () => ({ + sessionId: 's', + accounts: [{ uid: 'u', stableId: 's', label: '…1' }], + validUntil: new Date(Date.now() + 90 * 86400_000).toISOString(), + }), + fetchTransactions: async () => { + throw new ProviderError('nope', kind) + }, + revoke: async () => {}, + } + } + async function connected(kind: 'consent_expired' | 'rate_limited' | 'unavailable') { + const t = await make({ provider: failing(kind) }) + const r = await request(t.server).post('/api/banking/connect').set(H).send({ bank: 'B' }).expect(200) + const cb = await request(t.server) + .get(`/api/banking/callback?code=c&state=${stateOf(r.body.url)}`) + .set('Cookie', cookieOf(r)) + return { t, id: idOf(cb) } + } + const sync = (t: TestApp, id: string) => request(t.server).post(`/api/banking/connections/${id}/sync`).set(H).send({}) + + it('marks the connection expired when the bank says access ended', async () => { + const { t, id } = await connected('consent_expired') + expect((await sync(t, id)).status).toBe(409) + expect((await request(t.server).get('/api/banking/connections')).body.connections[0].status).toBe('expired') + expect((await sync(t, id)).status).toBe(409) + }) + it('reports rate limiting as 429', async () => { + const { t, id } = await connected('rate_limited') + expect((await sync(t, id)).status).toBe(429) + }) + it('reports outages without leaking detail, and logs them', async () => { + const { t, id } = await connected('unavailable') + const r = await sync(t, id) + expect(r.status).toBe(502) + expect(JSON.stringify(r.body)).not.toContain('nope') + expect((await request(t.server).get('/api/banking/admin/sync-log')).body.entries[0]).toMatchObject({ + status: 'failed', + bank: 'B', + }) + }) +}) + +describe('back office (development mode)', () => { + it('reports setup checks, sync stats and an audit trail', async () => { + const t = await make() + const id = idOf(await connectBank(t)) + await request(t.server).post(`/api/banking/connections/${id}/sync`).set(H).send({}) + const o = (await request(t.server).get('/api/banking/admin/overview').expect(200)).body + expect(o.connections).toMatchObject({ total: 1, active: 1 }) + expect(o.syncs30d.ok).toBe(1) + expect(o.checks.find((c: any) => c.id === 'access').ok).toBe(false) // development mode is flagged + const actions = (await request(t.server).get('/api/banking/admin/audit')).body.entries.map((e: any) => e.action) + expect(actions).toEqual(expect.arrayContaining(['connect.start', 'connect.complete', 'sync'])) + }) + it('can age a test connection to show the expiring and expired states', async () => { + const t = await make() + const id = idOf(await connectBank(t)) + await request(t.server).post(`/api/banking/admin/connections/${id}/expire`).set(H).expect(200) + expect((await request(t.server).get('/api/banking/connections')).body.connections[0].status).toBe('expiring') + await request(t.server).post(`/api/banking/admin/connections/${id}/expire?mode=expired`).set(H).expect(200) + expect((await request(t.server).get('/api/banking/connections')).body.connections[0].status).toBe('expired') + }) +}) + +void TEST_KEY diff --git a/api/src/bank-connections/bank-connections.tokens.ts b/api/src/bank-connections/bank-connections.tokens.ts new file mode 100644 index 0000000..37673d3 --- /dev/null +++ b/api/src/bank-connections/bank-connections.tokens.ts @@ -0,0 +1,10 @@ +import { BankingConfig } from './banking.config' + +export const BANKING_CONFIG = Symbol('BANKING_CONFIG') +export const BANKING_STATE = Symbol('BANKING_STATE') +export type BankingState = 'off' | 'misconfigured' +export const CLOCK = Symbol('CLOCK') +export interface Clock { + now(): Date +} +export type { BankingConfig } diff --git a/api/src/bank-connections/bank-provider.ts b/api/src/bank-connections/bank-provider.ts new file mode 100644 index 0000000..72f26b7 --- /dev/null +++ b/api/src/bank-connections/bank-provider.ts @@ -0,0 +1,65 @@ +// The contract every bank provider (the real one and the test bank) fulfils. + +// What the rest of the API knows about bank providers. Real and mock providers both fit this. + +export interface BankInfo { + name: string + country: string + maxConsentDays: number +} + +export interface ProviderAccount { + uid: string // provider id, only valid for the life of a session + stableId: string // survives reconnects (used to recognise the same transaction again) + label: string // masked, safe to show: "…1234" + currency?: string +} + +export interface ProviderSession { + sessionId: string + accounts: ProviderAccount[] + validUntil: string // ISO +} + +// Shaped like the web app's RawTransaction, so a sync feeds the normal import path +export interface BankTransaction { + date: string // YYYY-MM-DD + description: string + amount: number // negative = money out + currency: string + recipient?: string + recipientIban?: string + externalId?: string + accountId?: string + // Where it came from, so the app can browse bank → account → transactions + bankName?: string + accountLabel?: string + accountRef?: string + rawData: Record +} + +export class ProviderError extends Error { + constructor( + message: string, + public readonly kind: 'consent_expired' | 'rate_limited' | 'unavailable' | 'rejected', + public readonly status?: number, + ) { + super(message) + } +} + +export interface BankProvider { + readonly name: 'mock' | 'enablebanking' + listBanks(country: string): Promise + startAuth(p: { bank: BankInfo; redirectUrl: string; state: string; validUntil: Date }): Promise<{ url: string }> + completeAuth(code: string): Promise + fetchTransactions(p: { + session: ProviderSession + account: ProviderAccount + from: string + to: string + }): Promise + revoke(sessionId: string): Promise +} + +export const BANK_PROVIDER = Symbol('BANK_PROVIDER') diff --git a/api/src/bank-connections/banking-unavailable.controller.ts b/api/src/bank-connections/banking-unavailable.controller.ts new file mode 100644 index 0000000..2e417aa --- /dev/null +++ b/api/src/bank-connections/banking-unavailable.controller.ts @@ -0,0 +1,19 @@ +import { All, Controller, Get, Inject, NotFoundException, ServiceUnavailableException } from '@nestjs/common' +import { BANKING_STATE, BankingState } from './bank-connections.tokens' + +/** Stands in for the real banking routes when banking is off or set up incorrectly (it fails closed). */ +@Controller('api/banking') +export class BankingUnavailableController { + constructor(@Inject(BANKING_STATE) private readonly state: BankingState) {} + + @Get('status') + status() { + return { enabled: false } + } + + @All('{*path}') + everythingElse(): never { + if (this.state === 'off') throw new NotFoundException({ error: 'Not Found' }) + throw new ServiceUnavailableException({ error: 'Banking is not available' }) + } +} diff --git a/api/src/bank-connections/banking.config.test.ts b/api/src/bank-connections/banking.config.test.ts new file mode 100644 index 0000000..9eaacb9 --- /dev/null +++ b/api/src/bank-connections/banking.config.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest' +import { loadAccessSettings } from '../access/access.settings' +import { loadBankingConfig } from './banking.config' + +const KEY = Buffer.alloc(32, 9) + +describe('config fails closed', () => { + const prod = { NODE_ENV: 'production' } + it('is off by default', () => expect(loadBankingConfig({}).config).toBeUndefined()) + it('rejects the mock bank in production', () => { + expect( + loadBankingConfig({ + ...prod, + BANKING_PROVIDER: 'mock', + CF_ACCESS_TEAM_DOMAIN: 't', + CF_ACCESS_AUD: 'a', + BANKING_ENCRYPTION_KEY: KEY.toString('base64'), + }).errors.join(), + ).toMatch(/mock/i) + }) + it('requires a real encryption key in production', () => { + expect( + loadBankingConfig({ + ...prod, + BANKING_PROVIDER: 'enablebanking', + EB_APP_ID: 'x', + EB_PRIVATE_KEY: 'k', + BANKING_REDIRECT_URL: 'https://a.b/cb', + }).errors.join(), + ).toMatch(/ENCRYPTION_KEY/) + }) + it('requires https for the return address in production', () => { + expect( + loadBankingConfig({ + ...prod, + BANKING_PROVIDER: 'enablebanking', + EB_APP_ID: 'x', + EB_PRIVATE_KEY: 'k', + BANKING_REDIRECT_URL: 'http://a.b/cb', + CF_ACCESS_TEAM_DOMAIN: 't', + CF_ACCESS_AUD: 'a', + BANKING_ENCRYPTION_KEY: KEY.toString('base64'), + }).errors.join(), + ).toMatch(/https/) + }) + it('accepts a complete production setup', () => { + const r = loadBankingConfig({ + ...prod, + BANKING_PROVIDER: 'enablebanking', + EB_APP_ID: 'app', + EB_PRIVATE_KEY: 'k', + BANKING_REDIRECT_URL: 'https://a.b/cb', + CF_ACCESS_TEAM_DOMAIN: 't', + CF_ACCESS_AUD: 'a', + BANKING_ENCRYPTION_KEY: KEY.toString('base64'), + BANKING_ADMIN_EMAILS: 'Me@X.com', + }) + expect(r.errors).toEqual([]) + const access = loadAccessSettings({ + ...prod, + CF_ACCESS_TEAM_DOMAIN: 't', + CF_ACCESS_AUD: 'a', + BANKING_ADMIN_EMAILS: 'Me@X.com', + }) + expect(access.mode).toBe('cloudflare') + expect(access.adminEmails).toEqual(['me@x.com']) + }) +}) + +describe('sign-in settings', () => { + it('is development mode (everyone is the admin) without Cloudflare values', () => { + expect(loadAccessSettings({}).mode).toBe('dev') + }) +}) diff --git a/api/src/bank-connections/banking.config.ts b/api/src/bank-connections/banking.config.ts new file mode 100644 index 0000000..d36e923 --- /dev/null +++ b/api/src/bank-connections/banking.config.ts @@ -0,0 +1,107 @@ +import { readFileSync } from 'node:fs' + +export type ProviderMode = 'off' | 'mock' | 'enablebanking' + +export interface BankingConfig { + provider: ProviderMode + isProd: boolean + encryptionKey: Buffer + usesDevKey: boolean + redirectUrl: string + // Access control + accessMode: 'cloudflare' | 'dev' + cfTeamDomain?: string + cfAudience?: string + adminEmails: string[] + allowedOrigin?: string + // Enable Banking + ebAppId?: string + ebPrivateKey?: string +} + +interface ConfigResult { + config?: BankingConfig + errors: string[] // why banking is unavailable (logged, never shown to clients) +} + +// A fixed key for local development only. Never accepted in production. +const DEV_KEY = Buffer.alloc(32, 7) + +/** + * Banking fails closed: in production it refuses to run unless access control, a real + * encryption key and (for the real provider) credentials are all configured. + */ +export function loadBankingConfig(env: NodeJS.ProcessEnv = process.env): ConfigResult { + const errors: string[] = [] + const provider = (env.BANKING_PROVIDER || 'off') as ProviderMode + if (!['off', 'mock', 'enablebanking'].includes(provider)) { + return { + errors: [`BANKING_PROVIDER must be off, mock or enablebanking (got "${provider}")`], + } + } + if (provider === 'off') return { errors: [] } + + const isProd = env.NODE_ENV === 'production' + + if (provider === 'mock' && isProd && env.BANKING_ALLOW_MOCK_IN_PROD !== '1') { + errors.push('The mock provider is not allowed in production') + } + + let encryptionKey: Buffer + if (env.BANKING_ENCRYPTION_KEY) { + encryptionKey = Buffer.from(env.BANKING_ENCRYPTION_KEY, 'base64') + if (encryptionKey.length !== 32) errors.push('BANKING_ENCRYPTION_KEY must be 32 bytes, base64 encoded') + } else if (!isProd) { + encryptionKey = DEV_KEY + } else { + encryptionKey = DEV_KEY + errors.push('BANKING_ENCRYPTION_KEY is required in production') + } + + const cfTeamDomain = env.CF_ACCESS_TEAM_DOMAIN + const cfAudience = env.CF_ACCESS_AUD + const accessMode: 'cloudflare' | 'dev' = cfTeamDomain && cfAudience ? 'cloudflare' : 'dev' + if (isProd && accessMode !== 'cloudflare') { + errors.push('CF_ACCESS_TEAM_DOMAIN and CF_ACCESS_AUD are required in production') + } + const adminEmails = (env.BANKING_ADMIN_EMAILS || '') + .split(',') + .map(s => s.trim().toLowerCase()) + .filter(Boolean) + + const redirectUrl = env.BANKING_REDIRECT_URL || '/api/banking/callback' + if (provider === 'enablebanking') { + if (!redirectUrl.startsWith('https://') && isProd) errors.push('BANKING_REDIRECT_URL must be an https URL') + } + + let ebPrivateKey: string | undefined + if (provider === 'enablebanking') { + if (!env.EB_APP_ID) errors.push('EB_APP_ID is required') + try { + ebPrivateKey = + env.EB_PRIVATE_KEY || (env.EB_PRIVATE_KEY_PATH ? readFileSync(env.EB_PRIVATE_KEY_PATH, 'utf8') : undefined) + } catch { + errors.push('Could not read EB_PRIVATE_KEY_PATH') + } + if (!ebPrivateKey) errors.push('EB_PRIVATE_KEY_PATH (or EB_PRIVATE_KEY) is required') + } + + if (errors.length) return { errors } + return { + errors, + config: { + provider, + isProd, + encryptionKey, + usesDevKey: !env.BANKING_ENCRYPTION_KEY, + redirectUrl, + accessMode, + cfTeamDomain, + cfAudience, + adminEmails, + allowedOrigin: env.ALLOWED_ORIGIN, + ebAppId: env.EB_APP_ID, + ebPrivateKey, + }, + } +} diff --git a/api/src/bank-connections/providers/enable-banking.provider.test.ts b/api/src/bank-connections/providers/enable-banking.provider.test.ts new file mode 100644 index 0000000..4e9fa5a --- /dev/null +++ b/api/src/bank-connections/providers/enable-banking.provider.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect } from 'vitest' +import { createVerify, generateKeyPairSync } from 'node:crypto' +import { ProviderError } from '../bank-provider' +import { EnableBankingProvider, mapEbTransaction, signJwt } from './enable-banking.provider' + +describe('mapper', () => { + const tx = (o: object) => + ({ + transaction_amount: { amount: '12.50', currency: 'EUR' }, + credit_debit_indicator: 'DBIT', + status: 'BOOK', + booking_date: '2026-10-05', + ...o, + }) as never + it('makes debits negative and uses the creditor as recipient', () => { + const m = mapEbTransaction( + tx({ creditor: { name: 'REWE' }, remittance_information: ['Einkauf', '123'], entry_reference: 'r1' }), + 'acc', + )! + expect(m).toMatchObject({ + amount: -12.5, + recipient: 'REWE', + description: 'Einkauf 123', + externalId: 'acc:r1', + date: '2026-10-05', + }) + }) + it('keeps credits positive and uses the debtor', () => { + expect(mapEbTransaction(tx({ credit_debit_indicator: 'CRDT', debtor: { name: 'Acme' } }), 'a')).toMatchObject({ + amount: 12.5, + recipient: 'Acme', + }) + }) + it('skips pending transactions and rows without a date or amount', () => { + expect(mapEbTransaction(tx({ status: 'PDNG' }), 'a')).toBeNull() + expect(mapEbTransaction(tx({ booking_date: null }), 'a')).toBeNull() + expect(mapEbTransaction(tx({ transaction_amount: { amount: 'x', currency: 'EUR' } }), 'a')).toBeNull() + }) +}) + +describe('Enable Banking client (fake server)', () => { + const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }) + const pem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString() + + it('signs RS256 JWTs the API will accept', () => { + const t = signJwt('app-123', pem, 1000, 3600) + const [h, p, s] = t.split('.') + expect(JSON.parse(Buffer.from(h, 'base64url').toString())).toEqual({ typ: 'JWT', alg: 'RS256', kid: 'app-123' }) + expect(JSON.parse(Buffer.from(p, 'base64url').toString())).toEqual({ + iss: 'enablebanking.com', + aud: 'api.enablebanking.com', + iat: 1000, + exp: 4600, + }) + expect(createVerify('RSA-SHA256').update(`${h}.${p}`).verify(publicKey, Buffer.from(s, 'base64url'))).toBe(true) + }) + + function fake( + handler: (req: { method: string; path: string; body: any; auth: string | null }) => { + status?: number + body?: unknown + }, + ) { + const calls: Array<{ method: string; path: string; body: any; auth: string | null }> = [] + const fetchImpl = (async (url: string, init: RequestInit) => { + const u = new URL(url) + const req = { + method: init.method || 'GET', + path: u.pathname + u.search, + body: init.body ? JSON.parse(String(init.body)) : undefined, + auth: (init.headers as Record).Authorization || null, + } + calls.push(req) + const r = handler(req) + return new Response(JSON.stringify(r.body ?? {}), { status: r.status ?? 200 }) + }) as unknown as typeof fetch + return { provider: new EnableBankingProvider({ appId: 'app', privateKey: pem, fetchImpl }), calls } + } + + it('lists personal-account banks with their consent length', async () => { + const { provider, calls } = fake(() => ({ + body: { + aspsps: [ + { name: 'N26', country: 'DE', psu_types: ['business', 'personal'], maximum_consent_validity: 15552000 }, + { name: 'BizOnly', country: 'DE', psu_types: ['business'] }, + ], + }, + })) + expect(await provider.listBanks('de')).toEqual([{ name: 'N26', country: 'DE', maxConsentDays: 180 }]) + expect(calls[0].path).toBe('/aspsps?country=DE') + }) + it('starts and completes authorisation', async () => { + const { provider, calls } = fake(r => + r.path === '/auth' + ? { body: { url: 'https://bank/login' } } + : { + body: { + session_id: 'S1', + accounts: [{ uid: 'u1', identification_hash: 'h1', account_id: { iban: 'DE001234' }, currency: 'EUR' }], + access: { valid_until: '2027-01-01T00:00:00Z' }, + }, + }, + ) + const { url } = await provider.startAuth({ + bank: { name: 'N26', country: 'DE', maxConsentDays: 180 }, + redirectUrl: 'https://x/cb', + state: 'st', + validUntil: new Date('2027-01-01'), + }) + expect(url).toBe('https://bank/login') + expect(calls[0].body).toMatchObject({ + aspsp: { name: 'N26', country: 'DE' }, + state: 'st', + redirect_url: 'https://x/cb', + psu_type: 'personal', + }) + expect(calls[0]).toBeDefined() + const s = await provider.completeAuth('code1') + expect(calls[1].body).toEqual({ code: 'code1' }) + expect(s).toMatchObject({ + sessionId: 'S1', + validUntil: '2027-01-01T00:00:00Z', + accounts: [{ uid: 'u1', stableId: 'h1', label: '…1234', currency: 'EUR' }], + }) + }) + it('follows continuation keys and skips pending rows', async () => { + const { provider, calls } = fake(r => + r.path.includes('continuation_key=k2') + ? { + body: { + transactions: [ + { + transaction_amount: { amount: '1', currency: 'EUR' }, + credit_debit_indicator: 'DBIT', + status: 'BOOK', + booking_date: '2026-10-02', + entry_reference: 'b', + }, + ], + }, + } + : { + body: { + continuation_key: 'k2', + transactions: [ + { + transaction_amount: { amount: '2', currency: 'EUR' }, + credit_debit_indicator: 'DBIT', + status: 'BOOK', + booking_date: '2026-10-01', + entry_reference: 'a', + }, + { + transaction_amount: { amount: '3', currency: 'EUR' }, + credit_debit_indicator: 'DBIT', + status: 'PDNG', + booking_date: '2026-10-01', + }, + ], + }, + }, + ) + const out = await provider.fetchTransactions({ + session: { sessionId: 'S', accounts: [], validUntil: '' }, + account: { uid: 'u1', stableId: 'h1', label: '' }, + from: '2026-10-01', + to: '2026-10-31', + }) + expect(out.map(t => t.externalId)).toEqual(['h1:a', 'h1:b']) + expect(calls).toHaveLength(2) + expect(calls[0].path).toContain('date_from=2026-10-01') + expect(calls[0].path).toContain('date_to=2026-10-31') + }) + it('sends the signed token and maps failures to clear kinds', async () => { + for (const [status, kind] of [ + [401, 'consent_expired'], + [429, 'rate_limited'], + [503, 'unavailable'], + [400, 'rejected'], + ] as const) { + const { provider, calls } = fake(() => ({ status, body: { secret: 'must-not-leak' } })) + const err = (await provider.listBanks('DE').catch(e => e)) as ProviderError + expect(err.kind).toBe(kind) + expect(err.message).not.toContain('must-not-leak') + expect(calls[0].auth).toMatch(/^Bearer [\w-]+\.[\w-]+\.[\w-]+$/) + } + }) +}) diff --git a/api/src/bank-connections/providers/enable-banking.provider.ts b/api/src/bank-connections/providers/enable-banking.provider.ts new file mode 100644 index 0000000..90680b5 --- /dev/null +++ b/api/src/bank-connections/providers/enable-banking.provider.ts @@ -0,0 +1,217 @@ +import { createSign } from 'node:crypto' +import { + BankInfo, + BankProvider, + BankTransaction, + ProviderAccount, + ProviderError, + ProviderSession, +} from '../bank-provider' + +// ---------------- transaction mapping ---------------- +// Shape of an Enable Banking transaction (only the fields we use) +interface EbTransaction { + entry_reference?: string | null + transaction_id?: string | null + transaction_amount: { amount: string; currency: string } + credit_debit_indicator: 'CRDT' | 'DBIT' + status?: string + booking_date?: string | null + value_date?: string | null + transaction_date?: string | null + creditor?: { name?: string | null } | null + debtor?: { name?: string | null } | null + creditor_account?: { iban?: string | null } | null + debtor_account?: { iban?: string | null } | null + remittance_information?: string[] | null + note?: string | null +} + +/** Booked transactions only: pending ones can change or disappear. */ +export function mapEbTransaction(tx: EbTransaction, accountStableId: string): BankTransaction | null { + if (tx.status && tx.status !== 'BOOK') return null + const date = tx.booking_date || tx.value_date || tx.transaction_date + const raw = Number(tx.transaction_amount?.amount) + if (!date || !Number.isFinite(raw)) return null + + const debit = tx.credit_debit_indicator === 'DBIT' + const counterparty = debit ? tx.creditor : tx.debtor + const counterpartyAccount = debit ? tx.creditor_account : tx.debtor_account + const remittance = (tx.remittance_information || []).filter(Boolean).join(' ').trim() + const ref = tx.entry_reference || tx.transaction_id || undefined + + return { + date: date.slice(0, 10), + description: remittance || tx.note || counterparty?.name || '', + amount: debit ? -Math.abs(raw) : Math.abs(raw), + currency: tx.transaction_amount.currency, + recipient: counterparty?.name || undefined, + recipientIban: counterpartyAccount?.iban || undefined, + // Stable across syncs AND reconnects, so re-fetching the same day never duplicates + externalId: ref ? `${accountStableId}:${ref}` : undefined, + accountId: accountStableId, + rawData: { source: 'enablebanking' }, + } +} + +// ---------------- API client ---------------- +// Client for the Enable Banking API (https://enablebanking.com/docs/api/reference/). +// Requests are signed with the application's RSA private key (RS256 JWT). The key never leaves +// this process, is never logged, and is never sent to the browser. +// +// Built from their documentation. Exercised in tests against a fake server, but not yet against +// the live API. + +const BASE_URL = 'https://api.enablebanking.com' +const MAX_PAGES = 50 + +const b64url = (v: Buffer | string) => Buffer.from(v).toString('base64url') + +export function signJwt( + appId: string, + privateKeyPem: string, + nowSeconds = Math.floor(Date.now() / 1000), + ttlSeconds = 3600, +): string { + const header = { typ: 'JWT', alg: 'RS256', kid: appId } + const payload = { + iss: 'enablebanking.com', + aud: 'api.enablebanking.com', + iat: nowSeconds, + exp: nowSeconds + ttlSeconds, + } + const unsigned = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}` + const signature = createSign('RSA-SHA256').update(unsigned).sign(privateKeyPem) + return `${unsigned}.${b64url(signature)}` +} + +interface EbAccount { + uid: string + identification_hash?: string + account_id?: { iban?: string | null } | null + currency?: string | null + name?: string | null +} + +export class EnableBankingProvider implements BankProvider { + readonly name = 'enablebanking' as const + private cachedJwt: { token: string; expires: number } | null = null + + constructor( + private readonly opts: { appId: string; privateKey: string; baseUrl?: string; fetchImpl?: typeof fetch }, + ) {} + + private jwt(): string { + const now = Math.floor(Date.now() / 1000) + if (this.cachedJwt && this.cachedJwt.expires - 60 > now) return this.cachedJwt.token + const token = signJwt(this.opts.appId, this.opts.privateKey, now, 3600) + this.cachedJwt = { token, expires: now + 3600 } + return token + } + + private async api(method: string, path: string, body?: unknown): Promise { + const doFetch = this.opts.fetchImpl ?? fetch + let res: Response + try { + res = await doFetch(`${this.opts.baseUrl ?? BASE_URL}${path}`, { + method, + headers: { Authorization: `Bearer ${this.jwt()}`, ...(body ? { 'Content-Type': 'application/json' } : {}) }, + body: body ? JSON.stringify(body) : undefined, + signal: AbortSignal.timeout(30_000), + }) + } catch { + throw new ProviderError('Could not reach the bank service', 'unavailable') + } + if (res.ok) return (res.status === 204 ? undefined : await res.json()) as T + // Keep error detail short and free of anything sensitive + const kind = + res.status === 429 + ? 'rate_limited' + : res.status === 401 || res.status === 403 || res.status === 404 + ? 'consent_expired' + : res.status >= 500 + ? 'unavailable' + : 'rejected' + throw new ProviderError(`Bank service returned ${res.status}`, kind, res.status) + } + + async listBanks(country: string): Promise { + const data = await this.api<{ + aspsps: Array<{ name: string; country: string; psu_types?: string[]; maximum_consent_validity?: number }> + }>('GET', `/aspsps?country=${encodeURIComponent(country.toUpperCase())}`) + return (data.aspsps || []) + .filter(a => !a.psu_types || a.psu_types.includes('personal')) + .map(a => ({ + name: a.name, + country: a.country, + maxConsentDays: Math.floor((a.maximum_consent_validity ?? 90 * 86400) / 86400), + })) + .toSorted((a, b) => a.name.localeCompare(b.name)) + } + + async startAuth(p: { bank: BankInfo; redirectUrl: string; state: string; validUntil: Date }) { + const data = await this.api<{ url: string }>('POST', '/auth', { + access: { valid_until: p.validUntil.toISOString() }, + aspsp: { name: p.bank.name, country: p.bank.country }, + state: p.state, + redirect_url: p.redirectUrl, + psu_type: 'personal', + }) + return { url: data.url } + } + + async completeAuth(code: string): Promise { + const data = await this.api<{ session_id: string; accounts: EbAccount[]; access?: { valid_until?: string } }>( + 'POST', + '/sessions', + { code }, + ) + const accounts: ProviderAccount[] = (data.accounts || []).map(a => { + const iban = a.account_id?.iban || '' + return { + uid: a.uid, + stableId: a.identification_hash || iban || a.uid, + label: iban ? `…${iban.slice(-4)}` : a.name || 'Account', + currency: a.currency || undefined, + } + }) + return { + sessionId: data.session_id, + accounts, + validUntil: data.access?.valid_until || new Date(Date.now() + 90 * 86400_000).toISOString(), + } + } + + async fetchTransactions(p: { + session: ProviderSession + account: ProviderAccount + from: string + to: string + }): Promise { + const out: BankTransaction[] = [] + let key: string | undefined + for (let page = 0; page < MAX_PAGES; page++) { + const qs = new URLSearchParams({ date_from: p.from, date_to: p.to }) + if (key) qs.set('continuation_key', key) + const data = await this.api<{ transactions?: EbTransaction[]; continuation_key?: string | null }>( + 'GET', + `/accounts/${encodeURIComponent(p.account.uid)}/transactions?${qs}`, + ) + for (const tx of data.transactions || []) { + const mapped = mapEbTransaction(tx, p.account.stableId) + if (mapped) out.push(mapped) + } + key = data.continuation_key || undefined + if (!key) break + } + return out + } + + async revoke(sessionId: string): Promise { + try { + await this.api('DELETE', `/sessions/${encodeURIComponent(sessionId)}`) + } catch { + /* best effort */ + } + } +} diff --git a/api/src/bank-connections/providers/test-bank.provider.ts b/api/src/bank-connections/providers/test-bank.provider.ts new file mode 100644 index 0000000..1fcf36a --- /dev/null +++ b/api/src/bank-connections/providers/test-bank.provider.ts @@ -0,0 +1,159 @@ +import { Injectable } from '@nestjs/common' +import { randomBytes } from 'node:crypto' +import { + BankInfo, + BankProvider, + BankTransaction, + ProviderAccount, + ProviderError, + ProviderSession, +} from '../bank-provider' + +// The test bank: a fake bank for development and tests. It has its own approval page (served by the banking +// routes), deterministic transactions, and a few "bad" banks to exercise error states. +// It is refused at startup in production (see config.ts). + +const TEST_BANKS: BankInfo[] = [ + { name: 'N26', country: 'DE', maxConsentDays: 180 }, + { name: 'Wise', country: 'DE', maxConsentDays: 180 }, + { name: 'Revolut', country: 'DE', maxConsentDays: 180 }, + { name: 'Test Bank (always denies)', country: 'DE', maxConsentDays: 180 }, + { name: 'Test Bank (sync fails)', country: 'DE', maxConsentDays: 180 }, +] + +// Small seeded PRNG so the same account and day always yield the same transactions +function mulberry32(seed: number) { + return () => { + seed |= 0 + seed = (seed + 0x6d2b79f5) | 0 + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} +function hash(s: string): number { + let h = 2166136261 + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i) + h = Math.imul(h, 16777619) + } + return h >>> 0 +} + +const SPEND: Array<[string, string, number, number]> = [ + ['REWE Markt', 'REWE Einkauf', 12, 70], + ['Shell', 'Fuel', 30, 60], + ['Trainline', 'Trainline.com ticket', 20, 90], + ['Amazon', 'Amazon order', 8, 120], + ['Cafe Central', 'Coffee', 3, 9], + ['Lidl', 'Lidl Einkauf', 8, 45], + ['DeepSeek', 'DeepSeek API credits', 5, 25], +] + +function dayList(from: string, to: string): string[] { + const start = new Date(from + 'T00:00:00Z').getTime() + const days = Math.min(Math.floor((new Date(to + 'T00:00:00Z').getTime() - start) / 86400_000) + 1, 400) + return Array.from({ length: Math.max(days, 0) }, (_, i) => new Date(start + i * 86400_000).toISOString().slice(0, 10)) +} + +@Injectable() +export class TestBankProvider implements BankProvider { + readonly name = 'mock' as const + private pending = new Map() // state -> request + private codes = new Map() // code -> bank + + async listBanks(country: string) { + return TEST_BANKS.filter(b => b.country === country.toUpperCase()) + } + + async startAuth(p: { bank: BankInfo; redirectUrl: string; state: string; validUntil: Date }) { + this.pending.set(p.state, { redirectUrl: p.redirectUrl, bank: p.bank }) + return { url: `/api/banking/mock-bank?state=${encodeURIComponent(p.state)}` } + } + + // ---- used by the mock bank pages ---- + pendingFor(state: string) { + return this.pending.get(state) + } + decide(state: string, approve: boolean): string | null { + const req = this.pending.get(state) + if (!req) return null + this.pending.delete(state) + const sep = req.redirectUrl.includes('?') ? '&' : '?' + if (!approve || req.bank.name.includes('always denies')) + return `${req.redirectUrl}${sep}error=access_denied&state=${encodeURIComponent(state)}` + const code = randomBytes(12).toString('hex') + this.codes.set(code, req.bank) + return `${req.redirectUrl}${sep}code=${code}&state=${encodeURIComponent(state)}` + } + + async completeAuth(code: string): Promise { + const bank = this.codes.get(code) + if (!bank) throw new ProviderError('Unknown code', 'rejected', 400) + this.codes.delete(code) + const slug = bank.name.replace(/\W+/g, '').toLowerCase() + const accounts: ProviderAccount[] = + bank.name === 'Wise' + ? [ + { + uid: `${slug}-usd-${randomBytes(3).toString('hex')}`, + stableId: `${slug}-usd`, + label: '…0377', + currency: 'USD', + }, + { + uid: `${slug}-eur-${randomBytes(3).toString('hex')}`, + stableId: `${slug}-eur`, + label: '…4410', + currency: 'EUR', + }, + ] + : [ + { + uid: `${slug}-main-${randomBytes(3).toString('hex')}`, + stableId: `${slug}-main`, + label: '…4821', + currency: 'EUR', + }, + ] + return { + sessionId: `mock-${randomBytes(8).toString('hex')}`, + accounts, + validUntil: new Date(Date.now() + 179 * 86400_000).toISOString(), + } + } + + async fetchTransactions(p: { + session: ProviderSession + account: ProviderAccount + from: string + to: string + }): Promise { + if (p.session.sessionId.includes('fail')) throw new ProviderError('Bank unavailable', 'unavailable', 503) + const out: BankTransaction[] = [] + for (const day of dayList(p.from, p.to)) { + const rnd = mulberry32(hash(p.account.stableId + day)) + const dom = Number(day.slice(8, 10)) + const add = (desc: string, who: string, amount: number, i: number) => + out.push({ + date: day, + description: desc, + amount: Math.round(amount * 100) / 100, + currency: p.account.currency || 'EUR', + recipient: who, + externalId: `${p.account.stableId}:${day}:${i}`, + accountId: p.account.stableId, + rawData: { source: 'mock' }, + }) + if (dom === 1) add('Salary', 'Acme GmbH', 2800, 0) + if (dom === 5) add('Netflix subscription', 'Netflix', -9.99, 1) + if (rnd() < 0.55) { + const [who, desc, lo, hi] = SPEND[Math.floor(rnd() * SPEND.length)] + add(desc, who, -(lo + rnd() * (hi - lo)), 2) + } + } + return out + } + + async revoke(): Promise {} +} diff --git a/api/src/bank-connections/rate-limiter.ts b/api/src/bank-connections/rate-limiter.ts new file mode 100644 index 0000000..e701d9c --- /dev/null +++ b/api/src/bank-connections/rate-limiter.ts @@ -0,0 +1,18 @@ +/** Small in-memory limiter (this is a single-instance personal app). */ +export class RateLimiter { + private readonly hits = new Map() + constructor(private readonly now: () => number) {} + + /** Returns false when `key` has already used up `max` hits in the last `windowMs`. */ + allow(key: string, windowMs: number, max: number): boolean { + const t = this.now() + const recent = (this.hits.get(key) || []).filter(x => t - x < windowMs) + if (recent.length >= max) { + this.hits.set(key, recent) + return false + } + recent.push(t) + this.hits.set(key, recent) + return true + } +} diff --git a/api/src/bank-connections/test-bank.controller.ts b/api/src/bank-connections/test-bank.controller.ts new file mode 100644 index 0000000..cb76396 --- /dev/null +++ b/api/src/bank-connections/test-bank.controller.ts @@ -0,0 +1,53 @@ +import { HttpCode, Controller, Get, Inject, Param, Post, Query, Res, UseGuards, UseInterceptors } from '@nestjs/common' +import type { Response } from 'express' +import { AccessGuard, CurrentActor } from '../access/access.guard' +import { Actor } from '../access/access.settings' +import { AdminGuard } from '../access/admin.guard' +import { CsrfGuard } from '../access/csrf.guard' +import { NoStoreInterceptor } from '../shared/http/no-store.interceptor' +import { BANK_PROVIDER } from './bank-provider' +import { BankConnectionsService } from './bank-connections.service' +import { TestBankProvider } from './providers/test-bank.provider' + +const esc = (s: string) => + s.replace(/[&<>"']/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch]!) + +/** + * Development only (never registered in production): a stand-in for the bank's own approval + * page, plus a way to age a connection to see the "expiring" and "expired" states. + */ +@Controller('api/banking') +@UseInterceptors(NoStoreInterceptor) +export class TestBankController { + constructor( + @Inject(BANK_PROVIDER) private readonly bank: TestBankProvider, + private readonly connections: BankConnectionsService, + ) {} + + @Get('mock-bank') + page(@Query('state') state = '', @Res() res: Response) { + const req = this.bank.pendingFor(state) + if (!req) return res.status(400).send('This test request has expired. Start again from FinKit.') + const q = encodeURIComponent(state) + return res.type('html') + .send(`Test bank +
+TEST BANK · ${esc(req.bank.name)} · not a real bank

Allow FinKit to read your account?

+

This stands in for your bank's own approval page while FinKit is in development.

+
  • Account details (name, last 4 digits)
  • Balances
  • Transactions
  • Cannot make payments or transfers
+ApproveDeny
`) + } + + @Get('mock-bank/decide') + decide(@Query('state') state = '', @Query('d') decision: string, @Res() res: Response) { + const target = this.bank.decide(state, decision === 'approve') + return target ? res.redirect(302, target) : res.status(400).send('This test request has expired.') + } + + @Post('admin/connections/:id/expire') + @HttpCode(200) // the previous API answered 200, not 201 + @UseGuards(AccessGuard, AdminGuard, CsrfGuard) + expire(@CurrentActor() actor: Actor, @Param('id') id: string, @Query('mode') mode?: string) { + return this.connections.ageConnection(actor, id, mode === 'expired' ? 'expired' : 'expiring') + } +} diff --git a/api/src/health/health.controller.ts b/api/src/health/health.controller.ts new file mode 100644 index 0000000..facf83b --- /dev/null +++ b/api/src/health/health.controller.ts @@ -0,0 +1,14 @@ +import { Controller, Get } from '@nestjs/common' + +const status = () => ({ status: 'ok', timestamp: new Date().toISOString() }) + +// Used by the container health check (/health) and by anything behind the /api proxy +@Controller() +export class HealthController { + @Get('health') plain() { + return status() + } + @Get('api/health') api() { + return status() + } +} diff --git a/api/src/health/health.module.ts b/api/src/health/health.module.ts new file mode 100644 index 0000000..8794680 --- /dev/null +++ b/api/src/health/health.module.ts @@ -0,0 +1,5 @@ +import { Module } from '@nestjs/common' +import { HealthController } from './health.controller' + +@Module({ controllers: [HealthController] }) +export class HealthModule {} diff --git a/api/src/index.ts b/api/src/index.ts deleted file mode 100644 index 9fc7fcf..0000000 --- a/api/src/index.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { serve } from '@hono/node-server' -import { Hono } from 'hono' -import { cors } from 'hono/cors' -import { logger } from 'hono/logger' -import { categorizeRoute } from './routes/categorize' -import { exportRoute } from './routes/export' -import { learnRoute } from './routes/learn' -import { settingsRoute } from './routes/settings' - -const app = new Hono() - -// Middleware -app.use('*', logger()) -app.use('*', cors({ - // In production, set ALLOWED_ORIGIN (e.g. https://finkit.example.com) to lock this down. - // Unset keeps the old behaviour (any origin), which is fine behind Cloudflare Access. - origin: process.env.NODE_ENV === 'production' - ? (process.env.ALLOWED_ORIGIN || '*') - : ['http://localhost:5173', 'http://localhost:3000'], - allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'], - allowHeaders: ['Content-Type'], -})) - -// Health check -app.get('/health', (c) => { - return c.json({ status: 'ok', timestamp: new Date().toISOString() }) -}) -app.get('/api/health', (c) => { - return c.json({ status: 'ok', timestamp: new Date().toISOString() }) -}) - -// Routes - authentication handled by Cloudflare Access at edge -app.route('/api/categorize', categorizeRoute) -app.route('/api/export', exportRoute) -app.route('/api/learn', learnRoute) -app.route('/api/settings', settingsRoute) - -// 404 handler -app.notFound((c) => { - return c.json({ error: 'Not Found' }, 404) -}) - -// Error handler -app.onError((err, c) => { - console.error('Server error:', err) - return c.json({ error: 'Internal Server Error' }, 500) -}) - -const port = Number(process.env.PORT) || 3003 -console.log(`Server starting on port ${port}...`) - -serve({ - fetch: app.fetch, - port, -}) - -console.log(`Server running at http://localhost:${port}`) diff --git a/api/src/lib/anonymizer.ts b/api/src/lib/anonymizer.ts deleted file mode 100644 index 6941345..0000000 --- a/api/src/lib/anonymizer.ts +++ /dev/null @@ -1,72 +0,0 @@ -// Server-side validation to ensure data is anonymized before processing - -interface TransactionData { - id: string; - description: string; - amount: number; - date: string; - category?: string; -} - -interface AnonymizationValidation { - isValid: boolean; - issues: string[]; -} - -// Patterns that indicate non-anonymized personal data -const PERSONAL_DATA_PATTERNS = { - // Full IBAN (not anonymized) - fullIban: /[A-Z]{2}\d{2}[A-Z0-9]{4}\d{7}[A-Z0-9]{0,16}/g, - - // Email addresses - email: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, - - // Phone numbers - phone: /(?:\+\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}/g, - - // German postal codes with city - addressWithPostal: /\d{5}\s+[A-Za-zäöüÄÖÜß]+/g, -}; - -// Patterns that indicate properly anonymized data -const ANONYMIZED_PATTERNS = { - // Anonymized IBAN: DE**************5515 - anonymizedIban: /[A-Z]{2}\*{10,}\d{4}/, - - // Pseudonymized name: Person_XXX - pseudonymizedName: /Person_[A-Z0-9]{3,}/, - - // Anonymized account: Acc_XXXXXX - anonymizedAccount: /Acc_[A-Z0-9]{6}/, -}; - -export function validateAnonymized(transactions: TransactionData[]): AnonymizationValidation { - const issues: string[] = []; - - for (const tx of transactions) { - // Check description for personal data - for (const [patternName, pattern] of Object.entries(PERSONAL_DATA_PATTERNS)) { - // Reset regex lastIndex for global patterns - pattern.lastIndex = 0; - - if (pattern.test(tx.description)) { - issues.push(`Transaction ${tx.id}: contains ${patternName} in description`); - } - } - } - - return { - isValid: issues.length === 0, - issues, - }; -} - -export function containsPersonalData(text: string): boolean { - for (const pattern of Object.values(PERSONAL_DATA_PATTERNS)) { - pattern.lastIndex = 0; - if (pattern.test(text)) { - return true; - } - } - return false; -} diff --git a/api/src/load-env.ts b/api/src/load-env.ts new file mode 100644 index 0000000..eeaafa5 --- /dev/null +++ b/api/src/load-env.ts @@ -0,0 +1,5 @@ +import { existsSync } from 'node:fs' + +// Local runs read api/.env. Variables already set (by Docker, the shell, CI) always win, +// and in the container there is no .env file, so this does nothing there. +if (existsSync('.env') && typeof process.loadEnvFile === 'function') process.loadEnvFile('.env') diff --git a/api/src/main.ts b/api/src/main.ts new file mode 100644 index 0000000..f6e391e --- /dev/null +++ b/api/src/main.ts @@ -0,0 +1,17 @@ +import './load-env' // first: later modules read the environment when they load +import { Logger } from '@nestjs/common' +import { NestFactory } from '@nestjs/core' +import { NestExpressApplication } from '@nestjs/platform-express' +import { AppModule } from './app.module' +import { configureApp } from './app.setup' + +async function bootstrap() { + const app = await NestFactory.create(AppModule) + configureApp(app) + app.enableShutdownHooks() + const port = Number(process.env.PORT) || 3003 + // Unset listens on all interfaces, which the container needs. HOST=127.0.0.1 keeps a local run private. + await app.listen(port, process.env.HOST || '0.0.0.0') + new Logger('Bootstrap').log(`Server running on port ${port}`) +} +bootstrap() diff --git a/api/src/merchant-rules/merchant-rules.controller.ts b/api/src/merchant-rules/merchant-rules.controller.ts new file mode 100644 index 0000000..64ec3b1 --- /dev/null +++ b/api/src/merchant-rules/merchant-rules.controller.ts @@ -0,0 +1,40 @@ +import { HttpCode, Body, Controller, Delete, Get, Param, Post } from '@nestjs/common' +import { ZodValidationPipe } from '../shared/http/zod-validation.pipe' +import { MerchantRulesService } from './merchant-rules.service' +import { SaveRuleInput, SaveRuleSchema, SaveRulesInput, SaveRulesSchema } from './merchant-rules.schemas' + +@Controller('api/learn') +export class MerchantRulesController { + constructor(private readonly service: MerchantRulesService) {} + + @Get('mappings') + list() { + return { success: true, data: this.service.list() } + } + + @Get('stats') + stats() { + return { success: true, data: this.service.stats() } + } + + @Post('mapping') + @HttpCode(200) // the previous API answered 200, not 201 + learn(@Body(new ZodValidationPipe(SaveRuleSchema)) body: SaveRuleInput) { + this.service.learn(body.merchant, body.category) + return { success: true, message: `Learned: "${body.merchant}" → "${body.category}"` } + } + + @Post('mappings/batch') + @HttpCode(200) // the previous API answered 200, not 201 + learnMany(@Body(new ZodValidationPipe(SaveRulesSchema)) body: SaveRulesInput) { + this.service.learnMany(body.mappings) + return { success: true, message: `Saved ${body.mappings.length} mappings` } + } + + @Delete('mapping/:merchant') + forget(@Param('merchant') merchant: string) { + // Express has already decoded the path segment + this.service.forget(merchant) + return { success: true, message: `Deleted mapping for "${merchant}"` } + } +} diff --git a/api/src/merchant-rules/merchant-rules.module.ts b/api/src/merchant-rules/merchant-rules.module.ts new file mode 100644 index 0000000..593e6b0 --- /dev/null +++ b/api/src/merchant-rules/merchant-rules.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common' +import { MerchantRulesController } from './merchant-rules.controller' +import { MerchantRulesRepository } from './merchant-rules.repository' +import { MerchantRulesService } from './merchant-rules.service' + +@Module({ + controllers: [MerchantRulesController], + providers: [MerchantRulesService, MerchantRulesRepository], + exports: [MerchantRulesService], +}) +export class MerchantRulesModule {} diff --git a/api/src/merchant-rules/merchant-rules.repository.ts b/api/src/merchant-rules/merchant-rules.repository.ts new file mode 100644 index 0000000..213943b --- /dev/null +++ b/api/src/merchant-rules/merchant-rules.repository.ts @@ -0,0 +1,95 @@ +import { Injectable } from '@nestjs/common' +import { DatabaseService } from '../shared/database/database.service' + +interface MerchantRule { + merchant: string + merchantDisplay?: string + category: string + confidence: number +} + +// Frontend also has this normalization function. They MUST match for consistent storage and retrieval. +function normalizeMerchant(name: string): string { + return name + .toLowerCase() + .trim() + .replace(/[^a-z0-9\s]/g, '') + .replace(/\s+/g, ' ') + .trim() +} + +@Injectable() +export class MerchantRulesRepository { + private readonly upsert + private readonly all + private readonly remove + + constructor(private readonly database: DatabaseService) { + const { db } = database + db.exec(` + CREATE TABLE IF NOT EXISTS merchant_categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + merchant_key TEXT NOT NULL UNIQUE, + merchant_display TEXT NOT NULL, + category TEXT NOT NULL, + confidence INTEGER DEFAULT 1, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX IF NOT EXISTS idx_merchant_key ON merchant_categories(merchant_key); + CREATE INDEX IF NOT EXISTS idx_category ON merchant_categories(category); + `) + this.upsert = db.prepare(` + INSERT INTO merchant_categories (merchant_key, merchant_display, category, updated_at) + VALUES (?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(merchant_key) DO UPDATE SET + category = excluded.category, confidence = confidence + 1, updated_at = CURRENT_TIMESTAMP + `) + this.all = db.prepare(` + SELECT merchant_key as merchant, merchant_display, category, confidence + FROM merchant_categories ORDER BY confidence DESC, updated_at DESC + `) + this.remove = db.prepare('DELETE FROM merchant_categories WHERE merchant_key = ?') + } + + save(merchant: string, category: string): void { + const key = normalizeMerchant(merchant) + if (!key || key.length < 2) return // skip empty/short merchants + this.upsert.run(key, merchant, category) + } + + saveMany(rules: Array<{ merchant: string; category: string }>): void { + this.database.db.transaction((items: typeof rules) => items.forEach(r => this.save(r.merchant, r.category)))(rules) + } + + findAll(): MerchantRule[] { + return ( + this.all.all() as Array<{ merchant: string; merchant_display: string; category: string; confidence: number }> + ).map(r => ({ + merchant: r.merchant, + merchantDisplay: r.merchant_display, + category: r.category, + confidence: r.confidence, + })) + } + + delete(merchant: string): void { + this.remove.run(normalizeMerchant(merchant)) + } + + stats() { + const { db } = this.database + const total = (db.prepare('SELECT COUNT(*) as count FROM merchant_categories').get() as { count: number }).count + const topCategories = db + .prepare( + 'SELECT category, COUNT(*) as count FROM merchant_categories GROUP BY category ORDER BY count DESC LIMIT 10', + ) + .all() as Array<{ category: string; count: number }> + const recentMappings = db + .prepare( + 'SELECT merchant_key as merchant, merchant_display, category, confidence FROM merchant_categories ORDER BY updated_at DESC LIMIT 10', + ) + .all() + return { total, topCategories, recentMappings } + } +} diff --git a/api/src/merchant-rules/merchant-rules.schemas.ts b/api/src/merchant-rules/merchant-rules.schemas.ts new file mode 100644 index 0000000..86804e4 --- /dev/null +++ b/api/src/merchant-rules/merchant-rules.schemas.ts @@ -0,0 +1,6 @@ +import { z } from 'zod' + +export const SaveRuleSchema = z.object({ merchant: z.string().min(1), category: z.string().min(1) }) +export const SaveRulesSchema = z.object({ mappings: z.array(SaveRuleSchema) }) +export type SaveRuleInput = z.infer +export type SaveRulesInput = z.infer diff --git a/api/src/merchant-rules/merchant-rules.service.ts b/api/src/merchant-rules/merchant-rules.service.ts new file mode 100644 index 0000000..c1d77e1 --- /dev/null +++ b/api/src/merchant-rules/merchant-rules.service.ts @@ -0,0 +1,27 @@ +import { Injectable } from '@nestjs/common' +import { MerchantRulesRepository } from './merchant-rules.repository' + +/** + * Rules the app learns when you recategorize a merchant ("DeepSeek is Subscriptions"). + * The web app loads them all and does its own fuzzy matching. + */ +@Injectable() +export class MerchantRulesService { + constructor(private readonly rules: MerchantRulesRepository) {} + + list() { + return this.rules.findAll() + } + stats() { + return this.rules.stats() + } + learn(merchant: string, category: string) { + this.rules.save(merchant, category) + } + learnMany(rules: Array<{ merchant: string; category: string }>) { + this.rules.saveMany(rules) + } + forget(merchant: string) { + this.rules.delete(merchant) + } +} diff --git a/api/src/routes/categorize.ts b/api/src/routes/categorize.ts deleted file mode 100644 index c6c4eeb..0000000 --- a/api/src/routes/categorize.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Hono } from 'hono' -import { z } from 'zod' -import { categorizeWithAI } from '../services/openai' -import { validateAnonymized } from '../lib/anonymizer' - -const categorizeRoute = new Hono() - -const TransactionSchema = z.object({ - id: z.string(), - description: z.string(), - amount: z.number(), - date: z.string(), - category: z.string().optional(), -}) - -const CategorizeRequestSchema = z.object({ - transactions: z.array(TransactionSchema), -}) - -categorizeRoute.post('/', async (c) => { - try { - const body = await c.req.json() - const parsed = CategorizeRequestSchema.safeParse(body) - - if (!parsed.success) { - return c.json({ error: 'Invalid request body', details: parsed.error.issues }, 400) - } - - const { transactions } = parsed.data - - // Validate that data is anonymized before processing - const anonymizationCheck = validateAnonymized(transactions) - if (!anonymizationCheck.isValid) { - return c.json({ - error: 'Data contains non-anonymized personal information', - details: anonymizationCheck.issues - }, 400) - } - - // Call OpenAI for categorization - const categorized = await categorizeWithAI(transactions) - - return c.json({ - success: true, - data: categorized - }) - } catch (error) { - console.error('Categorization error:', error) - return c.json({ error: 'Failed to categorize transactions' }, 500) - } -}) - -export { categorizeRoute } diff --git a/api/src/routes/export.ts b/api/src/routes/export.ts deleted file mode 100644 index 5935989..0000000 --- a/api/src/routes/export.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { Hono } from 'hono' -import { z } from 'zod' -import { exportToSheets, getSheetLayout, getSheetMetadata } from '../services/sheets' - -const exportRoute = new Hono() - -const CategoryValueSchema = z.object({ - column: z.string(), - value: z.number(), -}) - -const ExportRequestSchema = z.object({ - sheetId: z.string(), - tabName: z.string(), - month: z.string(), - monthFormat: z.enum(['short', 'long', 'numeric']), - categoryValues: z.array(CategoryValueSchema), - income: z.number(), - savings: z.number().optional(), - investment: z.number().optional(), - constantColumns: z.array(z.string()).optional(), - incomeColumn: z.string().optional(), - savingsColumn: z.string().optional(), - investmentColumn: z.string().optional(), -}) - -// Export data to Google Sheets -exportRoute.post('/sheets', async (c) => { - try { - const body = await c.req.json() - const parsed = ExportRequestSchema.safeParse(body) - - if (!parsed.success) { - return c.json({ error: 'Invalid request body', details: parsed.error.issues }, 400) - } - - const result = await exportToSheets(parsed.data) - - return c.json({ - success: true, - data: result - }) - } catch (error) { - console.error('Export error:', error) - const message = error instanceof Error ? error.message : 'Failed to export to Google Sheets' - return c.json({ error: message }, 500) - } -}) - -// Read the sheet's header row so the app mirrors the real column layout -exportRoute.post('/sheets/layout', async (c) => { - try { - const body = await c.req.json() - const { sheetId, tabName } = body - - if (!sheetId || !tabName) { - return c.json({ error: 'Missing sheetId or tabName' }, 400) - } - - const result = await getSheetLayout(sheetId, tabName) - - return c.json({ success: true, data: result }) - } catch (error) { - console.error('Layout error:', error) - const message = error instanceof Error ? error.message : 'Failed to read sheet layout' - return c.json({ error: message }, 500) - } -}) - -// Get sheet metadata (available tabs) -exportRoute.post('/sheets/metadata', async (c) => { - try { - const body = await c.req.json() - const { sheetId } = body - - if (!sheetId) { - return c.json({ error: 'Missing sheetId' }, 400) - } - - const result = await getSheetMetadata(sheetId) - - return c.json({ - success: true, - data: result - }) - } catch (error) { - console.error('Metadata error:', error) - const message = error instanceof Error ? error.message : 'Failed to fetch sheet metadata' - return c.json({ error: message }, 500) - } -}) - -export { exportRoute } diff --git a/api/src/routes/learn.ts b/api/src/routes/learn.ts deleted file mode 100644 index 1481710..0000000 --- a/api/src/routes/learn.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { Hono } from 'hono' -import { z } from 'zod' -import { - saveMerchantCategory, - getAllMerchantMappings, - deleteMerchantMapping, - saveBatchMappings, - getMappingStats, -} from '../services/database' - -const learnRoute = new Hono() - -// Schema for saving a single mapping -const SaveMappingSchema = z.object({ - merchant: z.string().min(1), - category: z.string().min(1), -}) - -// Schema for batch save -const BatchSaveSchema = z.object({ - mappings: z.array(SaveMappingSchema), -}) - -// Get all learned mappings -learnRoute.get('/mappings', (c) => { - try { - const mappings = getAllMerchantMappings() - return c.json({ - success: true, - data: mappings, - }) - } catch (error) { - console.error('Failed to get mappings:', error) - return c.json({ error: 'Failed to get mappings' }, 500) - } -}) - -// Get stats about learned mappings -learnRoute.get('/stats', (c) => { - try { - const stats = getMappingStats() - return c.json({ - success: true, - data: stats, - }) - } catch (error) { - console.error('Failed to get stats:', error) - return c.json({ error: 'Failed to get stats' }, 500) - } -}) - -// Save a single merchant → category mapping -learnRoute.post('/mapping', async (c) => { - try { - const body = await c.req.json() - const parsed = SaveMappingSchema.safeParse(body) - - if (!parsed.success) { - return c.json({ error: 'Invalid request', details: parsed.error.issues }, 400) - } - - const { merchant, category } = parsed.data - saveMerchantCategory(merchant, category) - - return c.json({ - success: true, - message: `Learned: "${merchant}" → "${category}"`, - }) - } catch (error) { - console.error('Failed to save mapping:', error) - return c.json({ error: 'Failed to save mapping' }, 500) - } -}) - -// Save multiple mappings at once -learnRoute.post('/mappings/batch', async (c) => { - try { - const body = await c.req.json() - const parsed = BatchSaveSchema.safeParse(body) - - if (!parsed.success) { - return c.json({ error: 'Invalid request', details: parsed.error.issues }, 400) - } - - saveBatchMappings(parsed.data.mappings) - - return c.json({ - success: true, - message: `Saved ${parsed.data.mappings.length} mappings`, - }) - } catch (error) { - console.error('Failed to save batch mappings:', error) - return c.json({ error: 'Failed to save batch mappings' }, 500) - } -}) - -// Delete a mapping -learnRoute.delete('/mapping/:merchant', (c) => { - try { - const merchant = decodeURIComponent(c.req.param('merchant')) - deleteMerchantMapping(merchant) - - return c.json({ - success: true, - message: `Deleted mapping for "${merchant}"`, - }) - } catch (error) { - console.error('Failed to delete mapping:', error) - return c.json({ error: 'Failed to delete mapping' }, 500) - } -}) - -export { learnRoute } diff --git a/api/src/routes/settings.ts b/api/src/routes/settings.ts deleted file mode 100644 index acfb11e..0000000 --- a/api/src/routes/settings.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { Hono } from 'hono' -import { z } from 'zod' -import { - getSettingValue, - setSettingValue, - getAllSettingsMap, -} from '../services/database' - -const settingsRoute = new Hono() - -// ============ Schema with Defaults ============ - -const CategoryMappingSchema = z.object({ - appCategory: z.string(), - sheetColumn: z.string(), -}) - -// Column layout read from the sheet's header row (source of truth when present) -const SheetColumnSchema = z.object({ - column: z.string(), - name: z.string(), - kind: z.enum(['month', 'category', 'total', 'income', 'incomeAfter', 'savings', 'investment', 'net', 'other']), -}) - -// Default category mappings matching your Google Sheet structure -const DEFAULT_CATEGORY_MAPPINGS = [ - { appCategory: 'Rent', sheetColumn: 'B' }, - { appCategory: 'Eating Out', sheetColumn: 'C' }, - { appCategory: 'Personal Entertainment', sheetColumn: 'D' }, - { appCategory: 'Subscriptions', sheetColumn: 'E' }, - { appCategory: 'Car', sheetColumn: 'F' }, - { appCategory: 'Public Transport', sheetColumn: 'G' }, - { appCategory: 'Internet', sheetColumn: 'H' }, - { appCategory: 'Electricity', sheetColumn: 'I' }, - { appCategory: 'Insurance', sheetColumn: 'J' }, - { appCategory: 'Travel', sheetColumn: 'K' }, - { appCategory: 'Groceries', sheetColumn: 'L' }, - { appCategory: 'Family', sheetColumn: 'M' }, - { appCategory: 'Radio Tax', sheetColumn: 'N' }, - { appCategory: 'Health & Wellbeing', sheetColumn: 'O' }, - { appCategory: 'Shopping', sheetColumn: 'P' }, - { appCategory: 'Other', sheetColumn: 'Q' }, - { appCategory: 'Gifts', sheetColumn: 'R' }, -] - -// Single source of truth: Schema with embedded defaults -const SheetConfigSchema = z.object({ - sheetId: z.string().default(''), - tabName: z.string().default('2026'), - monthColumn: z.string().default('A'), - monthFormat: z.enum(['short', 'long', 'numeric']).default('short'), - categoryMappings: z.array(CategoryMappingSchema).default(DEFAULT_CATEGORY_MAPPINGS), - // Formula columns (auto-calculated, don't overwrite) - constantColumns: z.array(z.string()).default(['S', 'U', 'X']), - // Data columns we write to - incomeColumn: z.string().default('T'), - savingsColumn: z.string().default('V'), - investmentColumn: z.string().default('W'), - // Synced from the sheet; undefined until the first sync - layout: z.array(SheetColumnSchema).optional(), - headerRow: z.number().optional(), - layoutSyncedAt: z.number().optional(), -}) - -// Infer the type from schema -type SheetConfig = z.infer - -// Generate defaults by parsing empty object -const getDefaultConfig = (): SheetConfig => SheetConfigSchema.parse({}) - -// ============ Routes ============ - -// Get sheet config (merges stored values with defaults) -settingsRoute.get('/sheet', (c) => { - const stored = getSettingValue>('sheetConfig', {}) - const config = SheetConfigSchema.parse(stored) // Applies defaults for missing fields - - return c.json({ success: true, data: config }) -}) - -// Save sheet config -settingsRoute.post('/sheet', async (c) => { - try { - const body = await c.req.json() - const config = SheetConfigSchema.parse(body) - - setSettingValue('sheetConfig', config) - - return c.json({ success: true, message: 'Settings saved' }) - } catch (error) { - if (error instanceof z.ZodError) { - return c.json({ success: false, error: error.issues }, 400) - } - return c.json({ success: false, error: 'Invalid request' }, 400) - } -}) - -// Reset to defaults -settingsRoute.delete('/sheet', (c) => { - setSettingValue('sheetConfig', getDefaultConfig()) - return c.json({ success: true, message: 'Reset to defaults' }) -}) - -// Get all settings -settingsRoute.get('/', (c) => { - const stored = getAllSettingsMap() - - // Apply defaults to sheetConfig if present - const sheetConfig = stored.sheetConfig - ? SheetConfigSchema.parse(stored.sheetConfig) - : getDefaultConfig() - - return c.json({ - success: true, - data: { ...stored, sheetConfig }, - }) -}) - -// Generic key-value get/set -settingsRoute.get('/:key', (c) => { - const key = c.req.param('key') - const value = getSettingValue(key, null) - - if (value === null) { - return c.json({ success: false, error: 'Not found' }, 404) - } - return c.json({ success: true, data: value }) -}) - -settingsRoute.post('/:key', async (c) => { - const key = c.req.param('key') - const body = await c.req.json() - - setSettingValue(key, body.value ?? body) - return c.json({ success: true }) -}) - -export { settingsRoute } diff --git a/api/src/services/database.ts b/api/src/services/database.ts deleted file mode 100644 index 7845743..0000000 --- a/api/src/services/database.ts +++ /dev/null @@ -1,239 +0,0 @@ -import Database from 'better-sqlite3' -import { join, dirname } from 'path' -import { fileURLToPath } from 'url' -import { mkdirSync, existsSync } from 'fs' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = dirname(__filename) - -// Data directory for persistent storage -const DATA_DIR = process.env.DATA_DIR || join(__dirname, '../../data') -const DB_PATH = join(DATA_DIR, 'categorizations.db') - -// Ensure data directory exists -if (!existsSync(DATA_DIR)) { - mkdirSync(DATA_DIR, { recursive: true }) -} - -// Initialize database -const db = new Database(DB_PATH) - -// Enable WAL mode for better concurrent read/write performance -db.pragma('journal_mode = WAL') -db.pragma('synchronous = NORMAL') -db.pragma('cache_size = 10000') - -// Create tables -db.exec(` - -- Merchant category mappings - CREATE TABLE IF NOT EXISTS merchant_categories ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - merchant_key TEXT NOT NULL UNIQUE, - merchant_display TEXT NOT NULL, - category TEXT NOT NULL, - confidence INTEGER DEFAULT 1, - created_at TEXT DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT DEFAULT CURRENT_TIMESTAMP - ); - CREATE INDEX IF NOT EXISTS idx_merchant_key ON merchant_categories(merchant_key); - CREATE INDEX IF NOT EXISTS idx_category ON merchant_categories(category); - - -- App settings (key-value store) - CREATE TABLE IF NOT EXISTS settings ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - updated_at TEXT DEFAULT CURRENT_TIMESTAMP - ); -`) - -// ============ Normalization ============ -// Note: Frontend also has this same normalization function -// They MUST match for consistent storage and retrieval - -function normalizeMerchant(name: string): string { - return name - .toLowerCase() - .trim() - .replace(/[^a-z0-9\s]/g, '') // Remove special chars - .replace(/\s+/g, ' ') // Multiple spaces → single space - .trim() -} - -// ============ Prepared Statements ============ - -const insertOrUpdateMapping = db.prepare(` - INSERT INTO merchant_categories (merchant_key, merchant_display, category, updated_at) - VALUES (?, ?, ?, CURRENT_TIMESTAMP) - ON CONFLICT(merchant_key) DO UPDATE SET - category = excluded.category, - confidence = confidence + 1, - updated_at = CURRENT_TIMESTAMP -`) - -const getAllMappings = db.prepare(` - SELECT merchant_key as merchant, merchant_display, category, confidence - FROM merchant_categories - ORDER BY confidence DESC, updated_at DESC -`) - -const getMapping = db.prepare(` - SELECT category, confidence, merchant_display - FROM merchant_categories - WHERE merchant_key = ? -`) - -const deleteMapping = db.prepare(` - DELETE FROM merchant_categories - WHERE merchant_key = ? -`) - -// ============ Export Types ============ - -export interface MerchantMapping { - merchant: string - merchantDisplay?: string - category: string - confidence: number -} - -// ============ Core Functions ============ - -/** - * Save a merchant → category mapping - * - Normalizes merchant name for deduplication - * - Uses UPSERT: inserts new or increments confidence if exists - */ -export function saveMerchantCategory(merchant: string, category: string): void { - const key = normalizeMerchant(merchant) - if (!key || key.length < 2) return // Skip empty/short merchants - - insertOrUpdateMapping.run(key, merchant, category) -} - -/** - * Get category for a specific merchant (exact normalized match) - * Note: Fuzzy matching is handled by frontend - */ -export function getMerchantCategory(merchant: string): string | null { - const key = normalizeMerchant(merchant) - const result = getMapping.get(key) as { category: string } | undefined - return result?.category ?? null -} - -/** - * Get all merchant → category mappings - * Frontend loads these once and does its own fuzzy matching - */ -export function getAllMerchantMappings(): MerchantMapping[] { - const results = getAllMappings.all() as Array<{ - merchant: string - merchant_display: string - category: string - confidence: number - }> - - return results.map(r => ({ - merchant: r.merchant, - merchantDisplay: r.merchant_display, - category: r.category, - confidence: r.confidence, - })) -} - -/** - * Delete a merchant mapping - */ -export function deleteMerchantMapping(merchant: string): void { - const key = normalizeMerchant(merchant) - deleteMapping.run(key) -} - -/** - * Batch save mappings (wrapped in transaction for efficiency) - */ -export function saveBatchMappings(mappings: Array<{ merchant: string; category: string }>): void { - const transaction = db.transaction((items: typeof mappings) => { - for (const { merchant, category } of items) { - saveMerchantCategory(merchant, category) - } - }) - transaction(mappings) -} - -// ============ Stats ============ - -export function getMappingStats(): { - total: number - topCategories: Array<{ category: string; count: number }> - recentMappings: MerchantMapping[] -} { - const total = (db.prepare('SELECT COUNT(*) as count FROM merchant_categories').get() as { count: number }).count - - const topCategories = db.prepare(` - SELECT category, COUNT(*) as count - FROM merchant_categories - GROUP BY category - ORDER BY count DESC - LIMIT 10 - `).all() as Array<{ category: string; count: number }> - - const recentMappings = db.prepare(` - SELECT merchant_key as merchant, merchant_display, category, confidence - FROM merchant_categories - ORDER BY updated_at DESC - LIMIT 10 - `).all() as MerchantMapping[] - - return { total, topCategories, recentMappings } -} - -// ============ Settings ============ - -const getSetting = db.prepare('SELECT value FROM settings WHERE key = ?') -const setSetting = db.prepare(` - INSERT INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) - ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP -`) -const deleteSetting = db.prepare('DELETE FROM settings WHERE key = ?') -const getAllSettings = db.prepare('SELECT key, value FROM settings') - -export function getSettingValue(key: string, defaultValue: T): T { - const result = getSetting.get(key) as { value: string } | undefined - if (!result) return defaultValue - try { - return JSON.parse(result.value) as T - } catch { - return defaultValue - } -} - -export function setSettingValue(key: string, value: T): void { - setSetting.run(key, JSON.stringify(value)) -} - -export function deleteSettingValue(key: string): void { - deleteSetting.run(key) -} - -export function getAllSettingsMap(): Record { - const results = getAllSettings.all() as Array<{ key: string; value: string }> - const settings: Record = {} - for (const { key, value } of results) { - try { - settings[key] = JSON.parse(value) - } catch { - settings[key] = value - } - } - return settings -} - -// ============ Maintenance ============ - -export function optimizeDatabase(): void { - db.pragma('optimize') -} - -export function closeDatabase(): void { - db.close() -} diff --git a/api/src/services/openai.ts b/api/src/services/openai.ts deleted file mode 100644 index 729d7f2..0000000 --- a/api/src/services/openai.ts +++ /dev/null @@ -1,99 +0,0 @@ -import OpenAI from 'openai' - -interface TransactionInput { - id: string; - description: string; - amount: number; - date: string; - category?: string; -} - -interface CategorizedTransaction { - id: string; - category: string; - merchant?: string; -} - -// Lazy initialization to avoid throwing on import -let openaiClient: OpenAI | null = null - -function getOpenAIClient(): OpenAI { - if (!openaiClient) { - if (!process.env.OPENAI_API_KEY) { - throw new Error('OPENAI_API_KEY environment variable is not set') - } - openaiClient = new OpenAI({ - apiKey: process.env.OPENAI_API_KEY, - }) - } - return openaiClient -} - -const SYSTEM_PROMPT = `You are a transaction categorization assistant. Given a list of bank transactions, categorize each one into the most appropriate category. - -Available categories: -- Groceries (supermarkets, food stores) -- Eating Out (restaurants, cafes, food delivery) -- Personal Entertainment (games, streaming, apps) -- Subscriptions (recurring digital services) -- Car (fuel, parking, car-related) -- Public Transport (trains, buses, metro) -- Internet (internet/phone bills) -- Electricity (utility bills) -- Insurance (all types of insurance) -- Travel (flights, hotels, vacation) -- Family (gifts to family, family expenses) -- Radio Tax (broadcasting fees) -- Health & Wellbeing (pharmacy, doctors, gym) -- Other (anything that doesn't fit above) -- Gifts (presents, donations) -- Income (salary, refunds, incoming transfers) -- Investment (stocks, ETFs, savings) -- Transfer (between own accounts - internal) - -For each transaction, return the category and extract the merchant name if possible. - -Respond in JSON format only: -[ - { "id": "...", "category": "...", "merchant": "..." }, - ... -]` - -export async function categorizeWithAI(transactions: TransactionInput[]): Promise { - const openai = getOpenAIClient() - - // Batch transactions for efficiency - const BATCH_SIZE = 25 - const results: CategorizedTransaction[] = [] - - for (let i = 0; i < transactions.length; i += BATCH_SIZE) { - const batch = transactions.slice(i, i + BATCH_SIZE) - - const userMessage = batch.map(tx => - `ID: ${tx.id}, Amount: ${tx.amount}, Date: ${tx.date}, Description: ${tx.description}` - ).join('\n') - - const response = await openai.chat.completions.create({ - model: 'gpt-4o-mini', - messages: [ - { role: 'system', content: SYSTEM_PROMPT }, - { role: 'user', content: userMessage } - ], - temperature: 0.1, - response_format: { type: 'json_object' } - }) - - const content = response.choices[0]?.message?.content - if (content) { - try { - const parsed = JSON.parse(content) - const items = Array.isArray(parsed) ? parsed : parsed.transactions || [] - results.push(...items) - } catch (e) { - console.error('Failed to parse AI response:', e) - } - } - } - - return results -} diff --git a/api/src/settings/settings.controller.ts b/api/src/settings/settings.controller.ts new file mode 100644 index 0000000..901f269 --- /dev/null +++ b/api/src/settings/settings.controller.ts @@ -0,0 +1,47 @@ +import { HttpCode, Body, Controller, Delete, Get, NotFoundException, Param, Post } from '@nestjs/common' +import { ZodValidationPipe } from '../shared/http/zod-validation.pipe' +import { SettingsService } from './settings.service' +import { SheetConfig, SheetConfigSchema } from './sheet-config.schema' + +@Controller('api/settings') +export class SettingsController { + constructor(private readonly service: SettingsService) {} + + @Get('sheet') + sheet() { + return { success: true, data: this.service.sheetConfig() } + } + + @Post('sheet') + @HttpCode(200) // the previous API answered 200, not 201 + saveSheet(@Body(new ZodValidationPipe(SheetConfigSchema, { shape: 'success-false' })) config: SheetConfig) { + this.service.saveSheetConfig(config) + return { success: true, message: 'Settings saved' } + } + + @Delete('sheet') + resetSheet() { + this.service.resetSheetConfig() + return { success: true, message: 'Reset to defaults' } + } + + @Get() + all() { + return { success: true, data: this.service.everything() } + } + + // Generic key-value (declared after the fixed routes above) + @Get(':key') + get(@Param('key') key: string) { + const value = this.service.get(key) + if (value === null) throw new NotFoundException({ success: false, error: 'Not found' }) + return { success: true, data: value } + } + + @Post(':key') + @HttpCode(200) // the previous API answered 200, not 201 + set(@Param('key') key: string, @Body() body: { value?: unknown } & Record) { + this.service.set(key, body?.value ?? body) + return { success: true } + } +} diff --git a/api/src/settings/settings.module.ts b/api/src/settings/settings.module.ts new file mode 100644 index 0000000..ac53180 --- /dev/null +++ b/api/src/settings/settings.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common' +import { SettingsController } from './settings.controller' +import { SettingsRepository } from './settings.repository' +import { SettingsService } from './settings.service' + +@Module({ + controllers: [SettingsController], + providers: [SettingsService, SettingsRepository], + exports: [SettingsService], +}) +export class SettingsModule {} diff --git a/api/src/settings/settings.repository.ts b/api/src/settings/settings.repository.ts new file mode 100644 index 0000000..433b5a3 --- /dev/null +++ b/api/src/settings/settings.repository.ts @@ -0,0 +1,52 @@ +import { Injectable } from '@nestjs/common' +import { DatabaseService } from '../shared/database/database.service' + +@Injectable() +export class SettingsRepository { + private readonly selectOne + private readonly upsertOne + private readonly selectAll + + constructor(database: DatabaseService) { + const { db } = database + db.exec(` + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP + ); + `) + this.selectOne = db.prepare('SELECT value FROM settings WHERE key = ?') + this.upsertOne = db.prepare(` + INSERT INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP + `) + this.selectAll = db.prepare('SELECT key, value FROM settings') + } + + read(key: string, fallback: T): T { + const row = this.selectOne.get(key) as { value: string } | undefined + if (!row) return fallback + try { + return JSON.parse(row.value) as T + } catch { + return fallback + } + } + + write(key: string, value: T): void { + this.upsertOne.run(key, JSON.stringify(value)) + } + + readAll(): Record { + const out: Record = {} + for (const { key, value } of this.selectAll.all() as Array<{ key: string; value: string }>) { + try { + out[key] = JSON.parse(value) + } catch { + out[key] = value + } + } + return out + } +} diff --git a/api/src/settings/settings.service.ts b/api/src/settings/settings.service.ts new file mode 100644 index 0000000..1d29db9 --- /dev/null +++ b/api/src/settings/settings.service.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@nestjs/common' +import { SettingsRepository } from './settings.repository' +import { SheetConfig, SheetConfigSchema, defaultSheetConfig } from './sheet-config.schema' + +/** Preferences that need to follow you across browsers: mainly how your Google Sheet is laid out. */ +@Injectable() +export class SettingsService { + constructor(private readonly settings: SettingsRepository) {} + + sheetConfig(): SheetConfig { + return SheetConfigSchema.parse(this.settings.read>('sheetConfig', {})) // fills in defaults + } + saveSheetConfig(config: SheetConfig) { + this.settings.write('sheetConfig', config) + } + resetSheetConfig() { + this.settings.write('sheetConfig', defaultSheetConfig()) + } + + everything() { + const stored = this.settings.readAll() + return { + ...stored, + sheetConfig: stored.sheetConfig ? SheetConfigSchema.parse(stored.sheetConfig) : defaultSheetConfig(), + } + } + get(key: string): T | null { + return this.settings.read(key, null) + } + set(key: string, value: unknown) { + this.settings.write(key, value) + } +} diff --git a/api/src/settings/sheet-config.schema.ts b/api/src/settings/sheet-config.schema.ts new file mode 100644 index 0000000..3cc2212 --- /dev/null +++ b/api/src/settings/sheet-config.schema.ts @@ -0,0 +1,49 @@ +import { z } from 'zod' + +const CategoryMappingSchema = z.object({ appCategory: z.string(), sheetColumn: z.string() }) + +// One column of the Google Sheet's header row, read from the sheet (or pasted by the user) +const SheetColumnSchema = z.object({ + column: z.string(), + name: z.string(), + kind: z.enum(['month', 'category', 'total', 'income', 'incomeAfter', 'savings', 'investment', 'net', 'other']), +}) + +// Default category mappings matching the original sheet structure +const DEFAULT_CATEGORY_MAPPINGS = [ + { appCategory: 'Rent', sheetColumn: 'B' }, + { appCategory: 'Eating Out', sheetColumn: 'C' }, + { appCategory: 'Personal Entertainment', sheetColumn: 'D' }, + { appCategory: 'Subscriptions', sheetColumn: 'E' }, + { appCategory: 'Car', sheetColumn: 'F' }, + { appCategory: 'Public Transport', sheetColumn: 'G' }, + { appCategory: 'Internet', sheetColumn: 'H' }, + { appCategory: 'Electricity', sheetColumn: 'I' }, + { appCategory: 'Insurance', sheetColumn: 'J' }, + { appCategory: 'Travel', sheetColumn: 'K' }, + { appCategory: 'Groceries', sheetColumn: 'L' }, + { appCategory: 'Family', sheetColumn: 'M' }, + { appCategory: 'Radio Tax', sheetColumn: 'N' }, + { appCategory: 'Health & Wellbeing', sheetColumn: 'O' }, + { appCategory: 'Shopping', sheetColumn: 'P' }, + { appCategory: 'Other', sheetColumn: 'Q' }, + { appCategory: 'Gifts', sheetColumn: 'R' }, +] + +// Single source of truth: the schema carries the defaults +export const SheetConfigSchema = z.object({ + sheetId: z.string().default(''), + tabName: z.string().default('2026'), + monthColumn: z.string().default('A'), + monthFormat: z.enum(['short', 'long', 'numeric']).default('short'), + categoryMappings: z.array(CategoryMappingSchema).default(DEFAULT_CATEGORY_MAPPINGS), + constantColumns: z.array(z.string()).default(['S', 'U', 'X']), // formula columns: don't overwrite + incomeColumn: z.string().default('T'), + savingsColumn: z.string().default('V'), + investmentColumn: z.string().default('W'), + layout: z.array(SheetColumnSchema).optional(), // synced from the sheet; undefined until first sync + headerRow: z.number().optional(), + layoutSyncedAt: z.number().optional(), +}) +export type SheetConfig = z.infer +export const defaultSheetConfig = (): SheetConfig => SheetConfigSchema.parse({}) diff --git a/api/src/shared/crypto/encryption.test.ts b/api/src/shared/crypto/encryption.test.ts new file mode 100644 index 0000000..d632a7d --- /dev/null +++ b/api/src/shared/crypto/encryption.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest' +import { decrypt, encrypt } from './encryption' + +const KEY = Buffer.alloc(32, 9) + +describe('crypto', () => { + it('round-trips and uses a fresh IV each time', () => { + const a = encrypt('secret', KEY), + b = encrypt('secret', KEY) + expect(decrypt(a, KEY)).toBe('secret') + expect(a).not.toBe(b) + }) + it('rejects tampered or wrongly keyed data', () => { + const blob = encrypt('secret', KEY) + const parts = blob.split('.') + parts[3] = Buffer.from('tampered').toString('base64') + expect(() => decrypt(parts.join('.'), KEY)).toThrow(/authenticate/i) + expect(() => decrypt(blob, Buffer.alloc(32, 1))).toThrow(/authenticate/i) + }) +}) diff --git a/api/src/shared/crypto/encryption.ts b/api/src/shared/crypto/encryption.ts new file mode 100644 index 0000000..f5ddc74 --- /dev/null +++ b/api/src/shared/crypto/encryption.ts @@ -0,0 +1,26 @@ +import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto' + +// Bank session ids are credentials, so they're encrypted at rest (AES-256-GCM, random IV per value). +export function encrypt(plain: string, key: Buffer): string { + const iv = randomBytes(12) + const cipher = createCipheriv('aes-256-gcm', key, iv) + const ct = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]) + return ['v1', iv.toString('base64'), cipher.getAuthTag().toString('base64'), ct.toString('base64')].join('.') +} + +export function decrypt(blob: string, key: Buffer): string { + const [v, iv, tag, ct] = blob.split('.') + if (v !== 'v1' || !iv || !tag || !ct) throw new Error('Unsupported ciphertext') + const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'base64')) + decipher.setAuthTag(Buffer.from(tag, 'base64')) + return Buffer.concat([decipher.update(Buffer.from(ct, 'base64')), decipher.final()]).toString('utf8') +} + +export function randomToken(bytes = 32): string { + return randomBytes(bytes).toString('base64url') +} + +/** A short, opaque id for an account, safe to put in URLs: derived from the bank's id, which stays private. */ +export function accountRef(stableId: string): string { + return createHash('sha256').update(`finkit-account:${stableId}`).digest('hex').slice(0, 16) +} diff --git a/api/src/shared/database/database.module.ts b/api/src/shared/database/database.module.ts new file mode 100644 index 0000000..5ba6b11 --- /dev/null +++ b/api/src/shared/database/database.module.ts @@ -0,0 +1,15 @@ +import { DynamicModule, Global, Module } from '@nestjs/common' +import { DATABASE_FILE, DatabaseService } from './database.service' + +@Global() +@Module({}) +export class DatabaseModule { + /** `file` overrides where the database lives (tests pass ':memory:'). */ + static forRoot(file?: string): DynamicModule { + return { + module: DatabaseModule, + providers: [{ provide: DATABASE_FILE, useValue: file }, DatabaseService], + exports: [DatabaseService], + } + } +} diff --git a/api/src/shared/database/database.service.ts b/api/src/shared/database/database.service.ts new file mode 100644 index 0000000..177da67 --- /dev/null +++ b/api/src/shared/database/database.service.ts @@ -0,0 +1,29 @@ +import { Inject, Injectable, OnModuleDestroy, Optional } from '@nestjs/common' +import Database from 'better-sqlite3' +import { existsSync, mkdirSync } from 'node:fs' +import { dirname, join } from 'node:path' + +/** Overrides the database file (tests pass ':memory:'). */ +export const DATABASE_FILE = Symbol('DATABASE_FILE') + +/** + * The single SQLite connection. Feature modules own their tables and queries; this only + * opens the database. Data lives in DATA_DIR (a mounted volume in production). + */ +@Injectable() +export class DatabaseService implements OnModuleDestroy { + readonly db: Database.Database + + constructor(@Optional() @Inject(DATABASE_FILE) file?: string) { + const path = file ?? join(process.env.DATA_DIR || join(process.cwd(), 'data'), 'categorizations.db') + if (path !== ':memory:' && !existsSync(dirname(path))) mkdirSync(dirname(path), { recursive: true }) + this.db = new Database(path) + this.db.pragma('journal_mode = WAL') + this.db.pragma('synchronous = NORMAL') + this.db.pragma('cache_size = 10000') + } + + onModuleDestroy() { + this.db.close() + } +} diff --git a/api/src/shared/http/http-error.filter.ts b/api/src/shared/http/http-error.filter.ts new file mode 100644 index 0000000..9c84f2a --- /dev/null +++ b/api/src/shared/http/http-error.filter.ts @@ -0,0 +1,37 @@ +import { ArgumentsHost, Catch, ExceptionFilter, HttpException, Logger } from '@nestjs/common' +import type { Response } from 'express' + +const TEXT: Record = { + 400: 'Bad Request', + 401: 'Unauthorized', + 403: 'Forbidden', + 404: 'Not Found', + 413: 'Payload Too Large', + 429: 'Too Many Requests', +} + +/** + * Keeps error bodies in the shape the web app already reads ({ error: "…" }) and never leaks + * internals: anything unexpected becomes a plain 500. + */ +@Catch() +export class HttpErrorFilter implements ExceptionFilter { + private readonly logger = new Logger('HttpError') + + catch(exception: unknown, host: ArgumentsHost) { + const res = host.switchToHttp().getResponse() + if (exception instanceof HttpException) { + const status = exception.getStatus() + const body = exception.getResponse() + // Nest's own errors look like { statusCode, message, error }; ours are already final + const ours = typeof body === 'object' && body !== null && !('statusCode' in body) + return res.status(status).json(ours ? body : { error: TEXT[status] ?? 'Error' }) + } + // body-parser errors (oversized or malformed JSON) carry their own status + const status = (exception as { status?: number })?.status + if (typeof status === 'number' && status >= 400 && status < 500) + return res.status(status).json({ error: TEXT[status] ?? 'Bad Request' }) + this.logger.error(exception instanceof Error ? (exception.stack ?? exception.message) : String(exception)) + return res.status(500).json({ error: 'Internal Server Error' }) + } +} diff --git a/api/src/shared/http/no-store.interceptor.ts b/api/src/shared/http/no-store.interceptor.ts new file mode 100644 index 0000000..2711313 --- /dev/null +++ b/api/src/shared/http/no-store.interceptor.ts @@ -0,0 +1,12 @@ +import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common' +import type { Response } from 'express' +import { Observable } from 'rxjs' + +/** Bank data must never be cached by a browser or a proxy. */ +@Injectable() +export class NoStoreInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + context.switchToHttp().getResponse().setHeader('Cache-Control', 'no-store') + return next.handle() + } +} diff --git a/api/src/shared/http/request-logger.middleware.ts b/api/src/shared/http/request-logger.middleware.ts new file mode 100644 index 0000000..94ea6e0 --- /dev/null +++ b/api/src/shared/http/request-logger.middleware.ts @@ -0,0 +1,18 @@ +import { Injectable, Logger, NestMiddleware } from '@nestjs/common' +import type { NextFunction, Request, Response } from 'express' + +// The bank's return URL carries a one-time code and state. Keep them out of the logs. +export const redact = (line: string) => line.replace(/([?&](?:code|state)=)[^&\s]+/g, '$1[redacted]') + +@Injectable() +export class RequestLoggerMiddleware implements NestMiddleware { + private readonly logger = new Logger('HTTP') + + use(req: Request, res: Response, next: NextFunction) { + const started = Date.now() + res.on('finish', () => + this.logger.log(redact(`${req.method} ${req.originalUrl} ${res.statusCode} ${Date.now() - started}ms`)), + ) + next() + } +} diff --git a/api/src/shared/http/request-logger.test.ts b/api/src/shared/http/request-logger.test.ts new file mode 100644 index 0000000..eb835f2 --- /dev/null +++ b/api/src/shared/http/request-logger.test.ts @@ -0,0 +1,13 @@ +import { describe, it, expect } from 'vitest' +import { redact } from './request-logger.middleware' + +describe('logging', () => { + it('redacts the one-time code and state', () => { + expect(redact('GET /api/banking/callback?code=abc123&state=xyz789 302 4ms')).toBe( + 'GET /api/banking/callback?code=[redacted]&state=[redacted] 302 4ms', + ) + }) + it('leaves other URLs alone', () => { + expect(redact('GET /api/learn/mappings 200 1ms')).toBe('GET /api/learn/mappings 200 1ms') + }) +}) diff --git a/api/src/shared/http/zod-validation.pipe.ts b/api/src/shared/http/zod-validation.pipe.ts new file mode 100644 index 0000000..c7a082b --- /dev/null +++ b/api/src/shared/http/zod-validation.pipe.ts @@ -0,0 +1,28 @@ +import { BadRequestException, PipeTransform } from '@nestjs/common' +import { ZodType } from 'zod' + +interface Options { + /** Error text in the response body */ + message?: string + /** Body shape the existing clients expect: { error, details } or { success: false, error: issues } */ + shape?: 'error-details' | 'success-false' +} + +/** Validates a request body against a zod schema and passes on the parsed value. */ +export class ZodValidationPipe implements PipeTransform { + constructor( + private readonly schema: ZodType, + private readonly options: Options = {}, + ) {} + + transform(value: unknown): T { + const result = this.schema.safeParse(value) + if (result.success) return result.data + const { message = 'Invalid request', shape = 'error-details' } = this.options + throw new BadRequestException( + shape === 'success-false' + ? { success: false, error: result.error.issues } + : { error: message, details: result.error.issues }, + ) + } +} diff --git a/api/src/services/sheets.ts b/api/src/sheet-export/google-sheets.service.ts similarity index 59% rename from api/src/services/sheets.ts rename to api/src/sheet-export/google-sheets.service.ts index f083c8e..ec9275d 100644 --- a/api/src/services/sheets.ts +++ b/api/src/sheet-export/google-sheets.service.ts @@ -1,144 +1,140 @@ +import { Injectable } from '@nestjs/common' import { google, sheets_v4 } from 'googleapis' interface CategoryValue { - column: string; - value: number; + column: string + value: number } interface ExportData { - sheetId: string; - tabName: string; - month: string; - monthFormat: 'short' | 'long' | 'numeric'; - categoryValues: CategoryValue[]; - income: number; - savings?: number; - investment?: number; - constantColumns?: string[]; - incomeColumn?: string; - savingsColumn?: string; - investmentColumn?: string; + sheetId: string + tabName: string + month: string + monthFormat: 'short' | 'long' | 'numeric' + categoryValues: CategoryValue[] + income: number + savings?: number + investment?: number + constantColumns?: string[] + incomeColumn?: string + savingsColumn?: string + investmentColumn?: string } interface ExportResult { - success: boolean; - updatedCells: number; - rowNumber: number; + success: boolean + updatedCells: number + rowNumber: number } // Fallbacks used only when the caller doesn't say which columns hold these values. // Category columns are never hardcoded: they come from the request (read from the sheet header row). -const DEFAULT_INCOME_COLUMN = 'T'; -const DEFAULT_SAVINGS_COLUMN = 'V'; -const DEFAULT_INVESTMENT_COLUMN = 'W'; +const DEFAULT_INCOME_COLUMN = 'T' +const DEFAULT_SAVINGS_COLUMN = 'V' +const DEFAULT_INVESTMENT_COLUMN = 'W' async function getAuthClient() { - const keyPath = process.env.GOOGLE_SERVICE_ACCOUNT_KEY_PATH; - + const keyPath = process.env.GOOGLE_SERVICE_ACCOUNT_KEY_PATH + if (!keyPath) { - throw new Error('GOOGLE_SERVICE_ACCOUNT_KEY_PATH not configured'); + throw new Error('GOOGLE_SERVICE_ACCOUNT_KEY_PATH not configured') } const auth = new google.auth.GoogleAuth({ keyFile: keyPath, scopes: ['https://www.googleapis.com/auth/spreadsheets'], - }); + }) - return auth.getClient(); + return auth.getClient() } async function findMonthRow( sheets: sheets_v4.Sheets, sheetId: string, tabName: string, - month: string + month: string, ): Promise { // Get column A (month column) data const response = await sheets.spreadsheets.values.get({ spreadsheetId: sheetId, range: `${tabName}!A:A`, - }); + }) + + const values = response.data.values || [] + const monthLower = month.toLowerCase() - const values = response.data.values || []; - const monthLower = month.toLowerCase(); - // Find matching month row // Matches: "July 2025", "Jul 2025", "07/2025", etc. for (let i = 0; i < values.length; i++) { - const cellValue = values[i]?.[0]?.toString().toLowerCase(); - if (!cellValue) continue; - + const cellValue = values[i]?.[0]?.toString().toLowerCase() + if (!cellValue) continue + // Check for exact match or partial match if (cellValue === monthLower || cellValue.includes(monthLower)) { - return i + 1; // 1-indexed row number + return i + 1 // 1-indexed row number } - + // Also try matching just the month name - const monthName = month.split(' ')[0]?.toLowerCase(); + const monthName = month.split(' ')[0]?.toLowerCase() if (monthName && cellValue.includes(monthName)) { // Verify year matches if present - const year = month.split(' ')[1]; + const year = month.split(' ')[1] if (!year || cellValue.includes(year)) { - return i + 1; + return i + 1 } } } - throw new Error(`Month "${month}" not found in sheet. Please ensure the row exists in column A.`); + throw new Error(`Month "${month}" not found in sheet. Please ensure the row exists in column A.`) } -export async function exportToSheets(data: ExportData): Promise { - const authClient = await getAuthClient(); - const sheets = google.sheets({ version: 'v4', auth: authClient as any }); +async function writeRow(data: ExportData): Promise { + const authClient = await getAuthClient() + const sheets = google.sheets({ version: 'v4', auth: authClient as any }) // Find the row for the specified month - const rowNumber = await findMonthRow( - sheets, - data.sheetId, - data.tabName, - data.month - ); + const rowNumber = await findMonthRow(sheets, data.sheetId, data.tabName, data.month) // Prepare batch update data - const updateData: sheets_v4.Schema$ValueRange[] = []; - const constantCols = new Set(data.constantColumns || ['S', 'U', 'X']); // Default: skip formula columns + const updateData: sheets_v4.Schema$ValueRange[] = [] + const constantCols = new Set(data.constantColumns || ['S', 'U', 'X']) // Default: skip formula columns // Category values (columns come from the sheet's header row) for (const { column, value } of data.categoryValues) { // Skip constant/formula columns if (constantCols.has(column)) { - continue; + continue } updateData.push({ range: `${data.tabName}!${column}${rowNumber}`, values: [[value]], - }); + }) } - const incomeColumn = data.incomeColumn || DEFAULT_INCOME_COLUMN; - const savingsColumn = data.savingsColumn || DEFAULT_SAVINGS_COLUMN; - const investmentColumn = data.investmentColumn || DEFAULT_INVESTMENT_COLUMN; + const incomeColumn = data.incomeColumn || DEFAULT_INCOME_COLUMN + const savingsColumn = data.savingsColumn || DEFAULT_SAVINGS_COLUMN + const investmentColumn = data.investmentColumn || DEFAULT_INVESTMENT_COLUMN if (!constantCols.has(incomeColumn)) { updateData.push({ range: `${data.tabName}!${incomeColumn}${rowNumber}`, values: [[data.income]], - }); + }) } if (data.savings !== undefined && !constantCols.has(savingsColumn)) { updateData.push({ range: `${data.tabName}!${savingsColumn}${rowNumber}`, values: [[data.savings]], - }); + }) } if (data.investment !== undefined && !constantCols.has(investmentColumn)) { updateData.push({ range: `${data.tabName}!${investmentColumn}${rowNumber}`, values: [[data.investment]], - }); + }) } if (updateData.length === 0) { @@ -146,7 +142,7 @@ export async function exportToSheets(data: ExportData): Promise { success: true, updatedCells: 0, rowNumber, - }; + } } // Execute batch update @@ -156,22 +152,22 @@ export async function exportToSheets(data: ExportData): Promise { valueInputOption: 'USER_ENTERED', data: updateData, }, - }); + }) return { success: true, updatedCells: response.data.totalUpdatedCells || 0, rowNumber, - }; + } } -export async function getSheetMetadata(sheetId: string) { - const authClient = await getAuthClient(); - const sheets = google.sheets({ version: 'v4', auth: authClient as any }); +async function readMetadata(sheetId: string) { + const authClient = await getAuthClient() + const sheets = google.sheets({ version: 'v4', auth: authClient as any }) const response = await sheets.spreadsheets.get({ spreadsheetId: sheetId, - }); + }) return { title: response.data.properties?.title, @@ -179,32 +175,32 @@ export async function getSheetMetadata(sheetId: string) { title: s.properties?.title, index: s.properties?.index, })), - }; + } } function columnLetter(index: number): string { - let n = index + 1; - let out = ''; + let n = index + 1 + let out = '' while (n > 0) { - const rem = (n - 1) % 26; - out = String.fromCharCode(65 + rem) + out; - n = Math.floor((n - 1) / 26); + const rem = (n - 1) % 26 + out = String.fromCharCode(65 + rem) + out + n = Math.floor((n - 1) / 26) } - return out; + return out } function normalizeHeader(value: string): string { - return value.toLowerCase().replace(/[^a-z0-9]/g, ''); + return value.toLowerCase().replace(/[^a-z0-9]/g, '') } -export interface SheetHeaderColumn { - column: string; - name: string; +interface SheetHeaderColumn { + column: string + name: string } -export interface SheetLayoutResult { - headerRow: number; // 1-indexed - columns: SheetHeaderColumn[]; +interface SheetLayoutResult { + headerRow: number // 1-indexed + columns: SheetHeaderColumn[] } /** @@ -212,36 +208,48 @@ export interface SheetLayoutResult { * Sheets often have a banner above the headers ("ACTUAL EXPENSES"), so we look through the * first rows for the one that contains "Total Expenditure", falling back to the fullest row. */ -export async function getSheetLayout(sheetId: string, tabName: string): Promise { - const authClient = await getAuthClient(); - const sheets = google.sheets({ version: 'v4', auth: authClient as any }); +async function readLayout(sheetId: string, tabName: string): Promise { + const authClient = await getAuthClient() + const sheets = google.sheets({ version: 'v4', auth: authClient as any }) - const quotedTab = `'${tabName.replace(/'/g, "''")}'`; + const quotedTab = `'${tabName.replace(/'/g, "''")}'` const response = await sheets.spreadsheets.values.get({ spreadsheetId: sheetId, range: `${quotedTab}!A1:ZZ10`, - }); + }) - const rows = (response.data.values || []).map(r => r.map((c: unknown) => (c ?? '').toString().trim())); + const rows = (response.data.values || []).map(r => r.map((c: unknown) => (c ?? '').toString().trim())) if (rows.length === 0) { - throw new Error(`Tab "${tabName}" is empty.`); + throw new Error(`Tab "${tabName}" is empty.`) } - let headerIndex = rows.findIndex(r => r.some(c => normalizeHeader(c) === 'totalexpenditure')); + let headerIndex = rows.findIndex(r => r.some(c => normalizeHeader(c) === 'totalexpenditure')) if (headerIndex === -1) { - let best = -1; + let best = -1 rows.forEach((r, i) => { - const filled = r.filter(Boolean).length; + const filled = r.filter(Boolean).length if (filled > best) { - best = filled; - headerIndex = i; + best = filled + headerIndex = i } - }); + }) } - const columns = rows[headerIndex] - .map((name, i) => ({ column: columnLetter(i), name })) - .filter(c => c.name); + const columns = rows[headerIndex].map((name, i) => ({ column: columnLetter(i), name })).filter(c => c.name) + + return { headerRow: headerIndex + 1, columns } +} - return { headerRow: headerIndex + 1, columns }; +/** Talks to Google Sheets with a service account (optional: the export screen can also just copy a row). */ +@Injectable() +export class GoogleSheetsService { + exportRow(data: ExportData): Promise { + return writeRow(data) + } + metadata(sheetId: string) { + return readMetadata(sheetId) + } + layout(sheetId: string, tabName: string): Promise { + return readLayout(sheetId, tabName) + } } diff --git a/api/src/sheet-export/sheet-export.controller.ts b/api/src/sheet-export/sheet-export.controller.ts new file mode 100644 index 0000000..ed6dfbd --- /dev/null +++ b/api/src/sheet-export/sheet-export.controller.ts @@ -0,0 +1,62 @@ +import { + HttpCode, + BadRequestException, + Body, + Controller, + InternalServerErrorException, + Logger, + Post, +} from '@nestjs/common' +import { ZodValidationPipe } from '../shared/http/zod-validation.pipe' +import { GoogleSheetsService } from './google-sheets.service' +import { ExportRequest, ExportRequestSchema } from './sheet-export.schemas' + +@Controller('api/export') +export class SheetExportController { + private readonly logger = new Logger(SheetExportController.name) + + constructor(private readonly sheets: GoogleSheetsService) {} + + // Write a month's figures into the sheet + @Post('sheets') + @HttpCode(200) // the previous API answered 200, not 201 + async exportRow( + @Body(new ZodValidationPipe(ExportRequestSchema, { message: 'Invalid request body' })) body: ExportRequest, + ) { + return { + success: true, + data: await this.run(() => this.sheets.exportRow(body), 'Failed to export to Google Sheets'), + } + } + + // Read the sheet's header row so the app mirrors the real column layout + @Post('sheets/layout') + @HttpCode(200) // the previous API answered 200, not 201 + async layout(@Body() body: { sheetId?: string; tabName?: string }) { + if (!body?.sheetId || !body?.tabName) throw new BadRequestException({ error: 'Missing sheetId or tabName' }) + return { + success: true, + data: await this.run(() => this.sheets.layout(body.sheetId!, body.tabName!), 'Failed to read sheet layout'), + } + } + + // The tabs available in the sheet + @Post('sheets/metadata') + @HttpCode(200) // the previous API answered 200, not 201 + async metadata(@Body() body: { sheetId?: string }) { + if (!body?.sheetId) throw new BadRequestException({ error: 'Missing sheetId' }) + return { + success: true, + data: await this.run(() => this.sheets.metadata(body.sheetId!), 'Failed to fetch sheet metadata'), + } + } + + private async run(action: () => Promise, fallback: string): Promise { + try { + return await action() + } catch (error) { + this.logger.error(error instanceof Error ? error.message : String(error)) + throw new InternalServerErrorException({ error: error instanceof Error ? error.message : fallback }) + } + } +} diff --git a/api/src/sheet-export/sheet-export.module.ts b/api/src/sheet-export/sheet-export.module.ts new file mode 100644 index 0000000..44d116c --- /dev/null +++ b/api/src/sheet-export/sheet-export.module.ts @@ -0,0 +1,6 @@ +import { Module } from '@nestjs/common' +import { GoogleSheetsService } from './google-sheets.service' +import { SheetExportController } from './sheet-export.controller' + +@Module({ controllers: [SheetExportController], providers: [GoogleSheetsService] }) +export class SheetExportModule {} diff --git a/api/src/sheet-export/sheet-export.schemas.ts b/api/src/sheet-export/sheet-export.schemas.ts new file mode 100644 index 0000000..38fc490 --- /dev/null +++ b/api/src/sheet-export/sheet-export.schemas.ts @@ -0,0 +1,17 @@ +import { z } from 'zod' + +export const ExportRequestSchema = z.object({ + sheetId: z.string(), + tabName: z.string(), + month: z.string(), + monthFormat: z.enum(['short', 'long', 'numeric']), + categoryValues: z.array(z.object({ column: z.string(), value: z.number() })), + income: z.number(), + savings: z.number().optional(), + investment: z.number().optional(), + constantColumns: z.array(z.string()).optional(), + incomeColumn: z.string().optional(), + savingsColumn: z.string().optional(), + investmentColumn: z.string().optional(), +}) +export type ExportRequest = z.infer diff --git a/api/src/testing/test-app.ts b/api/src/testing/test-app.ts new file mode 100644 index 0000000..e1b7a4b --- /dev/null +++ b/api/src/testing/test-app.ts @@ -0,0 +1,69 @@ +import { INestApplication } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { AccessModule } from '../access/access.module' +import { ACCESS_VERIFIER, AccessVerifier } from '../access/access.settings' +import { BankConnectionsModule } from '../bank-connections/bank-connections.module' +import { BANK_PROVIDER, BankProvider } from '../bank-connections/bank-provider' +import { CLOCK, Clock } from '../bank-connections/bank-connections.tokens' +import { configureApp } from '../app.setup' +import { HealthModule } from '../health/health.module' +import { MerchantRulesModule } from '../merchant-rules/merchant-rules.module' +import { SettingsModule } from '../settings/settings.module' +import { SheetExportModule } from '../sheet-export/sheet-export.module' +import { DatabaseModule } from '../shared/database/database.module' +import { DatabaseService } from '../shared/database/database.service' +import { BankConnectionsStore } from '../bank-connections/bank-connections.store' + +export const TEST_KEY = Buffer.alloc(32, 9) +export const SIGNED_IN_HEADERS = { + 'Content-Type': 'application/json', + 'X-FinKit': '1', +} + +export interface TestAppOptions { + env?: NodeJS.ProcessEnv + provider?: BankProvider + verify?: AccessVerifier | null + clock?: Clock +} + +/** The whole app, wired the same way as in production, on an in-memory database. */ +export async function createTestApp(options: TestAppOptions = {}) { + const env = { + NODE_ENV: 'test', + BANKING_PROVIDER: 'mock', + BANKING_ENCRYPTION_KEY: TEST_KEY.toString('base64'), + ...options.env, + } + let builder = Test.createTestingModule({ + imports: [ + DatabaseModule.forRoot(':memory:'), + AccessModule.forRoot(env), + HealthModule, + MerchantRulesModule, + SettingsModule, + SheetExportModule, + BankConnectionsModule.forRoot(env), + ], + }) + if (options.provider) builder = builder.overrideProvider(BANK_PROVIDER).useValue(options.provider) + if (options.clock) builder = builder.overrideProvider(CLOCK).useValue(options.clock) + if (options.verify !== undefined) builder = builder.overrideProvider(ACCESS_VERIFIER).useValue(options.verify) + const moduleRef = await builder.compile() + const app: INestApplication = moduleRef.createNestApplication() + configureApp(app, env) + await app.init() + return { + app, + server: app.getHttpServer(), + db: moduleRef.get(DatabaseService).db, + store: (() => { + try { + return moduleRef.get(BankConnectionsStore) + } catch { + return undefined + } + })(), + close: () => app.close(), + } +} diff --git a/api/tsconfig.build.json b/api/tsconfig.build.json new file mode 100644 index 0000000..4edcedf --- /dev/null +++ b/api/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/**/*.spec.ts", "src/testing/**"] +} diff --git a/api/tsconfig.json b/api/tsconfig.json index 4cbbd9a..86528ec 100644 --- a/api/tsconfig.json +++ b/api/tsconfig.json @@ -1,16 +1,20 @@ { "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", - "lib": ["ES2022"], + "module": "commonjs", + "moduleResolution": "node", + "target": "ES2023", + "lib": ["ES2023"], "outDir": "./dist", "rootDir": "./src", "strict": true, + "strictPropertyInitialization": false, "esModuleInterop": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true + "resolveJsonModule": true, + "sourceMap": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] diff --git a/api/vitest.config.mts b/api/vitest.config.mts new file mode 100644 index 0000000..2f23259 --- /dev/null +++ b/api/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config' +import swc from 'unplugin-swc' + +// Nest's dependency injection reads constructor types from decorator metadata, which +// esbuild (Vitest's default) doesn't emit. SWC does. +export default defineConfig({ + test: { include: ['src/**/*.test.ts'] }, + plugins: [swc.vite({ module: { type: 'es6' } })], +}) diff --git a/docker-compose.yml b/docker-compose.yml index 5895383..6cacc3b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,7 +34,6 @@ services: - NODE_ENV=production - PORT=3000 - DATA_DIR=/app/data - - OPENAI_API_KEY=${OPENAI_API_KEY:-} - GOOGLE_SERVICE_ACCOUNT_EMAIL=${GOOGLE_SERVICE_ACCOUNT_EMAIL:-} - GOOGLE_PRIVATE_KEY=${GOOGLE_PRIVATE_KEY:-} volumes: diff --git a/example.env b/example.env index 2ae5026..64e5740 100644 --- a/example.env +++ b/example.env @@ -14,13 +14,23 @@ GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx # Cloudflare Tunnel token (from Cloudflare Zero Trust dashboard) CLOUDFLARE_TUNNEL_TOKEN=your-tunnel-token -# OpenAI API Key (for AI categorization) - Optional -OPENAI_API_KEY=sk-... - -# Google Sheets API (for export functionality) - Optional -# Create a service account at https://console.cloud.google.com -GOOGLE_SERVICE_ACCOUNT_EMAIL=your-service-account@project.iam.gserviceaccount.com -GOOGLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n" +# Google Sheets API (for sheet sync) - Optional +# Path to the service account JSON key, as seen inside the API container (mount it as a secret) +# GOOGLE_SERVICE_ACCOUNT_KEY_PATH=/run/secrets/google-sa.json # Lock the API's CORS to your site (optional; recommended in production) # ALLOWED_ORIGIN=https://finkit.example.com + +# --- Bank sync (Enable Banking). Off unless BANKING_PROVIDER is set. Full notes: api/.env.example --- +# off | mock | enablebanking (mock is refused in production) +BANKING_PROVIDER=off +# Cloudflare Access: the API verifies the Access token itself. Required in production. +# CF_ACCESS_TEAM_DOMAIN=yourteam.cloudflareaccess.com +# CF_ACCESS_AUD= +# BANKING_ADMIN_EMAILS=you@example.com +# openssl rand -base64 32 +# BANKING_ENCRYPTION_KEY= +# Must be https in production and registered exactly like this in your Enable Banking app +# BANKING_REDIRECT_URL=https://finkit.example.com/api/banking/callback +# EB_APP_ID= +# EB_PRIVATE_KEY_PATH=/run/secrets/enablebanking.pem From 1a14e208cbea89773f5b5a153f1e30297be897ce Mon Sep 17 00:00:00 2001 From: nedson202 Date: Sun, 1 Nov 2026 17:35:35 +0000 Subject: [PATCH 02/11] feat(web): page-based app with TanStack Router, bank sync UI, year and month filters Restructure the single dashboard into routed pages under /app, with a left sidebar (drawer and bottom tab bar on phones), breadcrumbs and one global period picker. Pages and hierarchy: - Overview, Banks, Connect, a bank (accounts), Transactions, merchant, Review, Income, Analytics, Trends, Export, Settings (sheet, columns, rules, back office for admins) - banks -> accounts -> transactions -> merchant groups -> one merchant, each a link - filters live in the address (?cat=Groceries&bank=N26&view=merchants), so views can be bookmarked and survive the back button; saved views and removable chips - bank pages redirect to the overview when banking is off; clear messages when FinKit's own server is unreachable Bank sync (replaces the modal): connect flow, status and expiry bars, sync, sync all, reconnect, disconnect, back office; rows carry bank and account. Period handling: year-first picker (all time, quick picks, years with months) and year/month tabs on Analytics, Trends, Income, Review and Transactions. Export picks a year, then a month, and copies that year's rows. Mobile: transactions as cards with a sort menu, bottom tab bar, no horizontal overflow on any route. Also: review queue "up next" list and counts, category legend with amounts, income vs spending running totals, category-chart and trend colours that match their meaning, vendor chunks (react, charts, xlsx, tanstack), serve.json rewrites for /app/* routes, oxlint/oxfmt, tests for period summaries (28). Co-Authored-By: Claude Sonnet 5.5 --- web/.oxfmtrc.json | 9 + web/.oxlintrc.json | 39 + web/index.html | 11 +- web/package-lock.json | 929 +++++++++++++++++- web/package.json | 13 +- web/public/privacy.html | 42 +- web/public/serve.json | 44 + web/public/terms.html | 35 +- web/src/app/AppShell.tsx | 258 +++++ web/src/{App.tsx => app/AppState.tsx} | 395 ++++---- web/src/app/Breadcrumbs.tsx | 79 ++ web/src/app/PageHeader.tsx | 21 + web/src/app/router.tsx | 153 +++ web/src/app/search.ts | 51 + web/src/components/BankBanners.tsx | 40 + web/src/components/BulkActionBar.tsx | 9 +- web/src/components/CurrencyRates.tsx | 6 +- web/src/components/Dashboard.tsx | 326 ------ web/src/components/ExportButton.tsx | 51 - web/src/components/ExportPreview.tsx | 157 +-- web/src/components/FileUpload.tsx | 191 ++-- web/src/components/HeaderMenu.tsx | 5 +- web/src/components/ImportConfirm.tsx | 25 +- web/src/components/IncomeReview.tsx | 63 +- web/src/components/PeriodPicker.tsx | 138 +++ web/src/components/PeriodTabs.tsx | 75 ++ web/src/components/RecurringBadge.tsx | 14 +- web/src/components/ReviewQueue.tsx | 61 +- web/src/components/RulesManager.tsx | 14 +- web/src/components/SavedViews.tsx | 99 ++ web/src/components/SearchFilter.tsx | 392 ++------ web/src/components/SettingsPanel.tsx | 521 +++++----- web/src/components/SplitDialog.tsx | 28 +- web/src/components/SummaryCards.tsx | 6 +- web/src/components/TransactionTable.tsx | 627 +++++++----- web/src/components/banks/BackOfficePanel.tsx | 190 ++++ web/src/components/banks/BankTile.tsx | 27 + web/src/components/banks/ConnectBank.tsx | 116 +++ .../components/banks/ConnectionActions.tsx | 136 +++ web/src/components/banks/ConnectionCard.tsx | 79 ++ web/src/components/banks/StatusPill.tsx | 21 + .../components/banks/useConnectionActions.ts | 55 ++ web/src/components/charts/CategoryChart.tsx | 71 +- web/src/components/charts/SpendingTrend.tsx | 133 ++- web/src/components/charts/TopExpenses.tsx | 13 +- web/src/index.css | 16 +- web/src/lib/aliases.ts | 4 +- web/src/lib/anonymizer.ts | 181 ++-- web/src/lib/api.ts | 57 +- web/src/lib/banking-queries.ts | 101 ++ web/src/lib/banking.ts | 131 +++ web/src/lib/categorizer.ts | 159 +-- web/src/lib/csv.ts | 11 +- web/src/lib/dedupe.ts | 2 +- web/src/lib/double-booking.ts | 61 +- web/src/lib/ids.ts | 5 +- web/src/lib/lib.test.ts | 153 ++- web/src/lib/parser.ts | 281 +++--- web/src/lib/periods.ts | 33 + web/src/lib/recategorize.ts | 4 +- web/src/lib/recurring.ts | 108 +- web/src/lib/sample.ts | 3 +- web/src/lib/sheetLayout.ts | 44 +- web/src/lib/splits.ts | 2 +- web/src/lib/storage.ts | 2 +- web/src/lib/summary.ts | 55 ++ web/src/main.tsx | 11 +- web/src/pages/AnalyticsPage.tsx | 44 + web/src/pages/BankDetailPage.tsx | 86 ++ web/src/pages/BanksPage.tsx | 103 ++ web/src/pages/ConnectBankPage.tsx | 17 + web/src/pages/ExportPage.tsx | 24 + web/src/pages/IncomePage.tsx | 20 + web/src/pages/MerchantPage.tsx | 137 +++ web/src/pages/OverviewPage.tsx | 131 +++ web/src/pages/ReviewPage.tsx | 17 + web/src/pages/SettingsPage.tsx | 52 + web/src/pages/TransactionList.tsx | 37 + web/src/pages/TransactionsPage.tsx | 195 ++++ web/src/pages/useSelection.ts | 23 + web/src/types.ts | 138 +-- web/tailwind.config.js | 5 +- web/vite.config.ts | 52 +- 83 files changed, 5910 insertions(+), 2363 deletions(-) create mode 100644 web/.oxfmtrc.json create mode 100644 web/.oxlintrc.json create mode 100644 web/src/app/AppShell.tsx rename web/src/{App.tsx => app/AppState.tsx} (65%) create mode 100644 web/src/app/Breadcrumbs.tsx create mode 100644 web/src/app/PageHeader.tsx create mode 100644 web/src/app/router.tsx create mode 100644 web/src/app/search.ts create mode 100644 web/src/components/BankBanners.tsx delete mode 100644 web/src/components/Dashboard.tsx delete mode 100644 web/src/components/ExportButton.tsx create mode 100644 web/src/components/PeriodPicker.tsx create mode 100644 web/src/components/PeriodTabs.tsx create mode 100644 web/src/components/SavedViews.tsx create mode 100644 web/src/components/banks/BackOfficePanel.tsx create mode 100644 web/src/components/banks/BankTile.tsx create mode 100644 web/src/components/banks/ConnectBank.tsx create mode 100644 web/src/components/banks/ConnectionActions.tsx create mode 100644 web/src/components/banks/ConnectionCard.tsx create mode 100644 web/src/components/banks/StatusPill.tsx create mode 100644 web/src/components/banks/useConnectionActions.ts create mode 100644 web/src/lib/banking-queries.ts create mode 100644 web/src/lib/banking.ts create mode 100644 web/src/lib/periods.ts create mode 100644 web/src/lib/summary.ts create mode 100644 web/src/pages/AnalyticsPage.tsx create mode 100644 web/src/pages/BankDetailPage.tsx create mode 100644 web/src/pages/BanksPage.tsx create mode 100644 web/src/pages/ConnectBankPage.tsx create mode 100644 web/src/pages/ExportPage.tsx create mode 100644 web/src/pages/IncomePage.tsx create mode 100644 web/src/pages/MerchantPage.tsx create mode 100644 web/src/pages/OverviewPage.tsx create mode 100644 web/src/pages/ReviewPage.tsx create mode 100644 web/src/pages/SettingsPage.tsx create mode 100644 web/src/pages/TransactionList.tsx create mode 100644 web/src/pages/TransactionsPage.tsx create mode 100644 web/src/pages/useSelection.ts diff --git a/web/.oxfmtrc.json b/web/.oxfmtrc.json new file mode 100644 index 0000000..7609490 --- /dev/null +++ b/web/.oxfmtrc.json @@ -0,0 +1,9 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "printWidth": 120, + "semi": false, + "singleQuote": true, + "trailingComma": "all", + "arrowParens": "avoid", + "ignorePatterns": ["dist", "node_modules", "package-lock.json", "tsconfig.tsbuildinfo", "*.html", "public"] +} diff --git a/web/.oxlintrc.json b/web/.oxlintrc.json new file mode 100644 index 0000000..dabb86e --- /dev/null +++ b/web/.oxlintrc.json @@ -0,0 +1,39 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["typescript", "unicorn", "oxc", "react", "jsx-a11y", "import", "vitest"], + "categories": { + "correctness": "error", + "suspicious": "warn" + }, + "rules": { + "react/rules-of-hooks": "error", + "react/exhaustive-deps": "warn", + "react/react-in-jsx-scope": "off", + "jsx-a11y/prefer-tag-over-role": "off", + "jsx-a11y/no-autofocus": "off", + "unicorn/no-array-sort": "off", + "unicorn/prefer-add-event-listener": "off", + "import/no-unassigned-import": [ + "warn", + { + "allow": ["**/*.css"] + } + ], + "react/set-state-in-effect": "warn", + "react/purity": "warn", + "react/immutability": "warn" + }, + "env": { + "builtin": true, + "browser": true + }, + "ignorePatterns": ["dist", "node_modules"], + "overrides": [ + { + "files": ["**/*.test.ts", "**/*.test.tsx"], + "rules": { + "unicorn/consistent-function-scoping": "off" + } + } + ] +} diff --git a/web/index.html b/web/index.html index f10148b..60157c5 100644 --- a/web/index.html +++ b/web/index.html @@ -22,8 +22,8 @@
-

A private dashboard for one person's bank accounts

-

FinKit reads the owner's own bank transactions, sorts them into categories, and prepares the monthly figures for a budget spreadsheet. It is a personal tool, not a service for the public.

+

A private, self-hosted dashboard for your bank accounts

+

FinKit runs on infrastructure you control. It reads your transactions (file import or optional bank sync), categorizes them in your browser, and can prepare monthly figures for a spreadsheet you own. No FinKit cloud, no telemetry, and no data sold or shared—unless you turn on optional Google Sheets or bank sync yourself.

Sign in Privacy @@ -31,9 +31,10 @@

A private dashboard for one person's bank accounts

-

Read-only

It can see account names, balances and transactions. It cannot make payments or move money.

-

Kept private

Transactions are shown in the owner's browser and are not kept on the server. Bank logins never reach FinKit.

-

Owner access only

Signing in is limited to the owner. There are no public accounts or sign-ups.

+

Self-hosted

Web app and API run on your machine or server. SQLite rules and tokens stay on your disk; transactions stay in your browser unless you export.

+

No data gathering

No analytics, ads, or crash reports built into FinKit. Nothing is sent to the software authors by default.

+

Read-only banking

With sync enabled, it can fetch accounts and transactions. It cannot move money. Bank passwords never reach FinKit.

+

Optional export only

Transactions are not uploaded elsewhere unless you configure Google Sheets export or open-banking sync.

diff --git a/web/package-lock.json b/web/package-lock.json index 112cfb9..30089fd 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,6 +8,8 @@ "name": "finkit-web", "version": "2.0.0", "dependencies": { + "@tanstack/react-query": "^5.105.1", + "@tanstack/react-router": "^1.170.46", "lucide-react": "^0.577.0", "papaparse": "^5.7.0", "react": "^18.3.1", @@ -22,6 +24,8 @@ "@types/react-dom": "^18.3.7", "@vitejs/plugin-react": "^4.7.0", "autoprefixer": "^10.6.2", + "oxfmt": "^0.75.0", + "oxlint": "^1.90.0", "postcss": "^8.5.32", "tailwindcss": "^3.4.19", "typescript": "~5.6.2", @@ -880,6 +884,652 @@ "node": ">= 8" } }, + "node_modules/@oxfmt/binding-android-arm-eabi": { + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.75.0.tgz", + "integrity": "sha512-zHJ7LcTN46/5mtoLxsUu14pdF9ixFwDURtmxZ0CeNwqom5qB5NRVqU6o2GRZ7QPdNBW8G6j9UTJzQ+EDevMH0w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-android-arm64": { + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.75.0.tgz", + "integrity": "sha512-IdxsKZJvHZUvI7q6kLmzXdEFpFiBEDQw/WJtSp1QlzNXBw1JlLN3tY50WwWd4doPnq86XW4P+7BsQwu6meCW+w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-darwin-arm64": { + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.75.0.tgz", + "integrity": "sha512-vyiqZilO5jal/nXZ0143NyKcP6TkzjaQdVEWU/kwgyWqugAgIRgAbrnO3MOF+e6LkQu3CrtrkPoc3mrsZZY6cA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-darwin-x64": { + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.75.0.tgz", + "integrity": "sha512-oua7eVqVe769mDam6ORcavygxTzG3ovo5QRQ1kJCPaEbnWXPYhtAiYDj8pajVmFjO7j8Elf3gInnWpTX0LLYtg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-freebsd-x64": { + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.75.0.tgz", + "integrity": "sha512-NWkbezhg7mx2Ep94g/TysR3c1bsISsU4lQ6t5xI4841PAk0Kqcn1+UEe+BoFH2/UK2YegPXq+OF+0bUcSvUalA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.75.0.tgz", + "integrity": "sha512-CAHX87kJplTq2E0OWP7opG8gEVlHc7CeLEi4nMJOyx3psuqBKf84HZ3UIkX2guWEJ5UljU2zJF0o46XylywVVA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-musleabihf": { + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.75.0.tgz", + "integrity": "sha512-jmxYOiQzx2fp+OwQb7717K1zI8AMKEykw04sfr95/mU8SIL/oKgQq8nXH42sEznEquBL2RU7nLY36eBnYFT+vg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-gnu": { + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.75.0.tgz", + "integrity": "sha512-eimaiAhx08dbOg12Fj3PYhymXY8vRbaiJB505vDXMy6ohne3Y1qTS0e5KEwFMq3LmDjEO+16BX38c/oXFI/SAQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-musl": { + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.75.0.tgz", + "integrity": "sha512-bjSONIG1ba2xugr9oHQau0E6oPFYOMjPaa5hK/wpVC/Q1pcgf2BZMKwbuA8/cWzhWX5QV/UdPzD+rw1kQHpikQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-ppc64-gnu": { + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.75.0.tgz", + "integrity": "sha512-wDksWQXC/KZucISyqlhBtvpW+pyHh8z0Cjm6Qxiv22XlllM2Bx5gJGDnPYjV+H0lL+o1iqLlEli8jmBuRCLcjw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-gnu": { + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.75.0.tgz", + "integrity": "sha512-5JkNwViqaVuk4I+9+zgVrEYiHOAPXFxgDgP6iDnpdOBjm7XAHAOjSvolHZZAokWPx7CGOw8tKteXmn4WPIInaQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-musl": { + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.75.0.tgz", + "integrity": "sha512-1EVqRIE0eZLB08j3ZCVN47Ors7LCXGmOI+3xJYgyoFUKUerD3Glxb+Q1Bp3lK8NeT5R3ZdS9uhX/wobNBYZ6jw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-s390x-gnu": { + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.75.0.tgz", + "integrity": "sha512-UJWRVNT6Yuw4ssLv2BwaBUWU1GWtByE+EnoHu8I+P1t8Kn+WozYU5WYOqb5aj8gbXJJ8gGZt+w40Hi/7L6iVLw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-gnu": { + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.75.0.tgz", + "integrity": "sha512-FHdvHhyz8ChBctBUvVGti49kSEwANd1PZQwlXUEoF9Ed4plxXxk/wWc0mfZmrze2lRgmME7ytDmVIOixYauQhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-musl": { + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.75.0.tgz", + "integrity": "sha512-6MjtsF96s9mSBV0GEwUe4pOFPP2Mz7ahcHPAit0DSKL5fM6oMM7RG1zvYxwB7+SBkIxsWBRhhxuqmy7pibNwsA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-openharmony-arm64": { + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.75.0.tgz", + "integrity": "sha512-1D6T6aL1Pj+U5GLxqwV1fKjUJ5+FFm+bn7UrqNDMveZmWs0jo/3vc+dzzuV/jF/FdVo12gowODk3SnAO2YOwLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-arm64-msvc": { + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.75.0.tgz", + "integrity": "sha512-RX2eUmy7J/nM8+tnM3nzR8t1eqkwj1q8Kgo7beA+4DVgKYYHHyrEa3qEmthxw7Z+HqpCCEOWuoqa+jelo9/w2Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-ia32-msvc": { + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.75.0.tgz", + "integrity": "sha512-UpKta0iSZmPLII0dYXYdbzdBTsGREUbSHLqgLdtKcqBvs40qjmUz+cgUcaU1smPx13DfbEG4XqnaSn5MoFN0og==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-x64-msvc": { + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.75.0.tgz", + "integrity": "sha512-pjiLiCk0QMCWxs0n5XiNU85YLAKA+wJxPjxlDVygmr4bP4glcfTU2nOpso5nR4Rdi2Pxv5YwakD4UE+eh7M0BQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.90.0.tgz", + "integrity": "sha512-sgfalb1HuxYjiAa4wESZ626z1FGusqNcXYHyB5oIOEprS2sbPg0UYEfo83uF1GNG70DIfZxHkXuE6KFrlSdn8Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.90.0.tgz", + "integrity": "sha512-RcKhz1ETMq+fazE9TKS2+5NIRGwigm/nW8egqF0LI+A8Yw2iW5r0E/C3XAPRiwIm/vcPiutIRIa6IQCIKhZsTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.90.0.tgz", + "integrity": "sha512-PSrxMvP6mx+p1karZcUxLdy9lpaP+JWQPti4al9FgTaPhxE+pT/BjhGExloVVSWgZgIfOlLOjVyyq8BXlodhLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.90.0.tgz", + "integrity": "sha512-OLbU9qsrV2Q8QxT2OEzOPdO8zCCpyI8qB+hDI8yr6RsYELtkf0FAZhJdfjM/RnLHKzt44HWwAPnk92vBtKofvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.90.0.tgz", + "integrity": "sha512-fBA4pWyVDARmNucQqpyFuPC49xYf1r+sX7dxPL5fNPQrSMLum39Sn56tZ9JPleWC7FzLPwSQ7plYYfirYBHF8A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.90.0.tgz", + "integrity": "sha512-zhxH78eP2nR6/pGdhBc7MIfjhhcgATeu8fkmkFJbuzfk6Cj0FzBxyEhL85yDqrCybjx+G2YDzZGsxIO9kvO4Iw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.90.0.tgz", + "integrity": "sha512-MvYmpHyJqUJjEvkZbJHdwfdL45cGn8EVBkYmoXFiaYFW9suoD3dbJl5mBdpy0DVgdjlyddmldbRtk3cCgETadQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.90.0.tgz", + "integrity": "sha512-L13Sy+Kg0nZhwTsToniqjUFPA2TuM6962qCZ5Wp5KeB517Tw7ApdLTTtlb+bF86fcZZa2EtvCVvAJxk4Dya/MQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.90.0.tgz", + "integrity": "sha512-wFDdZWGpzIPFdJFjSLWnKB0TGzFriTKUIrl04iMqMyM9BBIJTuerfCHYa0WpDSjr5x3lcW/CHLyacSt886WpGg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.90.0.tgz", + "integrity": "sha512-FRywfn+MoKn8xpBSzgNwNVliSKRI2QXkxi/iF7o6FEQs8Bh8z2RpVTaFAcO3pchXjvB2eGNFIoLLQABIQ+u7VA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.90.0.tgz", + "integrity": "sha512-IS2PaUgpc/qeLADTyhUNmdx3x4ijs9pjJdPKBYHaT+mAE7k3FsjnHPTjhpsak1b++h6CIPhv6WX9Z+y7Pw0voQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.90.0.tgz", + "integrity": "sha512-s7JFKRkGS8O7i2ZVODfuO++NC7sH6HDqpMn8lYxw91w0SXJOxnAVjQ8utvmDwLMMT0jaSkuOKuUYJDGDtu4sHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.90.0.tgz", + "integrity": "sha512-b0GxrOQr5VONCkd7KLSqmP6b6nqZuTpuS8QPQHzltmqdXP5bY0Jl0CZ0QmlCTrS3ExaLYbioFYOARzNbYvcApQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.90.0.tgz", + "integrity": "sha512-iaJeShJtgUp8rZbt9YUdTAGOpmX5KogCLba17Jp1tWdJ3F4M3D2g2TKUyI1FMTHxKH9NNHuQcuC2kZRg6IV7yQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.90.0.tgz", + "integrity": "sha512-/aZoXnL5WBLCXuzfHqvEFXcm4iwYmr/cYByHDTXzLFzJdE5DC7bm6phpWqHv6BeqpVgY9IpBAxGDv2WEkl5JCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.90.0.tgz", + "integrity": "sha512-SMhk7ozwoxYDKfYqGZCQEfULOKDneF9uhaoIf2neoDLWvNtjY9yVeL7YHmE5L9WdKvMpWNwwZxhenxBu6AgfOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.90.0.tgz", + "integrity": "sha512-KBvR3A9fO7fCi2vCP8ifWvlia7tGEQV9NaYh+IGwa/7Groy48Mt/UaYqMK9/zOtAWl20BUCf6RPqzJ32fRw33Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.90.0.tgz", + "integrity": "sha512-+x42rDHQGbTCuT3EzpWAzhP3NoVPE/cJsG4gnrakOSg0olV+GCdMpPU23Nvrl3zZMnXxFZdTtDv4D3SuaA5ZeQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.90.0.tgz", + "integrity": "sha512-30JiaFEBUB9peEOcvVM8ggo88SoN/IdSr9DW505JdBji/R6dM66gKEiG65hw6qcaF2f4mO0cEJ+RwRZprmun9g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1251,6 +1901,121 @@ "win32" ] }, + "node_modules/@tanstack/history": { + "version": "1.162.5", + "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.162.5.tgz", + "integrity": "sha512-w1+YvwmTkwt5n8yUqEH3IWZ9DsmorCZW/EQ3KYHnqEgIkOZpH54NvW6/Epq3Dzb8H0ifIeYX8PExvrNLIH5FIA==", + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.105.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.105.1.tgz", + "integrity": "sha512-B/rNlZoZ54w1dlddiUt9oEe1SLM+37c00SKLJEg9YH+7as3Y54P81o9Fh+2ujZ36tXPnQJ5oVe7AcyEWkyFhPg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.105.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.105.1.tgz", + "integrity": "sha512-qcu+/+PtBOjW8qU4/0l47QVmNG7hPOWsCLzP0bTRhE1X7HYa757C4732wXTUH0QhCjTV1yedX8onsCiEwlNYrA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.105.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@types/react": "^18 || ^19", + "react": "^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@tanstack/react-router": { + "version": "1.170.46", + "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.170.46.tgz", + "integrity": "sha512-ez1iNIhsPjGlaDHg3BXWs67EWVIYzB2tbopEHYDESdkO7owgkGb5de52+5Kv2v1fxdkggcNzo35bdk4arTVGQw==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.162.5", + "@tanstack/react-store": "^0.11.2", + "@tanstack/router-core": "1.171.39", + "isbot": "^5.1.22" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=18.0.0 || >=19.0.0", + "react-dom": ">=18.0.0 || >=19.0.0" + } + }, + "node_modules/@tanstack/react-store": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.11.2.tgz", + "integrity": "sha512-oj5O5DmiMWE+ih+SioX2ayC1kbK7d3fpLx0B75fPJr9nY3zU+hoW010yr64qXsY/9fyiLVE2bn5mGNV3owm8RQ==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.11.2", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/router-core": { + "version": "1.171.39", + "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.171.39.tgz", + "integrity": "sha512-qUhoJ0meuSUqGYQbHbcJYdASr06YtK+bulbj6vtrhKYFkLgkSsZ6tXmMyym7sFxiSJSPYgasgozrL01LyyR/6A==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.162.5", + "cookie-es": "^3.0.0", + "seroval": "^1.6.7", + "seroval-plugins": "^1.6.7" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/store": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.11.2.tgz", + "integrity": "sha512-sJ4mjol8uQsHV0gOJzzjwXfh2Fwm+Sz0+8deqiTm4jGbMdjzNSW+xZCFm0kUa870uhd8yi+DpKnZb5Kc4apa0Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1390,14 +2155,14 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.31", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -1832,6 +2597,12 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz", + "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", + "license": "MIT" + }, "node_modules/crc-32": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", @@ -2361,6 +3132,15 @@ "node": ">=0.12.0" } }, + "node_modules/isbot": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.2.2.tgz", + "integrity": "sha512-iQcBXcd+Rv/pkubRyGh2utW2j1oPG5hZY6TUhVPpqK4G+o3IbxpJNx04hgksjc/N7GK5pEorUxDeg31cFgEk/w==", + "license": "Unlicense", + "engines": { + "node": ">=18" + } + }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -2578,6 +3358,121 @@ "node": ">= 6" } }, + "node_modules/oxfmt": { + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.75.0.tgz", + "integrity": "sha512-/ZKvY5asfsVi9hdkOmRIumqOPu6kxlTgiJSZf54uq29kKew+Gu1FFAnEkGePyvLVW+MAYu41D6XggnzoElQzPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinypool": "2.2.0" + }, + "bin": { + "oxfmt": "bin/oxfmt" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxfmt/binding-android-arm-eabi": "0.75.0", + "@oxfmt/binding-android-arm64": "0.75.0", + "@oxfmt/binding-darwin-arm64": "0.75.0", + "@oxfmt/binding-darwin-x64": "0.75.0", + "@oxfmt/binding-freebsd-x64": "0.75.0", + "@oxfmt/binding-linux-arm-gnueabihf": "0.75.0", + "@oxfmt/binding-linux-arm-musleabihf": "0.75.0", + "@oxfmt/binding-linux-arm64-gnu": "0.75.0", + "@oxfmt/binding-linux-arm64-musl": "0.75.0", + "@oxfmt/binding-linux-ppc64-gnu": "0.75.0", + "@oxfmt/binding-linux-riscv64-gnu": "0.75.0", + "@oxfmt/binding-linux-riscv64-musl": "0.75.0", + "@oxfmt/binding-linux-s390x-gnu": "0.75.0", + "@oxfmt/binding-linux-x64-gnu": "0.75.0", + "@oxfmt/binding-linux-x64-musl": "0.75.0", + "@oxfmt/binding-openharmony-arm64": "0.75.0", + "@oxfmt/binding-win32-arm64-msvc": "0.75.0", + "@oxfmt/binding-win32-ia32-msvc": "0.75.0", + "@oxfmt/binding-win32-x64-msvc": "0.75.0" + }, + "peerDependencies": { + "@astrojs/compiler-rs": "^0.5.0", + "svelte": "^5.0.0", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "@astrojs/compiler-rs": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/oxfmt/node_modules/tinypool": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.2.0.tgz", + "integrity": "sha512-jBrmx4lYmaC9k/mgPbylxs7kBUxHtD8256up+HjLaDFfXScKJQyil+SWXSvhAtT7XHo+yTVlpB81PGmHP8oLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, + "node_modules/oxlint": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.90.0.tgz", + "integrity": "sha512-RkTN9EmgIUpLuhNoLhO3OliMGJlvb3Dk7XVjBZnfoMCHrVpuz5ESd97klfiyYeV7XfA2lNLnDCPMkUI+Qw5How==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.90.0", + "@oxlint/binding-android-arm64": "1.90.0", + "@oxlint/binding-darwin-arm64": "1.90.0", + "@oxlint/binding-darwin-x64": "1.90.0", + "@oxlint/binding-freebsd-x64": "1.90.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.90.0", + "@oxlint/binding-linux-arm-musleabihf": "1.90.0", + "@oxlint/binding-linux-arm64-gnu": "1.90.0", + "@oxlint/binding-linux-arm64-musl": "1.90.0", + "@oxlint/binding-linux-ppc64-gnu": "1.90.0", + "@oxlint/binding-linux-riscv64-gnu": "1.90.0", + "@oxlint/binding-linux-riscv64-musl": "1.90.0", + "@oxlint/binding-linux-s390x-gnu": "1.90.0", + "@oxlint/binding-linux-x64-gnu": "1.90.0", + "@oxlint/binding-linux-x64-musl": "1.90.0", + "@oxlint/binding-openharmony-arm64": "1.90.0", + "@oxlint/binding-win32-arm64-msvc": "1.90.0", + "@oxlint/binding-win32-ia32-msvc": "1.90.0", + "@oxlint/binding-win32-x64-msvc": "1.90.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2005", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, "node_modules/papaparse": { "version": "5.7.0", "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.7.0.tgz", @@ -3087,6 +3982,27 @@ "semver": "bin/semver.js" } }, + "node_modules/seroval": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.7.6.tgz", + "integrity": "sha512-oKNe6A8I3lSA5J0xg9xsrWCWDpET6CFhCMVuIsCmqrjNpBXzQXDAAQI5UfuC4Ner4DIR+VAtSw7TC0AmRMqFFQ==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/seroval-plugins": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.7.6.tgz", + "integrity": "sha512-/TPCAd6q5stAZnugD2fsvNKtM2UpgwrS8/fO1cXTgPC0EqCsGB+UfkEEsToFCfg35CDTMlWkppfr/zWnLJGpBA==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "seroval": "^1.0" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -3397,6 +4313,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/web/package.json b/web/package.json index 40827e7..942d67b 100644 --- a/web/package.json +++ b/web/package.json @@ -1,15 +1,22 @@ { "name": "finkit-web", - "private": true, "version": "2.0.0", + "private": true, "type": "module", "scripts": { "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", - "test": "vitest run" + "test": "vitest run", + "lint": "oxlint", + "lint:fix": "oxlint --fix", + "format": "oxfmt", + "format:check": "oxfmt --check", + "check": "oxlint && oxfmt --check" }, "dependencies": { + "@tanstack/react-query": "^5.105.1", + "@tanstack/react-router": "^1.170.46", "lucide-react": "^0.577.0", "papaparse": "^5.7.0", "react": "^18.3.1", @@ -24,6 +31,8 @@ "@types/react-dom": "^18.3.7", "@vitejs/plugin-react": "^4.7.0", "autoprefixer": "^10.6.2", + "oxfmt": "^0.75.0", + "oxlint": "^1.90.0", "postcss": "^8.5.32", "tailwindcss": "^3.4.19", "typescript": "~5.6.2", diff --git a/web/public/privacy.html b/web/public/privacy.html index 89238cb..6943650 100644 --- a/web/public/privacy.html +++ b/web/public/privacy.html @@ -25,31 +25,45 @@

FinKit privacy

Last updated: 1 November 2026

-

FinKit is a personal tool that one person uses to look at their own bank accounts. It is not offered to the public and has no other users.

+

FinKit is self-hosted software: the web app and API run on infrastructure you (or your operator) control. This page describes how that deployment handles data. It is written for a personal, non-public instance used by its owner on their own accounts—not a multi-tenant service sold to the public.

-

What FinKit reads

+

No data gathering by FinKit

    -
  • Account names (with only the last digits), balances and transactions of the owner's own accounts.
  • -
  • This happens only after the owner approves access at their bank, through the open-banking service Enable Banking.
  • -
  • FinKit never sees or stores the owner's bank login or password.
  • +
  • FinKit does not include analytics, advertising, crash reporting, or usage telemetry.
  • +
  • There is no FinKit-operated cloud that receives your transactions, categories, or settings.
  • +
  • The application does not phone home to the software authors or any other third party unless you turn on an optional integration listed below.
-

Where the data goes

+

Where your data lives (all self-hosted)

+

“Server” here means the FinKit API you run yourself (Docker container, VPS, or local `npm run dev`)—not a service operated by the FinKit authors. The static web app is served from the same deployment or another host you control.

    -
  • Transactions are shown in the owner's browser. The FinKit server does not keep them.
  • -
  • The server keeps the bank connection itself, encrypted, so it can fetch new transactions later.
  • -
  • The owner's browser may keep a local copy of the transactions so the page can be reopened. It can be switched off and cleared in the app.
  • -
  • Nothing is sold, shared with other people, or used for advertising or profiling.
  • +
  • Your browser — Transaction rows you import or sync are processed and shown here. If you enable “remember session”, a copy can be stored locally in your browser (IndexedDB) so a refresh does not lose work. You can turn that off and clear it in the app.
  • +
  • Your self-hosted API (SQLite file on your disk or Docker volume) — Merchant→category rules, sheet/settings preferences, and (if bank sync is enabled) encrypted tokens needed to refresh bank access. Transaction history is not stored in this database.
  • +
  • Your backups and host — Anyone with access to your machine, server disk, database volume, or browser profile can see what is stored there. Securing that infrastructure is part of self-hosting.
-

Control and deletion

+

What leaves your deployment

+

By default, nothing is exported or uploaded anywhere else. Data only leaves your setup if you configure one of these:

    -
  • A bank connection can be removed in FinKit or in the bank's own app at any time. Removing it in FinKit deletes the stored connection.
  • -
  • Access to each bank expires on its own, after at most 180 days, unless approved again.
  • +
  • Google Sheets (optional) — When you set up a Google service account and export from the app, FinKit sends the summary row you choose to a spreadsheet you control. Google’s terms and privacy policy apply to that copy.
  • +
  • Open Banking (optional) — When bank sync is enabled, your self-hosted API calls Enable Banking and your bank; transactions are returned to your browser. FinKit never receives or stores your bank login password. Enable Banking’s and your bank’s terms apply to that traffic.
  • +
  • Access gate (optional) — Production setups may sit behind Cloudflare Access or similar; that provider processes sign-in metadata according to its own policy.
+

FinKit does not sell, share, or use your financial data for advertising or profiling.

+ +

Bank connections

+
    +
  • Read-only access: account labels, balances, and transactions after you approve access at your bank (via Enable Banking when sync is on).
  • +
  • Connection secrets are encrypted at rest on your server so sync can continue until you disconnect or consent expires.
  • +
  • You can remove a connection in FinKit or revoke it at your bank. Removing it in FinKit deletes the stored connection on your server.
  • +
  • Bank consent expires on its own (typically within the period your bank allows, up to about 180 days) unless renewed.
  • +
+ +

File import

+

CSV/XLSX files you upload are parsed in your browser. Merchant names and categories may be sent to your own API when you save learned rules or settings—not for archiving full transaction history on that machine.

Contact

-

Questions about data protection: samson.negedu.a@gmail.com

+

Questions about this deployment: samson.negedu.a@gmail.com