commit c3cf0f3eb87fa2a7255973ab87c79d48361d01c7 Author: Chris Olson Date: Wed Oct 7 19:52:07 2026 -0400 Milestones 0 and 1: foundation and money in (20261007.10) Foundation: single arm64 container (Hono + SQLite via Drizzle, React + Vite), local sign-in with long-lived sessions and passkeys, roles (read-only, full access, admin), nightly and pre-upgrade backups, light and dark themes, date-based version numbers, and the ideas lightbulb behind IDEAS_ENABLED. Money in: accounts with balances, CSV import with bank detection, a column mapping pop-up for unknown layouts, duplicate checks, transfer pairing and undo; one-tap review with learned rules; activity with search, splits, tags, notes and cash entries. Statement files are blocked from commits by a pre-commit hook; tests use synthetic fixtures only. Co-Authored-By: Claude Opus 5.5 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..daa8fb8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +**/node_modules +**/dist +.git +.npm-cache +data +backups +samples +.env +*.db +*.tsbuildinfo diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..82d861b --- /dev/null +++ b/.env.example @@ -0,0 +1,28 @@ +# Spendlight settings. Copy this file to .env and adjust. + +# The address people use to open Spendlight, through your HTTPS proxy. +# Needed for Face ID / fingerprint sign-in. Example: https://spendlight.example.com +PUBLIC_URL= + +# Port on this server that the proxy forwards to. +HOST_PORT=8095 + +# Household time zone: used for "today", nightly backups and version stamps. +TZ=America/New_York + +# The ideas box (lightbulb at the top right). Handy while building; set to +# false to hide it completely. +IDEAS_ENABLED=true + +# Where the database and the backups live on this server. +DATA_PATH=./data +BACKUP_PATH=./backups + +# Nightly backup time (24-hour clock) and how many copies to keep. +BACKUP_TIME=02:30 +BACKUP_KEEP_DAILY=14 +BACKUP_KEEP_MONTHLY=12 + +# The user and group the app runs as, matching the owner of the folders above. +PUID=1000 +PGID=1000 diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..eeaeb3c --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,14 @@ +#!/bin/sh +# Spendlight safety check: refuse any commit that would include real +# statements, databases or backups. Only made-up test files under a +# fixtures/synthetic/ folder may be CSV, OFX, QFX, QBO or Excel. +staged=$(git diff --cached --name-only --diff-filter=ACMR) +blocked=$(printf '%s\n' "$staged" \ + | grep -E -i '^(samples|data|backups)/|\.(csv|ofx|qfx|qbo|xls|xlsx|sqlite|sqlite3|db)$' \ + | grep -v -E '^samples/README\.md$|(^|/)fixtures/synthetic/' || true) +if [ -n "$blocked" ]; then + echo "Commit blocked: these files look like real financial data." >&2 + printf ' %s\n' $blocked >&2 + echo "Statement files stay on the server. Test files belong in fixtures/synthetic/." >&2 + exit 1 +fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8a23f51 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Real financial data never enters Git +/samples/* +!/samples/README.md +/data/ +/backups/ +*.sqlite +*.sqlite3 +*.db + +# Secrets and local config +.env +.env.* +!.env.example + +# Build output and dependencies +node_modules/ +dist/ +coverage/ +.DS_Store +.npm-cache/ +*.tsbuildinfo diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..a3e79ff --- /dev/null +++ b/.npmrc @@ -0,0 +1,5 @@ +# Every native dependency here (better-sqlite3, argon2, esbuild, sharp) ships +# ready-made binaries, so install scripts are never needed. Turning them off +# keeps installs reproducible (no compiler required, even as root in Docker) +# and stops packages from running their own code, such as telemetry. +ignore-scripts=true diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ac3f23a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,57 @@ +# syntax=docker/dockerfile:1 +# Spendlight: one image, one data folder. Builds for whatever architecture +# the Docker host runs (arm64 on the Pi, amd64 elsewhere). + +FROM node:24-bookworm-slim AS base +WORKDIR /app +ENV npm_config_update_notifier=false \ + npm_config_fund=false \ + npm_config_audit=false + +# All dependencies, for building +FROM base AS deps +COPY package.json package-lock.json .npmrc ./ +COPY shared/package.json shared/ +COPY server/package.json server/ +COPY web/package.json web/ +RUN npm ci + +FROM deps AS build +ARG APP_VERSION=dev +ENV VITE_APP_VERSION=${APP_VERSION} +COPY . . +RUN npm run build + +# Only what the server needs at runtime +FROM base AS prod-deps +COPY package.json package-lock.json .npmrc ./ +COPY shared/package.json shared/ +COPY server/package.json server/ +COPY web/package.json web/ +RUN npm ci --omit=dev --workspace server + +FROM base AS runtime +ARG APP_VERSION=dev +ENV NODE_ENV=production \ + APP_VERSION=${APP_VERSION} \ + PORT=3000 \ + DATA_DIR=/data \ + BACKUP_DIR=/backups \ + WEB_DIST=/app/web/dist +COPY --from=prod-deps /app/node_modules ./node_modules +COPY --from=build /app/server/dist ./server/dist +COPY --from=build /app/server/drizzle ./server/drizzle +COPY --from=build /app/web/dist ./web/dist +COPY deploy/spendlight-cli.sh /usr/local/bin/spendlight +RUN chmod 755 /usr/local/bin/spendlight \ + && mkdir -p /data /backups \ + && chown node:node /data /backups +USER node +EXPOSE 3000 +VOLUME ["/data", "/backups"] +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:3000/api/v1/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" +LABEL org.opencontainers.image.title="Spendlight" \ + org.opencontainers.image.description="Private, self-hosted household budgeting" \ + org.opencontainers.image.version="${APP_VERSION}" +CMD ["node", "server/dist/index.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..c14203a --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# Spendlight + +A private, self-hosted household budgeting app: import bank and card +statements, categorize in a tap, budget by month, and keep an eye on +subscriptions. Runs as a single container on arm64 or amd64. + +Status: planning. The build starts once the plan is approved. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..347ba35 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +20261007.10 diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..edcb688 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,34 @@ +# Spendlight. Start with scripts/deploy.sh, which builds a versioned image +# first. Settings live in .env (copy .env.example to begin). +name: spendlight + +services: + spendlight: + image: spendlight:latest + build: + context: . + args: + APP_VERSION: ${APP_VERSION:-dev} + container_name: spendlight + restart: unless-stopped + env_file: + - path: .env + required: false + environment: + TZ: ${TZ:-America/New_York} + # HTTPS is handled by a reverse proxy (NGINX) in front of the app. + TRUST_PROXY: "true" + ports: + - "${HOST_PORT:-8095}:3000" + volumes: + - ${DATA_PATH:-./data}:/data + - ${BACKUP_PATH:-./backups}:/backups + user: "${PUID:-1000}:${PGID:-1000}" + read_only: true + tmpfs: + - /tmp + mem_limit: 256m + security_opt: + - no-new-privileges:true + cap_drop: + - ALL diff --git a/deploy/nginx.conf.example b/deploy/nginx.conf.example new file mode 100644 index 0000000..d7d0163 --- /dev/null +++ b/deploy/nginx.conf.example @@ -0,0 +1,31 @@ +# Example NGINX site for Spendlight. Spendlight itself speaks plain HTTP on +# HOST_PORT; NGINX adds HTTPS. The forwarded headers matter: Spendlight uses +# them to keep sign-in cookies secure and to verify Face ID sign-in. + +server { + listen 443 ssl; + http2 on; + server_name spendlight.example.com; + + ssl_certificate /etc/ssl/spendlight/fullchain.pem; + ssl_certificate_key /etc/ssl/spendlight/privkey.pem; + + # Statement files are small, but leave room for a year of them at once. + client_max_body_size 25m; + + location / { + proxy_pass http://127.0.0.1:8095; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Real-IP $remote_addr; + } +} + +server { + listen 80; + server_name spendlight.example.com; + return 301 https://$host$request_uri; +} diff --git a/deploy/spendlight-cli.sh b/deploy/spendlight-cli.sh new file mode 100755 index 0000000..adbc034 --- /dev/null +++ b/deploy/spendlight-cli.sh @@ -0,0 +1,5 @@ +#!/bin/sh +# Admin commands inside the container, for example: +# docker compose exec spendlight spendlight ideas +# docker compose exec spendlight spendlight reset-password chris +exec node /app/server/dist/cli.js "$@" diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f24997a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6846 @@ +{ + "name": "spendlight", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "spendlight", + "workspaces": [ + "shared", + "server", + "web" + ], + "engines": { + "node": ">=24" + } + }, + "node_modules/@asteasolutions/zod-to-openapi": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/@asteasolutions/zod-to-openapi/-/zod-to-openapi-9.1.0.tgz", + "integrity": "sha512-pLMeRgRYS7/vZIgAAkOe2P6+XGTifR1MT9pqDoxZ11EmcJJ+DPmdPqLN8ZZF4U8R9KHCCQCS9c2wtuE8wSrfkw==", + "license": "MIT", + "dependencies": { + "openapi3-ts": "^4.6.0" + }, + "peerDependencies": { + "zod": "^4.0.0" + } + }, + "node_modules/@drizzle-team/brocli": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", + "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild-kit/core-utils": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", + "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", + "deprecated": "Merged into tsx: https://tsx.hirok.io", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.18.20", + "source-map-support": "^0.5.21" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "node_modules/@esbuild-kit/esm-loader": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", + "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", + "deprecated": "Merged into tsx: https://tsx.hirok.io", + "dev": true, + "license": "MIT", + "dependencies": { + "@esbuild-kit/core-utils": "^3.3.2", + "get-tsconfig": "^4.7.0" + } + }, + "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==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "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_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==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "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_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==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "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_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==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@fontsource-variable/inter": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz", + "integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@hexagon/base64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==", + "license": "MIT" + }, + "node_modules/@hono/node-server": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.3.tgz", + "integrity": "sha512-TA//nWMqPhbfdfneACk6t5a9eqbS9lABEPyKn0/xZTah3H3U2XaVg85rJFl0/Fyit0I552YDHgXGVSf3GwqbUw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@hono/zod-openapi": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@hono/zod-openapi/-/zod-openapi-1.6.3.tgz", + "integrity": "sha512-VYR82Y60plu8g3pN60OUXMVKpmxh3R04Qs++3DY9vtwZ9/sA/BRY0PcRIR+Z37/3itWtM7pYwT9bKiH9JhqV6Q==", + "license": "MIT", + "dependencies": { + "@asteasolutions/zod-to-openapi": "^9.1.0", + "@hono/zod-validator": "^0.9.1", + "openapi3-ts": "^4.5.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "hono": ">=4.10.0", + "zod": "^4.0.0" + } + }, + "node_modules/@hono/zod-validator": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@hono/zod-validator/-/zod-validator-0.9.1.tgz", + "integrity": "sha512-iiv6w0qrIc0arfvCtUqBWsvl4fXjzaTcQcCJTTtCnkawF9HHGE+KjEl0ox3gQJ6rKZEgE3mLExlQCF72M+mNuw==", + "license": "MIT", + "peerDependencies": { + "hono": ">=4.11.2", + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@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", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@levischuck/tiny-cbor": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz", + "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", + "license": "MIT" + }, + "node_modules/@node-rs/argon2": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2/-/argon2-2.2.2.tgz", + "integrity": "sha512-NtdlPPWtR5U4BxX5EGJYza+UklNaoWORmkaKCMqf/nyKeXWKVwNt8KfQoRpNT7OB1DcEwmePafO4wZyW+sbfCQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@node-rs/argon2-android-arm-eabi": "2.2.2", + "@node-rs/argon2-android-arm64": "2.2.2", + "@node-rs/argon2-darwin-arm64": "2.2.2", + "@node-rs/argon2-darwin-x64": "2.2.2", + "@node-rs/argon2-freebsd-x64": "2.2.2", + "@node-rs/argon2-linux-arm-gnueabihf": "2.2.2", + "@node-rs/argon2-linux-arm64-gnu": "2.2.2", + "@node-rs/argon2-linux-arm64-musl": "2.2.2", + "@node-rs/argon2-linux-x64-gnu": "2.2.2", + "@node-rs/argon2-linux-x64-musl": "2.2.2", + "@node-rs/argon2-win32-arm64-msvc": "2.2.2", + "@node-rs/argon2-win32-ia32-msvc": "2.2.2", + "@node-rs/argon2-win32-x64-msvc": "2.2.2" + } + }, + "node_modules/@node-rs/argon2-android-arm-eabi": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm-eabi/-/argon2-android-arm-eabi-2.2.2.tgz", + "integrity": "sha512-c11BOrPLBWNC1cDyzGB+Bil+flmxfZbB2hv8EhcobmAsFKMyqkHPNhUY4qhcjv1U+4+Vmw4G8H+l224iO2g/Dw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-android-arm64": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm64/-/argon2-android-arm64-2.2.2.tgz", + "integrity": "sha512-dpMYIoxhN/wY6hFH+3MM5kQErcH3luMekkszbkDF3NKqhB1kFRntkOLDpFY87oItBSHJrsoYkXC3vBRhjYoCjQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-darwin-arm64": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-arm64/-/argon2-darwin-arm64-2.2.2.tgz", + "integrity": "sha512-vxVAXcIG0iiHizkCuImmzVhOFsRnU4Z79xiOxdISLyI+QhOAWbbTaVSXw0PNfThB3zAdkzzDJ/WWu52Hs/qHGg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-darwin-x64": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-x64/-/argon2-darwin-x64-2.2.2.tgz", + "integrity": "sha512-r7GbSFpFZZ9EAV9cvIm9prUjOupK7QQXTyP+FJ6bWSMq0h+qRDBlUOXQXduDIIrLEGoBVelixKUtf4ksZ0ljoQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-freebsd-x64": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-freebsd-x64/-/argon2-freebsd-x64-2.2.2.tgz", + "integrity": "sha512-AmWxDSmQgprilD6NX3jmxCgcDvWAW7kGJnnWQo+0qav6nfMXdPZYgSezK/skmw1GBWipr4yZMIUzpeNHnVpMxg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-arm-gnueabihf": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm-gnueabihf/-/argon2-linux-arm-gnueabihf-2.2.2.tgz", + "integrity": "sha512-M1gnPBTr9GFeEUWkQ53qCJeTsX03C3kcUeHnDbN7dU2xkI58wMP4Z1sEBN7WmKJ/fTHYEgti4truz7r4A82DLg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-arm64-gnu": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-gnu/-/argon2-linux-arm64-gnu-2.2.2.tgz", + "integrity": "sha512-4CYDlIdITlLSVcAXYUtEoGUahRaayPXM+4Rcfg3BZx9WGi6UB8HUycs6GNMTPYXV5kVXCpyE2u1HmYUpgmIzOQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-arm64-musl": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-musl/-/argon2-linux-arm64-musl-2.2.2.tgz", + "integrity": "sha512-FYlfj3XgQ1xrx6HzQbKbUcKiYMG/3iSi/X81KyhHaItTJ7vZlMwrbVLl9yqDmvotKUF6VjrhrlZ8KRbbzchseA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-x64-gnu": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-gnu/-/argon2-linux-x64-gnu-2.2.2.tgz", + "integrity": "sha512-ULXLs8UFYZANlwiROM7AeccxZndr2Xk3IstADq3jAUhnwV2W75mxMBTABUZ+7AdKRhPLOrxU77OgnMWlDkYJQg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-x64-musl": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-musl/-/argon2-linux-x64-musl-2.2.2.tgz", + "integrity": "sha512-zgjIwc9TyGodn62vGc+36DGwFqVV5e7L8Ptd2FQWuBxe5aFrcFxvtDGn1/qfYwoHLv09utgtR+RBAPCXepWG3Q==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-win32-arm64-msvc": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-arm64-msvc/-/argon2-win32-arm64-msvc-2.2.2.tgz", + "integrity": "sha512-3nLhvLie/ec5xfxUBXXqGMb7oGeqF+YXrcGckqaTaGkPtOBozho0Kh2IuP/zi9BcOriirTG2LW3gtIOfpuh+mQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-win32-ia32-msvc": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-ia32-msvc/-/argon2-win32-ia32-msvc-2.2.2.tgz", + "integrity": "sha512-6RJKD6cFl7UeiYJLjRsXPywVXSFSaSDdr6xGpztNDV31QHCEecWnsnNmKxzi4gVpln2F9XeWEtIgM4Nqw1hLbA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-win32-x64-msvc": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-x64-msvc/-/argon2-win32-x64-msvc-2.2.2.tgz", + "integrity": "sha512-dQOO9S4QKBaVul0Uyk/2quJc/OBeHNZRJP+GZZTy1A5kYIaAfIJGHUYLY6oFoAlcuLIkdFouLaHX3fCl8tZm/w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.153.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz", + "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@peculiar/asn1-android": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.10.0.tgz", + "integrity": "sha512-IHKdYL5MRW76DOQCv2HSE/vUMOf2jqH4E7yhtQYe/xx+RxpPiXfkUdhex0N4/s5crsnXCGftRNzy4RJn1s6CZA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.10.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-asym-key": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-asym-key/-/asn1-asym-key-2.10.0.tgz", + "integrity": "sha512-1R8xUTeqcUTSMLFTTCzJyFuKiq3y25EQKs9DezUetJ8qMqbJyxo3XkzykiwwT/Xzlt5/vRzS/sW4M1r+pq1FTg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-pkcs8": "^2.10.0", + "@peculiar/asn1-schema": "^2.10.0", + "@peculiar/asn1-x509": "^2.10.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.10.0.tgz", + "integrity": "sha512-CkX0H4NCIOMHOU3rh2xXZywinYZ/EnJlaOlJiGI0e5L4XjFqHf4iH30LMbXWChVppdA9ljbanjtzQFOhDNfTWg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.10.0", + "@peculiar/asn1-x509": "^2.10.0", + "@peculiar/asn1-x509-attr": "^2.10.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.10.0.tgz", + "integrity": "sha512-jTPTr/9rxKM+niQLMF3jiAMb0lWHUhUHIuSOhdGCIfpB4FAQYf9SoiXOgxP8bS/68IB+bj+1OHvVnDVCQfVUZw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.10.0", + "@peculiar/asn1-x509": "^2.10.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.10.0.tgz", + "integrity": "sha512-GFd3iOjFrWX+QWH2R2dO5QSJyyRGv9CIBKtRlGlPNCvb2RvmCbBDexe91MJgV76c69d998Xsa+CvuQ98seoiPg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.10.0", + "@peculiar/asn1-x509": "^2.10.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.10.0.tgz", + "integrity": "sha512-1y3QK9ZH1IPleAMmRoJlPS10eGkAWnULETW8zDFNUK1ffqpG7zmDY+kYBMYQgwlcQZ2iitLm2z2EBP0xzRXpaA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.10.0", + "@peculiar/asn1-pkcs8": "^2.10.0", + "@peculiar/asn1-rsa": "^2.10.0", + "@peculiar/asn1-schema": "^2.10.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.10.0.tgz", + "integrity": "sha512-Ri+BZT9bnwqlHWmhs7lvWjvJRxKev2hA2+4EbZajgeYWCbR8hyv0znF4DhsFouOhMj2nSDV/iGJ6DMQtwITnCw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.10.0", + "@peculiar/asn1-x509": "^2.10.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.10.0.tgz", + "integrity": "sha512-XIXsbDQFezYk6fudczkuvawkRD4GNpISGCqfYhdkJlvcGF/RFknU+0DDJagOaJqBf+PdPzk4J9oMqDGcvfR9HQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.10.0", + "@peculiar/asn1-pfx": "^2.10.0", + "@peculiar/asn1-pkcs8": "^2.10.0", + "@peculiar/asn1-schema": "^2.10.0", + "@peculiar/asn1-x509": "^2.10.0", + "@peculiar/asn1-x509-attr": "^2.10.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.10.0.tgz", + "integrity": "sha512-4Jvmwlh3gZAhNZ4/u7JSyLuce9hofOFZ4o3PYKAccCImGnyaT5CMym/ATgvAvqpOYFNW531oPZvJlEBhJy9VfA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.10.0", + "@peculiar/asn1-x509": "^2.10.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.10.0.tgz", + "integrity": "sha512-GhokD41lV4gQrrLYm3wCkHfBOnJrnhDMgt4XeMW8gzfE1UdJqIuSwsE+ggf82XBjUXRJylcm+KIGQFa4utIVLw==", + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.10.0.tgz", + "integrity": "sha512-ucNVg8+ANveTpMN3fy9lA2alryONdXc2A4cEG2hMniWbvQt+YOZoe8BI80YYNO8FBcuDY1qbDQ4uQGQVraHxGA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.10.0", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.10.0.tgz", + "integrity": "sha512-/85GtKOKmgvuSJNlaFfwGWNdRSZZ+hpF02NyM01XiCdzpaZONiBltDyfluFPvFx966CR+ZHNSG1jniwpy07oGg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.10.0", + "@peculiar/asn1-x509": "^2.10.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509-post-quantum": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-post-quantum/-/asn1-x509-post-quantum-2.10.0.tgz", + "integrity": "sha512-cXKcZ30wKbUkRK5RK4Z3W1imewXvHAB9apEAuIqJ0OOkOQCpJrwLOuN38QlsEXqOI+3gtUv8Kf3UZohUmCbf3w==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-asym-key": "^2.10.0", + "@peculiar/asn1-schema": "^2.10.0", + "@peculiar/asn1-x509": "^2.10.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-2.1.0.tgz", + "integrity": "sha512-IYbg1R03CSQGWwl24kGyqrdVtixNSbRaDvBg1r5wyYjTP+VwPQkka1BzTgU5+vxiuwqg04OxdvdJ1xYYFIdUSA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.9.4", + "@peculiar/asn1-csr": "^2.9.4", + "@peculiar/asn1-ecc": "^2.9.4", + "@peculiar/asn1-pkcs9": "^2.9.4", + "@peculiar/asn1-rsa": "^2.9.4", + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "@peculiar/asn1-x509-post-quantum": "^2.9.4", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz", + "integrity": "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", + "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-accessible-icon": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.16.tgz", + "integrity": "sha512-I+T/Hc6SY3WbFVZ9Ne1o0DJbpjQpTpLIBZcVzYiHCQ9z1yGUd/G88u5SKyM6eJ223txAOS7bp7bb9k0Ocfkjwg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-visually-hidden": "1.2.12" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.21", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.21.tgz", + "integrity": "sha512-2fmc6COuKLq2k3P7JZrOt9Zf8HT9nTQhHN5V+A3ih7ftqoyDcoEUrPoLq8ngr7QwTd7hpQ2xoUKWtvy1Xw9RAQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collapsible": "1.1.21", + "@radix-ui/react-collection": "1.1.16", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.5", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.24.tgz", + "integrity": "sha512-dRvB59HhcjIWcCPKzK5Px1yQ8pIULQQrxqgyepCqin7tNXi/xvmC86WkyMOKhuCbjz3+v9Q6oE7/tUN5NQ9dCg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dialog": "1.2.0", + "@radix-ui/react-primitive": "2.1.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.16.tgz", + "integrity": "sha512-nXPI1tzJsnQw8oXvB2r49rO9TIbbJ8VFeaWDiBsqY8+iwO5xb7WjUsrXDagjAgPKTdhW8WODQnAltUhBx8I+sg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-aspect-ratio": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.16.tgz", + "integrity": "sha512-w2u6oiTzb7WkWwV3ZClX/llYYlTbilUOkHIKkwb+PWWytQ/IdEVRNLIChjbcXy/mkqcKKM3wEhrOilJW+uPHDg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.2.7.tgz", + "integrity": "sha512-6AizCpbU3Zngojp9vQak8Lt4AXwdKJ2lkRYAfpoETraT+qKVNeRqytiADzneHetFEB0Wu4+qeSfmZEq0PiVDmQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.12.tgz", + "integrity": "sha512-gNOS2lLrpQxNLWHhCX0sayfVRiyB5gQp7nQIzpl+PM1Hkab52utsKtD27alAr78uqhpvX51kwqDp71hM5hNSwg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-size": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.21.tgz", + "integrity": "sha512-IzkusZHCZBnED5skW+b+syl8d+e3+zelvxZ2Ed9lfawqEBfa5osPj53sTJ+q/uNmGAj9e7wMYgyzwPu/luWJ4Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.16.tgz", + "integrity": "sha512-yEQrjfI5eBbeYEJXOASpm+gs9eNHY8+UjkZ81GzcuwdLrSMOlEM8jnGoGdeGo92oiBMkuwZUhHTsnsrDiJLcyA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-slot": "1.4.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz", + "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.3.8.tgz", + "integrity": "sha512-s8ABBkEuAOyhDub0Y99f8lVRlVsd28mUnOHtuqGg8LuHnefkmPN5rYzA2g8MAPLjAZMisnDHhN4dBHUM7ZF7Rg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-menu": "2.1.25", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.2.0.tgz", + "integrity": "sha512-l5CX8HdNL+9cc4s7HkNyplumSA3FIx68sg9dttBHKr286fRBwq1MBniDVTeI/tkof92/APntMQvC1YG1Khb0Cg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.20", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.2.0", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-portal": "1.1.18", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-slot": "1.4.0", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.5.tgz", + "integrity": "sha512-/NlO/rOqQabmg+GGr86vCmbJVhqTv85NfM9g6Gknf/JVygXeZ6e6HM/RW1omerW9HsFtecwNqgEAKV6UqDsvpA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.20.tgz", + "integrity": "sha512-35WHRrycfnj2m4LvPiKMKHGLM3Y2WvUA744CkRdxjs1BGDX1c8PVMfmiSGTUwnHRbqdtIoxveYTXvcJSt7VfgQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-effect-event": "0.0.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.25", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.25.tgz", + "integrity": "sha512-45QO76jn/a+cRl6OPgZKCKnIMdfYZaOa6SuAJeKHFiM3orX6bnscuFQJDx3qS4Agp/RE+eKv+9gmUL8t3Ma7bw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-menu": "2.1.25", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz", + "integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.2.0.tgz", + "integrity": "sha512-I5S7vsAOozx2lWSBAyuXjWIJ/A8qx4fjbv6115WoaRYQSb1yZDaeNHonUAQzXyE8oD+0whJHbQRl4HIE2gQ0Xw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-use-callback-ref": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-form": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.2.0.tgz", + "integrity": "sha512-5ELeC+bQUHXEjU51svw11DynjGFlaUjo5QOUCr5hptvLLtBiqWtva2O3IWdjrFWQwitw6k6KpHHEyI61/kCVeg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-label": "2.1.16", + "@radix-ui/react-primitive": "2.1.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.24.tgz", + "integrity": "sha512-0w/tZHRo1fv4WsfcyaOdi5eDftFA8wQkr8WCOOuxw7fx9S2ERfshQioM269aFZsmyAHd0X1LWoZ2BQTlA/JYUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.20", + "@radix-ui/react-focus-scope": "1.2.0", + "@radix-ui/react-popper": "1.3.8", + "@radix-ui/react-portal": "1.1.18", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", + "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.16.tgz", + "integrity": "sha512-2vTQj72YFmeoTxWKJEMbO/9uQt6zzLgbzi0+nWBffq49oo6KhIDP/D+fA2im0wsxBCAX7QfaF3xgsYBVdDbNFQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.25", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.25.tgz", + "integrity": "sha512-e8JaQc0ijBbTx37YVI09Nfq8ThUWJ4L1rvZ3Hvaufehco9/ug9+U3PHz9ue6w3gKd91UcKf9fgDWejeSO+VSKQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.16", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.5", + "@radix-ui/react-dismissable-layer": "1.1.20", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.2.0", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.8", + "@radix-ui/react-portal": "1.1.18", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-roving-focus": "1.1.20", + "@radix-ui/react-slot": "1.4.0", + "@radix-ui/react-use-callback-ref": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menubar": { + "version": "1.1.25", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.25.tgz", + "integrity": "sha512-JIQiKcXsgl6BtwcMe7BMLvCGH2p9Czgcmxl6Ie9Zoi+MW1mmitkcm5We01m/ndLYVI8PpRJnM/RcLV0eOCxaJQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.16", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.5", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-menu": "2.1.25", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-roving-focus": "1.1.20", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.3.0.tgz", + "integrity": "sha512-hZqTqDwc8Z5aI4rGIyPqCSJ2JoSNorqX/F1/gqNHkZzWJO6JuYzL7hn5sWOZJjpkfT+cbxWWRbKVU/IeiOM9ag==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.16", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.5", + "@radix-ui/react-dismissable-layer": "1.1.20", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-slot": "1.4.0", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.12" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-one-time-password-field": { + "version": "0.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.17.tgz", + "integrity": "sha512-+6jmOqjSNBr7F1uSc7HSDqD3XL54A2qA99I9rIH4JoOD/jDScw8tUmDz6HCAkjytbrE/cdL66i+QkeDyZ0WNyw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.16", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.5", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-roving-focus": "1.1.20", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-password-toggle-field": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.12.tgz", + "integrity": "sha512-+SWSgf3HRpE4zUira+p6t5KUR473PSqewtQgj9qzl3znZPRWPCGycwXNTfHwkiRTD3tEXwNIXJg0i1NTteiULw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-is-hydrated": "0.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.2.0.tgz", + "integrity": "sha512-e70Nh+0iQ+hRAIM2nAGib5BhaDL2Rjlzxw9kIzsU2nz+t3/t82Z5qtPhY5nTnfj8FbmrU6RRIKH8RF3ynCe+vw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.20", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.2.0", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.8", + "@radix-ui/react-portal": "1.1.18", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-slot": "1.4.0", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.8.tgz", + "integrity": "sha512-y92/9iIdG1FyNvow0Y0qP5WJZ7oRTAKDVKCy9kJnhZfItwIKYtt0KKQeOhBKfXz0ETizduKwDRbrQHrTQScs5A==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.16", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-rect": "1.1.4", + "@radix-ui/react-use-size": "1.1.5", + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.18.tgz", + "integrity": "sha512-ygFIJ+QXNKf72AAq8x3hd22+ws7pYpmc1S0E4kW0Je+qD4qEb3FvxeKVQRGrVDMOkQaYfsB+Qs+KDh81a1gAOw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.11.tgz", + "integrity": "sha512-Z7/atlkab36Z8hm+V8zYc79r0W+tiOB1o0tun1By94JyadkekLQmtEAnBSP9XKXBNQsStoStS4+gX10JpS5akA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.11.tgz", + "integrity": "sha512-RzcaSw5aKl0OPhBJ1wbolKSF6nVvN+0o/QLeIVq+09npiPdnCkPggy2k6gI6yetxl4ZyrlBPYFgCB8BQJZwXcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.4.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.17.tgz", + "integrity": "sha512-A+RfMARd9u+UaVbLQDmysGlAGuaFQrF+uxF3v+7+xU8tkKoYxtCSs+6HHnIKJVgam2sGZ7c0hLRPWSnmnFyGJQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.4.8.tgz", + "integrity": "sha512-MaOOZ1TlaYoOeSDnHeVIONss0rp5ngOTY5HJY+Xkq16PAopnj21iU9r9m0O/77cBYu8Ksaq3BcjlaOdpbfbzzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.5", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-roving-focus": "1.1.20", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-size": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.20.tgz", + "integrity": "sha512-+ZjbkU21fTSJdn4nq/IFKUdKS+JSZHORBZ8ma1f6YpOMNzRt0xf3sVAWVK/+ktHCajPy4HYDsyHXZIZmOMxzIA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.16", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.5", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.3.0.tgz", + "integrity": "sha512-1WSuAXaahfeLmFX/dfYo/ftg1filMfaWjVye2d40VlgtWMMq/NOVocfukZEXkG+dwrAoNI+qpo9bjhNSJ/XC8g==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.5", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-slot": "1.4.0", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.8.tgz", + "integrity": "sha512-Tz9xOpV9Rbca5WTk7pzmfAECV3zb++uScT9mP3Px7ziAhH7SlK53b0uwMw8VubPhRK4x7uDKCy5fwevT8I0fAg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.16", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.5", + "@radix-ui/react-dismissable-layer": "1.1.20", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.2.0", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.8", + "@radix-ui/react-portal": "1.1.18", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-slot": "1.4.0", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.12", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.16.tgz", + "integrity": "sha512-vugPjGQBXbJVVkftKqv/BVeZ108SyRGT3FR1oa14az6qyLgX8jPhnZYHgdSB7Gw8UJusqwb9eaK6mluOnsn/Vg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.5.0.tgz", + "integrity": "sha512-ZOBLK7KZvGL02V+4VwVbj8NYM4vPrAtyZdrgHSv+AMS9VL+5yAdnQF3DZT4Y8cbCp1Yn4xdp92orvez8fJa77w==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.16", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.5", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-use-size": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.4.0.tgz", + "integrity": "sha512-IuMVHZ+Ah5fkctu3X3qeFrV/ZrBcuWuDTFUUPfS/L5hvoTjzhp/HdWGE14CsQombPdEidsa33HVlYZXi49PZ1Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.3.8.tgz", + "integrity": "sha512-rr+zuoAeHWK3m6H4HgjOUiu3VsrQ/9JkjwY/uMnTgHJGPe9WNzDP/MOuIV7S+65p42DIxjGL0hAHktqBq82+2A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-size": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.22.tgz", + "integrity": "sha512-GmVH+P3wx1ZPMuH7WajQzygZIbqgnsb5h8z4Ow3sQMuDqgcqISSBNom6MLVNJvfrkuJTtqfXc3ZolHjX+w0CHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.5", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-roving-focus": "1.1.20", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.24.tgz", + "integrity": "sha512-XR9pDEz9CH0ez98zPXQZOpF46anvbarkoOpjXpPSX7gm21vEl5Tzoe1PSQd3EY1OTOHac4lNq+TPx/2Z7jObdQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.16", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.20", + "@radix-ui/react-portal": "1.1.18", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.12" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.19.tgz", + "integrity": "sha512-l7FQHtuZjUJ0vKLdhKu5LzZ8Jw6K60QEC1tC22QNLuXN2PkydSoqEhKTCqwxBFZJ52NTCjzudDvl9sUsZeUbPw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.20.tgz", + "integrity": "sha512-O+Sjiv64DfnByHYtnKEXmn63uUskU/X61vjf69X1v8JFn/ZvuNNLdJTBfYurVTSDv4/RJnJGtGgb/uvuS/rRJw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.5", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-roving-focus": "1.1.20", + "@radix-ui/react-toggle": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toolbar": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.20.tgz", + "integrity": "sha512-5gyzL6B5/zjBwU3ATBWAVZ4xXQC5F8E9CB4BmYC1UvZ08pN2OJQoooeIwJtZZ/2rz4sTiQmbGfdDvmU73wRJCQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.5", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-roving-focus": "1.1.20", + "@radix-ui/react-separator": "1.1.16", + "@radix-ui/react-toggle-group": "1.1.20" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.3.0.tgz", + "integrity": "sha512-4HM4b1Cft1CBXCGDRDG3S/fh8GedBuVOMNh145wMIfTbVqcuv+tyj03nZqYgFevWJMYo58H0s+8P6s6NaUHWtA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.20", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.8", + "@radix-ui/react-portal": "1.1.18", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-slot": "1.4.0", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.12" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", + "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz", + "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", + "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.5.tgz", + "integrity": "sha512-ge3ipobwSXTj4JyVtswQ7qZj0ZHdtbGuOno/LrgAAeSxtsJ6Vs4Gz5IkPH2bmqpjcLUFoqGhA/mueuIf63UXlA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.3.tgz", + "integrity": "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", + "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.4.tgz", + "integrity": "sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.4.tgz", + "integrity": "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.5.tgz", + "integrity": "sha512-V1McDQkMGzgivwqdu7u96APs7oi0Jvj9LTzgq3WWrDZqxWdAqU9yZnZQqk5OizGFR8badUQwZsrlao7MOlWYOA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.12.tgz", + "integrity": "sha512-/iPLVHRBzKWvekN+5xaDmXw80f+nleYYceomVORrGjuJe0LF2SCbSH4f/LG4hu32q92QSuPZx4hmSACYBVBfzQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.3.tgz", + "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", + "license": "MIT" + }, + "node_modules/@remix-run/route-pattern": { + "version": "0.22.1", + "resolved": "https://registry.npmjs.org/@remix-run/route-pattern/-/route-pattern-0.22.1.tgz", + "integrity": "sha512-czdGJWh09Lapvcqt7Vb09KlrU2PhRJ8xQaI+AItTuD9aDJ5MAgxnS38lnys6Ll+6RJEqPiaUqTwIQhVLwFvv+w==", + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", + "integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz", + "integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz", + "integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz", + "integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz", + "integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz", + "integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz", + "integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz", + "integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz", + "integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz", + "integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz", + "integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz", + "integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz", + "integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz", + "integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz", + "integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==", + "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.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, + "node_modules/@simplewebauthn/browser": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-14.0.0.tgz", + "integrity": "sha512-1odWVqeEBTl7lJ9zMKLEsmTlnyrDO5iRcTvfMKKk1WThUnp/i8JJdffdj2icP+tty159s4PgwE3BiMoEW9NFow==", + "license": "MIT" + }, + "node_modules/@simplewebauthn/server": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-14.0.3.tgz", + "integrity": "sha512-D39/0fiDiYmGV+nraY9s63D1Ho2AX/waMLy22/9gUadTUuvbAnstfb4bpbHYbwZuV5ZLDYN1SmzDKuiHzKAA4Q==", + "license": "MIT", + "dependencies": { + "@hexagon/base64": "^1.1.27", + "@levischuck/tiny-cbor": "^0.2.2", + "@peculiar/asn1-android": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.1", + "@peculiar/asn1-rsa": "^2.6.1", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/asn1-x509-post-quantum": "^2.9.4", + "@peculiar/x509": "^2.1.0", + "reflect-metadata": "^0.2.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@spendlight/server": { + "resolved": "server", + "link": true + }, + "node_modules/@spendlight/shared": { + "resolved": "shared", + "link": true + }, + "node_modules/@spendlight/web": { + "resolved": "web", + "link": true + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.104.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.1.tgz", + "integrity": "sha512-TiRghcrGkUTE+M4JBxvpPSpBhxvIzQairw+cksrHYGr+2uwC77yW1SY9nYxnYRYOppvucf8rEg6b5byoz4rTpw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.104.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.104.1.tgz", + "integrity": "sha512-Zz70EgjahNI7aCz8BZxM3vUGU44ycxEAzMuCUjLeA7SVhhOkg3ZgPjPJPvHjs6Rmky/dtmnu1WzjGHNHmhCoZQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.104.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/better-sqlite3": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-9.6.0.tgz", + "integrity": "sha512-ZEEwBSgMu7GYJOynoagg5X9JbxfL6dTJDsgViJIqh67jV44kyOr9RXfmFjLK5rzC4MWssP06t9hu/JwGDnUbCg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.1.tgz", + "integrity": "sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/papaparse": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz", + "integrity": "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.2.tgz", + "integrity": "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.152.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", + "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.3", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz", + "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/better-sqlite3": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", + "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/drizzle-kit": { + "version": "0.31.11", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.11.tgz", + "integrity": "sha512-YCYqxTLIB2OCqf6w9/Vef13baBVbwQIPcbT4Y56AfO6B9ajZhDhD8Jkveg/hJvT/iuMloKD/IYYkyMMNhr7Kqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@drizzle-team/brocli": "^0.10.2", + "@esbuild-kit/esm-loader": "^2.5.5", + "esbuild": "^0.25.4", + "tsx": "^4.21.0" + }, + "bin": { + "drizzle-kit": "bin.cjs" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/drizzle-orm": { + "version": "0.45.3", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.3.tgz", + "integrity": "sha512-CAloER21cDdcgZ1OmjrZX82EeRNsdP+y0onX/eyoYDaYzki/adrAa91lzM5jWh7zZPK4qUbw/6LGv9J+A+uizA==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=4", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", + "@netlify/db": ">=0.4.0", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1.13", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=14.0.0", + "gel": ">=2", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@netlify/db": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "gel": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + }, + "node_modules/enhanced-resolve": { + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz", + "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "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/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/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/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.3.tgz", + "integrity": "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/hono": { + "version": "4.13.13", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz", + "integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lucide-react": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.52.0.tgz", + "integrity": "sha512-TUgPtl5ZI9WgxOcbu4ckaC5B3l1qxPQrCMgb6OfUB9owx/OvSJfcf93SMgUWAxRw5/1XDfh9uI5F5iNGXCcGDQ==", + "license": "ISC", + "peerDependencies": { + "@types/react": "*", + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/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/node-addon-api": { + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/openapi3-ts": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-4.6.1.tgz", + "integrity": "sha512-XW9MOldkhoICNeXVzzmXzmOW5G73ppOEGmh7fLCqHjgfdEYCGGN+00MlVCeUZgovjjfC56j9tvtDt1zGabNjjA==", + "license": "MIT", + "dependencies": { + "yaml": "^2.9.0" + } + }, + "node_modules/papaparse": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.7.0.tgz", + "integrity": "sha512-qBGxg/7Q3Kl9Wfhrz2Z74UnvnHTXLNG6jmKJFeBvP2+y4lV7So+7SR62+Zd47JvdrCkX+nDcnr0ObPzek/+6RA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.19", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz", + "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/radix-ui": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.7.0.tgz", + "integrity": "sha512-x+z5TV2v6u0QpAQ4dQfNMcvtIdSQ1Op2oqBEyfDXjNldRZbMTP+hcjr5Iie31bep81Axeioa+70Ww7pZDzTEQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-accessible-icon": "1.1.16", + "@radix-ui/react-accordion": "1.2.21", + "@radix-ui/react-alert-dialog": "1.1.24", + "@radix-ui/react-arrow": "1.1.16", + "@radix-ui/react-aspect-ratio": "1.1.16", + "@radix-ui/react-avatar": "1.2.7", + "@radix-ui/react-checkbox": "1.3.12", + "@radix-ui/react-collapsible": "1.1.21", + "@radix-ui/react-collection": "1.1.16", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-context-menu": "2.3.8", + "@radix-ui/react-dialog": "1.2.0", + "@radix-ui/react-direction": "1.1.5", + "@radix-ui/react-dismissable-layer": "1.1.20", + "@radix-ui/react-dropdown-menu": "2.1.25", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.2.0", + "@radix-ui/react-form": "0.2.0", + "@radix-ui/react-hover-card": "1.1.24", + "@radix-ui/react-label": "2.1.16", + "@radix-ui/react-menu": "2.1.25", + "@radix-ui/react-menubar": "1.1.25", + "@radix-ui/react-navigation-menu": "1.3.0", + "@radix-ui/react-one-time-password-field": "0.1.17", + "@radix-ui/react-password-toggle-field": "0.1.12", + "@radix-ui/react-popover": "1.2.0", + "@radix-ui/react-popper": "1.3.8", + "@radix-ui/react-portal": "1.1.18", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-progress": "1.1.17", + "@radix-ui/react-radio-group": "1.4.8", + "@radix-ui/react-roving-focus": "1.1.20", + "@radix-ui/react-scroll-area": "1.3.0", + "@radix-ui/react-select": "2.3.8", + "@radix-ui/react-separator": "1.1.16", + "@radix-ui/react-slider": "1.5.0", + "@radix-ui/react-slot": "1.4.0", + "@radix-ui/react-switch": "1.3.8", + "@radix-ui/react-tabs": "1.1.22", + "@radix-ui/react-toast": "1.2.24", + "@radix-ui/react-toggle": "1.1.19", + "@radix-ui/react-toggle-group": "1.1.20", + "@radix-ui/react-toolbar": "1.1.20", + "@radix-ui/react-tooltip": "1.3.0", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-escape-keydown": "1.1.5", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-size": "1.1.5", + "@radix-ui/react-visually-hidden": "1.2.12" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-router": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-8.4.0.tgz", + "integrity": "sha512-JtydAkBvU9UTEe5qNC+POhu+ZBNM7rlKY2IegwXc7Idj7xfRG16x5RZrw3mju+XlqBxfvb2ky9P3jUp9WyWC1g==", + "license": "MIT", + "dependencies": { + "@remix-run/route-pattern": "^0.22.1", + "cookie-es": "^3.1.1" + }, + "engines": { + "node": ">=22.22.0" + }, + "peerDependencies": { + "react": ">=19.2.7", + "react-dom": ">=19.2.7" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "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/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/rolldown": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz", + "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.153.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.13", + "@rolldown/binding-android-arm64": "1.2.13", + "@rolldown/binding-darwin-arm64": "1.2.13", + "@rolldown/binding-darwin-x64": "1.2.13", + "@rolldown/binding-freebsd-x64": "1.2.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.13", + "@rolldown/binding-linux-arm64-gnu": "1.2.13", + "@rolldown/binding-linux-arm64-musl": "1.2.13", + "@rolldown/binding-linux-ppc64-gnu": "1.2.13", + "@rolldown/binding-linux-s390x-gnu": "1.2.13", + "@rolldown/binding-linux-x64-gnu": "1.2.13", + "@rolldown/binding-linux-x64-musl": "1.2.13", + "@rolldown/binding-openharmony-arm64": "1.2.13", + "@rolldown/binding-win32-arm64-msvc": "1.2.13", + "@rolldown/binding-win32-x64-msvc": "1.2.13" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/sonner": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.8.tgz", + "integrity": "sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "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/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/swagger-ui-dist": { + "version": "5.33.1", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.33.1.tgz", + "integrity": "sha512-H872wWkA53bFIsGgi7OWgmq+CRWw3nFQGdJWRqOB9wNwTm6e5ol34+qPDkV4AJlK+gglM2EsJiOOzsGsEGbluA==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/tailwind-merge": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.7.0.tgz", + "integrity": "sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinybench": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.1.tgz", + "integrity": "sha512-IAbQaPJIIdhVxi0mqy9lez1wEwje0UQf9F1vwtbelEG5Nbgn0nPOxCvU+mNeNS9gyJTAhrHp74CbUaSynQpZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "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/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "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/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/vite": { + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.3.tgz", + "integrity": "sha512-cTAldKPImjg6c+gk48U19POPn3GCBzZwpdsN8ZMEEcbpes+6/wvfqUd0C2y3qYY4wsj8PwgFwrZ/1jBPVttMSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.11", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vitest": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", + "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.3", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "^6.1.4", + "tinyexec": "^1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.3", + "@vitest/browser-preview": "5.0.3", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.3", + "@vitest/coverage-v8": "5.0.3", + "@vitest/ui": "5.0.3", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "server": { + "name": "@spendlight/server", + "dependencies": { + "@hono/node-server": "^2.1.3", + "@hono/zod-openapi": "^1.6.3", + "@node-rs/argon2": "^2.2.2", + "@simplewebauthn/server": "^14.0.3", + "@spendlight/shared": "*", + "better-sqlite3": "^13.0.3", + "drizzle-orm": "^0.45.3", + "hono": "^4.13.13", + "papaparse": "^5.7.0", + "swagger-ui-dist": "^5.33.1", + "zod": "^4.6.5" + }, + "devDependencies": { + "@types/better-sqlite3": "^9.6.0", + "@types/node": "^24.0.0", + "@types/papaparse": "^5.5.2", + "drizzle-kit": "^0.31.11", + "esbuild": "^0.28.2", + "tsx": "^4.23.15", + "typescript": "~6.0.2", + "vitest": "^5.0.3" + } + }, + "shared": { + "name": "@spendlight/shared" + }, + "web": { + "name": "@spendlight/web", + "dependencies": { + "@fontsource-variable/inter": "^5.3.0", + "@simplewebauthn/browser": "^14.0.0", + "@spendlight/shared": "*", + "@tanstack/react-query": "^5.104.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.52.0", + "radix-ui": "^1.7.0", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "react-router": "^8.4.0", + "sonner": "^2.0.8", + "tailwind-merge": "^3.7.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^6.1.2", + "sharp": "^0.35.5", + "tailwindcss": "^4.3.3", + "typescript": "~6.0.2", + "vite": "^8.3.3" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b7b101a --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "spendlight", + "private": true, + "type": "module", + "workspaces": [ + "shared", + "server", + "web" + ], + "scripts": { + "dev:server": "npm run dev -w server", + "dev:web": "npm run dev -w web", + "build": "npm run build -w web && npm run build -w server", + "typecheck": "npm run typecheck -w shared -w server -w web", + "test": "npm test -w server", + "db:generate": "npm run db:generate -w server", + "icons": "npm run icons -w web" + }, + "engines": { + "node": ">=24" + } +} diff --git a/samples/README.md b/samples/README.md new file mode 100644 index 0000000..47ce499 --- /dev/null +++ b/samples/README.md @@ -0,0 +1,18 @@ +# Statement samples + +Drop your bank and card exports here, exactly as downloaded. + +1. Make one folder per account, named however you like, e.g. `bofa-checking`, + `bofa-savings`, `discover-1`, `discover-2`, `chase-prime-visa`. +2. Put that account's export in its folder untouched: same file name, same + contents. Two or three months per account is plenty. +3. That's it. Tell Claude when they're in place. + +How these files are protected: + +- Git ignores everything in this folder except this note, and a pre-commit + check blocks statement files anywhere in the project. +- Only the `pi` user can open this folder. +- Claude never opens the raw files. A script on this server turns each one + into a masked profile (column headers, date and amount formats, value + shapes such as `Aaaaaa #999` and `-99.99`), and Claude works from that. diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..bc23c17 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Build the Spendlight container image and stamp it with the next version. +# The VERSION file is only updated once the build succeeds. +set -euo pipefail +cd "$(dirname "$0")/.." + +version="$(scripts/next-version.sh)" +echo "Building Spendlight ${version}" + +docker build \ + --build-arg APP_VERSION="${version}" \ + --label org.opencontainers.image.version="${version}" \ + -t "spendlight:${version}" \ + -t spendlight:latest \ + . + +echo "${version}" > VERSION +echo "Built spendlight:${version}" diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..3d541bc --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Build a new version and (re)start Spendlight with it. +set -euo pipefail +cd "$(dirname "$0")/.." + +if [ ! -f .env ]; then + cp .env.example .env + echo "Created .env from .env.example; review it, then run this again if needed." +fi + +# Read the folder settings so the bind mounts exist and belong to this user +# (otherwise Docker creates them as root and the app can't write). +set -a; . ./.env; set +a +mkdir -p "${DATA_PATH:-./data}" "${BACKUP_PATH:-./backups}" + +scripts/build.sh +docker compose up -d +echo "Spendlight $(cat VERSION) is starting on port ${HOST_PORT:-8095}." diff --git a/scripts/dev.sh b/scripts/dev.sh new file mode 100755 index 0000000..41d3d0c --- /dev/null +++ b/scripts/dev.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Run a command with the project's Node 24 toolchain, inside the official +# Node image, so the host needs nothing but Docker. +# +# scripts/dev.sh npm install +# scripts/dev.sh npm test +# scripts/dev.sh npm run dev:server +set -euo pipefail +cd "$(dirname "$0")/.." + +tty_flag=() +if [ -t 0 ] && [ -t 1 ]; then tty_flag=(-t); fi + +exec docker run --rm -i "${tty_flag[@]}" \ + --user "$(id -u):$(id -g)" \ + --network host \ + -e HOME=/tmp \ + -e npm_config_cache=/app/.npm-cache \ + -e npm_config_update_notifier=false \ + -e npm_config_fund=false \ + -e npm_config_audit=false \ + -e TZ="${TZ:-America/New_York}" \ + -v "$PWD":/app -w /app \ + node:24-bookworm-slim "$@" diff --git a/scripts/next-version.sh b/scripts/next-version.sh new file mode 100755 index 0000000..4045bc6 --- /dev/null +++ b/scripts/next-version.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Print the next build version, YYYYMMDD.NN: today's date in the household's +# time zone, then a counter that starts at .01 each day. +# +# Reads the last version from the VERSION file (or $1). Set TODAY=YYYYMMDD to +# pretend it's another day, which the tests use. +set -euo pipefail +cd "$(dirname "$0")/.." + +previous="${1:-$(cat VERSION 2>/dev/null || true)}" +today="${TODAY:-$(TZ="${TZ:-America/New_York}" date +%Y%m%d)}" + +if [[ "$previous" =~ ^${today}\.([0-9]+)$ ]]; then + counter=$((10#${BASH_REMATCH[1]} + 1)) +else + counter=1 +fi + +printf '%s.%02d\n' "$today" "$counter" diff --git a/scripts/profile-samples.mjs b/scripts/profile-samples.mjs new file mode 100644 index 0000000..f5a4d9a --- /dev/null +++ b/scripts/profile-samples.mjs @@ -0,0 +1,155 @@ +#!/usr/bin/env node +// Describe statement files without revealing their contents. +// +// node scripts/profile-samples.mjs [folder] (default: samples) +// +// Prints, for each file: its layout, header labels, and value *shapes* only. +// Every letter becomes A/a and every digit 9, so "KROGER #123" reads +// "AAAAAA #999" and "-54.20" reads "-99.99". Rows made only of labels (no +// digits at all, such as a CSV header) are shown as they are. This is what +// Claude reads to build importers; the raw files never leave this server. + +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { basename, extname, join, relative } from 'node:path'; + +const root = process.argv[2] ?? 'samples'; +const SKIP = new Set(['README.md', '.DS_Store']); + +const mask = (s) => s.replace(/[A-Z]/g, 'A').replace(/[a-z]/g, 'a').replace(/[0-9]/g, '9').replace(/[^\x20-\x7e]/g, '?'); +const shape = (s) => + s.trim() === '' + ? '(empty)' + : s.trim().replace(/[0-9]+/g, '9').replace(/[A-Z]+/g, 'A').replace(/[a-z]+/g, 'a').replace(/[^\x20-\x7e]/g, '?'); +const maskName = (name) => name.replace(/[0-9]{3,}/g, (d) => '9'.repeat(d.length)); + +function files(dir) { + return readdirSync(dir).flatMap((name) => { + if (SKIP.has(name) || name.startsWith('_') || name.startsWith('.')) return []; + const path = join(dir, name); + return statSync(path).isDirectory() ? files(path) : [path]; + }); +} + +/** A small CSV reader that understands quoted fields and embedded commas or newlines. */ +function parseCsv(text, delimiter) { + const rows = []; + let row = []; + let field = ''; + let quoted = false; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (quoted) { + if (ch === '"' && text[i + 1] === '"') { + field += '"'; + i++; + } else if (ch === '"') quoted = false; + else field += ch; + } else if (ch === '"') quoted = true; + else if (ch === delimiter) { + row.push(field); + field = ''; + } else if (ch === '\n' || ch === '\r') { + if (ch === '\r' && text[i + 1] === '\n') i++; + row.push(field); + rows.push(row); + row = []; + field = ''; + } else field += ch; + } + if (field !== '' || row.length) { + row.push(field); + rows.push(row); + } + return rows; +} + +const isLabelRow = (cells) => cells.filter((c) => c.trim()).length >= 2 && cells.every((c) => !/[0-9]/.test(c)); +const showRow = (cells) => (isLabelRow(cells) ? cells.map((c) => c.trim()) : cells.map(mask)); +const numeric = (s) => /^\s*[-+(]?\s*\$?\s*[0-9][0-9,]*(\.[0-9]+)?\s*\)?\s*(CR|DR)?\s*$/i.test(s); + +function profileCsv(text) { + const firstLines = text.split(/\r?\n/).slice(0, 20).join('\n'); + const counts = { ',': 0, '\t': 0, ';': 0, '|': 0 }; + for (const ch of firstLines) if (ch in counts) counts[ch]++; + const delimiter = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0]; + const rows = parseCsv(text, delimiter).filter((r) => r.some((c) => c.trim() !== '')); + const out = []; + + out.push(`- Delimiter: ${JSON.stringify(delimiter)} · rows (non-blank): ${rows.length}`); + const widths = {}; + for (const r of rows) widths[r.length] = (widths[r.length] ?? 0) + 1; + out.push(`- Columns per row: ${Object.entries(widths).map(([w, n]) => `${w} cols × ${n}`).join(', ')}`); + + const headerIndex = rows.findIndex((r, i) => isLabelRow(r) && rows.slice(i + 1, i + 4).some((n) => n.length === r.length)); + out.push(`- Header row: ${headerIndex >= 0 ? `row ${headerIndex + 1}` : 'none found'}`); + + out.push('', '```text'); + rows.slice(0, 12).forEach((r, i) => out.push(`${String(i + 1).padStart(3)}: ${showRow(r).join(' | ')}`)); + if (rows.length > 15) out.push(' …'); + rows.slice(Math.max(12, rows.length - 3)).forEach((r, i, arr) => out.push(`${String(rows.length - arr.length + i + 1).padStart(3)}: ${showRow(r).join(' | ')}`)); + out.push('```'); + + if (headerIndex >= 0) { + const header = rows[headerIndex]; + const data = rows.slice(headerIndex + 1).filter((r) => r.length === header.length); + out.push('', `Columns (${data.length} data rows):`, ''); + header.forEach((name, col) => { + const values = data.map((r) => r[col] ?? ''); + const shapes = {}; + for (const v of values) shapes[shape(v)] = (shapes[shape(v)] ?? 0) + 1; + const top = Object.entries(shapes) + .sort((a, b) => b[1] - a[1]) + .slice(0, 4) + .map(([s, n]) => `\`${s}\` ×${n}`) + .join(', '); + let extra = ''; + const nums = values.filter((v) => numeric(v)); + if (nums.length >= values.length * 0.6 && nums.length > 0) { + const neg = nums.filter((v) => /^\s*-|\(|DR/i.test(v)).length; + extra = ` · numeric: ${nums.length - neg} positive, ${neg} negative`; + } else { + const distinct = new Set(values.map((v) => v.trim()).filter(Boolean)).size; + extra = ` · ${distinct} distinct values`; + if (/^\d{1,2}\/\d{1,2}\/\d{2,4}$/.test(values.find((v) => v.trim()) ?? '')) { + const firsts = values.map((v) => Number(v.split('/')[0])).filter(Number.isFinite); + const seconds = values.map((v) => Number(v.split('/')[1])).filter(Number.isFinite); + extra += ` · first part max ${Math.max(...firsts)}, second part max ${Math.max(...seconds)}`; + } + } + out.push(`- **${name.trim() || `(column ${col + 1})`}**: ${top}${extra}`); + }); + } + return out; +} + +function profileOfx(text) { + const out = []; + const headerLines = text.split(/\r?\n/).filter((l) => /^[A-Z]+:[A-Z0-9]+$/.test(l.trim())); + if (headerLines.length) out.push(`- OFX header: ${headerLines.join(' ')}`); + const tags = {}; + const examples = {}; + for (const m of text.matchAll(/<([A-Z0-9.]+)>([^<\r\n]*)/g)) { + tags[m[1]] = (tags[m[1]] ?? 0) + 1; + if (m[2].trim() && !examples[m[1]]) examples[m[1]] = mask(m[2].trim()); + } + out.push(`- Transactions (STMTTRN): ${tags.STMTTRN ?? 0}`); + out.push('', 'Tags (count, masked example):', ''); + for (const [tag, n] of Object.entries(tags)) out.push(`- ${tag} ×${n}${examples[tag] ? ` · \`${examples[tag]}\`` : ''}`); + return out; +} + +const list = files(root); +console.log(`# Sample profiles\n\n${list.length} file(s) under ${root}/. Values are masked: letters → A/a, digits → 9.\n`); +for (const path of list) { + const buffer = readFileSync(path); + const bom = buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf; + const text = buffer.toString('utf8').replace(/^/, ''); + const ext = extname(path).toLowerCase(); + const rel = relative(root, path); + console.log(`## ${maskName(rel)}\n`); + console.log(`- Size: ${buffer.length} bytes · extension: ${ext || '(none)'} · BOM: ${bom ? 'yes' : 'no'} · line endings: ${text.includes('\r\n') ? 'CRLF' : 'LF'} · non-ASCII bytes: ${/[^\x00-\x7f]/.test(text) ? 'yes' : 'no'}`); + const looksOfx = /|OFXHEADER/i.test(text.slice(0, 2000)); + const lines = looksOfx ? profileOfx(text) : profileCsv(text); + console.log(lines.join('\n') + '\n'); +} +void basename; diff --git a/server/build.mjs b/server/build.mjs new file mode 100644 index 0000000..d6caab2 --- /dev/null +++ b/server/build.mjs @@ -0,0 +1,19 @@ +// Bundle the server into dist/ with esbuild. npm packages stay external and +// are installed in the image; our own shared workspace is bundled in. +import { build } from 'esbuild'; +import { readFileSync } from 'node:fs'; + +const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')); +const external = Object.keys(pkg.dependencies).filter((name) => name !== '@spendlight/shared'); + +await build({ + entryPoints: ['src/index.ts', 'src/cli.ts'], + outdir: 'dist', + bundle: true, + platform: 'node', + format: 'esm', + target: 'node24', + sourcemap: true, + external, + logLevel: 'info', +}); diff --git a/server/drizzle.config.ts b/server/drizzle.config.ts new file mode 100644 index 0000000..b0ea525 --- /dev/null +++ b/server/drizzle.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + dialect: 'sqlite', + schema: './src/db/schema.ts', + out: './drizzle', +}); diff --git a/server/drizzle/0000_foundation.sql b/server/drizzle/0000_foundation.sql new file mode 100644 index 0000000..ad8e368 --- /dev/null +++ b/server/drizzle/0000_foundation.sql @@ -0,0 +1,77 @@ +CREATE TABLE `change_log` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `at` text NOT NULL, + `user_id` integer, + `action` text NOT NULL, + `entity` text NOT NULL, + `entity_id` text, + `detail` text, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE INDEX `change_log_at_idx` ON `change_log` (`at`);--> statement-breakpoint +CREATE TABLE `idea` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `title` text NOT NULL, + `description` text DEFAULT '' NOT NULL, + `status` text DEFAULT 'new' NOT NULL, + `admin_note` text, + `path` text, + `app_version` text, + `user_agent` text, + `user_id` integer, + `created_at` text NOT NULL, + `updated_at` text NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE INDEX `idea_status_idx` ON `idea` (`status`);--> statement-breakpoint +CREATE TABLE `passkey` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` integer NOT NULL, + `name` text NOT NULL, + `public_key` blob NOT NULL, + `counter` integer DEFAULT 0 NOT NULL, + `transports` text, + `device_type` text, + `backed_up` integer DEFAULT false NOT NULL, + `created_at` text NOT NULL, + `last_used_at` text, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `passkey_user_idx` ON `passkey` (`user_id`);--> statement-breakpoint +CREATE TABLE `session` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` integer NOT NULL, + `kind` text DEFAULT 'browser' NOT NULL, + `device_label` text NOT NULL, + `user_agent` text, + `ip` text, + `created_at` text NOT NULL, + `last_seen_at` text NOT NULL, + `expires_at` text NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `session_user_idx` ON `session` (`user_id`);--> statement-breakpoint +CREATE TABLE `setting` ( + `key` text PRIMARY KEY NOT NULL, + `value` text NOT NULL +); +--> statement-breakpoint +CREATE TABLE `user` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `username` text NOT NULL, + `display_name` text NOT NULL, + `password_hash` text NOT NULL, + `access` text DEFAULT 'full' NOT NULL, + `is_admin` integer DEFAULT false NOT NULL, + `theme` text DEFAULT 'system' NOT NULL, + `must_change_password` integer DEFAULT false NOT NULL, + `created_at` text NOT NULL, + `updated_at` text NOT NULL, + `deleted_at` text +); +--> statement-breakpoint +CREATE UNIQUE INDEX `user_username_unique` ON `user` (`username`); \ No newline at end of file diff --git a/server/drizzle/0001_money_in.sql b/server/drizzle/0001_money_in.sql new file mode 100644 index 0000000..f9fa227 --- /dev/null +++ b/server/drizzle/0001_money_in.sql @@ -0,0 +1,145 @@ +CREATE TABLE `account` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` text NOT NULL, + `kind` text NOT NULL, + `institution` text, + `last4` text, + `color` text DEFAULT 'indigo' NOT NULL, + `status` text DEFAULT 'open' NOT NULL, + `balance_cents` integer DEFAULT 0 NOT NULL, + `balance_as_of` text, + `balance_source` text, + `layout_key` text, + `file_name_pattern` text, + `sort_order` integer DEFAULT 0 NOT NULL, + `created_at` text NOT NULL, + `updated_at` text NOT NULL, + `retired_at` text +); +--> statement-breakpoint +CREATE TABLE `category` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `group_id` integer NOT NULL, + `name` text NOT NULL, + `kind` text DEFAULT 'expense' NOT NULL, + `icon` text DEFAULT 'circle' NOT NULL, + `color` text DEFAULT 'slate' NOT NULL, + `archived` integer DEFAULT false NOT NULL, + `sort_order` integer DEFAULT 0 NOT NULL, + FOREIGN KEY (`group_id`) REFERENCES `category_group`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE INDEX `category_group_idx` ON `category` (`group_id`);--> statement-breakpoint +CREATE TABLE `category_group` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` text NOT NULL, + `sort_order` integer DEFAULT 0 NOT NULL +); +--> statement-breakpoint +CREATE TABLE `import_layout` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` text NOT NULL, + `signature` text NOT NULL, + `config` text NOT NULL, + `created_at` text NOT NULL +); +--> statement-breakpoint +CREATE TABLE `import` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `account_id` integer NOT NULL, + `file_name` text NOT NULL, + `file_hash` text NOT NULL, + `layout_key` text NOT NULL, + `date_from` text, + `date_to` text, + `row_count` integer DEFAULT 0 NOT NULL, + `added_count` integer DEFAULT 0 NOT NULL, + `duplicate_count` integer DEFAULT 0 NOT NULL, + `skipped_count` integer DEFAULT 0 NOT NULL, + `transfer_count` integer DEFAULT 0 NOT NULL, + `status` text DEFAULT 'committed' NOT NULL, + `previous_balance` text, + `user_id` integer, + `created_at` text NOT NULL, + `undone_at` text, + FOREIGN KEY (`account_id`) REFERENCES `account`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE INDEX `import_account_idx` ON `import` (`account_id`);--> statement-breakpoint +CREATE TABLE `rule` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `merchant` text NOT NULL, + `account_id` integer, + `category_id` integer, + `rename_to` text, + `source` text DEFAULT 'learned' NOT NULL, + `hits` integer DEFAULT 0 NOT NULL, + `created_at` text NOT NULL, + `last_used_at` text, + FOREIGN KEY (`account_id`) REFERENCES `account`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`category_id`) REFERENCES `category`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `rule_merchant_idx` ON `rule` (`merchant`);--> statement-breakpoint +CREATE TABLE `split` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `transaction_id` integer NOT NULL, + `category_id` integer NOT NULL, + `amount_cents` integer NOT NULL, + `note` text, + FOREIGN KEY (`transaction_id`) REFERENCES `txn`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`category_id`) REFERENCES `category`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE INDEX `split_txn_idx` ON `split` (`transaction_id`);--> statement-breakpoint +CREATE INDEX `split_category_idx` ON `split` (`category_id`);--> statement-breakpoint +CREATE TABLE `tag` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` text NOT NULL, + `color` text DEFAULT 'slate' NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `tag_name_unique` ON `tag` (`name`);--> statement-breakpoint +CREATE TABLE `txn_tag` ( + `transaction_id` integer NOT NULL, + `tag_id` integer NOT NULL, + FOREIGN KEY (`transaction_id`) REFERENCES `txn`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`tag_id`) REFERENCES `tag`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `txn_tag_pk` ON `txn_tag` (`transaction_id`,`tag_id`);--> statement-breakpoint +CREATE INDEX `txn_tag_tag_idx` ON `txn_tag` (`tag_id`);--> statement-breakpoint +CREATE TABLE `txn` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `account_id` integer NOT NULL, + `import_id` integer, + `date` text NOT NULL, + `posted_date` text, + `amount_cents` integer NOT NULL, + `description` text NOT NULL, + `merchant` text NOT NULL, + `category_id` integer, + `needs_review` integer DEFAULT true NOT NULL, + `suggested_by` text, + `is_split` integer DEFAULT false NOT NULL, + `bank_category` text, + `bank_type` text, + `note` text, + `transfer_peer_id` integer, + `source` text DEFAULT 'import' NOT NULL, + `dedupe_key` text NOT NULL, + `created_at` text NOT NULL, + `updated_at` text NOT NULL, + FOREIGN KEY (`account_id`) REFERENCES `account`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`import_id`) REFERENCES `import`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`category_id`) REFERENCES `category`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE INDEX `txn_account_date_idx` ON `txn` (`account_id`,`date`);--> statement-breakpoint +CREATE INDEX `txn_date_idx` ON `txn` (`date`);--> statement-breakpoint +CREATE INDEX `txn_category_date_idx` ON `txn` (`category_id`,`date`);--> statement-breakpoint +CREATE INDEX `txn_merchant_idx` ON `txn` (`merchant`);--> statement-breakpoint +CREATE INDEX `txn_dedupe_idx` ON `txn` (`dedupe_key`);--> statement-breakpoint +CREATE INDEX `txn_review_idx` ON `txn` (`needs_review`);--> statement-breakpoint +CREATE INDEX `txn_import_idx` ON `txn` (`import_id`); \ No newline at end of file diff --git a/server/drizzle/meta/0000_snapshot.json b/server/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..cb6739a --- /dev/null +++ b/server/drizzle/meta/0000_snapshot.json @@ -0,0 +1,533 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "1d213858-0a17-4e4b-bce5-77f99e612d9f", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "change_log": { + "name": "change_log", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "at": { + "name": "at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity": { + "name": "entity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "change_log_at_idx": { + "name": "change_log_at_idx", + "columns": [ + "at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "change_log_user_id_user_id_fk": { + "name": "change_log_user_id_user_id_fk", + "tableFrom": "change_log", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "idea": { + "name": "idea", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'new'" + }, + "admin_note": { + "name": "admin_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_version": { + "name": "app_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idea_status_idx": { + "name": "idea_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "idea_user_id_user_id_fk": { + "name": "idea_user_id_user_id_fk", + "tableFrom": "idea", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "passkey": { + "name": "passkey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "backed_up": { + "name": "backed_up", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "passkey_user_idx": { + "name": "passkey_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'browser'" + }, + "device_label": { + "name": "device_label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_user_idx": { + "name": "session_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "setting": { + "name": "setting", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access": { + "name": "access", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'full'" + }, + "is_admin": { + "name": "is_admin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'system'" + }, + "must_change_password": { + "name": "must_change_password", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_username_unique": { + "name": "user_username_unique", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/0001_snapshot.json b/server/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..cc07bab --- /dev/null +++ b/server/drizzle/meta/0001_snapshot.json @@ -0,0 +1,1548 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "9a9ab3f9-9b4e-490a-830c-02dacb25c682", + "prevId": "1d213858-0a17-4e4b-bce5-77f99e612d9f", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "institution": { + "name": "institution", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last4": { + "name": "last4", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'indigo'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "balance_cents": { + "name": "balance_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "balance_as_of": { + "name": "balance_as_of", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "balance_source": { + "name": "balance_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "layout_key": { + "name": "layout_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_name_pattern": { + "name": "file_name_pattern", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retired_at": { + "name": "retired_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "category": { + "name": "category", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "group_id": { + "name": "group_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'expense'" + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'circle'" + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'slate'" + }, + "archived": { + "name": "archived", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "category_group_idx": { + "name": "category_group_idx", + "columns": [ + "group_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "category_group_id_category_group_id_fk": { + "name": "category_group_id_category_group_id_fk", + "tableFrom": "category", + "tableTo": "category_group", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "category_group": { + "name": "category_group", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "change_log": { + "name": "change_log", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "at": { + "name": "at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity": { + "name": "entity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "change_log_at_idx": { + "name": "change_log_at_idx", + "columns": [ + "at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "change_log_user_id_user_id_fk": { + "name": "change_log_user_id_user_id_fk", + "tableFrom": "change_log", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "idea": { + "name": "idea", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'new'" + }, + "admin_note": { + "name": "admin_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_version": { + "name": "app_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idea_status_idx": { + "name": "idea_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "idea_user_id_user_id_fk": { + "name": "idea_user_id_user_id_fk", + "tableFrom": "idea", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "import_layout": { + "name": "import_layout", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "import": { + "name": "import", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_hash": { + "name": "file_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout_key": { + "name": "layout_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date_from": { + "name": "date_from", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "date_to": { + "name": "date_to", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "added_count": { + "name": "added_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "duplicate_count": { + "name": "duplicate_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "skipped_count": { + "name": "skipped_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "transfer_count": { + "name": "transfer_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'committed'" + }, + "previous_balance": { + "name": "previous_balance", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "undone_at": { + "name": "undone_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "import_account_idx": { + "name": "import_account_idx", + "columns": [ + "account_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "import_account_id_account_id_fk": { + "name": "import_account_id_account_id_fk", + "tableFrom": "import", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "import_user_id_user_id_fk": { + "name": "import_user_id_user_id_fk", + "tableFrom": "import", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "passkey": { + "name": "passkey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "backed_up": { + "name": "backed_up", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "passkey_user_idx": { + "name": "passkey_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rule": { + "name": "rule", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "merchant": { + "name": "merchant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rename_to": { + "name": "rename_to", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'learned'" + }, + "hits": { + "name": "hits", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "rule_merchant_idx": { + "name": "rule_merchant_idx", + "columns": [ + "merchant" + ], + "isUnique": false + } + }, + "foreignKeys": { + "rule_account_id_account_id_fk": { + "name": "rule_account_id_account_id_fk", + "tableFrom": "rule", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rule_category_id_category_id_fk": { + "name": "rule_category_id_category_id_fk", + "tableFrom": "rule", + "tableTo": "category", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'browser'" + }, + "device_label": { + "name": "device_label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_user_idx": { + "name": "session_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "setting": { + "name": "setting", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "split": { + "name": "split", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "transaction_id": { + "name": "transaction_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "split_txn_idx": { + "name": "split_txn_idx", + "columns": [ + "transaction_id" + ], + "isUnique": false + }, + "split_category_idx": { + "name": "split_category_idx", + "columns": [ + "category_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "split_transaction_id_txn_id_fk": { + "name": "split_transaction_id_txn_id_fk", + "tableFrom": "split", + "tableTo": "txn", + "columnsFrom": [ + "transaction_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "split_category_id_category_id_fk": { + "name": "split_category_id_category_id_fk", + "tableFrom": "split", + "tableTo": "category", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tag": { + "name": "tag", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'slate'" + } + }, + "indexes": { + "tag_name_unique": { + "name": "tag_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "txn_tag": { + "name": "txn_tag", + "columns": { + "transaction_id": { + "name": "transaction_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "txn_tag_pk": { + "name": "txn_tag_pk", + "columns": [ + "transaction_id", + "tag_id" + ], + "isUnique": true + }, + "txn_tag_tag_idx": { + "name": "txn_tag_tag_idx", + "columns": [ + "tag_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "txn_tag_transaction_id_txn_id_fk": { + "name": "txn_tag_transaction_id_txn_id_fk", + "tableFrom": "txn_tag", + "tableTo": "txn", + "columnsFrom": [ + "transaction_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "txn_tag_tag_id_tag_id_fk": { + "name": "txn_tag_tag_id_tag_id_fk", + "tableFrom": "txn_tag", + "tableTo": "tag", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "txn": { + "name": "txn", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "import_id": { + "name": "import_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "posted_date": { + "name": "posted_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "merchant": { + "name": "merchant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "needs_review": { + "name": "needs_review", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "suggested_by": { + "name": "suggested_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_split": { + "name": "is_split", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "bank_category": { + "name": "bank_category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bank_type": { + "name": "bank_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "transfer_peer_id": { + "name": "transfer_peer_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'import'" + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "txn_account_date_idx": { + "name": "txn_account_date_idx", + "columns": [ + "account_id", + "date" + ], + "isUnique": false + }, + "txn_date_idx": { + "name": "txn_date_idx", + "columns": [ + "date" + ], + "isUnique": false + }, + "txn_category_date_idx": { + "name": "txn_category_date_idx", + "columns": [ + "category_id", + "date" + ], + "isUnique": false + }, + "txn_merchant_idx": { + "name": "txn_merchant_idx", + "columns": [ + "merchant" + ], + "isUnique": false + }, + "txn_dedupe_idx": { + "name": "txn_dedupe_idx", + "columns": [ + "dedupe_key" + ], + "isUnique": false + }, + "txn_review_idx": { + "name": "txn_review_idx", + "columns": [ + "needs_review" + ], + "isUnique": false + }, + "txn_import_idx": { + "name": "txn_import_idx", + "columns": [ + "import_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "txn_account_id_account_id_fk": { + "name": "txn_account_id_account_id_fk", + "tableFrom": "txn", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "txn_import_id_import_id_fk": { + "name": "txn_import_id_import_id_fk", + "tableFrom": "txn", + "tableTo": "import", + "columnsFrom": [ + "import_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "txn_category_id_category_id_fk": { + "name": "txn_category_id_category_id_fk", + "tableFrom": "txn", + "tableTo": "category", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access": { + "name": "access", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'full'" + }, + "is_admin": { + "name": "is_admin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'system'" + }, + "must_change_password": { + "name": "must_change_password", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_username_unique": { + "name": "user_username_unique", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json new file mode 100644 index 0000000..2c5e2e2 --- /dev/null +++ b/server/drizzle/meta/_journal.json @@ -0,0 +1,20 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1791402989316, + "tag": "0000_foundation", + "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1791404838766, + "tag": "0001_money_in", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..c256601 --- /dev/null +++ b/server/package.json @@ -0,0 +1,35 @@ +{ + "name": "@spendlight/server", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "node build.mjs", + "test": "vitest run", + "typecheck": "tsc -p tsconfig.json", + "db:generate": "drizzle-kit generate" + }, + "dependencies": { + "@hono/node-server": "^2.1.3", + "@hono/zod-openapi": "^1.6.3", + "@node-rs/argon2": "^2.2.2", + "@simplewebauthn/server": "^14.0.3", + "@spendlight/shared": "*", + "better-sqlite3": "^13.0.3", + "drizzle-orm": "^0.45.3", + "hono": "^4.13.13", + "papaparse": "^5.7.0", + "swagger-ui-dist": "^5.33.1", + "zod": "^4.6.5" + }, + "devDependencies": { + "@types/better-sqlite3": "^9.6.0", + "@types/node": "^24.0.0", + "@types/papaparse": "^5.5.2", + "drizzle-kit": "^0.31.11", + "esbuild": "^0.28.2", + "tsx": "^4.23.15", + "typescript": "~6.0.2", + "vitest": "^5.0.3" + } +} diff --git a/server/src/accounts/balance.ts b/server/src/accounts/balance.ts new file mode 100644 index 0000000..ec024ac --- /dev/null +++ b/server/src/accounts/balance.ts @@ -0,0 +1,20 @@ +import { and, eq, gt, sql } from 'drizzle-orm'; +import type { DB } from '../db/client'; +import { transactions, type AccountRow } from '../db/schema'; + +/** + * An account's balance: its last known balance (from a statement's running + * balance, or typed in), plus every transaction dated after that day. Money + * owed on a card is negative. + */ +export function currentBalance(db: DB, account: AccountRow): number { + const where = account.balanceAsOf + ? and(eq(transactions.accountId, account.id), gt(transactions.date, account.balanceAsOf)) + : eq(transactions.accountId, account.id); + const later = db + .select({ total: sql`coalesce(sum(${transactions.amountCents}), 0)` }) + .from(transactions) + .where(where) + .get(); + return account.balanceCents + (later?.total ?? 0); +} diff --git a/server/src/app.ts b/server/src/app.ts new file mode 100644 index 0000000..536ad6c --- /dev/null +++ b/server/src/app.ts @@ -0,0 +1,107 @@ +import { OpenAPIHono } from '@hono/zod-openapi'; +import type { MiddlewareHandler } from 'hono'; +import { HTTPException } from 'hono/http-exception'; +import { secureHeaders } from 'hono/secure-headers'; +import { sessionMiddleware } from './auth/guards'; +import type { AppContext } from './context'; +import { docsRoutes } from './docs'; +import { fail, requestOrigin, type Env } from './http'; +import { accountRoutes } from './routes/accounts'; +import { authRoutes } from './routes/auth'; +import { backupRoutes } from './routes/backups'; +import { deviceRoutes } from './routes/devices'; +import { ideaRoutes } from './routes/ideas'; +import { importRoutes } from './routes/imports'; +import { passkeyRoutes } from './routes/passkeys'; +import { peopleRoutes } from './routes/people'; +import { systemRoutes } from './routes/system'; +import { transactionRoutes } from './routes/transactions'; +import { validationHook } from './schemas'; +import { webApp } from './static'; + +/** Changes must come from Spendlight's own pages, never from another site. */ +function sameOriginWrites(ctx: AppContext): MiddlewareHandler { + return async (c, next) => { + if (!['GET', 'HEAD', 'OPTIONS'].includes(c.req.method)) { + const origin = c.req.header('origin'); + if (origin) { + const allowed = new Set([requestOrigin(c, ctx)]); + if (ctx.config.publicUrl) allowed.add(ctx.config.publicUrl.origin); + if (!allowed.has(origin)) fail(403, 'wrong_origin', 'This request didn’t come from Spendlight.'); + } + } + await next(); + }; +} + +export function createApp(ctx: AppContext) { + const app = new OpenAPIHono({ defaultHook: validationHook }); + + app.onError((error, c) => { + if (error instanceof HTTPException) { + const code = (error.cause as { code?: string } | undefined)?.code ?? 'error'; + return c.json({ error: { code, message: error.message } }, error.status); + } + ctx.log.error(`${c.req.method} ${new URL(c.req.url).pathname} failed`, error); + return c.json({ error: { code: 'server_error', message: 'Something went wrong on our side. Please try again.' } }, 500); + }); + + app.use( + '*', + secureHeaders({ + contentSecurityPolicy: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'"], + imgSrc: ["'self'", 'data:', 'blob:'], + fontSrc: ["'self'", 'data:'], + connectSrc: ["'self'"], + manifestSrc: ["'self'"], + workerSrc: ["'self'"], + objectSrc: ["'none'"], + baseUri: ["'self'"], + formAction: ["'self'"], + frameAncestors: ["'none'"], + }, + crossOriginEmbedderPolicy: false, + referrerPolicy: 'same-origin', + }), + ); + app.use('*', async (c, next) => { + await next(); + c.header('X-Spendlight-Version', ctx.config.version); + }); + app.use('/api/*', async (c, next) => { + await next(); + c.header('Cache-Control', 'no-store'); + }); + app.use('/api/*', sessionMiddleware(ctx)); + app.use('/api/*', sameOriginWrites(ctx)); + + app.route('/api/v1', systemRoutes(ctx)); + app.route('/api/v1', authRoutes(ctx)); + app.route('/api/v1', passkeyRoutes(ctx)); + app.route('/api/v1', deviceRoutes(ctx)); + app.route('/api/v1', peopleRoutes(ctx)); + app.route('/api/v1', backupRoutes(ctx)); + app.route('/api/v1', accountRoutes(ctx)); + app.route('/api/v1', importRoutes(ctx)); + app.route('/api/v1', transactionRoutes(ctx)); + if (ctx.config.ideasEnabled) app.route('/api/v1', ideaRoutes(ctx)); + + app.doc31('/api/v1/openapi.json', { + openapi: '3.1.0', + info: { + title: 'Spendlight API', + version: ctx.config.version, + description: + 'Everything the Spendlight screens do goes through this API. Sign in at /api/v1/auth/login; the session cookie authenticates the rest.', + }, + }); + app.route('/api/docs', docsRoutes()); + + app.all('/api/*', (c) => c.json({ error: { code: 'not_found', message: 'No such address in the Spendlight API.' } }, 404)); + app.use('*', webApp(ctx.config.webDist)); + + return app; +} diff --git a/server/src/audit.ts b/server/src/audit.ts new file mode 100644 index 0000000..7beca86 --- /dev/null +++ b/server/src/audit.ts @@ -0,0 +1,24 @@ +import type { AppContext } from './context'; +import { changeLog } from './db/schema'; + +/** Record who changed what, for history and (later) undo. */ +export function logChange( + ctx: AppContext, + userId: number | null, + action: string, + entity: string, + entityId?: string | number | null, + detail?: Record, +): void { + ctx.db + .insert(changeLog) + .values({ + at: new Date().toISOString(), + userId, + action, + entity, + entityId: entityId == null ? null : String(entityId), + detail: detail ? JSON.stringify(detail) : null, + }) + .run(); +} diff --git a/server/src/auth/guards.ts b/server/src/auth/guards.ts new file mode 100644 index 0000000..cfdfb56 --- /dev/null +++ b/server/src/auth/guards.ts @@ -0,0 +1,57 @@ +import type { Context, MiddlewareHandler } from 'hono'; +import { getCookie } from 'hono/cookie'; +import type { AppContext } from '../context'; +import type { UserRow } from '../db/schema'; +import { clientIp, fail, hostnameOf, isLocalHost, requestHost, requestProto, type Env } from '../http'; +import { clearSessionCookie, loadSession, SESSION_COOKIE, setSessionCookie, touchSession } from './sessions'; + +/** Attach the signed-in person (if any) to every API request, and keep their session fresh. */ +export function sessionMiddleware(ctx: AppContext): MiddlewareHandler { + return async (c, next) => { + c.set('user', null); + c.set('session', null); + const token = getCookie(c, SESSION_COOKIE); + if (token) { + const found = loadSession(ctx, token); + if (found) { + c.set('user', found.user); + c.set('session', found.session); + if (touchSession(ctx, found.session, clientIp(c, ctx))) setSessionCookie(c, ctx, token); + } else { + clearSessionCookie(c); + } + } + await next(); + }; +} + +export function requireUser(c: Context): UserRow { + const user = c.get('user'); + if (!user) fail(401, 'signed_out', 'Please sign in to continue.'); + return user; +} + +/** Changing household data needs Full access; Read-only people can look but not touch. */ +export function requireWrite(c: Context): UserRow { + const user = requireUser(c); + if (user.access !== 'full') fail(403, 'read_only', 'Your access is read-only, so this change isn’t available.'); + return user; +} + +export function requireAdmin(c: Context): UserRow { + const user = requireUser(c); + if (!user.isAdmin) fail(403, 'admin_only', 'Only an admin can do that.'); + return user; +} + +/** + * Refuse password sign-in over plain HTTP from anywhere but this machine, so + * passwords and cookies only ever travel encrypted. + */ +export function assertSecureSignIn(c: Context, ctx: AppContext): void { + if (ctx.config.allowInsecureSignin) return; + if (requestProto(c, ctx) === 'https') return; + if (isLocalHost(hostnameOf(requestHost(c, ctx)))) return; + const where = ctx.config.publicUrl ? `: ${ctx.config.publicUrl.origin}` : ''; + fail(400, 'insecure_connection', `For your security, open Spendlight at its secure (https) address to sign in${where}.`); +} diff --git a/server/src/auth/password.ts b/server/src/auth/password.ts new file mode 100644 index 0000000..429c5f7 --- /dev/null +++ b/server/src/auth/password.ts @@ -0,0 +1,35 @@ +import { hash, verify } from '@node-rs/argon2'; +import { randomInt } from 'node:crypto'; + +export const MIN_PASSWORD_LENGTH = 8; + +/** Argon2id with the library's defaults (19 MiB, 2 passes): about 75 ms on the Pi. */ +export function hashPassword(password: string): Promise { + return hash(password); +} + +let dummyHash: Promise | undefined; + +/** + * Check a password. With no stored hash (unknown username) it still does the + * same work, so response times don't reveal which usernames exist. + */ +export async function verifyPassword(stored: string | undefined, password: string): Promise { + if (!stored) { + dummyHash ??= hash('spendlight-timing-equalizer'); + await verify(await dummyHash, password).catch(() => false); + return false; + } + try { + return await verify(stored, password); + } catch { + return false; + } +} + +/** A temporary password that's easy to read aloud, such as "kq7m-2xpd-9tfa". */ +export function temporaryPassword(): string { + const alphabet = 'abcdefghjkmnpqrstuvwxyz23456789'; + const group = () => Array.from({ length: 4 }, () => alphabet[randomInt(alphabet.length)]).join(''); + return `${group()}-${group()}-${group()}`; +} diff --git a/server/src/auth/ratelimit.ts b/server/src/auth/ratelimit.ts new file mode 100644 index 0000000..e522b28 --- /dev/null +++ b/server/src/auth/ratelimit.ts @@ -0,0 +1,75 @@ +// Slows down password guessing. After 5 failures the key is locked for a +// minute, doubling with each further failure up to 15 minutes. Quiet for 15 +// minutes and the slate is wiped. + +interface Entry { + failures: number; + lastFailure: number; + lockedUntil: number; +} + +const FREE_FAILURES = 5; +const BASE_LOCK_MS = 60_000; +const MAX_LOCK_MS = 15 * 60_000; +const FORGET_AFTER_MS = 15 * 60_000; + +export class RateLimiter { + private entries = new Map(); + + constructor(private now: () => number = Date.now) {} + + /** Seconds to wait before another attempt, or 0 if allowed now. */ + retryAfter(key: string): number { + const entry = this.fresh(key); + if (!entry) return 0; + const wait = entry.lockedUntil - this.now(); + return wait > 0 ? Math.ceil(wait / 1000) : 0; + } + + fail(key: string): void { + const now = this.now(); + const entry = this.fresh(key) ?? { failures: 0, lastFailure: now, lockedUntil: 0 }; + entry.failures += 1; + entry.lastFailure = now; + if (entry.failures >= FREE_FAILURES) { + const lock = Math.min(MAX_LOCK_MS, BASE_LOCK_MS * 2 ** (entry.failures - FREE_FAILURES)); + entry.lockedUntil = now + lock; + } + this.entries.set(key, entry); + } + + succeed(key: string): void { + this.entries.delete(key); + } + + private fresh(key: string): Entry | undefined { + const entry = this.entries.get(key); + if (entry && this.now() - entry.lastFailure > FORGET_AFTER_MS && entry.lockedUntil < this.now()) { + this.entries.delete(key); + return undefined; + } + return entry; + } +} + +/** Short-lived WebAuthn challenges, held in memory for 5 minutes. */ +export class ChallengeStore { + private entries = new Map(); + + put(key: string, challenge: string, ttlMs = 5 * 60_000): void { + this.sweep(); + this.entries.set(key, { challenge, expires: Date.now() + ttlMs }); + } + + /** The challenge for `key`, removed so it can be used only once. */ + take(key: string): string | undefined { + const entry = this.entries.get(key); + this.entries.delete(key); + return entry && entry.expires > Date.now() ? entry.challenge : undefined; + } + + private sweep(): void { + const now = Date.now(); + for (const [key, entry] of this.entries) if (entry.expires <= now) this.entries.delete(key); + } +} diff --git a/server/src/auth/sessions.ts b/server/src/auth/sessions.ts new file mode 100644 index 0000000..b12dc68 --- /dev/null +++ b/server/src/auth/sessions.ts @@ -0,0 +1,135 @@ +import { and, eq, isNull, ne } from 'drizzle-orm'; +import type { Context } from 'hono'; +import { deleteCookie, setCookie } from 'hono/cookie'; +import { createHash, randomBytes } from 'node:crypto'; +import type { AppContext } from '../context'; +import { sessions, users, type SessionRow, type UserRow } from '../db/schema'; +import { requestProto } from '../http'; + +export const SESSION_COOKIE = 'sl_session'; + +const DAY_MS = 86_400_000; + +export const hashToken = (token: string) => createHash('sha256').update(token).digest('hex'); + +/** "iPhone · Safari", "Mac · Chrome" and so on, for the devices list. */ +export function deviceLabel(userAgent: string | null | undefined): string { + const ua = userAgent ?? ''; + const device = /iPhone/.test(ua) + ? 'iPhone' + : /iPad/.test(ua) + ? 'iPad' + : /Android/.test(ua) + ? 'Android' + : /Macintosh|Mac OS X/.test(ua) + ? 'Mac' + : /Windows/.test(ua) + ? 'Windows PC' + : /CrOS/.test(ua) + ? 'Chromebook' + : /Linux/.test(ua) + ? 'Linux' + : 'Unknown device'; + const browser = /Edg\//.test(ua) + ? 'Edge' + : /Firefox\//.test(ua) + ? 'Firefox' + : /Chrome\/|CriOS\//.test(ua) + ? 'Chrome' + : /Safari\//.test(ua) + ? 'Safari' + : null; + return browser ? `${device} · ${browser}` : device; +} + +export function createSession( + ctx: AppContext, + userId: number, + meta: { userAgent?: string | null; ip?: string | null }, + now = new Date(), +): { token: string; session: SessionRow } { + const token = randomBytes(32).toString('base64url'); + const session = ctx.db + .insert(sessions) + .values({ + id: hashToken(token), + userId, + kind: 'browser', + deviceLabel: deviceLabel(meta.userAgent), + userAgent: meta.userAgent ?? null, + ip: meta.ip ?? null, + createdAt: now.toISOString(), + lastSeenAt: now.toISOString(), + expiresAt: new Date(now.getTime() + ctx.config.sessionDays * DAY_MS).toISOString(), + }) + .returning() + .get(); + return { token, session }; +} + +export function loadSession(ctx: AppContext, token: string, now = new Date()): { session: SessionRow; user: UserRow } | null { + const row = ctx.db + .select({ session: sessions, user: users }) + .from(sessions) + .innerJoin(users, eq(users.id, sessions.userId)) + .where(and(eq(sessions.id, hashToken(token)), isNull(users.deletedAt))) + .get(); + if (!row) return null; + if (Date.parse(row.session.expiresAt) <= now.getTime()) { + ctx.db.delete(sessions).where(eq(sessions.id, row.session.id)).run(); + return null; + } + return row; +} + +/** + * Keep a session alive. The expiry slides forward (at most once a day, to + * save writes) so a device in regular use never signs out. Returns true when + * the cookie should be re-sent with a fresh lifetime. + */ +export function touchSession(ctx: AppContext, session: SessionRow, ip: string, now = new Date()): boolean { + const fullLife = ctx.config.sessionDays * DAY_MS; + const remaining = Date.parse(session.expiresAt) - now.getTime(); + if (remaining < fullLife - DAY_MS) { + ctx.db + .update(sessions) + .set({ + expiresAt: new Date(now.getTime() + fullLife).toISOString(), + lastSeenAt: now.toISOString(), + ip, + }) + .where(eq(sessions.id, session.id)) + .run(); + return true; + } + if (now.getTime() - Date.parse(session.lastSeenAt) > 5 * 60_000) { + ctx.db.update(sessions).set({ lastSeenAt: now.toISOString(), ip }).where(eq(sessions.id, session.id)).run(); + } + return false; +} + +export function revokeSession(ctx: AppContext, sessionId: string): void { + ctx.db.delete(sessions).where(eq(sessions.id, sessionId)).run(); +} + +/** Sign a person out everywhere, optionally keeping the device they're on. */ +export function revokeUserSessions(ctx: AppContext, userId: number, exceptSessionId?: string): void { + const where = exceptSessionId + ? and(eq(sessions.userId, userId), ne(sessions.id, exceptSessionId)) + : eq(sessions.userId, userId); + ctx.db.delete(sessions).where(where).run(); +} + +export function setSessionCookie(c: Context, ctx: AppContext, token: string): void { + setCookie(c, SESSION_COOKIE, token, { + httpOnly: true, + secure: requestProto(c, ctx) === 'https', + sameSite: 'Lax', + path: '/', + maxAge: ctx.config.sessionDays * 86_400, + }); +} + +export function clearSessionCookie(c: Context): void { + deleteCookie(c, SESSION_COOKIE, { path: '/' }); +} diff --git a/server/src/backups/backup.ts b/server/src/backups/backup.ts new file mode 100644 index 0000000..226530d --- /dev/null +++ b/server/src/backups/backup.ts @@ -0,0 +1,162 @@ +import type { BackupInfo, BackupKind } from '@spendlight/shared'; +import { existsSync, mkdirSync, readdirSync, renameSync, statSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import type { Sqlite } from '../db/client'; +import type { Logger } from '../log'; + +// Backup files are named spendlight---.db in the +// household's local time. Each is written to a hidden partial file first and +// renamed when complete, so an outside backup tool never collects half a file. + +const NAME = /^spendlight-(nightly|manual|pre-upgrade|pre-restore)-(\d{8})-(\d{6})\.db$/; +const PARTIAL = /^\.spendlight-.*\.partial$/; + +const pad = (n: number) => String(n).padStart(2, '0'); + +function stampOf(d: Date): string { + return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`; +} + +function parseName(name: string): { kind: BackupKind; date: string; createdAt: Date } | null { + const m = NAME.exec(name); + if (!m) return null; + const [, kind, date, time] = m as unknown as [string, BackupKind, string, string]; + const createdAt = new Date( + Number(date.slice(0, 4)), + Number(date.slice(4, 6)) - 1, + Number(date.slice(6, 8)), + Number(time.slice(0, 2)), + Number(time.slice(2, 4)), + Number(time.slice(4, 6)), + ); + return { kind, date, createdAt }; +} + +export async function createBackup( + sqlite: Sqlite, + dir: string, + kind: BackupKind, + now = new Date(), +): Promise { + mkdirSync(dir, { recursive: true }); + // Two backups in the same second get consecutive timestamps instead of colliding. + let when = new Date(now); + let name = `spendlight-${kind}-${stampOf(when)}.db`; + while (existsSync(join(dir, name))) { + when = new Date(when.getTime() + 1000); + name = `spendlight-${kind}-${stampOf(when)}.db`; + } + const partial = join(dir, `.${name}.partial`); + await sqlite.backup(partial); + renameSync(partial, join(dir, name)); + return { name, kind, size: statSync(join(dir, name)).size, createdAt: when.toISOString() }; +} + +export function listBackups(dir: string): BackupInfo[] { + if (!existsSync(dir)) return []; + const list: BackupInfo[] = []; + for (const name of readdirSync(dir)) { + const parsed = parseName(name); + if (!parsed) continue; + list.push({ + name, + kind: parsed.kind, + size: statSync(join(dir, name)).size, + createdAt: parsed.createdAt.toISOString(), + }); + } + return list.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); +} + +export interface RetentionPolicy { + keepDaily: number; + keepMonthly: number; + keepManual?: number; + keepUpgrade?: number; +} + +/** + * Which backups to delete. Nightly copies keep the newest `keepDaily`, plus + * the newest copy from each of the last `keepMonthly` months. Manual copies + * keep the newest 10; safety copies taken before upgrades or restores, 5 each. + */ +export function planRetention(backups: Pick[], policy: RetentionPolicy): string[] { + const newestFirst = [...backups].sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + const ofKind = (kind: BackupKind) => newestFirst.filter((b) => b.kind === kind); + const doomed: string[] = []; + + const nightly = ofKind('nightly'); + const keep = new Set(nightly.slice(0, policy.keepDaily).map((b) => b.name)); + const months = new Set(); + for (const b of nightly) { + const month = parseName(b.name)?.date.slice(0, 6) ?? b.createdAt.slice(0, 7); + if (months.has(month)) continue; + months.add(month); + if (months.size <= policy.keepMonthly) keep.add(b.name); + } + doomed.push(...nightly.filter((b) => !keep.has(b.name)).map((b) => b.name)); + + doomed.push(...ofKind('manual').slice(policy.keepManual ?? 10).map((b) => b.name)); + doomed.push(...ofKind('pre-upgrade').slice(policy.keepUpgrade ?? 5).map((b) => b.name)); + doomed.push(...ofKind('pre-restore').slice(policy.keepUpgrade ?? 5).map((b) => b.name)); + return doomed; +} + +export function pruneBackups(dir: string, policy: RetentionPolicy): string[] { + const doomed = planRetention(listBackups(dir), policy); + for (const name of doomed) unlinkSync(join(dir, name)); + // Clear partial files left behind by an interrupted backup. + if (existsSync(dir)) { + const dayAgo = Date.now() - 86_400_000; + for (const name of readdirSync(dir)) { + if (PARTIAL.test(name) && statSync(join(dir, name)).mtimeMs < dayAgo) unlinkSync(join(dir, name)); + } + } + return doomed; +} + +/** The full path of a backup, or null when the name isn't a Spendlight backup (blocks path tricks). */ +export function backupPath(dir: string, name: string): string | null { + return NAME.test(name) ? join(dir, name) : null; +} + +/** Run a nightly backup at the configured local time, plus a catch-up if the last one is overdue. */ +export function scheduleNightlyBackups(options: { + sqlite: Sqlite; + dir: string; + at: { hour: number; minute: number }; + policy: RetentionPolicy; + log: Logger; +}): { stop(): void } { + const { sqlite, dir, at, policy, log } = options; + let timer: NodeJS.Timeout | undefined; + + const run = async () => { + try { + const backup = await createBackup(sqlite, dir, 'nightly'); + const removed = pruneBackups(dir, policy); + log.info(`Nightly backup ${backup.name} (${removed.length} old copies removed)`); + } catch (error) { + log.error('Nightly backup failed', error); + } + }; + + const scheduleNext = () => { + const now = new Date(); + const next = new Date(now); + next.setHours(at.hour, at.minute, 0, 0); + if (next <= now) next.setDate(next.getDate() + 1); + timer = setTimeout(async () => { + await run(); + scheduleNext(); + }, next.getTime() - now.getTime()); + timer.unref(); + }; + + const newest = listBackups(dir).find((b) => b.kind === 'nightly'); + const overdue = !newest || Date.now() - Date.parse(newest.createdAt) > 26 * 3_600_000; + if (overdue) setTimeout(run, 60_000).unref(); + + scheduleNext(); + return { stop: () => clearTimeout(timer) }; +} diff --git a/server/src/categories/seed.ts b/server/src/categories/seed.ts new file mode 100644 index 0000000..ee05dd8 --- /dev/null +++ b/server/src/categories/seed.ts @@ -0,0 +1,132 @@ +import type { CategoryKind, PaletteColor } from '@spendlight/shared'; +import { count } from 'drizzle-orm'; +import type { DB } from '../db/client'; +import { categories, categoryGroups } from '../db/schema'; + +// The starter category set from the plan. Everything references categories +// by ID, so renaming one later changes it everywhere. + +type Starter = [name: string, icon: string, color: PaletteColor]; + +const STARTER: { group: string; kind: CategoryKind; items: Starter[] }[] = [ + { + group: 'Income', + kind: 'income', + items: [ + ['Paycheck', 'banknote', 'emerald'], + ['Bonus', 'award', 'emerald'], + ['Interest and Dividends', 'percent', 'teal'], + ['Other Income', 'circle-dollar-sign', 'teal'], + ], + }, + { + group: 'Home', + kind: 'expense', + items: [ + ['Mortgage or Rent', 'house', 'indigo'], + ['Home Insurance', 'shield', 'indigo'], + ['Property Tax', 'landmark', 'indigo'], + ['Repairs and Maintenance', 'wrench', 'indigo'], + ['Furnishings', 'sofa', 'indigo'], + ], + }, + { + group: 'Utilities', + kind: 'expense', + items: [ + ['Electric and Gas', 'zap', 'amber'], + ['Water and Trash', 'droplets', 'sky'], + ['Internet', 'wifi', 'sky'], + ['Mobile Phone', 'smartphone', 'sky'], + ], + }, + { + group: 'Food', + kind: 'expense', + items: [ + ['Groceries', 'shopping-cart', 'lime'], + ['Dining Out', 'utensils', 'orange'], + ['Coffee and Snacks', 'coffee', 'orange'], + ], + }, + { + group: 'Transportation', + kind: 'expense', + items: [ + ['Fuel', 'fuel', 'cyan'], + ['Car Payment', 'car', 'cyan'], + ['Car Insurance', 'shield-check', 'cyan'], + ['Car Service', 'car-front', 'cyan'], + ['Parking and Tolls', 'circle-parking', 'cyan'], + ], + }, + { + group: 'Shopping', + kind: 'expense', + items: [ + ['Clothing', 'shirt', 'pink'], + ['Household Supplies', 'spray-can', 'pink'], + ['Electronics', 'monitor-smartphone', 'violet'], + ['General Merchandise', 'shopping-bag', 'violet'], + ], + }, + { + group: 'Health and Personal', + kind: 'expense', + items: [ + ['Medical and Dental', 'stethoscope', 'rose'], + ['Pharmacy', 'pill', 'rose'], + ['Personal Care', 'sparkles', 'pink'], + ['Fitness', 'dumbbell', 'rose'], + ], + }, + { + group: 'Fun', + kind: 'expense', + items: [ + ['Entertainment', 'ticket', 'violet'], + ['Streaming and Subscriptions', 'tv', 'violet'], + ['Hobbies', 'palette', 'violet'], + ['Travel', 'plane', 'sky'], + ], + }, + { + group: 'Family and Giving', + kind: 'expense', + items: [ + ['Kids', 'baby', 'amber'], + ['Pets', 'paw-print', 'amber'], + ['Gifts', 'gift', 'rose'], + ['Charity', 'heart-handshake', 'rose'], + ], + }, + { + group: 'Financial', + kind: 'expense', + items: [ + ['Bank Fees and Interest', 'receipt', 'slate'], + ['Taxes', 'scale', 'slate'], + ], + }, + { + group: 'Transfers', + kind: 'transfer', + items: [ + ['Credit Card Payment', 'credit-card', 'slate'], + ['Transfer Between Accounts', 'arrow-left-right', 'slate'], + ], + }, +]; + +/** Add the starter categories the first time Spendlight runs. */ +export function seedCategories(db: DB): void { + if ((db.select({ n: count() }).from(categoryGroups).get()?.n ?? 0) > 0) return; + db.transaction((tx) => { + STARTER.forEach((group, g) => { + const { id } = tx.insert(categoryGroups).values({ name: group.group, sortOrder: g }).returning().get(); + group.items.forEach(([name, icon, color], i) => { + tx.insert(categories).values({ groupId: id, name, kind: group.kind, icon, color, sortOrder: i }).run(); + }); + }); + }); +} diff --git a/server/src/categorize/known.ts b/server/src/categorize/known.ts new file mode 100644 index 0000000..227b2a4 --- /dev/null +++ b/server/src/categorize/known.ts @@ -0,0 +1,231 @@ +// Built-in knowledge: common merchants and the category they usually belong +// to, plus how the banks' own categories map onto Spendlight's. Every entry is +// only a first guess; your own choices always win. + +/** A pattern, the merchant's proper name (null: keep the cleaned-up name), and its usual category. */ +type Known = [pattern: RegExp, name: string | null, category: string | null]; + +export const KNOWN_MERCHANTS: Known[] = [ + // Amazon family (order matters: specific before general) + [/PRIME ?VIDEO/i, 'Prime Video', 'Streaming and Subscriptions'], + [/AMAZON PRIME|PRIME MEMBERSHIP|AMZN PRIME/i, 'Amazon Prime', 'Streaming and Subscriptions'], + [/AUDIBLE/i, 'Audible', 'Streaming and Subscriptions'], + [/KINDLE/i, 'Kindle', 'Hobbies'], + [/WHOLE ?FOODS|\bWFM\b/i, 'Whole Foods', 'Groceries'], + [/AMZN|AMAZON/i, 'Amazon', null], + // Streaming and software + [/NETFLIX/i, 'Netflix', 'Streaming and Subscriptions'], + [/SPOTIFY/i, 'Spotify', 'Streaming and Subscriptions'], + [/HULU/i, 'Hulu', 'Streaming and Subscriptions'], + [/DISNEY ?\+|DISNEYPLUS|DISNEY PLUS/i, 'Disney+', 'Streaming and Subscriptions'], + [/HBO ?MAX|\bMAX\.COM/i, 'Max', 'Streaming and Subscriptions'], + [/YOUTUBE ?PREMIUM|YOUTUBEPREMIUM|YOUTUBE TV/i, 'YouTube', 'Streaming and Subscriptions'], + [/PEACOCK/i, 'Peacock', 'Streaming and Subscriptions'], + [/PARAMOUNT/i, 'Paramount+', 'Streaming and Subscriptions'], + [/SIRIUS ?XM/i, 'SiriusXM', 'Streaming and Subscriptions'], + [/APPLE\.COM\/BILL|APPLE\.COM BILL|ITUNES/i, 'Apple', 'Streaming and Subscriptions'], + [/GOOGLE \*?(STORAGE|ONE)|GOOGLE ONE/i, 'Google One', 'Streaming and Subscriptions'], + [/OPENAI|CHATGPT/i, 'OpenAI', 'Streaming and Subscriptions'], + [/ANTHROPIC|CLAUDE\.AI/i, 'Anthropic', 'Streaming and Subscriptions'], + [/MICROSOFT ?\*?(365|OFFICE)|MSFT \*/i, 'Microsoft 365', 'Streaming and Subscriptions'], + [/ADOBE/i, 'Adobe', 'Streaming and Subscriptions'], + // Groceries and warehouse clubs + [/COSTCO GAS/i, 'Costco Gas', 'Fuel'], + [/COSTCO/i, 'Costco', 'Groceries'], + [/SAMS ?CLUB|SAM'S CLUB/i, "Sam's Club", 'Groceries'], + [/BJ'?S WHOLESALE/i, "BJ's", 'Groceries'], + [/KROGER/i, 'Kroger', 'Groceries'], + [/PUBLIX/i, 'Publix', 'Groceries'], + [/HARRIS ?TEETER/i, 'Harris Teeter', 'Groceries'], + [/FOOD ?LION/i, 'Food Lion', 'Groceries'], + [/\bALDI\b/i, 'Aldi', 'Groceries'], + [/TRADER JOE/i, "Trader Joe's", 'Groceries'], + [/WEGMANS/i, 'Wegmans', 'Groceries'], + [/SAFEWAY/i, 'Safeway', 'Groceries'], + [/\bLIDL\b/i, 'Lidl', 'Groceries'], + [/SPROUTS/i, 'Sprouts', 'Groceries'], + [/\bH-?E-?B\b/i, 'H-E-B', 'Groceries'], + [/MEIJER/i, 'Meijer', 'Groceries'], + [/INGLES/i, 'Ingles', 'Groceries'], + [/WINN.?DIXIE/i, 'Winn-Dixie', 'Groceries'], + [/FRESH MARKET/i, 'The Fresh Market', 'Groceries'], + [/GIANT ?(FOOD|EAGLE)?\b/i, 'Giant', 'Groceries'], + [/STOP ?& ?SHOP/i, 'Stop & Shop', 'Groceries'], + [/HY-?VEE/i, 'Hy-Vee', 'Groceries'], + [/INSTACART/i, 'Instacart', 'Groceries'], + // Fuel + [/\bSHELL\b/i, 'Shell', 'Fuel'], + [/EXXON/i, 'Exxon', 'Fuel'], + [/\bMOBIL\b/i, 'Mobil', 'Fuel'], + [/\bBP\b|BP#/i, 'BP', 'Fuel'], + [/CHEVRON/i, 'Chevron', 'Fuel'], + [/SUNOCO/i, 'Sunoco', 'Fuel'], + [/SPEEDWAY/i, 'Speedway', 'Fuel'], + [/CIRCLE ?K\b/i, 'Circle K', 'Fuel'], + [/MARATHON PETRO/i, 'Marathon', 'Fuel'], + [/RACETRAC/i, 'RaceTrac', 'Fuel'], + [/QUIKTRIP|\bQT \d/i, 'QuikTrip', 'Fuel'], + [/SHEETZ/i, 'Sheetz', 'Fuel'], + [/WAWA/i, 'Wawa', 'Fuel'], + [/7-?ELEVEN|SEVEN ?ELEVEN/i, '7-Eleven', 'Fuel'], + [/QUICK ?STOP|KANGAROO EXPRESS/i, 'Kangaroo Express', 'Fuel'], + [/CITGO/i, 'Citgo', 'Fuel'], + [/VALERO/i, 'Valero', 'Fuel'], + [/MURPHY ?(USA|EXPRESS)/i, 'Murphy USA', 'Fuel'], + // Coffee + [/STARBUCKS/i, 'Starbucks', 'Coffee and Snacks'], + [/DUNKIN/i, "Dunkin'", 'Coffee and Snacks'], + [/PEET'?S/i, "Peet's Coffee", 'Coffee and Snacks'], + [/DUTCH BROS/i, 'Dutch Bros', 'Coffee and Snacks'], + // Dining out and delivery + [/DOORDASH/i, 'DoorDash', 'Dining Out'], + [/GRUBHUB/i, 'Grubhub', 'Dining Out'], + [/UBER ?EATS/i, 'Uber Eats', 'Dining Out'], + [/MCDONALD/i, "McDonald's", 'Dining Out'], + [/CHICK-?FIL-?A/i, 'Chick-fil-A', 'Dining Out'], + [/CHIPOTLE/i, 'Chipotle', 'Dining Out'], + [/TACO BELL/i, 'Taco Bell', 'Dining Out'], + [/WENDY'?S/i, "Wendy's", 'Dining Out'], + [/BURGER KING/i, 'Burger King', 'Dining Out'], + [/PANERA/i, 'Panera Bread', 'Dining Out'], + [/\bSUBWAY\b/i, 'Subway', 'Dining Out'], + [/DOMINO'?S/i, "Domino's", 'Dining Out'], + [/PIZZA HUT/i, 'Pizza Hut', 'Dining Out'], + [/PAPA JOHN/i, "Papa John's", 'Dining Out'], + [/FIVE GUYS/i, 'Five Guys', 'Dining Out'], + [/JERSEY MIKE/i, "Jersey Mike's", 'Dining Out'], + [/JIMMY JOHN/i, "Jimmy John's", 'Dining Out'], + [/PANDA EXPRESS/i, 'Panda Express', 'Dining Out'], + [/OLIVE GARDEN/i, 'Olive Garden', 'Dining Out'], + [/CRACKER BARREL/i, 'Cracker Barrel', 'Dining Out'], + [/TEXAS ROADHOUSE/i, 'Texas Roadhouse', 'Dining Out'], + [/WAFFLE HOUSE/i, 'Waffle House', 'Dining Out'], + [/SWEETGREEN/i, 'Sweetgreen', 'Dining Out'], + [/\bCAVA\b/i, 'Cava', 'Dining Out'], + [/SHAKE SHACK/i, 'Shake Shack', 'Dining Out'], + // Health + [/\bCVS\b/i, 'CVS', 'Pharmacy'], + [/WALGREENS/i, 'Walgreens', 'Pharmacy'], + [/RITE ?AID/i, 'Rite Aid', 'Pharmacy'], + [/PLANET FITNESS/i, 'Planet Fitness', 'Fitness'], + [/\bYMCA\b/i, 'YMCA', 'Fitness'], + [/ORANGETHEORY/i, 'Orangetheory', 'Fitness'], + [/PELOTON/i, 'Peloton', 'Fitness'], + // Home and shopping + [/HOME ?DEPOT/i, 'The Home Depot', 'Repairs and Maintenance'], + [/LOWE'?S\b/i, "Lowe's", 'Repairs and Maintenance'], + [/ACE HARDWARE/i, 'Ace Hardware', 'Repairs and Maintenance'], + [/\bIKEA\b/i, 'IKEA', 'Furnishings'], + [/WAYFAIR/i, 'Wayfair', 'Furnishings'], + [/BEST ?BUY/i, 'Best Buy', 'Electronics'], + [/APPLE STORE|APPLE ONLINE/i, 'Apple Store', 'Electronics'], + [/\bTARGET\b/i, 'Target', 'General Merchandise'], + [/WAL-?MART|WM SUPERCENTER/i, 'Walmart', 'General Merchandise'], + [/DOLLAR TREE/i, 'Dollar Tree', 'General Merchandise'], + [/DOLLAR GENERAL/i, 'Dollar General', 'General Merchandise'], + [/\bETSY\b/i, 'Etsy', 'General Merchandise'], + [/\bEBAY\b/i, 'eBay', 'General Merchandise'], + [/TJ ?MAXX|T\.J\. MAXX/i, 'TJ Maxx', 'Clothing'], + [/MARSHALLS/i, 'Marshalls', 'Clothing'], + [/OLD NAVY/i, 'Old Navy', 'Clothing'], + [/KOHL'?S/i, "Kohl's", 'Clothing'], + [/NORDSTROM/i, 'Nordstrom', 'Clothing'], + [/MACY'?S/i, "Macy's", 'Clothing'], + [/\bNIKE\b/i, 'Nike', 'Clothing'], + [/\bGAP\b/i, 'Gap', 'Clothing'], + [/PETSMART/i, 'PetSmart', 'Pets'], + [/PETCO/i, 'Petco', 'Pets'], + [/CHEWY/i, 'Chewy', 'Pets'], + // Bills + [/COMCAST|XFINITY/i, 'Xfinity', 'Internet'], + [/SPECTRUM/i, 'Spectrum', 'Internet'], + [/COX COMM/i, 'Cox', 'Internet'], + [/T-?MOBILE/i, 'T-Mobile', 'Mobile Phone'], + [/VERIZON/i, 'Verizon', 'Mobile Phone'], + [/AT&T|ATT\*/i, 'AT&T', 'Mobile Phone'], + [/DUKE ?ENERGY/i, 'Duke Energy', 'Electric and Gas'], + [/DOMINION ?ENERGY/i, 'Dominion Energy', 'Electric and Gas'], + [/PIEDMONT ?N(ATURAL)? ?GAS/i, 'Piedmont Natural Gas', 'Electric and Gas'], + [/GEICO/i, 'GEICO', 'Car Insurance'], + [/STATE ?FARM/i, 'State Farm', 'Car Insurance'], + [/PROGRESSIVE/i, 'Progressive', 'Car Insurance'], + [/ALLSTATE/i, 'Allstate', 'Car Insurance'], + [/E-?Z ?PASS|SUNPASS|QUICK ?PASS|PARKMOBILE|SPOTHERO/i, 'Tolls and Parking', 'Parking and Tolls'], + // Fun and travel + [/\bAMC\b/i, 'AMC Theatres', 'Entertainment'], + [/REGAL/i, 'Regal', 'Entertainment'], + [/TICKETMASTER/i, 'Ticketmaster', 'Entertainment'], + [/STEAM ?(GAMES|POWERED)|STEAMGAMES/i, 'Steam', 'Hobbies'], + [/PLAYSTATION|SONY ?INTERACTIVE/i, 'PlayStation', 'Hobbies'], + [/NINTENDO/i, 'Nintendo', 'Hobbies'], + [/AIRBNB/i, 'Airbnb', 'Travel'], + [/MARRIOTT/i, 'Marriott', 'Travel'], + [/HILTON/i, 'Hilton', 'Travel'], + [/DELTA AIR/i, 'Delta', 'Travel'], + [/SOUTHWEST/i, 'Southwest', 'Travel'], + [/AMERICAN AIR/i, 'American Airlines', 'Travel'], + [/UNITED AIR/i, 'United', 'Travel'], + [/EXPEDIA/i, 'Expedia', 'Travel'], + // People-to-people + [/^VENMO\b(?!.*\b(TO|FROM)\b)/i, 'Venmo', null], + [/^ZELLE\b(?!.*\b(TO|FROM)\b)/i, 'Zelle', null], + [/PAYPAL/i, 'PayPal', null], + [/\bUBER\b/i, 'Uber', null], + [/\bLYFT\b/i, 'Lyft', null], + // Money in and fees + [/PAYROLL|DIR(ECT)? ?DEP|SALARY/i, null, 'Paycheck'], + [/INTEREST (EARNED|PAID|PAYMENT)/i, 'Interest', 'Interest and Dividends'], + [/CASH ?BACK|CASHBACK|REWARDS? (CREDIT|REDEMPTION)|STATEMENT CREDIT/i, 'Rewards', 'Other Income'], + [/INTEREST CHARGE|LATE FEE|ANNUAL FEE|FOREIGN TRANSACTION FEE|OVERDRAFT|SERVICE FEE|MONTHLY MAINTENANCE/i, 'Bank fee', 'Bank Fees and Interest'], + [/\bIRS\b|US TREASURY TAX|DEPT OF REVENUE|TAX PAYMENT/i, 'Taxes', 'Taxes'], +]; + +/** Lower-cased bank category → Spendlight category (null: no useful hint). */ +export const BANK_CATEGORIES: Record = { + // Discover + supermarkets: 'Groceries', + restaurants: 'Dining Out', + gasoline: 'Fuel', + merchandise: 'General Merchandise', + 'department stores': 'General Merchandise', + 'warehouse clubs': 'Groceries', + 'home improvement': 'Repairs and Maintenance', + 'medical services': 'Medical and Dental', + automotive: 'Car Service', + 'travel/ entertainment': 'Travel', + 'travel/entertainment': 'Travel', + 'government services': 'Taxes', + 'awards and rebate credits': 'Other Income', + fees: 'Bank Fees and Interest', + interest: 'Bank Fees and Interest', + // Chase + groceries: 'Groceries', + 'food & drink': 'Dining Out', + gas: 'Fuel', + shopping: 'General Merchandise', + entertainment: 'Entertainment', + travel: 'Travel', + 'health & wellness': 'Medical and Dental', + personal: 'Personal Care', + home: 'Furnishings', + 'gifts & donations': 'Gifts', + 'fees & adjustments': 'Bank Fees and Interest', + // Capital One, Apple and others + dining: 'Dining Out', + grocery: 'Groceries', + 'gas/automotive': 'Fuel', + 'health care': 'Medical and Dental', + airfare: 'Travel', + lodging: 'Travel', + 'phone/cable': 'Internet', + restaurant: 'Dining Out', +}; + +/** Words that mark a card payment or a move between accounts. */ +export const TRANSFER_PATTERN = + /PAYMENT\s*-?\s*THANK ?YOU|AUTOPAY|AUTO ?PAY|E-?PAYMENT|EPAY|ONLINE PAYMENT|MOBILE PAYMENT|INTERNET PAYMENT|PAYMENT RECEIVED|DIRECTPAY|CRCARDPMT|CREDIT ?CRD|CARD ?SERVICES|ELECTRONIC PAYMENT|ONLINE BANKING TRANSFER|TRANSFER (TO|FROM)|\bXFER\b/i; + +export function knownMerchant(description: string): { name: string | null; category: string | null } | null { + for (const [pattern, name, category] of KNOWN_MERCHANTS) if (pattern.test(description)) return { name, category }; + return null; +} diff --git a/server/src/categorize/suggest.ts b/server/src/categorize/suggest.ts new file mode 100644 index 0000000..838a80f --- /dev/null +++ b/server/src/categorize/suggest.ts @@ -0,0 +1,207 @@ +import { and, count, desc, eq, isNotNull, isNull } from 'drizzle-orm'; +import type { AppContext } from '../context'; +import { accounts, categories, rules, transactions, type CategoryRow, type RuleRow } from '../db/schema'; +import { BANK_CATEGORIES, knownMerchant, TRANSFER_PATTERN } from './known'; + +// Where a pre-filled category comes from, strongest first: +// 1. your rules ("always file X as Y") +// 2. your history: the last 3 picks for this merchant (all agreeing = confident) +// 3. card payments and transfers, recognized by their wording +// 4. built-in knowledge of common merchants +// 5. the bank's own category +// Your choices always outrank built-in guesses. + +export interface SuggestInput { + accountId: number; + merchant: string; + description: string; + amountCents: number; + bankCategory?: string | null; + bankType?: string | null; +} + +export interface Suggestion { + categoryId: number | null; + /** Up to three likely categories, best first. */ + suggestions: number[]; + confident: boolean; + source: 'rule' | 'history' | 'transfer' | 'known' | 'bank' | null; +} + +const CARD_WORDS = /CARD|CRD|DISCOVER|CHASE|AMEX|AMERICAN EXPRESS|CITI|CAPITAL ONE|CAP ONE|SYNCHRONY|BARCLAY|APPLECARD|GSBANK/i; + +export class Suggester { + private byName = new Map(); + private byId = new Map(); + private rulesByMerchant = new Map(); + private accountKind = new Map(); + private popular: { expense: number[]; income: number[] } | null = null; + + constructor(private ctx: AppContext) { + for (const c of ctx.db.select().from(categories).where(eq(categories.archived, false)).all()) { + this.byName.set(c.name.toLowerCase(), c); + this.byId.set(c.id, c); + } + for (const r of ctx.db.select().from(rules).all()) { + const key = r.merchant.toLowerCase(); + this.rulesByMerchant.set(key, [...(this.rulesByMerchant.get(key) ?? []), r]); + } + for (const a of ctx.db.select({ id: accounts.id, kind: accounts.kind }).from(accounts).all()) this.accountKind.set(a.id, a.kind); + } + + categoryId(name: string): number | null { + return this.byName.get(name.toLowerCase())?.id ?? null; + } + + /** The rule that renames or files this merchant, if any (an account-specific rule wins). */ + rule(merchant: string, accountId: number): RuleRow | undefined { + const list = this.rulesByMerchant.get(merchant.toLowerCase()) ?? []; + return list.find((r) => r.accountId === accountId) ?? list.find((r) => r.accountId === null); + } + + isTransferLike(input: Pick): boolean { + return ( + TRANSFER_PATTERN.test(input.description) || + /^payment$/i.test(input.bankType ?? '') || + /^payments and credits$/i.test(input.bankCategory ?? '') + ); + } + + transferCategoryFor(accountId: number, description: string, otherAccountId?: number): number | null { + const cardInvolved = + this.accountKind.get(accountId) === 'credit_card' || + (otherAccountId !== undefined && this.accountKind.get(otherAccountId) === 'credit_card') || + CARD_WORDS.test(description); + return this.categoryId(cardInvolved ? 'Credit Card Payment' : 'Transfer Between Accounts'); + } + + private fits(categoryId: number | null, amountCents: number): categoryId is number { + if (categoryId === null) return false; + const c = this.byId.get(categoryId); + if (!c) return false; + // Income categories only make sense for money coming in. + return !(c.kind === 'income' && amountCents < 0); + } + + private history(merchant: string): number[] { + return this.ctx.db + .select({ categoryId: transactions.categoryId }) + .from(transactions) + .where( + and( + eq(transactions.merchant, merchant), + eq(transactions.needsReview, false), + eq(transactions.isSplit, false), + isNotNull(transactions.categoryId), + ), + ) + .orderBy(desc(transactions.date), desc(transactions.id)) + .limit(20) + .all() + .map((r) => r.categoryId!); + } + + private popularCategories(direction: 'expense' | 'income'): number[] { + if (!this.popular) { + const rows = this.ctx.db + .select({ categoryId: transactions.categoryId, n: count() }) + .from(transactions) + .where(and(eq(transactions.needsReview, false), isNotNull(transactions.categoryId))) + .groupBy(transactions.categoryId) + .orderBy(desc(count())) + .limit(30) + .all(); + const used = rows.map((r) => r.categoryId!).filter((id) => this.byId.has(id)); + const defaults = (names: string[]) => names.map((n) => this.categoryId(n)).filter((id): id is number => id !== null); + const of = (kind: 'expense' | 'income') => used.filter((id) => this.byId.get(id)!.kind === kind); + this.popular = { + expense: [...of('expense'), ...defaults(['Groceries', 'Dining Out', 'General Merchandise', 'Household Supplies'])], + income: [...of('income'), ...defaults(['Paycheck', 'Other Income', 'Interest and Dividends'])], + }; + } + return this.popular[direction]; + } + + suggest(input: SuggestInput): Suggestion { + const candidates: number[] = []; + const add = (id: number | null) => { + if (this.fits(id, input.amountCents) && !candidates.includes(id)) candidates.push(id); + }; + let source: Suggestion['source'] = null; + let confident = false; + + const rule = this.rule(input.merchant, input.accountId); + if (rule?.categoryId) { + add(rule.categoryId); + source = 'rule'; + confident = true; + } + + const past = this.history(input.merchant); + if (past.length) { + const last3 = past.slice(0, 3); + if (!source) { + add(last3[0]!); + source = 'history'; + confident = last3.length === 3 && last3.every((id) => id === last3[0]); + } + // Other categories this merchant has had, most frequent first. + const freq = new Map(); + for (const id of past) freq.set(id, (freq.get(id) ?? 0) + 1); + [...freq.entries()].sort((a, b) => b[1] - a[1]).forEach(([id]) => add(id)); + } + + if (this.isTransferLike(input)) { + add(this.transferCategoryFor(input.accountId, input.description)); + source ??= 'transfer'; + } + + const known = knownMerchant(input.description); + if (known?.category) { + add(this.categoryId(known.category)); + source ??= 'known'; + } + + const bank = input.bankCategory ? BANK_CATEGORIES[input.bankCategory.trim().toLowerCase()] : undefined; + if (bank) { + add(this.categoryId(bank)); + source ??= 'bank'; + } + + // Fill the alternates with the best guess's neighbours first (Electric and + // Gas → Water and Trash, Internet), then with what this household uses most. + const lead = candidates[0] !== undefined ? this.byId.get(candidates[0]) : undefined; + if (lead) { + for (const c of this.byId.values()) { + if (candidates.length >= 3) break; + if (c.groupId === lead.groupId) add(c.id); + } + } + for (const id of this.popularCategories(input.amountCents > 0 && this.accountKind.get(input.accountId) !== 'credit_card' ? 'income' : 'expense')) { + if (candidates.length >= 3) break; + add(id); + } + + return { + categoryId: source ? (candidates[0] ?? null) : null, + suggestions: candidates.slice(0, 3), + confident, + source: source && candidates.length ? source : null, + }; + } +} + +/** Remember "always file this merchant this way" (one rule per merchant, newest wins). */ +export function rememberRule(ctx: AppContext, merchant: string, categoryId: number, source: 'learned' | 'manual' = 'learned') { + const now = new Date().toISOString(); + const existing = ctx.db + .select() + .from(rules) + .where(and(eq(rules.merchant, merchant), isNull(rules.accountId))) + .get(); + if (existing) { + ctx.db.update(rules).set({ categoryId, source, lastUsedAt: now }).where(eq(rules.id, existing.id)).run(); + return existing.id; + } + return ctx.db.insert(rules).values({ merchant, categoryId, source, createdAt: now }).returning().get().id; +} diff --git a/server/src/cli.ts b/server/src/cli.ts new file mode 100644 index 0000000..a4d47d3 --- /dev/null +++ b/server/src/cli.ts @@ -0,0 +1,90 @@ +import { IDEA_STATUSES, type IdeaStatus } from '@spendlight/shared'; +import { eq } from 'drizzle-orm'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { hashPassword, temporaryPassword } from './auth/password'; +import { revokeUserSessions } from './auth/sessions'; +import { createBackup, pruneBackups } from './backups/backup'; +import { loadConfig } from './config'; +import type { AppContext } from './context'; +import { openDatabase } from './db/client'; +import { users } from './db/schema'; +import { ideasToMarkdown } from './ideas/markdown'; +import { listIdeas } from './ideas/queries'; +import { silentLogger } from './log'; +import { findActiveUser } from './users'; + +// Admin commands, run inside the container: +// spendlight ideas [--status=new|planned|done|declined] +// spendlight reset-password +// spendlight backup +// spendlight version + +const HELP = `Spendlight admin commands + + spendlight ideas [--status=new] Print ideas as Markdown (all statuses by default) + spendlight reset-password Give someone a temporary password + spendlight backup Back up the database now + spendlight version Show the running version +`; + +const config = loadConfig(); +const [command, ...args] = process.argv.slice(2); + +function openExisting() { + const file = join(config.dataDir, 'spendlight.db'); + if (!existsSync(file)) { + console.error(`No Spendlight database found at ${file}.`); + process.exit(1); + } + return openDatabase(file); +} + +switch (command) { + case 'ideas': { + const statusArg = args.find((a) => a.startsWith('--status='))?.split('=')[1]; + if (statusArg && !IDEA_STATUSES.includes(statusArg as IdeaStatus)) { + console.error(`Unknown status "${statusArg}". Use one of: ${IDEA_STATUSES.join(', ')}.`); + process.exit(1); + } + const { db } = openExisting(); + process.stdout.write(ideasToMarkdown(listIdeas(db, statusArg as IdeaStatus | undefined))); + break; + } + case 'reset-password': { + const username = args[0]; + if (!username) { + console.error('Usage: spendlight reset-password '); + process.exit(1); + } + const { db, sqlite } = openExisting(); + const ctx = { db, sqlite, config, log: silentLogger } as unknown as AppContext; + const user = findActiveUser(ctx, { username }); + if (!user) { + console.error(`Nobody uses the username "${username}".`); + process.exit(1); + } + const password = temporaryPassword(); + db.update(users) + .set({ passwordHash: await hashPassword(password), mustChangePassword: true, updatedAt: new Date().toISOString() }) + .where(eq(users.id, user.id)) + .run(); + revokeUserSessions(ctx, user.id); + console.log(`Temporary password for ${user.displayName} (${user.username}): ${password}`); + console.log('They will choose a new one when they sign in.'); + break; + } + case 'backup': { + const { sqlite } = openExisting(); + const backup = await createBackup(sqlite, config.backupDir, 'manual'); + pruneBackups(config.backupDir, { keepDaily: config.keepDaily, keepMonthly: config.keepMonthly }); + console.log(`Backed up to ${join(config.backupDir, backup.name)}`); + break; + } + case 'version': + console.log(config.version); + break; + default: + process.stdout.write(HELP); + process.exit(command ? 1 : 0); +} diff --git a/server/src/config.ts b/server/src/config.ts new file mode 100644 index 0000000..595bf0c --- /dev/null +++ b/server/src/config.ts @@ -0,0 +1,59 @@ +import { resolve } from 'node:path'; +import { DEFAULT_WEB_DIST } from './paths'; + +export interface Config { + port: number; + dataDir: string; + backupDir: string; + webDist: string; + /** The HTTPS address people use, when set. Pins Face ID sign-in to it. */ + publicUrl: URL | null; + /** Trust X-Forwarded-* headers from the reverse proxy in front of the app. */ + trustProxy: boolean; + ideasEnabled: boolean; + backupTime: { hour: number; minute: number }; + keepDaily: number; + keepMonthly: number; + sessionDays: number; + /** Allow password sign-in over plain HTTP from a non-local address. */ + allowInsecureSignin: boolean; + version: string; +} + +const truthy = (value: string | undefined) => + value !== undefined && ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase()); + +function int(value: string | undefined, fallback: number, min: number, max: number): number { + const n = Number.parseInt(value ?? '', 10); + return Number.isFinite(n) && n >= min && n <= max ? n : fallback; +} + +export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { + let publicUrl: URL | null = null; + const rawUrl = env.PUBLIC_URL?.trim(); + if (rawUrl) { + try { + publicUrl = new URL(rawUrl); + } catch { + throw new Error(`PUBLIC_URL is not a valid address: ${rawUrl}`); + } + } + + const [hour, minute] = (env.BACKUP_TIME ?? '02:30').split(':'); + + return { + port: int(env.PORT, 3000, 1, 65535), + dataDir: resolve(env.DATA_DIR ?? './data'), + backupDir: resolve(env.BACKUP_DIR ?? './backups'), + webDist: resolve(env.WEB_DIST ?? DEFAULT_WEB_DIST), + publicUrl, + trustProxy: truthy(env.TRUST_PROXY), + ideasEnabled: truthy(env.IDEAS_ENABLED), + backupTime: { hour: int(hour, 2, 0, 23), minute: int(minute, 30, 0, 59) }, + keepDaily: int(env.BACKUP_KEEP_DAILY, 14, 1, 365), + keepMonthly: int(env.BACKUP_KEEP_MONTHLY, 12, 0, 120), + sessionDays: int(env.SESSION_DAYS, 400, 1, 400), + allowInsecureSignin: truthy(env.ALLOW_INSECURE_SIGNIN), + version: env.APP_VERSION?.trim() || 'dev', + }; +} diff --git a/server/src/context.ts b/server/src/context.ts new file mode 100644 index 0000000..72b1697 --- /dev/null +++ b/server/src/context.ts @@ -0,0 +1,46 @@ +import { mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { ChallengeStore, RateLimiter } from './auth/ratelimit'; +import { seedCategories } from './categories/seed'; +import { createBackup } from './backups/backup'; +import type { Config } from './config'; +import { migrateDatabase, openDatabase, type DB, type Sqlite } from './db/client'; +import { consoleLogger, type Logger } from './log'; +import { StagingStore } from './import/service'; +import { MIGRATIONS_DIR } from './paths'; + +export interface AppContext { + config: Config; + db: DB; + sqlite: Sqlite; + limiter: RateLimiter; + challenges: ChallengeStore; + staging: StagingStore; + startedAt: Date; + log: Logger; +} + +export async function createContext(config: Config, options: { log?: Logger } = {}): Promise { + const log = options.log ?? consoleLogger; + mkdirSync(config.dataDir, { recursive: true }); + mkdirSync(config.backupDir, { recursive: true }); + + const { sqlite, db } = openDatabase(join(config.dataDir, 'spendlight.db')); + const { pending } = await migrateDatabase(db, sqlite, MIGRATIONS_DIR, async () => { + const backup = await createBackup(sqlite, config.backupDir, 'pre-upgrade'); + log.info(`Backed up before upgrading the database: ${backup.name}`); + }); + if (pending > 0) log.info(`Database upgraded (${pending} step${pending === 1 ? '' : 's'})`); + seedCategories(db); + + return { + config, + db, + sqlite, + limiter: new RateLimiter(), + challenges: new ChallengeStore(), + staging: new StagingStore(), + startedAt: new Date(), + log, + }; +} diff --git a/server/src/db/client.ts b/server/src/db/client.ts new file mode 100644 index 0000000..631c9d1 --- /dev/null +++ b/server/src/db/client.ts @@ -0,0 +1,55 @@ +import Database from 'better-sqlite3'; +import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import { migrate } from 'drizzle-orm/better-sqlite3/migrator'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import * as schema from './schema'; + +export type DB = BetterSQLite3Database; +export type Sqlite = Database.Database; + +export const APP_MARKER = { key: 'app', value: JSON.stringify('spendlight') }; + +export function openDatabase(file: string, options: { readonly?: boolean } = {}) { + const readonly = options.readonly ?? false; + const sqlite = new Database(file, { readonly, fileMustExist: readonly }); + if (!readonly) sqlite.pragma('journal_mode = WAL'); + sqlite.pragma('synchronous = NORMAL'); + sqlite.pragma('foreign_keys = ON'); + sqlite.pragma('busy_timeout = 5000'); + const db = drizzle(sqlite, { schema }); + return { sqlite, db }; +} + +/** How many migrations are waiting, and how many have already run. */ +export function migrationStatus(sqlite: Sqlite, migrationsDir: string) { + const journal = JSON.parse(readFileSync(join(migrationsDir, 'meta', '_journal.json'), 'utf8')) as { + entries: unknown[]; + }; + const hasTable = sqlite + .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '__drizzle_migrations'") + .get(); + const applied = hasTable + ? (sqlite.prepare('SELECT COUNT(*) AS n FROM __drizzle_migrations').get() as { n: number }).n + : 0; + return { pending: Math.max(0, journal.entries.length - applied), applied }; +} + +/** + * Bring the database up to date. When an existing database is about to be + * changed, `beforeUpgrade` runs first so a backup exists if anything goes wrong. + */ +export async function migrateDatabase( + db: DB, + sqlite: Sqlite, + migrationsDir: string, + beforeUpgrade?: () => Promise, +) { + const { pending, applied } = migrationStatus(sqlite, migrationsDir); + if (pending > 0 && applied > 0) await beforeUpgrade?.(); + if (pending > 0) migrate(db, { migrationsFolder: migrationsDir }); + sqlite + .prepare('INSERT INTO setting (key, value) VALUES (?, ?) ON CONFLICT(key) DO NOTHING') + .run(APP_MARKER.key, APP_MARKER.value); + return { pending, applied }; +} diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts new file mode 100644 index 0000000..ca3960b --- /dev/null +++ b/server/src/db/schema.ts @@ -0,0 +1,291 @@ +import { blob, index, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'; + +// Timestamps are ISO 8601 strings in UTC. Money, when it arrives in later +// milestones, is stored as whole cents. + +export const users = sqliteTable( + 'user', + { + id: integer('id').primaryKey({ autoIncrement: true }), + username: text('username').notNull(), + displayName: text('display_name').notNull(), + passwordHash: text('password_hash').notNull(), + access: text('access', { enum: ['read', 'full'] }).notNull().default('full'), + isAdmin: integer('is_admin', { mode: 'boolean' }).notNull().default(false), + theme: text('theme', { enum: ['system', 'light', 'dark'] }).notNull().default('system'), + mustChangePassword: integer('must_change_password', { mode: 'boolean' }).notNull().default(false), + createdAt: text('created_at').notNull(), + updatedAt: text('updated_at').notNull(), + deletedAt: text('deleted_at'), + }, + (t) => [uniqueIndex('user_username_unique').on(t.username)], +); + +export const sessions = sqliteTable( + 'session', + { + /** SHA-256 of the cookie token; the token itself is never stored. */ + id: text('id').primaryKey(), + userId: integer('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + kind: text('kind', { enum: ['browser', 'token'] }).notNull().default('browser'), + deviceLabel: text('device_label').notNull(), + userAgent: text('user_agent'), + ip: text('ip'), + createdAt: text('created_at').notNull(), + lastSeenAt: text('last_seen_at').notNull(), + expiresAt: text('expires_at').notNull(), + }, + (t) => [index('session_user_idx').on(t.userId)], +); + +export const passkeys = sqliteTable( + 'passkey', + { + /** The credential ID, base64url. */ + id: text('id').primaryKey(), + userId: integer('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + name: text('name').notNull(), + publicKey: blob('public_key', { mode: 'buffer' }).notNull(), + counter: integer('counter').notNull().default(0), + transports: text('transports'), + deviceType: text('device_type'), + backedUp: integer('backed_up', { mode: 'boolean' }).notNull().default(false), + createdAt: text('created_at').notNull(), + lastUsedAt: text('last_used_at'), + }, + (t) => [index('passkey_user_idx').on(t.userId)], +); + +/** Household-wide settings as JSON values. Also holds the "app" marker that identifies a Spendlight database. */ +export const settings = sqliteTable('setting', { + key: text('key').primaryKey(), + value: text('value').notNull(), +}); + +export const changeLog = sqliteTable( + 'change_log', + { + id: integer('id').primaryKey({ autoIncrement: true }), + at: text('at').notNull(), + userId: integer('user_id').references(() => users.id, { onDelete: 'set null' }), + action: text('action').notNull(), + entity: text('entity').notNull(), + entityId: text('entity_id'), + detail: text('detail'), + }, + (t) => [index('change_log_at_idx').on(t.at)], +); + +export const ideas = sqliteTable( + 'idea', + { + id: integer('id').primaryKey({ autoIncrement: true }), + title: text('title').notNull(), + description: text('description').notNull().default(''), + status: text('status', { enum: ['new', 'planned', 'done', 'declined'] }).notNull().default('new'), + adminNote: text('admin_note'), + path: text('path'), + appVersion: text('app_version'), + userAgent: text('user_agent'), + userId: integer('user_id').references(() => users.id, { onDelete: 'set null' }), + createdAt: text('created_at').notNull(), + updatedAt: text('updated_at').notNull(), + }, + (t) => [index('idea_status_idx').on(t.status)], +); + +export type UserRow = typeof users.$inferSelect; +export type SessionRow = typeof sessions.$inferSelect; +export type PasskeyRow = typeof passkeys.$inferSelect; +export type IdeaRow = typeof ideas.$inferSelect; + +// --------------------------------------------------------------------------- +// Milestone 1: money in. Amounts are whole cents; money out is negative. +// Dates from statements are plain calendar days, YYYY-MM-DD. + +export const ACCOUNT_KINDS = ['checking', 'savings', 'credit_card', 'cash', 'loan', 'mortgage', 'heloc'] as const; + +export const accounts = sqliteTable('account', { + id: integer('id').primaryKey({ autoIncrement: true }), + name: text('name').notNull(), + kind: text('kind', { enum: ACCOUNT_KINDS }).notNull(), + institution: text('institution'), + last4: text('last4'), + color: text('color').notNull().default('indigo'), + status: text('status', { enum: ['open', 'retired'] }).notNull().default('open'), + /** A known balance at the end of `balanceAsOf`; later transactions are added to it. */ + balanceCents: integer('balance_cents').notNull().default(0), + balanceAsOf: text('balance_as_of'), + balanceSource: text('balance_source', { enum: ['entered', 'statement'] }), + /** Hidden fingerprints that help match statement files to this account. */ + layoutKey: text('layout_key'), + fileNamePattern: text('file_name_pattern'), + sortOrder: integer('sort_order').notNull().default(0), + createdAt: text('created_at').notNull(), + updatedAt: text('updated_at').notNull(), + retiredAt: text('retired_at'), +}); + +export const categoryGroups = sqliteTable('category_group', { + id: integer('id').primaryKey({ autoIncrement: true }), + name: text('name').notNull(), + sortOrder: integer('sort_order').notNull().default(0), +}); + +export const categories = sqliteTable( + 'category', + { + id: integer('id').primaryKey({ autoIncrement: true }), + groupId: integer('group_id') + .notNull() + .references(() => categoryGroups.id), + name: text('name').notNull(), + kind: text('kind', { enum: ['expense', 'income', 'transfer'] }).notNull().default('expense'), + icon: text('icon').notNull().default('circle'), + color: text('color').notNull().default('slate'), + archived: integer('archived', { mode: 'boolean' }).notNull().default(false), + sortOrder: integer('sort_order').notNull().default(0), + }, + (t) => [index('category_group_idx').on(t.groupId)], +); + +/** Column layouts saved from the mapping pop-up (built-in layouts live in code). */ +export const importLayouts = sqliteTable('import_layout', { + id: integer('id').primaryKey({ autoIncrement: true }), + name: text('name').notNull(), + signature: text('signature').notNull(), + config: text('config').notNull(), + createdAt: text('created_at').notNull(), +}); + +export const imports = sqliteTable( + 'import', + { + id: integer('id').primaryKey({ autoIncrement: true }), + accountId: integer('account_id') + .notNull() + .references(() => accounts.id), + fileName: text('file_name').notNull(), + fileHash: text('file_hash').notNull(), + layoutKey: text('layout_key').notNull(), + dateFrom: text('date_from'), + dateTo: text('date_to'), + rowCount: integer('row_count').notNull().default(0), + addedCount: integer('added_count').notNull().default(0), + duplicateCount: integer('duplicate_count').notNull().default(0), + skippedCount: integer('skipped_count').notNull().default(0), + transferCount: integer('transfer_count').notNull().default(0), + status: text('status', { enum: ['committed', 'undone'] }).notNull().default('committed'), + /** The account's balance checkpoint before this import changed it, so undo can restore it. */ + previousBalance: text('previous_balance'), + userId: integer('user_id').references(() => users.id, { onDelete: 'set null' }), + createdAt: text('created_at').notNull(), + undoneAt: text('undone_at'), + }, + (t) => [index('import_account_idx').on(t.accountId)], +); + +export const transactions = sqliteTable( + 'txn', + { + id: integer('id').primaryKey({ autoIncrement: true }), + accountId: integer('account_id') + .notNull() + .references(() => accounts.id), + importId: integer('import_id').references(() => imports.id), + date: text('date').notNull(), + postedDate: text('posted_date'), + amountCents: integer('amount_cents').notNull(), + description: text('description').notNull(), + merchant: text('merchant').notNull(), + /** Null when the purchase is split (see `split`) or not yet categorized. */ + categoryId: integer('category_id').references(() => categories.id), + needsReview: integer('needs_review', { mode: 'boolean' }).notNull().default(true), + /** Where the pre-filled category came from: rule, history, known, bank, transfer. */ + suggestedBy: text('suggested_by'), + isSplit: integer('is_split', { mode: 'boolean' }).notNull().default(false), + bankCategory: text('bank_category'), + bankType: text('bank_type'), + note: text('note'), + transferPeerId: integer('transfer_peer_id'), + source: text('source', { enum: ['import', 'manual'] }).notNull().default('import'), + /** account|date|amount|cleaned description: how duplicates are recognized. */ + dedupeKey: text('dedupe_key').notNull(), + createdAt: text('created_at').notNull(), + updatedAt: text('updated_at').notNull(), + }, + (t) => [ + index('txn_account_date_idx').on(t.accountId, t.date), + index('txn_date_idx').on(t.date), + index('txn_category_date_idx').on(t.categoryId, t.date), + index('txn_merchant_idx').on(t.merchant), + index('txn_dedupe_idx').on(t.dedupeKey), + index('txn_review_idx').on(t.needsReview), + index('txn_import_idx').on(t.importId), + ], +); + +export const splits = sqliteTable( + 'split', + { + id: integer('id').primaryKey({ autoIncrement: true }), + transactionId: integer('transaction_id') + .notNull() + .references(() => transactions.id, { onDelete: 'cascade' }), + categoryId: integer('category_id') + .notNull() + .references(() => categories.id), + amountCents: integer('amount_cents').notNull(), + note: text('note'), + }, + (t) => [index('split_txn_idx').on(t.transactionId), index('split_category_idx').on(t.categoryId)], +); + +export const tags = sqliteTable( + 'tag', + { + id: integer('id').primaryKey({ autoIncrement: true }), + name: text('name').notNull(), + color: text('color').notNull().default('slate'), + }, + (t) => [uniqueIndex('tag_name_unique').on(t.name)], +); + +export const transactionTags = sqliteTable( + 'txn_tag', + { + transactionId: integer('transaction_id') + .notNull() + .references(() => transactions.id, { onDelete: 'cascade' }), + tagId: integer('tag_id') + .notNull() + .references(() => tags.id, { onDelete: 'cascade' }), + }, + (t) => [uniqueIndex('txn_tag_pk').on(t.transactionId, t.tagId), index('txn_tag_tag_idx').on(t.tagId)], +); + +/** "Always file this merchant this way." Created when someone says so, or by hand. */ +export const rules = sqliteTable( + 'rule', + { + id: integer('id').primaryKey({ autoIncrement: true }), + merchant: text('merchant').notNull(), + accountId: integer('account_id').references(() => accounts.id, { onDelete: 'cascade' }), + categoryId: integer('category_id').references(() => categories.id, { onDelete: 'cascade' }), + renameTo: text('rename_to'), + source: text('source', { enum: ['learned', 'manual'] }).notNull().default('learned'), + hits: integer('hits').notNull().default(0), + createdAt: text('created_at').notNull(), + lastUsedAt: text('last_used_at'), + }, + (t) => [index('rule_merchant_idx').on(t.merchant)], +); + +export type AccountRow = typeof accounts.$inferSelect; +export type CategoryRow = typeof categories.$inferSelect; +export type TransactionRow = typeof transactions.$inferSelect; +export type RuleRow = typeof rules.$inferSelect; diff --git a/server/src/docs.ts b/server/src/docs.ts new file mode 100644 index 0000000..24a0c05 --- /dev/null +++ b/server/src/docs.ts @@ -0,0 +1,50 @@ +import { Hono } from 'hono'; +import { createRequire } from 'node:module'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { requireUser } from './auth/guards'; +import type { Env } from './http'; + +// Interactive API documentation at /api/docs, served from the swagger-ui-dist +// package on this server (no outside scripts), for anyone signed in. + +const require = createRequire(import.meta.url); +const swaggerDir = dirname(require.resolve('swagger-ui-dist/package.json')); + +const FILES: Record = { + 'swagger-ui.css': 'text/css; charset=utf-8', + 'swagger-ui-bundle.js': 'text/javascript; charset=utf-8', +}; + +const PAGE = ` + + + + +Spendlight API + + + +
+ + + +`; + +const INIT = `window.ui = SwaggerUIBundle({ url: '/api/v1/openapi.json', dom_id: '#swagger-ui', deepLinking: true });`; + +export function docsRoutes() { + const r = new Hono(); + r.get('/', (c) => { + requireUser(c); + return c.html(PAGE); + }); + r.get('/init.js', (c) => c.body(INIT, 200, { 'Content-Type': 'text/javascript; charset=utf-8' })); + r.get('/:file', (c) => { + const file = c.req.param('file'); + const type = FILES[file]; + if (!type) return c.notFound(); + return c.body(readFileSync(join(swaggerDir, file)), 200, { 'Content-Type': type }); + }); + return r; +} diff --git a/server/src/http.ts b/server/src/http.ts new file mode 100644 index 0000000..9ddcdc8 --- /dev/null +++ b/server/src/http.ts @@ -0,0 +1,59 @@ +import { getConnInfo } from '@hono/node-server/conninfo'; +import type { Context } from 'hono'; +import { HTTPException } from 'hono/http-exception'; +import type { ContentfulStatusCode } from 'hono/utils/http-status'; +import type { AppContext } from './context'; +import type { SessionRow, UserRow } from './db/schema'; + +export type Env = { + Variables: { + user: UserRow | null; + session: SessionRow | null; + }; +}; + +/** Stop the request with a friendly, structured error: { error: { code, message } }. */ +export function fail(status: ContentfulStatusCode, code: string, message: string): never { + throw new HTTPException(status, { message, cause: { code } }); +} + +export const nowIso = () => new Date().toISOString(); + +const firstHeaderValue = (value: string | undefined) => value?.split(',')[0]?.trim() || undefined; + +/** http or https, as the visitor's browser sees it. */ +export function requestProto(c: Context, ctx: AppContext): 'http' | 'https' { + const forwarded = ctx.config.trustProxy ? firstHeaderValue(c.req.header('x-forwarded-proto')) : undefined; + const proto = forwarded ?? new URL(c.req.url).protocol.replace(':', ''); + return proto === 'https' ? 'https' : 'http'; +} + +/** host[:port], as the visitor's browser sees it. */ +export function requestHost(c: Context, ctx: AppContext): string { + const forwarded = ctx.config.trustProxy ? firstHeaderValue(c.req.header('x-forwarded-host')) : undefined; + return forwarded ?? c.req.header('host') ?? new URL(c.req.url).host; +} + +export function requestOrigin(c: Context, ctx: AppContext): string { + return `${requestProto(c, ctx)}://${requestHost(c, ctx)}`; +} + +export function hostnameOf(host: string): string { + return host.startsWith('[') ? host.slice(1, host.indexOf(']')) : host.split(':')[0]!; +} + +export function isLocalHost(hostname: string): boolean { + return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname.endsWith('.localhost'); +} + +export function clientIp(c: Context, ctx: AppContext): string { + if (ctx.config.trustProxy) { + const forwarded = firstHeaderValue(c.req.header('x-forwarded-for')) ?? c.req.header('x-real-ip'); + if (forwarded) return forwarded; + } + try { + return getConnInfo(c).remote.address ?? 'unknown'; + } catch { + return 'unknown'; + } +} diff --git a/server/src/ideas/markdown.ts b/server/src/ideas/markdown.ts new file mode 100644 index 0000000..b29226f --- /dev/null +++ b/server/src/ideas/markdown.ts @@ -0,0 +1,18 @@ +import type { Idea } from '@spendlight/shared'; + +/** Ideas as Markdown, ready to paste into a conversation with Claude. */ +export function ideasToMarkdown(ideas: Idea[], heading = 'Spendlight ideas'): string { + const lines = [`# ${heading}`, '', `${ideas.length} idea${ideas.length === 1 ? '' : 's'}, exported ${new Date().toISOString().slice(0, 10)}.`, '']; + for (const idea of ideas) { + lines.push(`## #${idea.id}: ${idea.title}`, ''); + lines.push( + `- Status: ${idea.status}`, + `- From: ${idea.author ?? 'unknown'} on ${idea.createdAt.slice(0, 10)}`, + `- Screen: ${idea.path ?? 'unknown'}`, + `- Version: ${idea.appVersion ?? 'unknown'}`, + ); + if (idea.adminNote) lines.push(`- Note: ${idea.adminNote}`); + lines.push('', idea.description.trim() || '_No description._', ''); + } + return lines.join('\n'); +} diff --git a/server/src/ideas/queries.ts b/server/src/ideas/queries.ts new file mode 100644 index 0000000..9a3a2b6 --- /dev/null +++ b/server/src/ideas/queries.ts @@ -0,0 +1,30 @@ +import type { Idea, IdeaStatus } from '@spendlight/shared'; +import { desc, eq } from 'drizzle-orm'; +import type { DB } from '../db/client'; +import { ideas, users } from '../db/schema'; + +export function listIdeas(db: DB, status?: IdeaStatus): Idea[] { + const query = db + .select({ idea: ideas, author: users.displayName }) + .from(ideas) + .leftJoin(users, eq(users.id, ideas.userId)) + .orderBy(desc(ideas.createdAt)); + const rows = status ? query.where(eq(ideas.status, status)).all() : query.all(); + return rows.map(({ idea, author }) => ({ + id: idea.id, + title: idea.title, + description: idea.description, + status: idea.status, + adminNote: idea.adminNote, + path: idea.path, + appVersion: idea.appVersion, + userAgent: idea.userAgent, + author: author ?? null, + createdAt: idea.createdAt, + updatedAt: idea.updatedAt, + })); +} + +export function getIdea(db: DB, id: number): Idea | undefined { + return listIdeas(db).find((i) => i.id === id); +} diff --git a/server/src/import/layouts.ts b/server/src/import/layouts.ts new file mode 100644 index 0000000..e9333d5 --- /dev/null +++ b/server/src/import/layouts.ts @@ -0,0 +1,199 @@ +import type { AccountKind, ImportColumnMap } from '@spendlight/shared'; +import { normalizeHeader } from './parse'; + +// Statement layouts Spendlight recognizes on sight. Each was confirmed against +// the masked profile of a real export; anything else goes to the mapping pop-up, +// and the answer is saved as a custom layout. + +export interface Layout { + key: string; + name: string; + institution: string | null; + kind: AccountKind | null; + /** Normalized header names that must all be present. */ + headers: string[]; + /** Header name → role. Missing roles are simply not used. */ + columns: { + date: string; + description: string; + amount?: string; + debit?: string; + credit?: string; + postedDate?: string; + category?: string; + type?: string; + balance?: string; + status?: string; + cardNumber?: string; + }; + positiveIs: 'out' | 'in'; + dateOrder: 'MDY' | 'DMY' | 'YMD'; +} + +export const BUILT_IN_LAYOUTS: Layout[] = [ + { + key: 'chase-card', + name: 'Chase credit card', + institution: 'Chase', + kind: 'credit_card', + headers: ['transaction date', 'post date', 'description', 'category', 'type', 'amount', 'memo'], + columns: { date: 'transaction date', postedDate: 'post date', description: 'description', category: 'category', type: 'type', amount: 'amount' }, + positiveIs: 'in', + dateOrder: 'MDY', + }, + { + key: 'discover-card', + name: 'Discover card', + institution: 'Discover', + kind: 'credit_card', + headers: ['trans. date', 'post date', 'description', 'amount', 'category'], + columns: { date: 'trans. date', postedDate: 'post date', description: 'description', amount: 'amount', category: 'category' }, + positiveIs: 'out', + dateOrder: 'MDY', + }, + { + key: 'bofa-deposit', + name: 'Bank of America checking or savings', + institution: 'Bank of America', + kind: 'checking', + headers: ['date', 'description', 'amount', 'running bal.'], + columns: { date: 'date', description: 'description', amount: 'amount', balance: 'running bal.' }, + positiveIs: 'in', + dateOrder: 'MDY', + }, + { + key: 'bofa-card', + name: 'Bank of America credit card', + institution: 'Bank of America', + kind: 'credit_card', + headers: ['posted date', 'reference number', 'payee', 'address', 'amount'], + columns: { date: 'posted date', description: 'payee', amount: 'amount' }, + positiveIs: 'in', + dateOrder: 'MDY', + }, + { + key: 'capital-one-card', + name: 'Capital One card', + institution: 'Capital One', + kind: 'credit_card', + headers: ['transaction date', 'posted date', 'card no.', 'description', 'category', 'debit', 'credit'], + columns: { date: 'transaction date', postedDate: 'posted date', description: 'description', category: 'category', debit: 'debit', credit: 'credit', cardNumber: 'card no.' }, + positiveIs: 'out', + dateOrder: 'YMD', + }, + { + key: 'citi-card', + name: 'Citi card', + institution: 'Citi', + kind: 'credit_card', + headers: ['status', 'date', 'description', 'debit', 'credit'], + columns: { date: 'date', description: 'description', debit: 'debit', credit: 'credit', status: 'status' }, + positiveIs: 'out', + dateOrder: 'MDY', + }, + { + key: 'apple-card', + name: 'Apple Card', + institution: 'Apple', + kind: 'credit_card', + headers: ['transaction date', 'clearing date', 'description', 'merchant', 'category', 'type', 'amount (usd)', 'purchased by'], + columns: { date: 'transaction date', postedDate: 'clearing date', description: 'description', category: 'category', type: 'type', amount: 'amount (usd)' }, + positiveIs: 'out', + dateOrder: 'MDY', + }, +]; + +export interface ResolvedLayout { + key: string; + name: string; + institution: string | null; + kind: AccountKind | null; + headerRow: number; + map: ImportColumnMap & { type?: number; status?: number; cardNumber?: number }; +} + +const rowSignature = (row: string[]) => row.map(normalizeHeader).join('|'); + +/** Find the header row and a built-in layout that fits it, scanning the top of the file. */ +export function detectBuiltIn(rows: string[][]): ResolvedLayout | null { + const limit = Math.min(rows.length, 40); + let best: { layout: Layout; row: number } | null = null; + for (let r = 0; r < limit; r++) { + const names = new Set(rows[r]!.map(normalizeHeader)); + for (const layout of BUILT_IN_LAYOUTS) { + if (!layout.headers.every((h) => names.has(h))) continue; + if (!best || layout.headers.length > best.layout.headers.length) best = { layout, row: r }; + } + } + if (!best) return null; + const header = rows[best.row]!.map(normalizeHeader); + const at = (name?: string) => (name ? header.indexOf(name) : -1); + const index = (name?: string) => (at(name) >= 0 ? at(name) : undefined); + const c = best.layout.columns; + return { + key: best.layout.key, + name: best.layout.name, + institution: best.layout.institution, + kind: best.layout.kind, + headerRow: best.row, + map: { + date: at(c.date), + description: at(c.description), + amount: index(c.amount), + debit: index(c.debit), + credit: index(c.credit), + postedDate: index(c.postedDate), + category: index(c.category), + balance: index(c.balance), + type: index(c.type), + status: index(c.status), + cardNumber: index(c.cardNumber), + positiveIs: best.layout.positiveIs, + dateOrder: best.layout.dateOrder, + }, + }; +} + +/** Where the column headings are, for a file nobody has seen before. */ +export function findHeaderRow(rows: string[][]): number { + for (let r = 0; r < Math.min(rows.length, 40); r++) { + const row = rows[r]!; + const labels = row.filter((c) => c && !/\d/.test(c)).length; + const next = rows.slice(r + 1, r + 4); + if (labels >= 2 && next.length > 0 && next.every((n) => n.length === row.length)) return r; + } + return -1; +} + +export function headerSignature(rows: string[][], headerRow: number): string { + return headerRow >= 0 ? rowSignature(rows[headerRow]!) : ''; +} + +/** Best guesses for the mapping pop-up, from heading names. */ +export function guessMap(header: string[]): ImportColumnMap | null { + const names = header.map(normalizeHeader); + const find = (...patterns: RegExp[]) => { + for (const p of patterns) { + const i = names.findIndex((n) => p.test(n)); + if (i >= 0) return i; + } + return -1; + }; + const date = find(/^(transaction |trans\.? )?date$/, /date/); + const description = find(/description/, /payee|merchant|name/, /memo|details/); + const amount = find(/^amount/, /amount/); + const debit = find(/debit|withdrawal|charge/); + const credit = find(/credit|deposit/); + const balance = find(/balance/); + const category = find(/category/); + if (date < 0 || description < 0 || (amount < 0 && debit < 0)) return null; + return { + date, + description, + ...(amount >= 0 ? { amount } : { debit, ...(credit >= 0 ? { credit } : {}) }), + ...(balance >= 0 ? { balance } : {}), + ...(category >= 0 ? { category } : {}), + positiveIs: 'in', + dateOrder: 'MDY', + }; +} diff --git a/server/src/import/merchant.ts b/server/src/import/merchant.ts new file mode 100644 index 0000000..4612963 --- /dev/null +++ b/server/src/import/merchant.ts @@ -0,0 +1,109 @@ +import { knownMerchant } from '../categorize/known'; + +// Turning "SQ *JOES COFFEE 8823 CHARLOTTE NC" into "Joes Coffee". Known +// merchants get their proper names; everything else is tidied by rules. + +const US_STATES = new Set( + 'AL AK AZ AR CA CO CT DE FL GA HI ID IL IN IA KS KY LA ME MD MA MI MN MS MO MT NE NV NH NJ NM NY NC ND OH OK OR PA RI SC SD TN TX UT VT VA WA WV WI WY DC PR'.split( + ' ', + ), +); + +// Payment processors that put their own name in front of the merchant's. +const PROCESSOR = /^(SQ|TST|SP|PY|DD|IC|PP|BT|CKO|FSP|PAR|SNACK|LS|SPO|PAYPAL|PP\*|GOOGLE|STRIPE)\s*\*\s*/i; +const NOISE_PREFIX = + /^(POS|DEBIT CARD PURCHASE|CHECKCARD|CHECK CARD|PURCHASE AUTHORIZED ON \d{1,2}\/\d{1,2}|PURCHASE|RECURRING|PREAUTHORIZED|ACH|WEB|ONLINE)\s+/i; +const KEEP_UPPER = new Set(['CVS', 'BP', 'AMC', 'UPS', 'USPS', 'IRS', 'YMCA', 'IKEA', 'AT&T', 'TJ', 'BJ', 'H&M', 'ATM', 'DMV', 'QT', 'KFC', 'USA', 'LLC', 'TV']); +const SMALL_WORDS = new Set(['and', 'of', 'the', 'for', 'at', 'in', 'on', 'to', 'a']); + +function titleCase(text: string): string { + return text + .toLowerCase() + .split(' ') + .map((word, i) => { + if (KEEP_UPPER.has(word.toUpperCase())) return word.toUpperCase(); + if (i > 0 && SMALL_WORDS.has(word)) return word; + return word.replace(/(^|[-/.'&])([a-z])/g, (_, sep: string, ch: string) => sep + ch.toUpperCase()); + }) + .join(' '); +} + +export interface CleanedMerchant { + merchant: string; + /** Last four digits of another account mentioned in the text, as in "transfer to SAV 1234". */ + mentionsLast4: string | null; +} + +export function cleanMerchant(description: string): CleanedMerchant { + const raw = description.replace(/\s+/g, ' ').trim(); + const last4 = /\b(?:SAV|CHK|CRD|CHECKING|SAVINGS|ACCT|ACCOUNT|CARD|X{2,}|\*{2,}|ENDING(?: IN)?)\s*#?(\d{4})\b/i.exec(raw)?.[1] ?? null; + + // Bank of America's own transfers and card payments read best spelled out. + const boa = /^Online Banking transfer (to|from) (SAV|CHK)\b/i.exec(raw); + if (boa) { + const kind = boa[2]!.toUpperCase() === 'SAV' ? 'savings' : 'checking'; + return { merchant: `Transfer ${boa[1]!.toLowerCase()} ${kind}`, mentionsLast4: last4 }; + } + if (/^Online Banking payment to CRD\b/i.test(raw)) { + return { merchant: last4 ? `Payment to card ${last4}` : 'Card payment', mentionsLast4: last4 }; + } + // Person-to-person payments keep the person's name. + const p2p = /^(Zelle|Venmo|Cash ?App)\s+(?:payment|transfer|instant pmt)?\s*(to|from)\s+(.+?)(?:\s+(?:Conf(?:irmation)?#?|ID:|on\s+\d).*)?$/i.exec(raw); + if (p2p) { + const service = p2p[1]!.replace(/\s+/g, ' '); + return { merchant: `${titleCase(service)} ${p2p[2]!.toLowerCase()} ${titleCase(p2p[3]!.trim())}`, mentionsLast4: null }; + } + + const known = knownMerchant(raw); + if (known?.name) return { merchant: known.name, mentionsLast4: last4 }; + + let s = raw; + // ACH entries: "COMPANY NAME DES:E-PAYMENT ID:123 INDN:... CO ID:... WEB" + const ach = /^(.*?)\s+DES:/i.exec(s); + if (ach) s = ach[1]!; + s = s.replace(NOISE_PREFIX, ''); + const processor = PROCESSOR.exec(s); + if (processor) s = s.slice(processor[0].length); + // "MERCHANT*ORDER123" keeps the merchant; anything after a * is a reference. + if (s.includes('*')) s = s.split('*')[0]!.trim() || s.replace(/\*/g, ' '); + // Card descriptors are written in capitals; that is judged on the merchant + // part alone, since bank text around it (like "INDN:Jane Doe") can be mixed. + const shouting = s === s.toUpperCase(); + + // Bill-pay entries name the payee, then say how it was paid. + s = s.replace(/\s+(Bill Payment|Bill Pay|Online Pmt|Online Payment|Web Pmt|ACH Pmt|Autopay)$/i, ''); + // "NAME - CODE123 ..." : the part after the dash is a reference. + const dash = / - (?=.*\d)/.exec(s); + if (dash && dash.index > 2) s = s.slice(0, dash.index); + // Everything after a store number is location detail. + const store = /\s#\s?\d+/.exec(s); + if (store) s = s.slice(0, store.index); + const hadLocation = !!store; + s = s + .replace(/\b\d{3}[-.]\d{3}[-.]\d{4}\b/g, ' ') // phone numbers + .replace(/[A-Z.]*(?=[A-Z0-9]*\d)(?=[A-Z0-9]*[A-Z])\b[A-Z0-9]{6,}\b/gi, ' ') // reference codes mixing letters and digits + .replace(/\b\d{3,}\b/g, ' ') // long numbers + .replace(/\s+/g, ' ') + .trim(); + + // Card descriptors (written in capitals) end with "CITY ST": drop the state, + // then the city word. Bill-pay payees in mixed case keep every word. + const words = s.split(' '); + if ((shouting || hadLocation) && words.length >= 3 && US_STATES.has(words.at(-1)!.toUpperCase()) && words.at(-1) === words.at(-1)!.toUpperCase()) { + words.pop(); + if (words.length >= 2) words.pop(); + s = words.join(' '); + } + s = s.replace(/[\s,.;:/-]+$/, '').trim(); + + // Capitals-only descriptors get title case; text already in normal case keeps its own. + const tidy = (text: string) => (shouting ? titleCase(text) : text.charAt(0).toUpperCase() + text.slice(1)); + const merchant = tidy(s || raw.slice(0, 40)); + return { merchant, mentionsLast4: last4 }; +} + +/** How duplicates are recognized: same account, day, amount and cleaned description. */ +export function dedupeKey(accountId: number, date: string, amountCents: number, description: string): string { + const desc = description.toUpperCase().replace(/[^A-Z0-9]/g, ''); + return `${accountId}|${date}|${amountCents}|${desc}`; +} diff --git a/server/src/import/parse.ts b/server/src/import/parse.ts new file mode 100644 index 0000000..40f9051 --- /dev/null +++ b/server/src/import/parse.ts @@ -0,0 +1,72 @@ +import Papa from 'papaparse'; + +// Reading statement files into plain rows, and turning their text into dates +// and cents. Banks disagree on almost everything, so these are forgiving. + +/** Every non-blank row of a delimited file, as trimmed strings. */ +export function readTable(text: string): string[][] { + const clean = text.replace(/^/, ''); + const result = Papa.parse(clean, { skipEmptyLines: 'greedy', delimitersToGuess: [',', '\t', ';', '|'] }); + return result.data.map((row) => row.map((cell) => (cell ?? '').trim())); +} + +export const normalizeHeader = (cell: string) => cell.trim().toLowerCase().replace(/\s+/g, ' '); + +/** + * "1,234.56" → 123456, "-$5.00" → -500, "(5.00)" → -500, "5.00 CR" → 500 with + * the sign flipped by the caller's rules. Returns null for anything else. + */ +export function parseCents(raw: string | undefined): number | null { + if (raw === undefined) return null; + let s = raw.trim().replace(/[−–]/g, '-'); + if (!s) return null; + let negative = false; + if (/^\(.*\)$/.test(s)) { + negative = true; + s = s.slice(1, -1); + } + const suffix = /\s*(CR|DR)$/i.exec(s); + if (suffix) s = s.slice(0, suffix.index); + s = s.replace(/[$,\s]/g, ''); + if (s.startsWith('-')) { + negative = !negative; + s = s.slice(1); + } else if (s.startsWith('+')) s = s.slice(1); + if (!/^\d+(\.\d{1,4})?$|^\.\d{1,4}$/.test(s)) return null; + const cents = Math.round(Number(s) * 100); + return negative ? -cents : cents; +} + +const pad = (n: number) => String(n).padStart(2, '0'); + +function validDate(y: number, m: number, d: number): string | null { + if (y < 1990 || y > 2100 || m < 1 || m > 12 || d < 1 || d > 31) return null; + const date = new Date(Date.UTC(y, m - 1, d)); + if (date.getUTCMonth() !== m - 1) return null; + return `${y}-${pad(m)}-${pad(d)}`; +} + +/** A statement date as YYYY-MM-DD, or null. `order` settles 03/04/2026. */ +export function parseDate(raw: string | undefined, order: 'MDY' | 'DMY' | 'YMD' = 'MDY'): string | null { + if (!raw) return null; + const s = raw.trim(); + let m = /^(\d{4})-(\d{1,2})-(\d{1,2})/.exec(s); + if (m) return validDate(+m[1]!, +m[2]!, +m[3]!); + m = /^(\d{4})(\d{2})(\d{2})(\d{6})?/.exec(s); + if (m && s.length >= 8 && /^\d{8}/.test(s)) return validDate(+m[1]!, +m[2]!, +m[3]!); + m = /^(\d{1,4})[/.-](\d{1,2})[/.-](\d{2,4})$/.exec(s); + if (!m) return null; + let [a, b, c] = [+m[1]!, +m[2]!, +m[3]!]; + if (m[1]!.length === 4) return validDate(a, b, c); + if (c < 100) c += 2000; + return order === 'DMY' ? validDate(c, b, a) : validDate(c, a, b); +} + +/** Days between two YYYY-MM-DD dates. */ +export function daysBetween(a: string, b: string): number { + return Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / 86_400_000); +} + +export function shiftDate(date: string, days: number): string { + return new Date(Date.parse(`${date}T00:00:00Z`) + days * 86_400_000).toISOString().slice(0, 10); +} diff --git a/server/src/import/service.ts b/server/src/import/service.ts new file mode 100644 index 0000000..800fca5 --- /dev/null +++ b/server/src/import/service.ts @@ -0,0 +1,534 @@ +import type { AccountKind, ImportColumnMap, ImportPreview, ImportSummary } from '@spendlight/shared'; +import { and, between, count, desc, eq, inArray } from 'drizzle-orm'; +import { createHash, randomUUID } from 'node:crypto'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { extname, join } from 'node:path'; +import { logChange } from '../audit'; +import { Suggester } from '../categorize/suggest'; +import type { AppContext } from '../context'; +import { accounts, importLayouts, imports, transactions, type AccountRow } from '../db/schema'; +import { fail } from '../http'; +import { detectBuiltIn, findHeaderRow, guessMap, headerSignature, type ResolvedLayout } from './layouts'; +import { cleanMerchant, dedupeKey } from './merchant'; +import { daysBetween, parseCents, parseDate, readTable, shiftDate } from './parse'; +import { pairTransfers } from './transfers'; + +// The import pipeline: stage a file, preview what it would add, then commit. +// Nothing is written until commit, and every commit can be undone whole. + +const MAX_BYTES = 10 * 1024 * 1024; +const STAGE_TTL_MS = 60 * 60_000; + +interface Staged { + id: string; + fileName: string; + text: string; + hash: string; + userId: number; + createdAt: number; +} + +export class StagingStore { + private files = new Map(); + + add(fileName: string, text: string, userId: number): Staged { + this.sweep(); + const staged = { + id: randomUUID(), + fileName, + text, + hash: createHash('sha256').update(text).digest('hex'), + userId, + createdAt: Date.now(), + }; + this.files.set(staged.id, staged); + return staged; + } + + get(id: string, userId: number): Staged { + const staged = this.files.get(id); + if (!staged || staged.userId !== userId) fail(404, 'expired', 'That file has expired. Please add it again.'); + return staged; + } + + delete(id: string): void { + this.files.delete(id); + } + + private sweep() { + for (const [id, f] of this.files) if (Date.now() - f.createdAt > STAGE_TTL_MS) this.files.delete(id); + } +} + +interface Row { + date: string; + postedDate: string | null; + description: string; + merchant: string; + mentionsLast4: string | null; + amountCents: number; + bankCategory: string | null; + bankType: string | null; + balanceCents: number | null; +} + +interface Parsed { + layout: ResolvedLayout | null; + rows: Row[]; + skipped: number; + header: string[] | null; + sample: string[][]; + suggestedMap: ImportColumnMap | null; + signature: string; + last4Hint: string | null; + openingBalance: { date: string; cents: number } | null; +} + +function fileNameLast4(fileName: string): string | null { + return /^[A-Za-z]+(\d{4})[_-]/.exec(fileName)?.[1] ?? /(?:x|ending|acct|card)[-_ ]?(\d{4})\b/i.exec(fileName)?.[1] ?? null; +} + +/** The file name with its digits generalized, so "Chase1234_Activity_20261007.csv" matches next month's file. */ +const fileNamePattern = (fileName: string) => fileName.replace(/\d{5,}/g, '#').replace(/\(\d+\)/g, ''); + +function parseFile(ctx: AppContext, staged: Staged, mapping?: ImportColumnMap): Parsed { + const table = readTable(staged.text); + let layout = mapping ? null : detectBuiltIn(table); + const headerRow = layout?.headerRow ?? findHeaderRow(table); + const signature = headerSignature(table, headerRow); + + if (!layout && headerRow >= 0) { + const custom = mapping + ? { name: 'Your saved layout', config: JSON.stringify(mapping) } + : ctx.db.select().from(importLayouts).where(eq(importLayouts.signature, signature)).get(); + if (custom) { + layout = { + key: 'id' in custom ? `custom-${custom.id}` : 'custom', + name: custom.name, + institution: null, + kind: null, + headerRow, + map: JSON.parse(custom.config) as ImportColumnMap, + }; + } + } + + const header = headerRow >= 0 ? table[headerRow]! : null; + const sample = table.slice(Math.max(0, headerRow + 1), Math.max(0, headerRow + 1) + 5); + if (!layout) { + return { layout: null, rows: [], skipped: 0, header, sample, suggestedMap: header ? guessMap(header) : null, signature, last4Hint: null, openingBalance: null }; + } + + const m = layout.map; + const rows: Row[] = []; + let skipped = 0; + let openingBalance: Parsed['openingBalance'] = null; + let last4Hint = fileNameLast4(staged.fileName); + + for (const cells of table.slice(layout.headerRow + 1)) { + if (cells.every((c) => !c)) continue; + const date = parseDate(cells[m.date], m.dateOrder); + const description = (cells[m.description] ?? '').trim(); + const balanceCents = m.balance !== undefined ? parseCents(cells[m.balance]) : null; + if (!date) { + skipped++; + continue; + } + // "Beginning balance as of ..." rows carry a balance but no amount. + let amount: number | null; + if (m.amount !== undefined) { + const raw = parseCents(cells[m.amount]); + amount = raw === null ? null : m.positiveIs === 'out' ? -raw : raw; + } else { + const debit = m.debit !== undefined ? parseCents(cells[m.debit]) : null; + const credit = m.credit !== undefined ? parseCents(cells[m.credit]) : null; + amount = debit ? -Math.abs(debit) : credit ? Math.abs(credit) : debit === 0 || credit === 0 ? 0 : null; + } + if (amount === null) { + if (balanceCents !== null && /beginning balance/i.test(description)) openingBalance = { date, cents: balanceCents }; + else skipped++; + continue; + } + if ('status' in m && m.status !== undefined && /pending/i.test(cells[m.status] ?? '')) { + skipped++; + continue; + } + if ('cardNumber' in m && m.cardNumber !== undefined) last4Hint ??= /(\d{4})\s*$/.exec(cells[m.cardNumber] ?? '')?.[1] ?? null; + const { merchant, mentionsLast4 } = cleanMerchant(description); + rows.push({ + date, + postedDate: m.postedDate !== undefined ? parseDate(cells[m.postedDate], m.dateOrder) : null, + description, + merchant, + mentionsLast4, + amountCents: amount, + bankCategory: m.category !== undefined ? cells[m.category] || null : null, + bankType: 'type' in m && m.type !== undefined ? cells[m.type] || null : null, + balanceCents, + }); + } + return { layout, rows, skipped, header, sample, suggestedMap: null, signature, last4Hint, openingBalance }; +} + +/** Which account a file belongs to, judged from fingerprints the person never has to manage. */ +function matchAccount(ctx: AppContext, parsed: Parsed, fileName: string): ImportPreview['account'] { + const open = ctx.db.select().from(accounts).where(eq(accounts.status, 'open')).all(); + if (!open.length || !parsed.layout) return null; + + if (parsed.last4Hint) { + const byLast4 = open.filter((a) => a.last4 === parsed.last4Hint); + if (byLast4.length === 1) return { id: byLast4[0]!.id, confidence: 'certain', reason: `The file is for the account ending ${parsed.last4Hint}.` }; + } + + // Transactions already in an account are the strongest evidence. + if (parsed.rows.length) { + const dates = parsed.rows.map((r) => r.date).sort(); + let best: { account: AccountRow; hits: number } | null = null; + for (const account of open) { + const keys = new Set( + ctx.db + .select({ key: transactions.dedupeKey }) + .from(transactions) + .where(and(eq(transactions.accountId, account.id), between(transactions.date, dates[0]!, dates.at(-1)!))) + .all() + .map((r) => r.key), + ); + const hits = parsed.rows.filter((r) => keys.has(dedupeKey(account.id, r.date, r.amountCents, r.description))).length; + if (hits && (!best || hits > best.hits)) best = { account, hits }; + } + if (best && (best.hits >= 3 || best.hits >= parsed.rows.length * 0.3)) { + return { id: best.account.id, confidence: 'certain', reason: `${best.hits} of these transactions are already in ${best.account.name}.` }; + } + } + + // A statement that starts exactly where an account's last one ended. + if (parsed.openingBalance) { + const continues = open.filter( + (a) => a.balanceSource === 'statement' && a.balanceCents === parsed.openingBalance!.cents && a.layoutKey === parsed.layout!.key, + ); + if (continues.length === 1) return { id: continues[0]!.id, confidence: 'certain', reason: `It picks up where ${continues[0]!.name}’s last statement ended.` }; + } + + // Softer hints only count when no other account at the same bank could be meant. + const institution = parsed.layout.institution?.toLowerCase(); + const sameBank = open.filter((a) => a.layoutKey === parsed.layout!.key || (institution && a.institution?.toLowerCase() === institution)); + const sameLayout = open.filter((a) => a.layoutKey === parsed.layout!.key); + const samePattern = sameLayout.filter((a) => a.fileNamePattern === fileNamePattern(fileName)); + if (sameBank.length === 1 && samePattern.length === 1) return { id: samePattern[0]!.id, confidence: 'likely', reason: `Files like this have gone to ${samePattern[0]!.name} before.` }; + if (sameBank.length === 1 && sameLayout.length === 1) return { id: sameLayout[0]!.id, confidence: 'likely', reason: `${sameLayout[0]!.name} uses this bank’s layout.` }; + + const fresh = open.filter( + (a) => !a.layoutKey && a.institution && parsed.layout!.institution && a.institution.toLowerCase() === parsed.layout!.institution.toLowerCase(), + ); + if (fresh.length === 1) return { id: fresh[0]!.id, confidence: 'likely', reason: `It’s a ${parsed.layout.institution} file.` }; + return null; +} + +interface Planned { + row: Row; + key: string; + status: 'new' | 'duplicate' | 'possible'; + possibleId: string | null; + existingDate: string | null; +} + +/** Count-based duplicate check: import only copies beyond those already stored. */ +function planRows(ctx: AppContext, accountId: number, rows: Row[]): Planned[] { + if (!rows.length) return []; + const dates = rows.map((r) => r.date).sort(); + const from = dates[0]!; + const to = dates.at(-1)!; + const existing = ctx.db + .select() + .from(transactions) + .where(and(eq(transactions.accountId, accountId), between(transactions.date, shiftDate(from, -3), shiftDate(to, 3)))) + .all(); + const existingCount = new Map(); + for (const t of existing) existingCount.set(t.dedupeKey, (existingCount.get(t.dedupeKey) ?? 0) + 1); + + const seen = new Map(); + const planned: Planned[] = rows.map((row) => { + const key = dedupeKey(accountId, row.date, row.amountCents, row.description); + const n = (seen.get(key) ?? 0) + 1; + seen.set(key, n); + return { row, key, status: n <= (existingCount.get(key) ?? 0) ? 'duplicate' : 'new', possibleId: null, existingDate: null }; + }); + + // Near matches: same amount and merchant a day or three apart, inside this + // file's dates, with no exact twin in the file. Usually a pending charge that + // later posted with a new date. + const fileKeys = new Set(planned.map((p) => p.key)); + const used = new Set(); + for (const p of planned) { + if (p.status !== 'new') continue; + const near = existing.find( + (t) => + !used.has(t.id) && + !fileKeys.has(t.dedupeKey) && + t.amountCents === p.row.amountCents && + t.merchant === p.row.merchant && + t.date !== p.row.date && + t.date >= from && + t.date <= to && + Math.abs(daysBetween(t.date, p.row.date)) <= 3, + ); + if (near) { + used.add(near.id); + p.status = 'possible'; + p.possibleId = `${p.key}#${seen.get(p.key)}`; + p.existingDate = near.date; + } + } + return planned; +} + +function accountHint(parsed: Parsed): ImportPreview['accountHint'] { + if (!parsed.layout) return null; + const kind: AccountKind = parsed.layout.kind ?? 'checking'; + const institution = parsed.layout.institution; + const last4 = parsed.last4Hint; + const what = kind === 'credit_card' ? 'card' : kind; + const name = [institution, what].filter(Boolean).join(' ') + (last4 ? ` ${last4}` : ''); + return { kind, institution, last4, name: name.charAt(0).toUpperCase() + name.slice(1) }; +} + +export function previewImport( + ctx: AppContext, + stagingId: string, + userId: number, + options: { mapping?: ImportColumnMap; accountId?: number }, +): ImportPreview { + const staged = ctx.staging.get(stagingId, userId); + const parsed = parseFile(ctx, staged, options.mapping); + const account = options.accountId + ? { id: options.accountId, confidence: 'certain' as const, reason: 'You chose this account.' } + : matchAccount(ctx, parsed, staged.fileName); + const planned = account ? planRows(ctx, account.id, parsed.rows) : parsed.rows.map((row) => ({ row, status: 'new' as const })); + const dates = parsed.rows.map((r) => r.date).sort(); + const fresh = planned.filter((p) => p.status === 'new'); + + return { + stagingId, + fileName: staged.fileName, + layout: parsed.layout ? { key: parsed.layout.key, name: parsed.layout.name } : null, + sampleRows: parsed.layout ? [] : parsed.sample, + headerRow: parsed.layout ? null : parsed.header, + suggestedMap: parsed.suggestedMap, + account, + dateFrom: dates[0] ?? null, + dateTo: dates.at(-1) ?? null, + counts: { + rows: parsed.rows.length, + new: fresh.length, + duplicates: planned.filter((p) => p.status === 'duplicate').length, + possibleDuplicates: planned.filter((p) => p.status === 'possible').length, + skipped: parsed.skipped, + }, + newRows: fresh.slice(0, 8).map((p) => ({ date: p.row.date, merchant: p.row.merchant, amountCents: p.row.amountCents })), + possibleDuplicates: planned + .filter((p): p is Planned => p.status === 'possible') + .map((p) => ({ key: p.possibleId!, date: p.row.date, merchant: p.row.merchant, amountCents: p.row.amountCents, existingDate: p.existingDate! })), + accountHint: accountHint(parsed), + }; +} + +export function commitImport( + ctx: AppContext, + stagingId: string, + userId: number, + options: { accountId: number; mapping?: ImportColumnMap; includePossible?: string[] }, +): ImportSummary { + const staged = ctx.staging.get(stagingId, userId); + const account = ctx.db.select().from(accounts).where(eq(accounts.id, options.accountId)).get(); + if (!account || account.status !== 'open') fail(400, 'no_account', 'Please choose an open account for this file.'); + const parsed = parseFile(ctx, staged, options.mapping); + if (!parsed.layout) fail(400, 'needs_mapping', 'Spendlight needs to know which column is which before importing this file.'); + if (!parsed.rows.length) fail(400, 'empty', 'No transactions were found in this file.'); + + const include = new Set(options.includePossible ?? []); + const planned = planRows(ctx, account.id, parsed.rows).filter( + (p) => p.status === 'new' || (p.status === 'possible' && include.has(p.possibleId!)), + ); + const duplicateCount = parsed.rows.length - planned.length; + const dates = parsed.rows.map((r) => r.date).sort(); + const now = new Date().toISOString(); + + const result = ctx.sqlite.transaction(() => { + const suggester = new Suggester(ctx); + const record = ctx.db + .insert(imports) + .values({ + accountId: account.id, + fileName: staged.fileName, + fileHash: staged.hash, + layoutKey: parsed.layout!.key, + dateFrom: dates[0] ?? null, + dateTo: dates.at(-1) ?? null, + rowCount: parsed.rows.length, + addedCount: planned.length, + duplicateCount, + skippedCount: parsed.skipped, + previousBalance: JSON.stringify({ + balanceCents: account.balanceCents, + balanceAsOf: account.balanceAsOf, + balanceSource: account.balanceSource, + }), + userId, + createdAt: now, + }) + .returning() + .get(); + + const ids: number[] = []; + for (const { row, key } of planned) { + const rule = suggester.rule(row.merchant, account.id); + const merchant = rule?.renameTo || row.merchant; + const s = suggester.suggest({ ...row, merchant, accountId: account.id }); + const inserted = ctx.db + .insert(transactions) + .values({ + accountId: account.id, + importId: record.id, + date: row.date, + postedDate: row.postedDate, + amountCents: row.amountCents, + description: row.description, + merchant, + categoryId: s.categoryId, + suggestedBy: s.source, + needsReview: true, + bankCategory: row.bankCategory, + bankType: row.bankType, + dedupeKey: key, + createdAt: now, + updatedAt: now, + }) + .returning({ id: transactions.id }) + .get(); + ids.push(inserted.id); + } + + const transferCount = pairTransfers(ctx, suggester, ids); + ctx.db.update(imports).set({ transferCount }).where(eq(imports.id, record.id)).run(); + + // A layout from the mapping pop-up is saved, and remembered by its stored key. + let layoutKey = parsed.layout!.key; + if (options.mapping) { + const existing = ctx.db.select().from(importLayouts).where(eq(importLayouts.signature, parsed.signature)).get(); + const layoutId = existing + ? (ctx.db.update(importLayouts).set({ config: JSON.stringify(options.mapping) }).where(eq(importLayouts.id, existing.id)).run(), existing.id) + : ctx.db + .insert(importLayouts) + .values({ name: `${account.name} layout`, signature: parsed.signature, config: JSON.stringify(options.mapping), createdAt: now }) + .returning() + .get().id; + layoutKey = `custom-${layoutId}`; + ctx.db.update(imports).set({ layoutKey }).where(eq(imports.id, record.id)).run(); + } + + // Learn this account's fingerprints and, from a running balance, its balance. + const updates: Partial = { + layoutKey, + fileNamePattern: fileNamePattern(staged.fileName), + updatedAt: now, + }; + if (!account.last4 && parsed.last4Hint) updates.last4 = parsed.last4Hint; + const withBalance = parsed.rows.filter((r) => r.balanceCents !== null); + const last = withBalance.reduce((acc, r) => (!acc || r.date >= acc.date ? r : acc), null); + if (last && (!account.balanceAsOf || last.date >= account.balanceAsOf || account.balanceSource !== 'statement')) { + updates.balanceCents = last.balanceCents!; + updates.balanceAsOf = last.date; + updates.balanceSource = 'statement'; + } + ctx.db.update(accounts).set(updates).where(eq(accounts.id, account.id)).run(); + return { record, transferCount }; + })(); + + // Keep the original file so a fixed importer can read it again later. + try { + const dir = join(ctx.config.dataDir, 'imports'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${result.record.id}-${staged.hash.slice(0, 12)}${extname(staged.fileName) || '.csv'}`), staged.text); + } catch (error) { + ctx.log.warn(`Could not keep a copy of ${staged.fileName}: ${(error as Error).message}`); + } + ctx.staging.delete(stagingId); + logChange(ctx, userId, 'import', 'import', result.record.id, { file: staged.fileName, added: planned.length }); + return importSummary(ctx, result.record.id); +} + +export function importSummary(ctx: AppContext, id: number): ImportSummary { + const row = ctx.db + .select({ i: imports, accountName: accounts.name }) + .from(imports) + .innerJoin(accounts, eq(accounts.id, imports.accountId)) + .where(eq(imports.id, id)) + .get(); + if (!row) fail(404, 'not_found', 'That import wasn’t found.'); + const toReview = + ctx.db + .select({ n: count() }) + .from(transactions) + .where(and(eq(transactions.importId, id), eq(transactions.needsReview, true))) + .get()?.n ?? 0; + const { i } = row; + return { + id: i.id, + accountId: i.accountId, + accountName: row.accountName, + fileName: i.fileName, + layoutKey: i.layoutKey, + dateFrom: i.dateFrom, + dateTo: i.dateTo, + addedCount: i.addedCount, + duplicateCount: i.duplicateCount, + skippedCount: i.skippedCount, + transferCount: i.transferCount, + toReview, + status: i.status, + createdAt: i.createdAt, + }; +} + +export function listImports(ctx: AppContext): ImportSummary[] { + return ctx.db + .select({ id: imports.id }) + .from(imports) + .orderBy(desc(imports.createdAt)) + .limit(100) + .all() + .map((r) => importSummary(ctx, r.id)); +} + +/** Remove everything one import added. Paired transfers in other accounts go back to Review. */ +export function undoImport(ctx: AppContext, id: number, userId: number): ImportSummary { + const record = ctx.db.select().from(imports).where(eq(imports.id, id)).get(); + if (!record) fail(404, 'not_found', 'That import wasn’t found.'); + if (record.status === 'undone') fail(400, 'already_undone', 'That import was already undone.'); + const now = new Date().toISOString(); + + ctx.sqlite.transaction(() => { + const added = ctx.db.select().from(transactions).where(eq(transactions.importId, id)).all(); + const addedIds = added.map((t) => t.id); + const peers = added.map((t) => t.transferPeerId).filter((p): p is number => p !== null && !addedIds.includes(p)); + if (peers.length) { + ctx.db.update(transactions).set({ transferPeerId: null, needsReview: true, updatedAt: now }).where(inArray(transactions.id, peers)).run(); + } + if (addedIds.length) ctx.db.delete(transactions).where(inArray(transactions.id, addedIds)).run(); + + // Put the balance back if this import was the one that set it. + const account = ctx.db.select().from(accounts).where(eq(accounts.id, record.accountId)).get(); + if (record.previousBalance && account?.balanceSource === 'statement' && account.balanceAsOf) { + const fromThisFile = account.balanceAsOf >= (record.dateFrom ?? '') && account.balanceAsOf <= (record.dateTo ?? ''); + if (fromThisFile) { + const prev = JSON.parse(record.previousBalance) as Pick; + ctx.db.update(accounts).set({ ...prev, updatedAt: now }).where(eq(accounts.id, account.id)).run(); + } + } + ctx.db.update(imports).set({ status: 'undone', undoneAt: now }).where(eq(imports.id, id)).run(); + })(); + logChange(ctx, userId, 'undo_import', 'import', id); + return importSummary(ctx, id); +} + +export { MAX_BYTES }; diff --git a/server/src/import/transfers.ts b/server/src/import/transfers.ts new file mode 100644 index 0000000..561cbc2 --- /dev/null +++ b/server/src/import/transfers.ts @@ -0,0 +1,89 @@ +import { and, between, eq, inArray, isNull, ne } from 'drizzle-orm'; +import type { Suggester } from '../categorize/suggest'; +import type { AppContext } from '../context'; +import { accounts, transactions, type TransactionRow } from '../db/schema'; +import { daysBetween, shiftDate } from './parse'; + +// Pairs the two halves of a card payment or a move between your own accounts: +// money leaving one account and the same amount arriving in another within a +// few days. Paired halves are filed as transfers and never count as spending +// or income. + +const WINDOW_DAYS = 5; + +export function pairTransfers(ctx: AppContext, suggester: Suggester, ids: number[]): number { + if (!ids.length) return 0; + const last4ByAccount = new Map( + ctx.db + .select({ id: accounts.id, last4: accounts.last4 }) + .from(accounts) + .all() + .map((a) => [a.id, a.last4]), + ); + const fresh = ctx.db.select().from(transactions).where(inArray(transactions.id, ids)).all(); + let paired = 0; + + for (const t of fresh) { + if (t.transferPeerId || t.amountCents === 0) continue; + const current = ctx.db.select().from(transactions).where(eq(transactions.id, t.id)).get(); + if (!current || current.transferPeerId) continue; + + const candidates = ctx.db + .select() + .from(transactions) + .where( + and( + ne(transactions.accountId, t.accountId), + eq(transactions.amountCents, -t.amountCents), + isNull(transactions.transferPeerId), + eq(transactions.isSplit, false), + between(transactions.date, shiftDate(t.date, -WINDOW_DAYS), shiftDate(t.date, WINDOW_DAYS)), + ), + ) + .all(); + + const tLike = suggester.isTransferLike(t); + const score = (c: TransactionRow) => { + const cLike = suggester.isTransferLike(c); + if (!tLike && !cLike) return -1; + let s = (tLike ? 2 : 0) + (cLike ? 2 : 0); + const cLast4 = last4ByAccount.get(c.accountId); + const tLast4 = last4ByAccount.get(t.accountId); + if (cLast4 && t.description.includes(cLast4)) s += 3; + if (tLast4 && c.description.includes(tLast4)) s += 3; + return s * 10 - Math.abs(daysBetween(t.date, c.date)); + }; + const best = candidates + .map((c) => ({ c, s: score(c) })) + .filter((x) => x.s >= 0) + .sort((a, b) => b.s - a.s)[0]; + if (!best) continue; + + const categoryId = suggester.transferCategoryFor(t.accountId, t.description, best.c.accountId); + const now = new Date().toISOString(); + for (const [self, peer] of [ + [t.id, best.c.id], + [best.c.id, t.id], + ] as const) { + ctx.db + .update(transactions) + .set({ transferPeerId: peer, categoryId, needsReview: false, suggestedBy: 'transfer', isSplit: false, updatedAt: now }) + .where(eq(transactions.id, self)) + .run(); + } + paired++; + } + return paired; +} + +/** Undo a pairing, putting both halves back in Review. */ +export function unpair(ctx: AppContext, transactionId: number): void { + const t = ctx.db.select().from(transactions).where(eq(transactions.id, transactionId)).get(); + if (!t?.transferPeerId) return; + const now = new Date().toISOString(); + ctx.db + .update(transactions) + .set({ transferPeerId: null, needsReview: true, updatedAt: now }) + .where(inArray(transactions.id, [t.id, t.transferPeerId])) + .run(); +} diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 0000000..b60ce10 --- /dev/null +++ b/server/src/index.ts @@ -0,0 +1,37 @@ +import { serve } from '@hono/node-server'; +import { createApp } from './app'; +import { scheduleNightlyBackups } from './backups/backup'; +import { loadConfig } from './config'; +import { createContext } from './context'; + +const config = loadConfig(); +const ctx = await createContext(config); +const app = createApp(ctx); + +const backups = scheduleNightlyBackups({ + sqlite: ctx.sqlite, + dir: config.backupDir, + at: config.backupTime, + policy: { keepDaily: config.keepDaily, keepMonthly: config.keepMonthly }, + log: ctx.log, +}); + +const server = serve({ fetch: app.fetch, port: config.port, hostname: '0.0.0.0' }, (info) => { + ctx.log.info( + `Spendlight ${config.version} listening on port ${info.port}` + + ` (ideas ${config.ideasEnabled ? 'on' : 'off'}, data in ${config.dataDir}, backups in ${config.backupDir})`, + ); +}); + +function shutdown(signal: string) { + ctx.log.info(`${signal} received, shutting down`); + backups.stop(); + server.close(() => { + ctx.sqlite.close(); + process.exit(0); + }); + setTimeout(() => process.exit(0), 5000).unref(); +} + +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT')); diff --git a/server/src/log.ts b/server/src/log.ts new file mode 100644 index 0000000..9a839b9 --- /dev/null +++ b/server/src/log.ts @@ -0,0 +1,15 @@ +export interface Logger { + info(message: string): void; + warn(message: string): void; + error(message: string, error?: unknown): void; +} + +const stamp = () => new Date().toISOString(); + +export const consoleLogger: Logger = { + info: (message) => console.log(`${stamp()} ${message}`), + warn: (message) => console.warn(`${stamp()} WARN ${message}`), + error: (message, error) => console.error(`${stamp()} ERROR ${message}`, error ?? ''), +}; + +export const silentLogger: Logger = { info() {}, warn() {}, error() {} }; diff --git a/server/src/paths.ts b/server/src/paths.ts new file mode 100644 index 0000000..59420ec --- /dev/null +++ b/server/src/paths.ts @@ -0,0 +1,6 @@ +import { fileURLToPath } from 'node:url'; + +// Resolved from this file, which sits one level below server/ both as source +// (server/src) and once bundled (server/dist), so the same paths work in both. +export const MIGRATIONS_DIR = fileURLToPath(new URL('../drizzle', import.meta.url)); +export const DEFAULT_WEB_DIST = fileURLToPath(new URL('../../web/dist', import.meta.url)); diff --git a/server/src/routes/accounts.ts b/server/src/routes/accounts.ts new file mode 100644 index 0000000..16f34a5 --- /dev/null +++ b/server/src/routes/accounts.ts @@ -0,0 +1,167 @@ +import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'; +import { asc, eq } from 'drizzle-orm'; +import { logChange } from '../audit'; +import { requireUser, requireWrite } from '../auth/guards'; +import type { AppContext } from '../context'; +import { accounts, transactions } from '../db/schema'; +import { fail, nowIso, type Env } from '../http'; +import { AccountKindSchema, AccountSchema, Cents, DateString, IdParam, jsonBody, jsonResponse, PaletteSchema, validationHook } from '../schemas'; +import { toAccount } from '../transactions/dto'; + +const today = () => new Date().toLocaleDateString('en-CA'); + +export function accountRoutes(ctx: AppContext) { + const r = new OpenAPIHono({ defaultHook: validationHook }); + + const find = (id: number) => ctx.db.select().from(accounts).where(eq(accounts.id, id)).get() ?? fail(404, 'not_found', 'That account wasn’t found.'); + + r.openapi( + createRoute({ + method: 'get', + path: '/accounts', + tags: ['Accounts'], + summary: 'Every account, open and retired, with current balances', + responses: jsonResponse(z.array(AccountSchema), 'Accounts'), + }), + (c) => { + requireUser(c); + const rows = ctx.db.select().from(accounts).orderBy(asc(accounts.sortOrder), asc(accounts.name)).all(); + return c.json(rows.map((a) => toAccount(ctx, a)), 200); + }, + ); + + r.openapi( + createRoute({ + method: 'post', + path: '/accounts', + tags: ['Accounts'], + summary: 'Add an account', + request: jsonBody( + z.object({ + name: z.string().trim().min(1, 'Please name the account.').max(60), + kind: AccountKindSchema, + institution: z.string().trim().max(60).nullable().optional(), + last4: z.string().regex(/^\d{4}$/, 'Use the last 4 digits.').nullable().optional(), + color: PaletteSchema.optional(), + /** Today's balance; for a card, the amount owed as a negative number. */ + balanceCents: Cents.optional(), + balanceAsOf: DateString.optional(), + }), + ), + responses: jsonResponse(AccountSchema, 'Added'), + }), + (c) => { + const user = requireWrite(c); + const body = c.req.valid('json'); + const now = nowIso(); + const row = ctx.db + .insert(accounts) + .values({ + name: body.name, + kind: body.kind, + institution: body.institution ?? null, + last4: body.last4 ?? null, + color: body.color ?? 'indigo', + balanceCents: body.balanceCents ?? 0, + balanceAsOf: body.balanceCents !== undefined ? (body.balanceAsOf ?? today()) : null, + balanceSource: body.balanceCents !== undefined ? 'entered' : null, + createdAt: now, + updatedAt: now, + }) + .returning() + .get(); + logChange(ctx, user.id, 'add_account', 'account', row.id, { name: row.name, kind: row.kind }); + return c.json(toAccount(ctx, row), 200); + }, + ); + + r.openapi( + createRoute({ + method: 'patch', + path: '/accounts/{id}', + tags: ['Accounts'], + summary: 'Rename, recolor, retire or reopen an account', + request: { + params: IdParam, + ...jsonBody( + z.object({ + name: z.string().trim().min(1).max(60).optional(), + kind: AccountKindSchema.optional(), + institution: z.string().trim().max(60).nullable().optional(), + last4: z.string().regex(/^\d{4}$/).nullable().optional(), + color: PaletteSchema.optional(), + status: z.enum(['open', 'retired']).optional(), + }), + ), + }, + responses: jsonResponse(AccountSchema, 'Updated'), + }), + (c) => { + const user = requireWrite(c); + const { id } = c.req.valid('param'); + const body = c.req.valid('json'); + find(id); + const now = nowIso(); + const updated = ctx.db + .update(accounts) + .set({ + ...body, + ...(body.status === 'retired' ? { retiredAt: now } : body.status === 'open' ? { retiredAt: null } : {}), + updatedAt: now, + }) + .where(eq(accounts.id, id)) + .returning() + .get()!; + logChange(ctx, user.id, 'update_account', 'account', id, body); + return c.json(toAccount(ctx, updated), 200); + }, + ); + + r.openapi( + createRoute({ + method: 'post', + path: '/accounts/{id}/balance', + tags: ['Accounts'], + summary: 'Correct an account’s balance, as of a day (today by default)', + request: { params: IdParam, ...jsonBody(z.object({ balanceCents: Cents, asOf: DateString.optional() })) }, + responses: jsonResponse(AccountSchema, 'Balance set'), + }), + (c) => { + const user = requireWrite(c); + const { id } = c.req.valid('param'); + const { balanceCents, asOf } = c.req.valid('json'); + find(id); + const updated = ctx.db + .update(accounts) + .set({ balanceCents, balanceAsOf: asOf ?? today(), balanceSource: 'entered', updatedAt: nowIso() }) + .where(eq(accounts.id, id)) + .returning() + .get()!; + logChange(ctx, user.id, 'set_balance', 'account', id, { balanceCents, asOf }); + return c.json(toAccount(ctx, updated), 200); + }, + ); + + r.openapi( + createRoute({ + method: 'delete', + path: '/accounts/{id}', + tags: ['Accounts'], + summary: 'Delete an account that has no transactions (retire it otherwise)', + request: { params: IdParam }, + responses: jsonResponse(z.object({ ok: z.literal(true) }), 'Deleted'), + }), + (c) => { + const user = requireWrite(c); + const { id } = c.req.valid('param'); + const account = find(id); + const used = ctx.db.select({ id: transactions.id }).from(transactions).where(eq(transactions.accountId, id)).limit(1).get(); + if (used) fail(400, 'has_transactions', 'This account has transactions. Retire it instead, so its history stays in your reports.'); + ctx.db.delete(accounts).where(eq(accounts.id, id)).run(); + logChange(ctx, user.id, 'delete_account', 'account', id, { name: account.name }); + return c.json({ ok: true as const }, 200); + }, + ); + + return r; +} diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts new file mode 100644 index 0000000..7a8f3d0 --- /dev/null +++ b/server/src/routes/auth.ts @@ -0,0 +1,175 @@ +import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'; +import { eq } from 'drizzle-orm'; +import { logChange } from '../audit'; +import { assertSecureSignIn, requireUser } from '../auth/guards'; +import { hashPassword, verifyPassword } from '../auth/password'; +import { clearSessionCookie, createSession, revokeSession, revokeUserSessions, setSessionCookie } from '../auth/sessions'; +import type { AppContext } from '../context'; +import { users } from '../db/schema'; +import { clientIp, fail, nowIso, type Env } from '../http'; +import { + DisplayName, + jsonBody, + jsonResponse, + MeSchema, + OkSchema, + Password, + ThemeSchema, + Username, + validationHook, +} from '../schemas'; +import { activeUserCount, findActiveUser, toMe } from '../users'; + +export function authRoutes(ctx: AppContext) { + const r = new OpenAPIHono({ defaultHook: validationHook }); + + r.openapi( + createRoute({ + method: 'post', + path: '/setup', + tags: ['Sign-in'], + summary: 'Create the first login (only works while there are no people yet)', + request: jsonBody(z.object({ displayName: DisplayName, username: Username, password: Password })), + responses: jsonResponse(MeSchema, 'Created and signed in'), + }), + async (c) => { + assertSecureSignIn(c, ctx); + const body = c.req.valid('json'); + const passwordHash = await hashPassword(body.password); + // Check and insert in one transaction so two setups can't race. + const user = ctx.sqlite.transaction(() => { + if (activeUserCount(ctx) > 0) fail(409, 'already_set_up', 'Spendlight is already set up. Please sign in.'); + const now = nowIso(); + return ctx.db + .insert(users) + .values({ + username: body.username, + displayName: body.displayName, + passwordHash, + access: 'full', + isAdmin: true, + createdAt: now, + updatedAt: now, + }) + .returning() + .get(); + })(); + logChange(ctx, user.id, 'setup', 'user', user.id); + const { token } = createSession(ctx, user.id, { userAgent: c.req.header('user-agent'), ip: clientIp(c, ctx) }); + setSessionCookie(c, ctx, token); + return c.json(toMe(ctx, user), 200); + }, + ); + + r.openapi( + createRoute({ + method: 'post', + path: '/auth/login', + tags: ['Sign-in'], + summary: 'Sign in with username and password', + request: jsonBody(z.object({ username: z.string().trim().min(1).max(40), password: z.string().min(1).max(200) })), + responses: jsonResponse(MeSchema, 'Signed in'), + }), + async (c) => { + assertSecureSignIn(c, ctx); + const { username, password } = c.req.valid('json'); + const ip = clientIp(c, ctx); + const keys = [`ip:${ip}`, `user:${username.toLowerCase()}`]; + const wait = Math.max(...keys.map((k) => ctx.limiter.retryAfter(k))); + if (wait > 0) { + c.header('Retry-After', String(wait)); + fail(429, 'slow_down', `Too many tries. Please wait ${wait > 60 ? `${Math.ceil(wait / 60)} minutes` : `${wait} seconds`} and try again.`); + } + + const user = findActiveUser(ctx, { username }); + if (!(await verifyPassword(user?.passwordHash, password)) || !user) { + keys.forEach((k) => ctx.limiter.fail(k)); + fail(401, 'wrong_password', 'That username and password don’t match.'); + } + keys.forEach((k) => ctx.limiter.succeed(k)); + + const { token } = createSession(ctx, user.id, { userAgent: c.req.header('user-agent'), ip }); + setSessionCookie(c, ctx, token); + return c.json(toMe(ctx, user), 200); + }, + ); + + r.openapi( + createRoute({ + method: 'post', + path: '/auth/logout', + tags: ['Sign-in'], + summary: 'Sign out of this device', + responses: jsonResponse(OkSchema, 'Signed out'), + }), + (c) => { + const session = c.get('session'); + if (session) revokeSession(ctx, session.id); + clearSessionCookie(c); + return c.json({ ok: true as const }, 200); + }, + ); + + r.openapi( + createRoute({ + method: 'get', + path: '/me', + tags: ['Me'], + summary: 'The person signed in on this device', + responses: jsonResponse(MeSchema, 'You'), + }), + (c) => c.json(toMe(ctx, requireUser(c)), 200), + ); + + r.openapi( + createRoute({ + method: 'patch', + path: '/me', + tags: ['Me'], + summary: 'Change your name or theme', + request: jsonBody(z.object({ displayName: DisplayName.optional(), theme: ThemeSchema.optional() })), + responses: jsonResponse(MeSchema, 'Updated'), + }), + (c) => { + const user = requireUser(c); + const body = c.req.valid('json'); + const updated = ctx.db + .update(users) + .set({ ...body, updatedAt: nowIso() }) + .where(eq(users.id, user.id)) + .returning() + .get(); + return c.json(toMe(ctx, updated ?? user), 200); + }, + ); + + r.openapi( + createRoute({ + method: 'post', + path: '/me/password', + tags: ['Me'], + summary: 'Change your password (signs out your other devices)', + request: jsonBody(z.object({ currentPassword: z.string().min(1).max(200), newPassword: Password })), + responses: jsonResponse(MeSchema, 'Password changed'), + }), + async (c) => { + const user = requireUser(c); + const { currentPassword, newPassword } = c.req.valid('json'); + if (!(await verifyPassword(user.passwordHash, currentPassword))) { + fail(400, 'wrong_password', 'Your current password isn’t right.'); + } + if (currentPassword === newPassword) fail(400, 'same_password', 'Please choose a different password.'); + const updated = ctx.db + .update(users) + .set({ passwordHash: await hashPassword(newPassword), mustChangePassword: false, updatedAt: nowIso() }) + .where(eq(users.id, user.id)) + .returning() + .get(); + revokeUserSessions(ctx, user.id, c.get('session')?.id); + logChange(ctx, user.id, 'change_password', 'user', user.id); + return c.json(toMe(ctx, updated ?? user), 200); + }, + ); + + return r; +} diff --git a/server/src/routes/backups.ts b/server/src/routes/backups.ts new file mode 100644 index 0000000..bf7e585 --- /dev/null +++ b/server/src/routes/backups.ts @@ -0,0 +1,76 @@ +import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'; +import { createReadStream, existsSync, statSync } from 'node:fs'; +import { Readable } from 'node:stream'; +import { logChange } from '../audit'; +import { requireAdmin } from '../auth/guards'; +import { backupPath, createBackup, listBackups, pruneBackups } from '../backups/backup'; +import type { AppContext } from '../context'; +import { fail, type Env } from '../http'; +import { BackupSchema, jsonResponse, validationHook } from '../schemas'; + +const pad = (n: number) => String(n).padStart(2, '0'); + +export function backupRoutes(ctx: AppContext) { + const r = new OpenAPIHono({ defaultHook: validationHook }); + const { backupDir, backupTime, keepDaily, keepMonthly } = ctx.config; + + r.openapi( + createRoute({ + method: 'get', + path: '/backups', + tags: ['Backups'], + summary: 'Backups in the backup folder, newest first', + responses: jsonResponse( + z + .object({ backups: z.array(BackupSchema), nightlyAt: z.string(), keepDaily: z.number(), keepMonthly: z.number() }) + .openapi('BackupOverview'), + 'Backups', + ), + }), + (c) => { + requireAdmin(c); + return c.json( + { + backups: listBackups(backupDir), + nightlyAt: `${pad(backupTime.hour)}:${pad(backupTime.minute)}`, + keepDaily, + keepMonthly, + }, + 200, + ); + }, + ); + + r.openapi( + createRoute({ + method: 'post', + path: '/backups', + tags: ['Backups'], + summary: 'Back up right now', + responses: jsonResponse(BackupSchema, 'Backed up'), + }), + async (c) => { + const admin = requireAdmin(c); + const backup = await createBackup(ctx.sqlite, backupDir, 'manual'); + pruneBackups(backupDir, { keepDaily, keepMonthly }); + logChange(ctx, admin.id, 'backup', 'backup', backup.name); + return c.json(backup, 200); + }, + ); + + // A file download, so it sits outside the JSON API description. + r.get('/backups/:name/download', (c) => { + requireAdmin(c); + const path = backupPath(backupDir, c.req.param('name')); + if (!path || !existsSync(path)) fail(404, 'not_found', 'That backup wasn’t found.'); + return new Response(Readable.toWeb(createReadStream(path)) as ReadableStream, { + headers: { + 'Content-Type': 'application/vnd.sqlite3', + 'Content-Length': String(statSync(path).size), + 'Content-Disposition': `attachment; filename="${c.req.param('name')}"`, + }, + }); + }); + + return r; +} diff --git a/server/src/routes/devices.ts b/server/src/routes/devices.ts new file mode 100644 index 0000000..0d85a2a --- /dev/null +++ b/server/src/routes/devices.ts @@ -0,0 +1,82 @@ +import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'; +import { and, desc, eq } from 'drizzle-orm'; +import { requireUser } from '../auth/guards'; +import { revokeSession, revokeUserSessions } from '../auth/sessions'; +import type { AppContext } from '../context'; +import { sessions } from '../db/schema'; +import { fail, type Env } from '../http'; +import { DeviceSchema, jsonResponse, OkSchema, validationHook } from '../schemas'; + +export function deviceRoutes(ctx: AppContext) { + const r = new OpenAPIHono({ defaultHook: validationHook }); + + r.openapi( + createRoute({ + method: 'get', + path: '/devices', + tags: ['Me'], + summary: 'Devices where you are signed in', + responses: jsonResponse(z.array(DeviceSchema), 'Your signed-in devices'), + }), + (c) => { + const user = requireUser(c); + const current = c.get('session')?.id; + const rows = ctx.db + .select() + .from(sessions) + .where(eq(sessions.userId, user.id)) + .orderBy(desc(sessions.lastSeenAt)) + .all(); + return c.json( + rows.map((s) => ({ + id: s.id, + deviceLabel: s.deviceLabel, + createdAt: s.createdAt, + lastSeenAt: s.lastSeenAt, + current: s.id === current, + })), + 200, + ); + }, + ); + + r.openapi( + createRoute({ + method: 'delete', + path: '/devices/{id}', + tags: ['Me'], + summary: 'Sign out one of your devices', + request: { params: z.object({ id: z.string().regex(/^[a-f0-9]{64}$/) }) }, + responses: jsonResponse(OkSchema, 'Signed out'), + }), + (c) => { + const user = requireUser(c); + const { id } = c.req.valid('param'); + const found = ctx.db + .select() + .from(sessions) + .where(and(eq(sessions.id, id), eq(sessions.userId, user.id))) + .get(); + if (!found) fail(404, 'not_found', 'That device is already signed out.'); + revokeSession(ctx, id); + return c.json({ ok: true as const }, 200); + }, + ); + + r.openapi( + createRoute({ + method: 'post', + path: '/devices/sign-out-others', + tags: ['Me'], + summary: 'Sign out everywhere except this device', + responses: jsonResponse(OkSchema, 'Signed out'), + }), + (c) => { + const user = requireUser(c); + revokeUserSessions(ctx, user.id, c.get('session')?.id); + return c.json({ ok: true as const }, 200); + }, + ); + + return r; +} diff --git a/server/src/routes/ideas.ts b/server/src/routes/ideas.ts new file mode 100644 index 0000000..43e7fa6 --- /dev/null +++ b/server/src/routes/ideas.ts @@ -0,0 +1,110 @@ +import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'; +import { eq } from 'drizzle-orm'; +import { logChange } from '../audit'; +import { requireAdmin, requireUser } from '../auth/guards'; +import type { AppContext } from '../context'; +import { ideas } from '../db/schema'; +import { fail, nowIso, type Env } from '../http'; +import { ideasToMarkdown } from '../ideas/markdown'; +import { getIdea, listIdeas } from '../ideas/queries'; +import { IdeaSchema, IdeaStatusSchema, IdParam, jsonBody, jsonResponse, validationHook } from '../schemas'; + +// The ideas box. Only mounted when IDEAS_ENABLED is on, so with the flag off +// these addresses simply don't exist. + +export function ideaRoutes(ctx: AppContext) { + const r = new OpenAPIHono({ defaultHook: validationHook }); + + r.openapi( + createRoute({ + method: 'post', + path: '/ideas', + tags: ['Ideas'], + summary: 'Share an idea (anyone signed in, including read-only people)', + request: jsonBody( + z.object({ + title: z.string().trim().min(1, 'Please give your idea a title.').max(120), + description: z.string().trim().max(4000).default(''), + path: z.string().max(200).optional(), + }), + ), + responses: jsonResponse(IdeaSchema, 'Saved'), + }), + (c) => { + const user = requireUser(c); + const body = c.req.valid('json'); + const now = nowIso(); + const row = ctx.db + .insert(ideas) + .values({ + title: body.title, + description: body.description, + path: body.path ?? null, + appVersion: ctx.config.version, + userAgent: c.req.header('user-agent')?.slice(0, 300) ?? null, + userId: user.id, + createdAt: now, + updatedAt: now, + }) + .returning() + .get(); + ctx.log.info(`New idea #${row.id} from ${user.username}: ${row.title}`); + return c.json(getIdea(ctx.db, row.id)!, 200); + }, + ); + + r.openapi( + createRoute({ + method: 'get', + path: '/ideas', + tags: ['Ideas'], + summary: 'All ideas, newest first', + request: { query: z.object({ status: IdeaStatusSchema.optional() }) }, + responses: jsonResponse(z.array(IdeaSchema), 'Ideas'), + }), + (c) => { + requireAdmin(c); + return c.json(listIdeas(ctx.db, c.req.valid('query').status), 200); + }, + ); + + r.openapi( + createRoute({ + method: 'patch', + path: '/ideas/{id}', + tags: ['Ideas'], + summary: 'Set an idea’s status or note', + request: { + params: IdParam, + ...jsonBody(z.object({ status: IdeaStatusSchema.optional(), adminNote: z.string().trim().max(2000).nullable().optional() })), + }, + responses: jsonResponse(IdeaSchema, 'Updated'), + }), + (c) => { + const admin = requireAdmin(c); + const { id } = c.req.valid('param'); + const body = c.req.valid('json'); + const updated = ctx.db + .update(ideas) + .set({ ...body, adminNote: body.adminNote === '' ? null : body.adminNote, updatedAt: nowIso() }) + .where(eq(ideas.id, id)) + .returning() + .get(); + if (!updated) fail(404, 'not_found', 'That idea wasn’t found.'); + logChange(ctx, admin.id, 'update_idea', 'idea', id, body); + return c.json(getIdea(ctx.db, id)!, 200); + }, + ); + + // Markdown for review with Claude, so it sits outside the JSON API description. + r.get('/ideas/export.md', (c) => { + requireAdmin(c); + const status = c.req.query('status'); + const list = listIdeas(ctx.db, IdeaStatusSchema.safeParse(status).success ? (status as never) : undefined); + c.header('Content-Type', 'text/markdown; charset=utf-8'); + c.header('Content-Disposition', 'attachment; filename="spendlight-ideas.md"'); + return c.body(ideasToMarkdown(list)); + }); + + return r; +} diff --git a/server/src/routes/imports.ts b/server/src/routes/imports.ts new file mode 100644 index 0000000..964e3e5 --- /dev/null +++ b/server/src/routes/imports.ts @@ -0,0 +1,120 @@ +import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'; +import { requireUser, requireWrite } from '../auth/guards'; +import type { AppContext } from '../context'; +import { fail, type Env } from '../http'; +import { commitImport, listImports, MAX_BYTES, previewImport, undoImport } from '../import/service'; +import { ColumnMapSchema, IdParam, ImportPreviewSchema, ImportSummarySchema, jsonBody, jsonResponse, validationHook } from '../schemas'; + +export function importRoutes(ctx: AppContext) { + const r = new OpenAPIHono({ defaultHook: validationHook }); + + r.openapi( + createRoute({ + method: 'post', + path: '/imports/files', + tags: ['Import'], + summary: 'Upload one statement file and get a preview of what it would add', + request: { + body: { + content: { + 'multipart/form-data': { + schema: z.object({ file: z.any().openapi({ type: 'string', format: 'binary' }) }), + }, + }, + required: true, + }, + }, + responses: jsonResponse(ImportPreviewSchema, 'Preview'), + }), + async (c) => { + const user = requireWrite(c); + const form = await c.req.parseBody(); + const file = form.file; + if (!(file instanceof File)) fail(400, 'no_file', 'Please choose a statement file.'); + if (file.size > MAX_BYTES) fail(413, 'too_big', 'That file is larger than 10 MB. Statements are usually much smaller; is it the right file?'); + const text = await file.text(); + if (/\u0000/.test(text.slice(0, 2000))) fail(400, 'not_text', 'That doesn’t look like a CSV statement. Try your bank’s “CSV” or “spreadsheet” download.'); + const staged = ctx.staging.add(file.name || 'statement.csv', text, user.id); + return c.json(previewImport(ctx, staged.id, user.id, {}), 200); + }, + ); + + r.openapi( + createRoute({ + method: 'post', + path: '/imports/preview', + tags: ['Import'], + summary: 'Preview an uploaded file again, with a chosen account or column layout', + request: jsonBody( + z.object({ stagingId: z.string().uuid(), accountId: z.number().int().optional(), mapping: ColumnMapSchema.optional() }), + ), + responses: jsonResponse(ImportPreviewSchema, 'Preview'), + }), + (c) => { + const user = requireWrite(c); + const { stagingId, accountId, mapping } = c.req.valid('json'); + return c.json(previewImport(ctx, stagingId, user.id, { accountId, mapping }), 200); + }, + ); + + r.openapi( + createRoute({ + method: 'post', + path: '/imports', + tags: ['Import'], + summary: 'Import a previewed file into an account', + request: jsonBody( + z.object({ + stagingId: z.string().uuid(), + accountId: z.number().int(), + mapping: ColumnMapSchema.optional(), + includePossibleDuplicates: z.array(z.string()).optional(), + }), + ), + responses: jsonResponse(ImportSummarySchema, 'Imported'), + }), + (c) => { + const user = requireWrite(c); + const body = c.req.valid('json'); + return c.json( + commitImport(ctx, body.stagingId, user.id, { + accountId: body.accountId, + mapping: body.mapping, + includePossible: body.includePossibleDuplicates, + }), + 200, + ); + }, + ); + + r.openapi( + createRoute({ + method: 'get', + path: '/imports', + tags: ['Import'], + summary: 'Recent imports, newest first', + responses: jsonResponse(z.array(ImportSummarySchema), 'Imports'), + }), + (c) => { + requireUser(c); + return c.json(listImports(ctx), 200); + }, + ); + + r.openapi( + createRoute({ + method: 'delete', + path: '/imports/{id}', + tags: ['Import'], + summary: 'Undo an import: remove everything it added', + request: { params: IdParam }, + responses: jsonResponse(ImportSummarySchema, 'Undone'), + }), + (c) => { + const user = requireWrite(c); + return c.json(undoImport(ctx, c.req.valid('param').id, user.id), 200); + }, + ); + + return r; +} diff --git a/server/src/routes/passkeys.ts b/server/src/routes/passkeys.ts new file mode 100644 index 0000000..ff75f0b --- /dev/null +++ b/server/src/routes/passkeys.ts @@ -0,0 +1,246 @@ +import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'; +import { + generateAuthenticationOptions, + generateRegistrationOptions, + verifyAuthenticationResponse, + verifyRegistrationResponse, + type AuthenticationResponseJSON, + type AuthenticatorTransport, + type RegistrationResponseJSON, +} from '@simplewebauthn/server'; +import { and, eq } from 'drizzle-orm'; +import type { Context } from 'hono'; +import { deleteCookie, getCookie, setCookie } from 'hono/cookie'; +import { randomBytes } from 'node:crypto'; +import { logChange } from '../audit'; +import { requireUser } from '../auth/guards'; +import { createSession, deviceLabel, setSessionCookie } from '../auth/sessions'; +import type { AppContext } from '../context'; +import { passkeys } from '../db/schema'; +import { clientIp, fail, hostnameOf, nowIso, requestHost, requestOrigin, requestProto, type Env } from '../http'; +import { jsonBody, jsonResponse, MeSchema, OkSchema, PasskeySchema, validationHook, WebAuthnJson } from '../schemas'; +import { findActiveUser, toMe } from '../users'; + +// Face ID, Touch ID, fingerprint and Windows Hello sign-in, through passkeys +// (WebAuthn). Passkeys are tied to the web address, so the relying party is +// PUBLIC_URL when set, otherwise the address the browser used. + +const LOGIN_CHALLENGE_COOKIE = 'sl_wa'; + +function relyingParty(c: Context, ctx: AppContext) { + if (ctx.config.publicUrl) return { rpID: ctx.config.publicUrl.hostname, origin: ctx.config.publicUrl.origin }; + return { rpID: hostnameOf(requestHost(c, ctx)), origin: requestOrigin(c, ctx) }; +} + +const parseTransports = (raw: string | null) => + raw ? (JSON.parse(raw) as AuthenticatorTransport[]) : undefined; + +export function passkeyRoutes(ctx: AppContext) { + const r = new OpenAPIHono({ defaultHook: validationHook }); + + r.openapi( + createRoute({ + method: 'get', + path: '/passkeys', + tags: ['Face ID and fingerprint'], + summary: 'Your passkeys', + responses: jsonResponse(z.array(PasskeySchema), 'Your passkeys'), + }), + (c) => { + const user = requireUser(c); + const rows = ctx.db.select().from(passkeys).where(eq(passkeys.userId, user.id)).all(); + return c.json( + rows.map((p) => ({ id: p.id, name: p.name, createdAt: p.createdAt, lastUsedAt: p.lastUsedAt })), + 200, + ); + }, + ); + + r.openapi( + createRoute({ + method: 'post', + path: '/passkeys/register/options', + tags: ['Face ID and fingerprint'], + summary: 'Start adding a passkey for this device', + responses: jsonResponse(WebAuthnJson, 'Options for navigator.credentials.create'), + }), + async (c) => { + const user = requireUser(c); + const session = c.get('session')!; + const { rpID } = relyingParty(c, ctx); + const existing = ctx.db.select().from(passkeys).where(eq(passkeys.userId, user.id)).all(); + const options = await generateRegistrationOptions({ + rpName: 'Spendlight', + rpID, + userName: user.username, + userDisplayName: user.displayName, + userID: new TextEncoder().encode(`spendlight-user-${user.id}`), + attestationType: 'none', + excludeCredentials: existing.map((p) => ({ id: p.id, transports: parseTransports(p.transports) })), + authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' }, + }); + ctx.challenges.put(`register:${session.id}`, options.challenge); + return c.json(options as unknown as Record, 200); + }, + ); + + r.openapi( + createRoute({ + method: 'post', + path: '/passkeys/register/verify', + tags: ['Face ID and fingerprint'], + summary: 'Finish adding a passkey', + request: jsonBody(z.object({ response: WebAuthnJson, name: z.string().trim().max(60).optional() })), + responses: jsonResponse(PasskeySchema, 'Passkey added'), + }), + async (c) => { + const user = requireUser(c); + const session = c.get('session')!; + const body = c.req.valid('json'); + const expectedChallenge = ctx.challenges.take(`register:${session.id}`); + if (!expectedChallenge) fail(400, 'expired', 'That took a little too long. Please try again.'); + const { rpID, origin } = relyingParty(c, ctx); + + let verification; + try { + verification = await verifyRegistrationResponse({ + response: body.response as unknown as RegistrationResponseJSON, + expectedChallenge, + expectedOrigin: origin, + expectedRPID: rpID, + requireUserVerification: false, + }); + } catch (error) { + ctx.log.warn(`Passkey registration rejected: ${(error as Error).message}`); + fail(400, 'passkey_rejected', 'This device’s passkey couldn’t be verified. Please try again.'); + } + if (!verification.verified || !verification.registrationInfo) { + fail(400, 'passkey_rejected', 'This device’s passkey couldn’t be verified. Please try again.'); + } + + const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo; + const name = body.name || deviceLabel(c.req.header('user-agent')).split(' · ')[0] || 'Passkey'; + const row = ctx.db + .insert(passkeys) + .values({ + id: credential.id, + userId: user.id, + name, + publicKey: Buffer.from(credential.publicKey), + counter: credential.counter, + transports: credential.transports ? JSON.stringify(credential.transports) : null, + deviceType: credentialDeviceType, + backedUp: credentialBackedUp, + createdAt: nowIso(), + }) + .returning() + .get(); + logChange(ctx, user.id, 'add_passkey', 'passkey', row.id, { name }); + return c.json({ id: row.id, name: row.name, createdAt: row.createdAt, lastUsedAt: row.lastUsedAt }, 200); + }, + ); + + r.openapi( + createRoute({ + method: 'delete', + path: '/passkeys/{id}', + tags: ['Face ID and fingerprint'], + summary: 'Remove one of your passkeys', + request: { params: z.object({ id: z.string().min(1).max(512) }) }, + responses: jsonResponse(OkSchema, 'Removed'), + }), + (c) => { + const user = requireUser(c); + const { id } = c.req.valid('param'); + const removed = ctx.db + .delete(passkeys) + .where(and(eq(passkeys.id, id), eq(passkeys.userId, user.id))) + .returning() + .get(); + if (!removed) fail(404, 'not_found', 'That passkey wasn’t found.'); + logChange(ctx, user.id, 'remove_passkey', 'passkey', id, { name: removed.name }); + return c.json({ ok: true as const }, 200); + }, + ); + + r.openapi( + createRoute({ + method: 'post', + path: '/auth/passkey/options', + tags: ['Sign-in'], + summary: 'Start signing in with Face ID or fingerprint', + responses: jsonResponse(WebAuthnJson, 'Options for navigator.credentials.get'), + }), + async (c) => { + const { rpID } = relyingParty(c, ctx); + const options = await generateAuthenticationOptions({ rpID, userVerification: 'preferred' }); + const key = randomBytes(16).toString('base64url'); + ctx.challenges.put(`login:${key}`, options.challenge); + setCookie(c, LOGIN_CHALLENGE_COOKIE, key, { + httpOnly: true, + secure: requestProto(c, ctx) === 'https', + sameSite: 'Strict', + path: '/api/v1/auth/passkey', + maxAge: 300, + }); + return c.json(options as unknown as Record, 200); + }, + ); + + r.openapi( + createRoute({ + method: 'post', + path: '/auth/passkey/verify', + tags: ['Sign-in'], + summary: 'Finish signing in with Face ID or fingerprint', + request: jsonBody(z.object({ response: WebAuthnJson })), + responses: jsonResponse(MeSchema, 'Signed in'), + }), + async (c) => { + const key = getCookie(c, LOGIN_CHALLENGE_COOKIE); + deleteCookie(c, LOGIN_CHALLENGE_COOKIE, { path: '/api/v1/auth/passkey' }); + const expectedChallenge = key ? ctx.challenges.take(`login:${key}`) : undefined; + if (!expectedChallenge) fail(400, 'expired', 'That took a little too long. Please try again.'); + + const response = c.req.valid('json').response as unknown as AuthenticationResponseJSON; + const stored = ctx.db.select().from(passkeys).where(eq(passkeys.id, String(response.id))).get(); + const user = stored ? findActiveUser(ctx, { id: stored.userId }) : undefined; + if (!stored || !user) { + fail(401, 'unknown_passkey', 'This passkey isn’t linked to anyone here. Sign in with your password, then add it in Settings.'); + } + + const { rpID, origin } = relyingParty(c, ctx); + let verification; + try { + verification = await verifyAuthenticationResponse({ + response, + expectedChallenge, + expectedOrigin: origin, + expectedRPID: rpID, + credential: { + id: stored.id, + publicKey: new Uint8Array(stored.publicKey), + counter: stored.counter, + transports: parseTransports(stored.transports), + }, + requireUserVerification: false, + }); + } catch (error) { + ctx.log.warn(`Passkey sign-in rejected: ${(error as Error).message}`); + fail(401, 'passkey_rejected', 'That didn’t work. Please try again, or use your password.'); + } + if (!verification.verified) fail(401, 'passkey_rejected', 'That didn’t work. Please try again, or use your password.'); + + ctx.db + .update(passkeys) + .set({ counter: verification.authenticationInfo.newCounter, lastUsedAt: nowIso() }) + .where(eq(passkeys.id, stored.id)) + .run(); + const { token } = createSession(ctx, user.id, { userAgent: c.req.header('user-agent'), ip: clientIp(c, ctx) }); + setSessionCookie(c, ctx, token); + return c.json(toMe(ctx, user), 200); + }, + ); + + return r; +} diff --git a/server/src/routes/people.ts b/server/src/routes/people.ts new file mode 100644 index 0000000..39fc30b --- /dev/null +++ b/server/src/routes/people.ts @@ -0,0 +1,202 @@ +import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'; +import type { Person } from '@spendlight/shared'; +import { and, eq, isNull, max } from 'drizzle-orm'; +import { logChange } from '../audit'; +import { requireAdmin } from '../auth/guards'; +import { hashPassword, temporaryPassword } from '../auth/password'; +import { revokeUserSessions } from '../auth/sessions'; +import type { AppContext } from '../context'; +import { passkeys, sessions, users, type UserRow } from '../db/schema'; +import { fail, nowIso, type Env } from '../http'; +import { + AccessSchema, + DisplayName, + IdParam, + jsonBody, + jsonResponse, + OkSchema, + Password, + PersonSchema, + Username, + validationHook, +} from '../schemas'; +import { adminCount, findActiveUser } from '../users'; + +// Managing the household's logins. Admin only. + +function toPerson(user: UserRow, lastActiveAt: string | null): Person { + return { + id: user.id, + username: user.username, + displayName: user.displayName, + access: user.access, + isAdmin: user.isAdmin, + mustChangePassword: user.mustChangePassword, + createdAt: user.createdAt, + lastActiveAt, + }; +} + +const PersonWithPassword = z + .object({ person: PersonSchema, temporaryPassword: z.string() }) + .openapi('PersonWithTemporaryPassword'); + +export function peopleRoutes(ctx: AppContext) { + const r = new OpenAPIHono({ defaultHook: validationHook }); + + const lastActive = (userId: number) => + ctx.db.select({ last: max(sessions.lastSeenAt) }).from(sessions).where(eq(sessions.userId, userId)).get()?.last ?? + null; + + const target = (id: number) => findActiveUser(ctx, { id }) ?? fail(404, 'not_found', 'That person wasn’t found.'); + + r.openapi( + createRoute({ + method: 'get', + path: '/people', + tags: ['People'], + summary: 'Everyone who can sign in', + responses: jsonResponse(z.array(PersonSchema), 'People'), + }), + (c) => { + requireAdmin(c); + const rows = ctx.db.select().from(users).where(isNull(users.deletedAt)).orderBy(users.displayName).all(); + return c.json( + rows.map((u) => toPerson(u, lastActive(u.id))), + 200, + ); + }, + ); + + r.openapi( + createRoute({ + method: 'post', + path: '/people', + tags: ['People'], + summary: 'Add a person. They choose their own password at first sign-in.', + request: jsonBody( + z.object({ + displayName: DisplayName, + username: Username, + access: AccessSchema, + isAdmin: z.boolean().default(false), + password: Password.optional(), + }), + ), + responses: jsonResponse(PersonWithPassword, 'Added'), + }), + async (c) => { + const admin = requireAdmin(c); + const body = c.req.valid('json'); + if (findActiveUser(ctx, { username: body.username })) { + fail(409, 'username_taken', 'Someone already uses that username.'); + } + const password = body.password ?? temporaryPassword(); + const now = nowIso(); + const user = ctx.db + .insert(users) + .values({ + username: body.username, + displayName: body.displayName, + passwordHash: await hashPassword(password), + access: body.access, + isAdmin: body.isAdmin, + mustChangePassword: true, + createdAt: now, + updatedAt: now, + }) + .returning() + .get(); + logChange(ctx, admin.id, 'add_person', 'user', user.id, { access: user.access, isAdmin: user.isAdmin }); + return c.json({ person: toPerson(user, null), temporaryPassword: password }, 200); + }, + ); + + r.openapi( + createRoute({ + method: 'patch', + path: '/people/{id}', + tags: ['People'], + summary: 'Change a person’s name, access or admin role', + request: { + params: IdParam, + ...jsonBody(z.object({ displayName: DisplayName.optional(), access: AccessSchema.optional(), isAdmin: z.boolean().optional() })), + }, + responses: jsonResponse(PersonSchema, 'Updated'), + }), + (c) => { + const admin = requireAdmin(c); + const { id } = c.req.valid('param'); + const body = c.req.valid('json'); + const person = target(id); + if (person.isAdmin && body.isAdmin === false && adminCount(ctx) <= 1) { + fail(400, 'last_admin', 'Spendlight needs at least one admin. Make someone else an admin first.'); + } + const updated = ctx.db + .update(users) + .set({ ...body, updatedAt: nowIso() }) + .where(eq(users.id, id)) + .returning() + .get()!; + logChange(ctx, admin.id, 'update_person', 'user', id, body); + return c.json(toPerson(updated, lastActive(id)), 200); + }, + ); + + r.openapi( + createRoute({ + method: 'post', + path: '/people/{id}/reset-password', + tags: ['People'], + summary: 'Give a person a temporary password and sign them out everywhere', + request: { params: IdParam }, + responses: jsonResponse(PersonWithPassword, 'Reset'), + }), + async (c) => { + const admin = requireAdmin(c); + const { id } = c.req.valid('param'); + target(id); + const password = temporaryPassword(); + const updated = ctx.db + .update(users) + .set({ passwordHash: await hashPassword(password), mustChangePassword: true, updatedAt: nowIso() }) + .where(eq(users.id, id)) + .returning() + .get()!; + revokeUserSessions(ctx, id); + logChange(ctx, admin.id, 'reset_password', 'user', id); + return c.json({ person: toPerson(updated, null), temporaryPassword: password }, 200); + }, + ); + + r.openapi( + createRoute({ + method: 'delete', + path: '/people/{id}', + tags: ['People'], + summary: 'Remove a person. Their past changes keep their name in the history.', + request: { params: IdParam }, + responses: jsonResponse(OkSchema, 'Removed'), + }), + (c) => { + const admin = requireAdmin(c); + const { id } = c.req.valid('param'); + const person = target(id); + if (person.id === admin.id) fail(400, 'cannot_remove_self', 'You can’t remove yourself.'); + if (person.isAdmin && adminCount(ctx) <= 1) fail(400, 'last_admin', 'Spendlight needs at least one admin.'); + ctx.sqlite.transaction(() => { + ctx.db + .update(users) + .set({ deletedAt: nowIso(), username: `${person.username}#removed-${person.id}`, updatedAt: nowIso() }) + .where(and(eq(users.id, id), isNull(users.deletedAt))) + .run(); + ctx.db.delete(passkeys).where(eq(passkeys.userId, id)).run(); + revokeUserSessions(ctx, id); + })(); + logChange(ctx, admin.id, 'remove_person', 'user', id, { username: person.username }); + return c.json({ ok: true as const }, 200); + }, + ); + + return r; +} diff --git a/server/src/routes/system.ts b/server/src/routes/system.ts new file mode 100644 index 0000000..a5c5b46 --- /dev/null +++ b/server/src/routes/system.ts @@ -0,0 +1,49 @@ +import { createRoute, OpenAPIHono } from '@hono/zod-openapi'; +import type { AppContext } from '../context'; +import type { Env } from '../http'; +import { HealthSchema, jsonResponse, PublicConfigSchema, validationHook } from '../schemas'; +import { activeUserCount } from '../users'; + +export function systemRoutes(ctx: AppContext) { + const r = new OpenAPIHono({ defaultHook: validationHook }); + + r.openapi( + createRoute({ + method: 'get', + path: '/health', + tags: ['System'], + summary: 'Is the app up, and which version is running?', + responses: jsonResponse(HealthSchema, 'The app is running'), + }), + (c) => + c.json( + { + status: 'ok' as const, + version: ctx.config.version, + uptimeSeconds: Math.round((Date.now() - ctx.startedAt.getTime()) / 1000), + }, + 200, + ), + ); + + r.openapi( + createRoute({ + method: 'get', + path: '/config', + tags: ['System'], + summary: 'What the sign-in screen needs before anyone signs in', + responses: jsonResponse(PublicConfigSchema, 'Version and switches'), + }), + (c) => + c.json( + { + version: ctx.config.version, + ideasEnabled: ctx.config.ideasEnabled, + needsSetup: activeUserCount(ctx) === 0, + }, + 200, + ), + ); + + return r; +} diff --git a/server/src/routes/transactions.ts b/server/src/routes/transactions.ts new file mode 100644 index 0000000..7f1a890 --- /dev/null +++ b/server/src/routes/transactions.ts @@ -0,0 +1,603 @@ +import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'; +import type { ReviewItem } from '@spendlight/shared'; +import { and, count, desc, eq, exists, gte, inArray, isNull, like, lte, ne, or, sql, type SQL } from 'drizzle-orm'; +import { logChange } from '../audit'; +import { requireUser, requireWrite } from '../auth/guards'; +import { rememberRule, Suggester } from '../categorize/suggest'; +import type { AppContext } from '../context'; +import { accounts, categories, rules, splits, tags, transactions, transactionTags, type TransactionRow } from '../db/schema'; +import { fail, nowIso, type Env } from '../http'; +import { dedupeKey } from '../import/merchant'; +import { unpair } from '../import/transfers'; +import { + CategoryGroupSchema, + Cents, + DateString, + IdParam, + jsonBody, + jsonResponse, + ReviewItemSchema, + TransactionSchema, + validationHook, +} from '../schemas'; +import { listCategoryGroups, newestFirst, toTransaction, toTransactions } from '../transactions/dto'; + +const SimilarSchema = z + .object({ merchant: z.string(), count: z.number().int(), categoryId: z.number().int() }) + .nullable() + .openapi('SimilarTransactions'); + +const TagList = z.array(z.string().trim().min(1).max(40)).max(20); + +export function transactionRoutes(ctx: AppContext) { + const r = new OpenAPIHono({ defaultHook: validationHook }); + + const find = (id: number): TransactionRow => + ctx.db.select().from(transactions).where(eq(transactions.id, id)).get() ?? fail(404, 'not_found', 'That transaction wasn’t found.'); + + const categoryKind = (id: number) => ctx.db.select({ kind: categories.kind }).from(categories).where(eq(categories.id, id)).get()?.kind; + + const assertCategory = (id: number) => { + if (!categoryKind(id)) fail(400, 'no_category', 'That category doesn’t exist.'); + }; + + function setTags(transactionId: number, names: string[]) { + ctx.db.delete(transactionTags).where(eq(transactionTags.transactionId, transactionId)).run(); + // The first spelling of a tag wins: "Back to school" and "back to school" are one tag. + const byKey = new Map(); + for (const n of names) if (n.trim() && !byKey.has(n.trim().toLowerCase())) byKey.set(n.trim().toLowerCase(), n.trim()); + const unique = [...byKey.values()]; + for (const name of unique) { + const existing = ctx.db + .select() + .from(tags) + .where(sql`lower(${tags.name}) = ${name.toLowerCase()}`) + .get(); + const tagId = existing?.id ?? ctx.db.insert(tags).values({ name }).returning().get().id; + ctx.db.insert(transactionTags).values({ transactionId, tagId }).onConflictDoNothing().run(); + } + } + + /** Other transactions from the same merchant still waiting in Review, for "also file these?". */ + function similarWaiting(t: TransactionRow, categoryId: number) { + const n = + ctx.db + .select({ n: count() }) + .from(transactions) + .where( + and( + eq(transactions.merchant, t.merchant), + eq(transactions.needsReview, true), + ne(transactions.id, t.id), + ), + ) + .get()?.n ?? 0; + return n > 0 ? { merchant: t.merchant, count: n, categoryId } : null; + } + + // --- Listing --------------------------------------------------------------- + + r.openapi( + createRoute({ + method: 'get', + path: '/transactions', + tags: ['Transactions'], + summary: 'Search and filter transactions across every account', + request: { + query: z.object({ + q: z.string().max(100).optional(), + accountId: z.coerce.number().int().optional(), + categoryId: z.union([z.coerce.number().int(), z.literal('none')]).optional(), + tag: z.string().max(40).optional(), + from: DateString.optional(), + to: DateString.optional(), + review: z.enum(['true', 'false']).optional(), + limit: z.coerce.number().int().min(1).max(500).default(100), + offset: z.coerce.number().int().min(0).default(0), + }), + }, + responses: jsonResponse( + z.object({ + items: z.array(TransactionSchema), + total: z.number().int(), + sums: z.object({ inCents: z.number().int(), outCents: z.number().int() }), + }), + 'Transactions, newest first', + ), + }), + (c) => { + requireUser(c); + const q = c.req.valid('query'); + const where: SQL[] = []; + if (q.q) { + const term = `%${q.q.replace(/[%_]/g, '')}%`; + where.push(or(like(transactions.merchant, term), like(transactions.description, term), like(transactions.note, term))!); + } + if (q.accountId) where.push(eq(transactions.accountId, q.accountId)); + if (q.categoryId === 'none') where.push(and(isNull(transactions.categoryId), eq(transactions.isSplit, false))!); + else if (q.categoryId !== undefined) { + const cid = q.categoryId; + where.push( + or( + eq(transactions.categoryId, cid), + exists(ctx.db.select().from(splits).where(and(eq(splits.transactionId, transactions.id), eq(splits.categoryId, cid)))), + )!, + ); + } + if (q.tag) { + where.push( + exists( + ctx.db + .select() + .from(transactionTags) + .innerJoin(tags, eq(tags.id, transactionTags.tagId)) + .where(and(eq(transactionTags.transactionId, transactions.id), sql`lower(${tags.name}) = ${q.tag.toLowerCase()}`)), + ), + ); + } + if (q.from) where.push(gte(transactions.date, q.from)); + if (q.to) where.push(lte(transactions.date, q.to)); + if (q.review) where.push(eq(transactions.needsReview, q.review === 'true')); + const condition = where.length ? and(...where) : undefined; + + const rows = ctx.db.select().from(transactions).where(condition).orderBy(...newestFirst).limit(q.limit).offset(q.offset).all(); + const total = ctx.db.select({ n: count() }).from(transactions).where(condition).get()?.n ?? 0; + const sums = ctx.db + .select({ + inCents: sql`coalesce(sum(case when ${transactions.amountCents} > 0 then ${transactions.amountCents} end), 0)`, + outCents: sql`coalesce(sum(case when ${transactions.amountCents} < 0 then ${transactions.amountCents} end), 0)`, + }) + .from(transactions) + .leftJoin(categories, eq(categories.id, transactions.categoryId)) + .where(and(condition, or(isNull(categories.kind), ne(categories.kind, 'transfer')))) + .get() ?? { inCents: 0, outCents: 0 }; + return c.json({ items: toTransactions(ctx, rows), total, sums }, 200); + }, + ); + + r.openapi( + createRoute({ + method: 'get', + path: '/transactions/{id}', + tags: ['Transactions'], + summary: 'One transaction', + request: { params: IdParam }, + responses: jsonResponse(TransactionSchema, 'The transaction'), + }), + (c) => { + requireUser(c); + return c.json(toTransaction(ctx, find(c.req.valid('param').id)), 200); + }, + ); + + // --- Changing one ------------------------------------------------------------ + + r.openapi( + createRoute({ + method: 'patch', + path: '/transactions/{id}', + tags: ['Transactions'], + summary: 'File, rename, note or tag a transaction', + request: { + params: IdParam, + ...jsonBody( + z.object({ + categoryId: z.number().int().nullable().optional(), + merchant: z.string().trim().min(1).max(80).optional(), + /** Rename this merchant on every transaction, and on future imports. */ + renameEverywhere: z.boolean().optional(), + note: z.string().trim().max(500).nullable().optional(), + tags: TagList.optional(), + needsReview: z.boolean().optional(), + date: DateString.optional(), + amountCents: Cents.optional(), + }), + ), + }, + responses: jsonResponse(z.object({ transaction: TransactionSchema, similar: SimilarSchema }), 'Updated'), + }), + (c) => { + const user = requireWrite(c); + const { id } = c.req.valid('param'); + const body = c.req.valid('json'); + const t = find(id); + const now = nowIso(); + const set: Partial = { updatedAt: now }; + let similar = null; + + if (body.categoryId !== undefined) { + if (body.categoryId !== null) assertCategory(body.categoryId); + if (t.transferPeerId && (body.categoryId === null || categoryKind(body.categoryId) !== 'transfer')) unpair(ctx, t.id); + if (t.isSplit) ctx.db.delete(splits).where(eq(splits.transactionId, t.id)).run(); + Object.assign(set, { categoryId: body.categoryId, isSplit: false, needsReview: body.categoryId === null, suggestedBy: null }); + if (body.categoryId !== null) similar = similarWaiting(t, body.categoryId); + } + if (body.needsReview !== undefined) set.needsReview = body.needsReview; + if (body.note !== undefined) set.note = body.note || null; + if ((body.date || body.amountCents !== undefined) && t.source !== 'manual') { + fail(400, 'from_statement', 'The date and amount come from your bank’s statement, so they can’t be changed here.'); + } + if (body.date) set.date = body.date; + if (body.amountCents !== undefined) set.amountCents = body.amountCents; + if (body.merchant && body.merchant !== t.merchant) { + set.merchant = body.merchant; + if (body.renameEverywhere) { + ctx.db.update(transactions).set({ merchant: body.merchant, updatedAt: now }).where(eq(transactions.merchant, t.merchant)).run(); + const existing = ctx.db.select().from(rules).where(and(eq(rules.merchant, t.merchant), isNull(rules.accountId))).get(); + if (existing) ctx.db.update(rules).set({ renameTo: body.merchant }).where(eq(rules.id, existing.id)).run(); + else ctx.db.insert(rules).values({ merchant: t.merchant, renameTo: body.merchant, source: 'manual', createdAt: now }).run(); + // Rules keyed on the old name now apply to the new one too. + ctx.db.update(rules).set({ merchant: body.merchant }).where(and(eq(rules.merchant, t.merchant), isNull(rules.renameTo))).run(); + } + } + ctx.sqlite.transaction(() => { + ctx.db.update(transactions).set(set).where(eq(transactions.id, id)).run(); + if (body.tags) setTags(id, body.tags); + })(); + logChange(ctx, user.id, 'update_transaction', 'transaction', id, body); + return c.json({ transaction: toTransaction(ctx, find(id)), similar }, 200); + }, + ); + + r.openapi( + createRoute({ + method: 'put', + path: '/transactions/{id}/split', + tags: ['Transactions'], + summary: 'Split a purchase across categories (the parts must add up to the total)', + request: { + params: IdParam, + ...jsonBody( + z.object({ + parts: z + .array(z.object({ categoryId: z.number().int(), amountCents: Cents, note: z.string().trim().max(200).nullable().optional() })) + .min(2, 'A split needs at least two parts.') + .max(20), + }), + ), + }, + responses: jsonResponse(TransactionSchema, 'Split'), + }), + (c) => { + const user = requireWrite(c); + const { id } = c.req.valid('param'); + const { parts } = c.req.valid('json'); + const t = find(id); + const total = parts.reduce((sum, p) => sum + p.amountCents, 0); + if (total !== t.amountCents) fail(400, 'split_total', 'The parts need to add up to the full amount.'); + if (parts.some((p) => p.amountCents === 0 || Math.sign(p.amountCents) !== Math.sign(t.amountCents))) { + fail(400, 'split_sign', 'Each part needs an amount, going the same way as the purchase.'); + } + parts.forEach((p) => assertCategory(p.categoryId)); + ctx.sqlite.transaction(() => { + if (t.transferPeerId) unpair(ctx, t.id); + ctx.db.delete(splits).where(eq(splits.transactionId, id)).run(); + for (const p of parts) ctx.db.insert(splits).values({ transactionId: id, categoryId: p.categoryId, amountCents: p.amountCents, note: p.note ?? null }).run(); + ctx.db + .update(transactions) + .set({ isSplit: true, categoryId: null, needsReview: false, suggestedBy: null, updatedAt: nowIso() }) + .where(eq(transactions.id, id)) + .run(); + })(); + logChange(ctx, user.id, 'split_transaction', 'transaction', id, { parts: parts.length }); + return c.json(toTransaction(ctx, find(id)), 200); + }, + ); + + r.openapi( + createRoute({ + method: 'delete', + path: '/transactions/{id}/split', + tags: ['Transactions'], + summary: 'Undo a split; the transaction goes back to Review', + request: { params: IdParam }, + responses: jsonResponse(TransactionSchema, 'Unsplit'), + }), + (c) => { + const user = requireWrite(c); + const { id } = c.req.valid('param'); + find(id); + ctx.db.delete(splits).where(eq(splits.transactionId, id)).run(); + ctx.db.update(transactions).set({ isSplit: false, categoryId: null, needsReview: true, updatedAt: nowIso() }).where(eq(transactions.id, id)).run(); + logChange(ctx, user.id, 'unsplit_transaction', 'transaction', id); + return c.json(toTransaction(ctx, find(id)), 200); + }, + ); + + // --- Many at once -------------------------------------------------------------- + + r.openapi( + createRoute({ + method: 'post', + path: '/transactions/bulk', + tags: ['Transactions'], + summary: 'File several transactions in one category, optionally remembering it for their merchants', + request: jsonBody( + z.object({ + ids: z.array(z.number().int()).max(2000).optional(), + /** Or: every transaction waiting in Review from this merchant. */ + merchant: z.string().max(80).optional(), + categoryId: z.number().int(), + remember: z.boolean().default(false), + }), + ), + responses: jsonResponse(z.object({ updated: z.number().int() }), 'Filed'), + }), + (c) => { + const user = requireWrite(c); + const { ids, merchant, categoryId, remember } = c.req.valid('json'); + assertCategory(categoryId); + const targets = merchant + ? ctx.db.select().from(transactions).where(and(eq(transactions.merchant, merchant), eq(transactions.needsReview, true))).all() + : ids?.length + ? ctx.db.select().from(transactions).where(inArray(transactions.id, ids)).all() + : []; + ctx.sqlite.transaction(() => { + for (const t of targets) { + if (t.transferPeerId && categoryKind(categoryId) !== 'transfer') unpair(ctx, t.id); + if (t.isSplit) ctx.db.delete(splits).where(eq(splits.transactionId, t.id)).run(); + } + if (targets.length) { + ctx.db + .update(transactions) + .set({ categoryId, isSplit: false, needsReview: false, suggestedBy: null, updatedAt: nowIso() }) + .where(inArray(transactions.id, targets.map((t) => t.id))) + .run(); + } + if (remember) for (const m of new Set(targets.map((t) => t.merchant).concat(merchant ? [merchant] : []))) rememberRule(ctx, m, categoryId); + })(); + logChange(ctx, user.id, 'bulk_categorize', 'transaction', null, { count: targets.length, categoryId, remember }); + return c.json({ updated: targets.length }, 200); + }, + ); + + r.openapi( + createRoute({ + method: 'post', + path: '/transactions/accept', + tags: ['Review'], + summary: 'Accept the pre-filled category on these transactions', + request: jsonBody(z.object({ ids: z.array(z.number().int()).min(1).max(2000) })), + responses: jsonResponse(z.object({ accepted: z.number().int() }), 'Accepted'), + }), + (c) => { + const user = requireWrite(c); + const { ids } = c.req.valid('json'); + const result = ctx.db + .update(transactions) + .set({ needsReview: false, updatedAt: nowIso() }) + .where(and(inArray(transactions.id, ids), eq(transactions.needsReview, true), sql`${transactions.categoryId} is not null`)) + .run(); + logChange(ctx, user.id, 'accept_suggestions', 'transaction', null, { count: result.changes }); + return c.json({ accepted: result.changes }, 200); + }, + ); + + // --- Cash and other entries by hand ------------------------------------------ + + r.openapi( + createRoute({ + method: 'post', + path: '/transactions', + tags: ['Transactions'], + summary: 'Add a transaction by hand, such as cash spending', + request: jsonBody( + z.object({ + accountId: z.number().int(), + date: DateString, + amountCents: Cents.refine((n) => n !== 0, 'Please enter an amount.'), + merchant: z.string().trim().min(1, 'Who was it paid to?').max(80), + categoryId: z.number().int().nullable().optional(), + note: z.string().trim().max(500).nullable().optional(), + tags: TagList.optional(), + }), + ), + responses: jsonResponse(TransactionSchema, 'Added'), + }), + (c) => { + const user = requireWrite(c); + const body = c.req.valid('json'); + const account = ctx.db.select().from(accounts).where(eq(accounts.id, body.accountId)).get(); + if (!account) fail(400, 'no_account', 'Please choose an account.'); + if (body.categoryId) assertCategory(body.categoryId); + const now = nowIso(); + const row = ctx.sqlite.transaction(() => { + const inserted = ctx.db + .insert(transactions) + .values({ + accountId: body.accountId, + date: body.date, + amountCents: body.amountCents, + description: body.merchant, + merchant: body.merchant, + categoryId: body.categoryId ?? null, + needsReview: !body.categoryId, + note: body.note ?? null, + source: 'manual', + dedupeKey: `${dedupeKey(body.accountId, body.date, body.amountCents, body.merchant)}|manual|${now}`, + createdAt: now, + updatedAt: now, + }) + .returning() + .get(); + if (body.tags) setTags(inserted.id, body.tags); + return inserted; + })(); + logChange(ctx, user.id, 'add_transaction', 'transaction', row.id); + return c.json(toTransaction(ctx, row), 200); + }, + ); + + r.openapi( + createRoute({ + method: 'delete', + path: '/transactions/{id}', + tags: ['Transactions'], + summary: 'Delete a transaction you added by hand', + request: { params: IdParam }, + responses: jsonResponse(z.object({ ok: z.literal(true) }), 'Deleted'), + }), + (c) => { + const user = requireWrite(c); + const { id } = c.req.valid('param'); + const t = find(id); + if (t.source !== 'manual') fail(400, 'from_statement', 'Transactions from a statement can’t be deleted one by one. Undo the import instead.'); + if (t.transferPeerId) unpair(ctx, t.id); + ctx.db.delete(transactions).where(eq(transactions.id, id)).run(); + logChange(ctx, user.id, 'delete_transaction', 'transaction', id, { merchant: t.merchant, amountCents: t.amountCents }); + return c.json({ ok: true as const }, 200); + }, + ); + + // --- Review --------------------------------------------------------------------- + + r.openapi( + createRoute({ + method: 'get', + path: '/review', + tags: ['Review'], + summary: 'Transactions waiting to be filed, newest first, with Spendlight’s best guesses', + responses: jsonResponse(z.array(ReviewItemSchema), 'Waiting'), + }), + (c) => { + requireUser(c); + const rows = ctx.db.select().from(transactions).where(eq(transactions.needsReview, true)).orderBy(...newestFirst).limit(500).all(); + const suggester = new Suggester(ctx); + const transferIds = new Set( + ctx.db.select({ id: categories.id }).from(categories).where(eq(categories.kind, 'transfer')).all().map((x) => x.id), + ); + const items: ReviewItem[] = toTransactions(ctx, rows).map((t, i) => { + const row = rows[i]!; + const s = suggester.suggest({ ...row, accountId: row.accountId }); + const suggestions = [...new Set([...(t.categoryId ? [t.categoryId] : []), ...s.suggestions])].slice(0, 3); + return { + ...t, + suggestions, + confident: s.confident && s.categoryId === t.categoryId, + unpairedTransfer: !t.transferPeer && (t.categoryId !== null ? transferIds.has(t.categoryId) : suggester.isTransferLike(row)), + }; + }); + return c.json(items, 200); + }, + ); + + r.openapi( + createRoute({ + method: 'get', + path: '/review/count', + tags: ['Review'], + summary: 'How many transactions are waiting', + responses: jsonResponse(z.object({ count: z.number().int() }), 'Count'), + }), + (c) => { + requireUser(c); + return c.json({ count: ctx.db.select({ n: count() }).from(transactions).where(eq(transactions.needsReview, true)).get()?.n ?? 0 }, 200); + }, + ); + + // --- Categories, tags, rules ------------------------------------------------------- + + r.openapi( + createRoute({ + method: 'get', + path: '/categories', + tags: ['Categories'], + summary: 'Categories, in their groups', + responses: jsonResponse(z.array(CategoryGroupSchema), 'Categories'), + }), + (c) => { + requireUser(c); + return c.json(listCategoryGroups(ctx), 200); + }, + ); + + r.openapi( + createRoute({ + method: 'get', + path: '/tags', + tags: ['Categories'], + summary: 'Tags in use, most used first', + responses: jsonResponse(z.array(z.object({ name: z.string(), count: z.number().int() })), 'Tags'), + }), + (c) => { + requireUser(c); + const rows = ctx.db + .select({ name: tags.name, count: count(transactionTags.transactionId) }) + .from(tags) + .leftJoin(transactionTags, eq(transactionTags.tagId, tags.id)) + .groupBy(tags.id) + .orderBy(desc(count(transactionTags.transactionId)), tags.name) + .all(); + return c.json(rows, 200); + }, + ); + + r.openapi( + createRoute({ + method: 'get', + path: '/rules', + tags: ['Categories'], + summary: 'Saved rules: merchants Spendlight always files or renames the same way', + responses: jsonResponse( + z.array( + z.object({ + id: z.number().int(), + merchant: z.string(), + categoryId: z.number().int().nullable(), + renameTo: z.string().nullable(), + source: z.enum(['learned', 'manual']), + createdAt: z.string(), + }), + ), + 'Rules', + ), + }), + (c) => { + requireUser(c); + const rows = ctx.db.select().from(rules).orderBy(rules.merchant).all(); + return c.json( + rows.map(({ id, merchant, categoryId, renameTo, source, createdAt }) => ({ id, merchant, categoryId, renameTo, source, createdAt })), + 200, + ); + }, + ); + + r.openapi( + createRoute({ + method: 'post', + path: '/rules', + tags: ['Categories'], + summary: 'Always file a merchant in a category', + request: jsonBody(z.object({ merchant: z.string().trim().min(1).max(80), categoryId: z.number().int() })), + responses: jsonResponse(z.object({ id: z.number().int() }), 'Saved'), + }), + (c) => { + const user = requireWrite(c); + const { merchant, categoryId } = c.req.valid('json'); + assertCategory(categoryId); + const id = rememberRule(ctx, merchant, categoryId, 'manual'); + logChange(ctx, user.id, 'add_rule', 'rule', id, { merchant, categoryId }); + return c.json({ id }, 200); + }, + ); + + r.openapi( + createRoute({ + method: 'delete', + path: '/rules/{id}', + tags: ['Categories'], + summary: 'Forget a rule', + request: { params: IdParam }, + responses: jsonResponse(z.object({ ok: z.literal(true) }), 'Removed'), + }), + (c) => { + const user = requireWrite(c); + const { id } = c.req.valid('param'); + ctx.db.delete(rules).where(eq(rules.id, id)).run(); + logChange(ctx, user.id, 'remove_rule', 'rule', id); + return c.json({ ok: true as const }, 200); + }, + ); + + return r; +} diff --git a/server/src/schemas.ts b/server/src/schemas.ts new file mode 100644 index 0000000..b519886 --- /dev/null +++ b/server/src/schemas.ts @@ -0,0 +1,256 @@ +import { z } from '@hono/zod-openapi'; +import type { Hook } from '@hono/zod-openapi'; +import type { Env } from './http'; + +// Request and response shapes. They validate input and also describe the +// API in the OpenAPI document at /api/v1/openapi.json. + +export const ErrorSchema = z + .object({ error: z.object({ code: z.string(), message: z.string() }) }) + .openapi('Error'); + +export const OkSchema = z.object({ ok: z.literal(true) }).openapi('Ok'); + +export const AccessSchema = z.enum(['read', 'full']).openapi('Access'); +export const ThemeSchema = z.enum(['system', 'light', 'dark']).openapi('Theme'); +export const IdeaStatusSchema = z.enum(['new', 'planned', 'done', 'declined']).openapi('IdeaStatus'); + +export const Username = z + .string() + .trim() + .min(2, 'Usernames need at least 2 characters.') + .max(40) + .regex(/^[a-zA-Z0-9._-]+$/, 'Use letters, numbers, dots, dashes or underscores.') + .transform((s) => s.toLowerCase()); +export const Password = z.string().min(8, 'Passwords need at least 8 characters.').max(200); +export const DisplayName = z.string().trim().min(1, 'Please enter a name.').max(60); + +export const MeSchema = z + .object({ + id: z.number().int(), + username: z.string(), + displayName: z.string(), + access: AccessSchema, + isAdmin: z.boolean(), + theme: ThemeSchema, + mustChangePassword: z.boolean(), + hasPasskey: z.boolean(), + }) + .openapi('Me'); + +export const PublicConfigSchema = z + .object({ version: z.string(), ideasEnabled: z.boolean(), needsSetup: z.boolean() }) + .openapi('PublicConfig'); + +export const HealthSchema = z + .object({ status: z.literal('ok'), version: z.string(), uptimeSeconds: z.number() }) + .openapi('Health'); + +export const PersonSchema = z + .object({ + id: z.number().int(), + username: z.string(), + displayName: z.string(), + access: AccessSchema, + isAdmin: z.boolean(), + mustChangePassword: z.boolean(), + createdAt: z.string(), + lastActiveAt: z.string().nullable(), + }) + .openapi('Person'); + +export const DeviceSchema = z + .object({ + id: z.string(), + deviceLabel: z.string(), + createdAt: z.string(), + lastSeenAt: z.string(), + current: z.boolean(), + }) + .openapi('Device'); + +export const PasskeySchema = z + .object({ id: z.string(), name: z.string(), createdAt: z.string(), lastUsedAt: z.string().nullable() }) + .openapi('Passkey'); + +export const BackupSchema = z + .object({ + name: z.string(), + kind: z.enum(['nightly', 'manual', 'pre-upgrade', 'pre-restore']), + size: z.number().int(), + createdAt: z.string(), + }) + .openapi('Backup'); + +export const IdeaSchema = z + .object({ + id: z.number().int(), + title: z.string(), + description: z.string(), + status: IdeaStatusSchema, + adminNote: z.string().nullable(), + path: z.string().nullable(), + appVersion: z.string().nullable(), + userAgent: z.string().nullable(), + author: z.string().nullable(), + createdAt: z.string(), + updatedAt: z.string(), + }) + .openapi('Idea'); + +/** WebAuthn payloads are defined by the browser standard; passed through as JSON. */ +export const WebAuthnJson = z.record(z.string(), z.unknown()).openapi('WebAuthnJson'); + +export const IdParam = z.object({ + id: z.coerce.number().int().positive().openapi({ param: { name: 'id', in: 'path' } }), +}); + +export const jsonBody = (schema: T) => ({ + body: { content: { 'application/json': { schema } }, required: true }, +}); + +export const jsonResponse = (schema: T, description: string) => ({ + 200: { content: { 'application/json': { schema } }, description }, +}); + +/** Turn validation failures into the same friendly error shape as everything else. */ +export const validationHook: Hook = (result, c) => { + if (!result.success) { + const issue = result.error.issues[0]; + const field = issue?.path.join('.'); + const message = issue ? (field ? `${field}: ${issue.message}` : issue.message) : 'Invalid input.'; + return c.json({ error: { code: 'invalid_input', message } }, 400); + } +}; + +// --- Milestone 1: money in -------------------------------------------------- + +export const AccountKindSchema = z.enum(['checking', 'savings', 'credit_card', 'cash', 'loan', 'mortgage', 'heloc']).openapi('AccountKind'); +export const PaletteSchema = z + .enum(['indigo', 'sky', 'teal', 'emerald', 'lime', 'amber', 'orange', 'rose', 'pink', 'violet', 'cyan', 'slate']) + .openapi('PaletteColor'); +export const DateString = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Use a date like 2026-10-07.'); +export const Cents = z.number().int().min(-1_000_000_000).max(1_000_000_000); + +export const AccountSchema = z + .object({ + id: z.number().int(), + name: z.string(), + kind: AccountKindSchema, + institution: z.string().nullable(), + last4: z.string().nullable(), + color: PaletteSchema, + status: z.enum(['open', 'retired']), + balanceCents: z.number().int(), + balanceConfirmedOn: z.string().nullable(), + transactionCount: z.number().int(), + lastImportAt: z.string().nullable(), + lastTransactionDate: z.string().nullable(), + }) + .openapi('Account'); + +export const CategorySchema = z + .object({ + id: z.number().int(), + groupId: z.number().int(), + groupName: z.string(), + name: z.string(), + kind: z.enum(['expense', 'income', 'transfer']), + icon: z.string(), + color: PaletteSchema, + archived: z.boolean(), + }) + .openapi('Category'); + +export const CategoryGroupSchema = z + .object({ id: z.number().int(), name: z.string(), categories: z.array(CategorySchema) }) + .openapi('CategoryGroup'); + +export const SplitSchema = z + .object({ id: z.number().int(), categoryId: z.number().int(), amountCents: z.number().int(), note: z.string().nullable() }) + .openapi('Split'); + +export const TransactionSchema = z + .object({ + id: z.number().int(), + accountId: z.number().int(), + date: z.string(), + amountCents: z.number().int(), + description: z.string(), + merchant: z.string(), + categoryId: z.number().int().nullable(), + needsReview: z.boolean(), + suggestedBy: z.string().nullable(), + isSplit: z.boolean(), + splits: z.array(SplitSchema), + note: z.string().nullable(), + tags: z.array(z.string()), + transferPeer: z.object({ id: z.number().int(), accountId: z.number().int(), accountName: z.string() }).nullable(), + source: z.enum(['import', 'manual']), + bankCategory: z.string().nullable(), + importId: z.number().int().nullable(), + }) + .openapi('Transaction'); + +export const ReviewItemSchema = TransactionSchema.extend({ + suggestions: z.array(z.number().int()), + confident: z.boolean(), + unpairedTransfer: z.boolean(), +}).openapi('ReviewItem'); + +export const ColumnMapSchema = z + .object({ + date: z.number().int().min(0), + description: z.number().int().min(0), + amount: z.number().int().min(0).optional(), + debit: z.number().int().min(0).optional(), + credit: z.number().int().min(0).optional(), + postedDate: z.number().int().min(0).optional(), + category: z.number().int().min(0).optional(), + balance: z.number().int().min(0).optional(), + positiveIs: z.enum(['out', 'in']), + dateOrder: z.enum(['MDY', 'DMY', 'YMD']), + }) + .refine((m) => m.amount !== undefined || m.debit !== undefined, { message: 'Choose the amount column.' }) + .openapi('ImportColumnMap'); + +export const ImportPreviewSchema = z + .object({ + stagingId: z.string(), + fileName: z.string(), + layout: z.object({ key: z.string(), name: z.string() }).nullable(), + sampleRows: z.array(z.array(z.string())), + headerRow: z.array(z.string()).nullable(), + suggestedMap: z.any().nullable(), + account: z.object({ id: z.number().int(), confidence: z.enum(['certain', 'likely']), reason: z.string() }).nullable(), + dateFrom: z.string().nullable(), + dateTo: z.string().nullable(), + counts: z.object({ rows: z.number(), new: z.number(), duplicates: z.number(), possibleDuplicates: z.number(), skipped: z.number() }), + newRows: z.array(z.object({ date: z.string(), merchant: z.string(), amountCents: z.number() })), + possibleDuplicates: z.array( + z.object({ key: z.string(), date: z.string(), merchant: z.string(), amountCents: z.number(), existingDate: z.string() }), + ), + accountHint: z + .object({ kind: AccountKindSchema, institution: z.string().nullable(), last4: z.string().nullable(), name: z.string() }) + .nullable(), + }) + .openapi('ImportPreview'); + +export const ImportSummarySchema = z + .object({ + id: z.number().int(), + accountId: z.number().int(), + accountName: z.string(), + fileName: z.string(), + layoutKey: z.string(), + dateFrom: z.string().nullable(), + dateTo: z.string().nullable(), + addedCount: z.number().int(), + duplicateCount: z.number().int(), + skippedCount: z.number().int(), + transferCount: z.number().int(), + toReview: z.number().int(), + status: z.enum(['committed', 'undone']), + createdAt: z.string(), + }) + .openapi('ImportSummary'); diff --git a/server/src/static.ts b/server/src/static.ts new file mode 100644 index 0000000..389839c --- /dev/null +++ b/server/src/static.ts @@ -0,0 +1,63 @@ +import type { MiddlewareHandler } from 'hono'; +import { createReadStream, existsSync, statSync } from 'node:fs'; +import { extname, join, normalize, sep } from 'node:path'; +import { Readable } from 'node:stream'; + +// Serves the built web app. Hashed files under /assets are cached for a year; +// everything else revalidates so a new version shows up straight away. Any +// path that isn't a file gets index.html, so the app can route it. + +const TYPES: Record = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.webmanifest': 'application/manifest+json; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.ico': 'image/x-icon', + '.woff2': 'font/woff2', + '.woff': 'font/woff', + '.txt': 'text/plain; charset=utf-8', + '.map': 'application/json; charset=utf-8', +}; + +function cacheControlFor(urlPath: string): string { + if (urlPath.startsWith('/assets/')) return 'public, max-age=31536000, immutable'; + if (urlPath === '/' || urlPath.endsWith('.html') || urlPath === '/sw.js' || urlPath.endsWith('.webmanifest')) { + return 'no-cache'; + } + return 'public, max-age=86400'; +} + +function fileResponse(path: string, urlPath: string, head: boolean): Response { + const headers = { + 'Content-Type': TYPES[extname(path)] ?? 'application/octet-stream', + 'Content-Length': String(statSync(path).size), + 'Cache-Control': cacheControlFor(urlPath), + }; + const body = head ? null : (Readable.toWeb(createReadStream(path)) as ReadableStream); + return new Response(body, { headers }); +} + +export function webApp(root: string): MiddlewareHandler { + const rootWithSep = root.endsWith(sep) ? root : root + sep; + const index = join(root, 'index.html'); + + return async (c, next) => { + const method = c.req.method; + const urlPath = decodeURIComponent(new URL(c.req.url).pathname); + if ((method !== 'GET' && method !== 'HEAD') || urlPath.startsWith('/api/')) return next(); + + const candidate = normalize(join(root, urlPath)); + if (candidate.startsWith(rootWithSep) && existsSync(candidate) && statSync(candidate).isFile()) { + return fileResponse(candidate, urlPath, method === 'HEAD'); + } + if (extname(urlPath)) return c.text('Not found', 404); + if (!existsSync(index)) { + return c.text('The Spendlight web app is not built yet. Run "npm run build" (or "npm run dev:web" while developing).', 503); + } + return fileResponse(index, '/index.html', method === 'HEAD'); + }; +} diff --git a/server/src/transactions/dto.ts b/server/src/transactions/dto.ts new file mode 100644 index 0000000..8fac557 --- /dev/null +++ b/server/src/transactions/dto.ts @@ -0,0 +1,111 @@ +import type { Account, Category, CategoryGroup, PaletteColor, Transaction } from '@spendlight/shared'; +import { and, asc, count, desc, eq, inArray, max } from 'drizzle-orm'; +import { currentBalance } from '../accounts/balance'; +import type { AppContext } from '../context'; +import { accounts, categories, categoryGroups, imports, splits, tags, transactions, transactionTags, type AccountRow, type TransactionRow } from '../db/schema'; + +/** Full transactions, with splits, tags and the other half of any transfer, loaded in a few queries. */ +export function toTransactions(ctx: AppContext, rows: TransactionRow[]): Transaction[] { + if (!rows.length) return []; + const ids = rows.map((r) => r.id); + const splitRows = ctx.db.select().from(splits).where(inArray(splits.transactionId, ids)).orderBy(asc(splits.id)).all(); + const tagRows = ctx.db + .select({ transactionId: transactionTags.transactionId, name: tags.name }) + .from(transactionTags) + .innerJoin(tags, eq(tags.id, transactionTags.tagId)) + .where(inArray(transactionTags.transactionId, ids)) + .all(); + const peerIds = rows.map((r) => r.transferPeerId).filter((p): p is number => p !== null); + const peers = peerIds.length + ? ctx.db + .select({ id: transactions.id, accountId: transactions.accountId, accountName: accounts.name }) + .from(transactions) + .innerJoin(accounts, eq(accounts.id, transactions.accountId)) + .where(inArray(transactions.id, peerIds)) + .all() + : []; + + return rows.map((r) => ({ + id: r.id, + accountId: r.accountId, + date: r.date, + amountCents: r.amountCents, + description: r.description, + merchant: r.merchant, + categoryId: r.categoryId, + needsReview: r.needsReview, + suggestedBy: r.suggestedBy, + isSplit: r.isSplit, + splits: splitRows + .filter((s) => s.transactionId === r.id) + .map((s) => ({ id: s.id, categoryId: s.categoryId, amountCents: s.amountCents, note: s.note })), + note: r.note, + tags: tagRows.filter((t) => t.transactionId === r.id).map((t) => t.name), + transferPeer: peers.find((p) => p.id === r.transferPeerId) ?? null, + source: r.source, + bankCategory: r.bankCategory, + importId: r.importId, + })); +} + +export function toTransaction(ctx: AppContext, row: TransactionRow): Transaction { + return toTransactions(ctx, [row])[0]!; +} + +export function toAccount(ctx: AppContext, a: AccountRow): Account { + const stats = ctx.db + .select({ n: count(), last: max(transactions.date) }) + .from(transactions) + .where(eq(transactions.accountId, a.id)) + .get(); + const lastImport = ctx.db + .select({ at: max(imports.createdAt) }) + .from(imports) + .where(and(eq(imports.accountId, a.id), eq(imports.status, 'committed'))) + .get(); + return { + id: a.id, + name: a.name, + kind: a.kind, + institution: a.institution, + last4: a.last4, + color: a.color as PaletteColor, + status: a.status, + balanceCents: currentBalance(ctx.db, a), + balanceConfirmedOn: a.balanceAsOf, + transactionCount: stats?.n ?? 0, + lastImportAt: lastImport?.at ?? null, + lastTransactionDate: stats?.last ?? null, + }; +} + +export function listCategoryGroups(ctx: AppContext, includeArchived = false): CategoryGroup[] { + const groups = ctx.db.select().from(categoryGroups).orderBy(asc(categoryGroups.sortOrder), asc(categoryGroups.id)).all(); + const rows = ctx.db + .select() + .from(categories) + .where(includeArchived ? undefined : eq(categories.archived, false)) + .orderBy(asc(categories.sortOrder), asc(categories.id)) + .all(); + return groups.map((g) => ({ + id: g.id, + name: g.name, + categories: rows + .filter((c) => c.groupId === g.id) + .map( + (c): Category => ({ + id: c.id, + groupId: g.id, + groupName: g.name, + name: c.name, + kind: c.kind, + icon: c.icon, + color: c.color as PaletteColor, + archived: c.archived, + }), + ), + })); +} + +/** The newest-first ordering used everywhere transactions are listed. */ +export const newestFirst = [desc(transactions.date), desc(transactions.id)] as const; diff --git a/server/src/users.ts b/server/src/users.ts new file mode 100644 index 0000000..d986d4a --- /dev/null +++ b/server/src/users.ts @@ -0,0 +1,42 @@ +import type { Me } from '@spendlight/shared'; +import { and, count, eq, isNull } from 'drizzle-orm'; +import type { AppContext } from './context'; +import { passkeys, users, type UserRow } from './db/schema'; + +export function toMe(ctx: AppContext, user: UserRow): Me { + const keys = ctx.db.select({ n: count() }).from(passkeys).where(eq(passkeys.userId, user.id)).get(); + return { + id: user.id, + username: user.username, + displayName: user.displayName, + access: user.access, + isAdmin: user.isAdmin, + theme: user.theme, + mustChangePassword: user.mustChangePassword, + hasPasskey: (keys?.n ?? 0) > 0, + }; +} + +export function activeUserCount(ctx: AppContext): number { + return ctx.db.select({ n: count() }).from(users).where(isNull(users.deletedAt)).get()?.n ?? 0; +} + +export function adminCount(ctx: AppContext): number { + return ( + ctx.db + .select({ n: count() }) + .from(users) + .where(and(isNull(users.deletedAt), eq(users.isAdmin, true))) + .get()?.n ?? 0 + ); +} + +export function findActiveUser(ctx: AppContext, where: { id?: number; username?: string }): UserRow | undefined { + const condition = + where.id !== undefined ? eq(users.id, where.id) : eq(users.username, (where.username ?? '').toLowerCase()); + return ctx.db + .select() + .from(users) + .where(and(condition, isNull(users.deletedAt))) + .get(); +} diff --git a/server/test/auth.test.ts b/server/test/auth.test.ts new file mode 100644 index 0000000..a7442ce --- /dev/null +++ b/server/test/auth.test.ts @@ -0,0 +1,161 @@ +import { eq } from 'drizzle-orm'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { hashToken, SESSION_COOKIE } from '../src/auth/sessions'; +import { sessions } from '../src/db/schema'; +import { Client, makeApp, setUpAdmin, type TestApp } from './helpers'; + +let t: TestApp; +beforeEach(async () => { + t = await makeApp(); +}); +afterEach(() => t.cleanup()); + +describe('first-run setup', () => { + it('asks for setup until the first person exists, then never again', async () => { + const visitor = new Client(t); + expect((await visitor.get('/api/v1/config')).json).toEqual({ + version: '20261007.01', + ideasEnabled: false, + needsSetup: true, + }); + + const admin = await setUpAdmin(t); + const me = (await admin.get('/api/v1/me')).json; + expect(me).toMatchObject({ username: 'chris', displayName: 'Chris', access: 'full', isAdmin: true, hasPasskey: false }); + expect((await visitor.get('/api/v1/config')).json.needsSetup).toBe(false); + + const again = await visitor.post('/api/v1/setup', { displayName: 'Eve', username: 'eve', password: 'password123' }); + expect(again.status).toBe(409); + }); + + it('rejects weak input with a friendly message', async () => { + const res = await new Client(t).post('/api/v1/setup', { displayName: 'A', username: 'a b', password: 'short' }); + expect(res.status).toBe(400); + expect(res.json.error.code).toBe('invalid_input'); + }); +}); + +describe('sign-in', () => { + it('signs in, stays signed in, and signs out', async () => { + await setUpAdmin(t); + const phone = new Client(t, 'http://localhost', { 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0) Safari/604.1' }); + expect((await phone.get('/api/v1/me')).status).toBe(401); + + const login = await phone.post('/api/v1/auth/login', { username: 'CHRIS', password: 'correct-horse' }); + expect(login.status).toBe(200); + const cookie = login.headers.getSetCookie().find((c) => c.startsWith(`${SESSION_COOKIE}=`))!; + expect(cookie).toMatch(/HttpOnly/i); + expect(cookie).toMatch(/SameSite=Lax/i); + expect(cookie).toMatch(/Max-Age=34560000/); // 400 days + + const devices = (await phone.get('/api/v1/devices')).json; + expect(devices.find((d: any) => d.current).deviceLabel).toBe('iPhone · Safari'); + + expect((await phone.post('/api/v1/auth/logout')).status).toBe(200); + expect((await phone.get('/api/v1/me')).status).toBe(401); + }); + + it('refuses a wrong password, then slows down repeated guessing', async () => { + await setUpAdmin(t); + const guesser = new Client(t); + for (let i = 0; i < 5; i++) { + expect((await guesser.post('/api/v1/auth/login', { username: 'chris', password: `guess-${i}` })).status).toBe(401); + } + const blocked = await guesser.post('/api/v1/auth/login', { username: 'chris', password: 'correct-horse' }); + expect(blocked.status).toBe(429); + expect(blocked.headers.get('retry-after')).toBeTruthy(); + }); + + it('renews an ageing session so a device in use never signs out', async () => { + const admin = await setUpAdmin(t); + const token = admin.cookie(SESSION_COOKIE)!; + const id = hashToken(token); + const soon = new Date(Date.now() + 2 * 86_400_000).toISOString(); + t.ctx.db.update(sessions).set({ expiresAt: soon }).where(eq(sessions.id, id)).run(); + + const res = await admin.get('/api/v1/me'); + expect(res.status).toBe(200); + expect(res.headers.getSetCookie().some((c) => c.startsWith(`${SESSION_COOKIE}=`))).toBe(true); + const renewed = t.ctx.db.select().from(sessions).where(eq(sessions.id, id)).get()!; + expect(Date.parse(renewed.expiresAt)).toBeGreaterThan(Date.now() + 399 * 86_400_000); + }); + + it('refuses password sign-in over plain HTTP from another machine', async () => { + await setUpAdmin(t); + const lan = new Client(t, 'http://192.168.1.20:8095'); + const res = await lan.post('/api/v1/auth/login', { username: 'chris', password: 'correct-horse' }); + expect(res.status).toBe(400); + expect(res.json.error.code).toBe('insecure_connection'); + }); + + it('trusts the HTTPS proxy and marks cookies Secure behind it', async () => { + t.cleanup(); + t = await makeApp({ TRUST_PROXY: 'true' }); + const viaProxy = new Client(t, 'http://192.168.1.20:8095', { + 'X-Forwarded-Proto': 'https', + 'X-Forwarded-Host': 'spendlight.example.com', + }); + const res = await viaProxy.post('/api/v1/setup', { displayName: 'Chris', username: 'chris', password: 'correct-horse' }); + expect(res.status).toBe(200); + expect(res.headers.getSetCookie().find((c) => c.startsWith(SESSION_COOKIE))).toMatch(/Secure/); + }); + + it('blocks changes sent from another website', async () => { + const admin = await setUpAdmin(t); + const res = await admin.patch('/api/v1/me', { theme: 'dark' }, { Origin: 'https://evil.example' }); + expect(res.status).toBe(403); + expect((await admin.patch('/api/v1/me', { theme: 'dark' }, { Origin: 'http://localhost' })).json.theme).toBe('dark'); + }); + + it('changes a password and signs out other devices', async () => { + const admin = await setUpAdmin(t); + const laptop = new Client(t); + await laptop.post('/api/v1/auth/login', { username: 'chris', password: 'correct-horse' }); + + const wrong = await admin.post('/api/v1/me/password', { currentPassword: 'nope', newPassword: 'battery-staple' }); + expect(wrong.status).toBe(400); + const ok = await admin.post('/api/v1/me/password', { currentPassword: 'correct-horse', newPassword: 'battery-staple' }); + expect(ok.status).toBe(200); + expect((await admin.get('/api/v1/me')).status).toBe(200); + expect((await laptop.get('/api/v1/me')).status).toBe(401); + }); +}); + +describe('people and roles', () => { + it('lets an admin add a read-only person who must choose a password', async () => { + const admin = await setUpAdmin(t); + const added = await admin.post('/api/v1/people', { displayName: 'Sam', username: 'sam', access: 'read' }); + expect(added.status).toBe(200); + expect(added.json.temporaryPassword).toMatch(/^[a-z2-9]{4}-[a-z2-9]{4}-[a-z2-9]{4}$/); + + const sam = new Client(t); + const login = await sam.post('/api/v1/auth/login', { username: 'sam', password: added.json.temporaryPassword }); + expect(login.json).toMatchObject({ access: 'read', isAdmin: false, mustChangePassword: true }); + expect((await sam.get('/api/v1/people')).status).toBe(403); + expect((await sam.post('/api/v1/backups')).status).toBe(403); + + const changed = await sam.post('/api/v1/me/password', { + currentPassword: added.json.temporaryPassword, + newPassword: 'sams-own-password', + }); + expect(changed.json.mustChangePassword).toBe(false); + }); + + it('never leaves the household without an admin', async () => { + const admin = await setUpAdmin(t); + const me = (await admin.get('/api/v1/me')).json; + expect((await admin.patch(`/api/v1/people/${me.id}`, { isAdmin: false })).json.error.code).toBe('last_admin'); + expect((await admin.del(`/api/v1/people/${me.id}`)).json.error.code).toBe('cannot_remove_self'); + }); + + it('removes a person, signs them out, and frees their username', async () => { + const admin = await setUpAdmin(t); + const added = await admin.post('/api/v1/people', { displayName: 'Sam', username: 'sam', access: 'full', password: 'sams-password' }); + const sam = new Client(t); + await sam.post('/api/v1/auth/login', { username: 'sam', password: 'sams-password' }); + + expect((await admin.del(`/api/v1/people/${added.json.person.id}`)).status).toBe(200); + expect((await sam.get('/api/v1/me')).status).toBe(401); + expect((await admin.post('/api/v1/people', { displayName: 'Sam 2', username: 'sam', access: 'read' })).status).toBe(200); + }); +}); diff --git a/server/test/backups.test.ts b/server/test/backups.test.ts new file mode 100644 index 0000000..845a466 --- /dev/null +++ b/server/test/backups.test.ts @@ -0,0 +1,96 @@ +import Database from 'better-sqlite3'; +import { execFileSync } from 'node:child_process'; +import { readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it } from 'vitest'; +import { createBackup, listBackups, planRetention } from '../src/backups/backup'; +import { makeApp, setUpAdmin, type TestApp } from './helpers'; + +const nightly = (y: number, m: number, d: number) => { + const date = `${y}${String(m).padStart(2, '0')}${String(d).padStart(2, '0')}`; + return { + name: `spendlight-nightly-${date}-023000.db`, + kind: 'nightly' as const, + createdAt: new Date(y, m - 1, d, 2, 30).toISOString(), + }; +}; + +describe('backup retention', () => { + it('keeps 14 daily copies plus the newest of each of 12 months', () => { + const list = []; + // Every night from Jan 1 2025 to Oct 7 2026. + for (let d = new Date(2025, 0, 1); d <= new Date(2026, 9, 7); d.setDate(d.getDate() + 1)) { + list.push(nightly(d.getFullYear(), d.getMonth() + 1, d.getDate())); + } + const doomed = new Set(planRetention(list, { keepDaily: 14, keepMonthly: 12 })); + const kept = list.filter((b) => !doomed.has(b.name)).map((b) => b.name.slice(19, 27)); + + expect(kept.filter((d) => d >= '20260924')).toHaveLength(14); // the last 14 nights + const months = new Set(kept.map((d) => d.slice(0, 6))); + expect(months.size).toBe(12); // Nov 2025 through Oct 2026 + expect(kept).toContain('20251130'); // newest of the oldest kept month + expect(kept).not.toContain('20251031'); + }); + + it('keeps the newest 10 manual and 5 pre-upgrade copies', () => { + const make = (kind: 'manual' | 'pre-upgrade', i: number) => ({ + name: `spendlight-${kind}-202610${String(i).padStart(2, '0')}-120000.db`, + kind, + createdAt: new Date(2026, 9, i, 12).toISOString(), + }); + const list = [...Array.from({ length: 12 }, (_, i) => make('manual', i + 1)), ...Array.from({ length: 7 }, (_, i) => make('pre-upgrade', i + 1))]; + const doomed = planRetention(list, { keepDaily: 14, keepMonthly: 12 }); + expect(doomed.filter((n) => n.includes('manual'))).toEqual([ + 'spendlight-manual-20261002-120000.db', + 'spendlight-manual-20261001-120000.db', + ]); + expect(doomed.filter((n) => n.includes('pre-upgrade'))).toHaveLength(2); + }); +}); + +describe('backup files', () => { + let t: TestApp; + afterEach(() => t.cleanup()); + + it('writes a complete, valid Spendlight database and lists it', async () => { + t = await makeApp(); + await setUpAdmin(t); + const dir = t.ctx.config.backupDir; + const backup = await createBackup(t.ctx.sqlite, dir, 'manual'); + expect(readdirSync(dir).filter((f) => f.endsWith('.partial'))).toHaveLength(0); + expect(listBackups(dir).map((b) => b.name)).toContain(backup.name); + + const copy = new Database(join(dir, backup.name), { readonly: true }); + expect((copy.prepare("SELECT value FROM setting WHERE key = 'app'").get() as { value: string }).value).toBe('"spendlight"'); + expect((copy.prepare('SELECT username FROM user').get() as { username: string }).username).toBe('chris'); + copy.close(); + }); + + it('lets an admin back up from the app and download the file', async () => { + t = await makeApp(); + const admin = await setUpAdmin(t); + const made = await admin.post('/api/v1/backups'); + expect(made.status).toBe(200); + expect((await admin.get('/api/v1/backups')).json).toMatchObject({ nightlyAt: '02:30', keepDaily: 14, keepMonthly: 12 }); + + const download = await admin.get(`/api/v1/backups/${made.json.name}/download`); + expect(download.status).toBe(200); + expect(download.headers.get('content-disposition')).toContain(made.json.name); + expect((await admin.get('/api/v1/backups/..%2F..%2Fetc%2Fpasswd/download')).status).toBe(404); + }); +}); + +describe('version stamps', () => { + const next = (previous: string, today: string) => + execFileSync('bash', [fileURLToPath(new URL('../../scripts/next-version.sh', import.meta.url)), previous], { env: { ...process.env, TODAY: today } }) + .toString() + .trim(); + + it('counts up through the day and restarts at .01 the next', () => { + expect(next('dev', '20261007')).toBe('20261007.01'); + expect(next('20261007.01', '20261007')).toBe('20261007.02'); + expect(next('20261007.09', '20261007')).toBe('20261007.10'); + expect(next('20261007.14', '20261008')).toBe('20261008.01'); + }); +}); diff --git a/server/test/fixtures/synthetic/Chase9876_Activity_20261007.csv b/server/test/fixtures/synthetic/Chase9876_Activity_20261007.csv new file mode 100644 index 0000000..7378868 --- /dev/null +++ b/server/test/fixtures/synthetic/Chase9876_Activity_20261007.csv @@ -0,0 +1,6 @@ +Transaction Date,Post Date,Description,Category,Type,Amount,Memo +09/02/2026,09/03/2026,AMAZON MKTPL*AB12CD3E4,Shopping,Sale,-23.99, +09/04/2026,09/05/2026,Amazon.com*ZX98YW7V6,Shopping,Sale,-112.45, +09/06/2026,09/07/2026,AMAZON MKTPL*QR56ST7U8,Shopping,Return,18.50, +09/09/2026,09/10/2026,JOES DINER,Food & Drink,Sale,-41.20, +09/16/2026,09/16/2026,Payment Thank You-Mobile,,Payment,150.00, diff --git a/server/test/fixtures/synthetic/Discover-Statement-20261007.csv b/server/test/fixtures/synthetic/Discover-Statement-20261007.csv new file mode 100644 index 0000000..73f7af5 --- /dev/null +++ b/server/test/fixtures/synthetic/Discover-Statement-20261007.csv @@ -0,0 +1,9 @@ +Trans. Date,Post Date,Description,Amount,Category +09/03/2026,09/03/2026,KROGER #123 CHARLOTTE NC,54.20,Supermarkets +09/03/2026,09/03/2026,STARBUCKS #4567 CHARLOTTE NC,5.45,Restaurants +09/03/2026,09/03/2026,STARBUCKS #4567 CHARLOTTE NC,5.45,Restaurants +09/08/2026,09/08/2026,JOES DINER #12 CHARLOTTE NC,38.75,Restaurants +09/11/2026,09/11/2026,INTERNET PAYMENT - THANK YOU,-500.00,Payments and Credits +09/14/2026,09/14/2026,SHELL OIL 57444 CHARLOTTE NC,47.10,Gasoline +09/18/2026,09/18/2026,NETFLIX.COM 866-579-7172 CA,17.99,Services +09/22/2026,09/22/2026,BLUE MOON BOOKS #9 ASHEVILLE NC,22.00,Merchandise diff --git a/server/test/fixtures/synthetic/bofa-checking.csv b/server/test/fixtures/synthetic/bofa-checking.csv new file mode 100644 index 0000000..f250566 --- /dev/null +++ b/server/test/fixtures/synthetic/bofa-checking.csv @@ -0,0 +1,13 @@ +Description,,Summary Amt. +Beginning balance as of 09/01/2026,,"2,000.00" +Total credits,,"3,000.00" +Total debits,,"-1,070.00" +Ending balance as of 09/30/2026,,"3,930.00" + +Date,Description,Amount,Running Bal. +09/01/2026,Beginning balance as of 09/01/2026,,"2,000.00" +09/05/2026,"ACME CORP DES:PAYROLL ID:XXXXX12345 INDN:DOE,JANE CO ID:XXXXX12345 PPD","3,000.00","5,000.00" +09/10/2026,"DISCOVER DES:E-PAYMENT ID:XXXXX99999 INDN:JANE DOE CO ID:XXXXX99999 WEB","-500.00","4,500.00" +09/12/2026,"Online Banking transfer to SAV 5678 Confirmation# XXXXX12345","-300.00","4,200.00" +09/15/2026,"CHASE CREDIT CRD DES:EPAY ID:XXXXX55555 INDN:JANE DOE CO ID:XXXXX55555 WEB","-150.00","4,050.00" +09/20/2026,"DUKE ENERGY DES:BILL PAY ID:XXXXX77777 INDN:JANE DOE CO ID:XXXXX77777 WEB","-120.00","3,930.00" diff --git a/server/test/fixtures/synthetic/bofa-savings.csv b/server/test/fixtures/synthetic/bofa-savings.csv new file mode 100644 index 0000000..60513b7 --- /dev/null +++ b/server/test/fixtures/synthetic/bofa-savings.csv @@ -0,0 +1,10 @@ +Description,,Summary Amt. +Beginning balance as of 09/01/2026,,"10,000.00" +Total credits,,"304.20" +Total debits,,"0.00" +Ending balance as of 09/30/2026,,"10,304.20" + +Date,Description,Amount,Running Bal. +09/01/2026,Beginning balance as of 09/01/2026,,"10,000.00" +09/12/2026,"Online Banking transfer from CHK 1234 Confirmation# XXXXX12345","300.00","10,300.00" +09/30/2026,Interest Earned,4.20,"10,304.20" diff --git a/server/test/fixtures/synthetic/unknown-bank.csv b/server/test/fixtures/synthetic/unknown-bank.csv new file mode 100644 index 0000000..8b30388 --- /dev/null +++ b/server/test/fixtures/synthetic/unknown-bank.csv @@ -0,0 +1,4 @@ +Posting Date;Payee Name;Withdrawal;Deposit;Balance +2026-09-02;Corner Hardware;12.50;;987.50 +2026-09-05;Paycheck Employer;;1,200.00;2,187.50 +2026-09-07;Corner Hardware;8.25;;2,179.25 diff --git a/server/test/helpers.ts b/server/test/helpers.ts new file mode 100644 index 0000000..2edc9db --- /dev/null +++ b/server/test/helpers.ts @@ -0,0 +1,104 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createApp } from '../src/app'; +import { loadConfig } from '../src/config'; +import { createContext, type AppContext } from '../src/context'; +import { silentLogger } from '../src/log'; + +export interface TestApp { + ctx: AppContext; + app: ReturnType; + dir: string; + cleanup(): void; +} + +export async function makeApp(env: Record = {}): Promise { + const dir = mkdtempSync(join(tmpdir(), 'spendlight-test-')); + const config = loadConfig({ + DATA_DIR: join(dir, 'data'), + BACKUP_DIR: join(dir, 'backups'), + WEB_DIST: join(dir, 'web'), + APP_VERSION: '20261007.01', + ...env, + }); + const ctx = await createContext(config, { log: silentLogger }); + const app = createApp(ctx); + return { + ctx, + app, + dir, + cleanup() { + ctx.sqlite.close(); + rmSync(dir, { recursive: true, force: true }); + }, + }; +} + +/** A tiny browser: keeps cookies between calls and sends JSON. */ +export class Client { + private cookies = new Map(); + + constructor( + private t: TestApp, + private base = 'http://localhost', + private extraHeaders: Record = {}, + ) {} + + async call(method: string, path: string, body?: unknown, headers: Record = {}) { + const res = await this.t.app.request(`${this.base}${path}`, { + method, + headers: { + ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), + ...(this.cookies.size ? { Cookie: [...this.cookies].map(([k, v]) => `${k}=${v}`).join('; ') } : {}), + ...this.extraHeaders, + ...headers, + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + for (const line of res.headers.getSetCookie()) { + const [pair] = line.split(';'); + const [name, ...rest] = pair!.split('='); + const value = rest.join('='); + if (!value || /max-age=0/i.test(line)) this.cookies.delete(name!); + else this.cookies.set(name!, value); + } + const text = await res.text(); + let json: any = undefined; + try { + json = JSON.parse(text); + } catch { + /* not JSON */ + } + return { status: res.status, json, text, headers: res.headers }; + } + + /** Send a file the way the browser's file picker does. */ + async upload(path: string, fileName: string, content: string) { + const form = new FormData(); + form.append('file', new File([content], fileName, { type: 'text/csv' })); + const res = await this.t.app.request(`${this.base}${path}`, { + method: 'POST', + headers: this.cookies.size ? { Cookie: [...this.cookies].map(([k, v]) => `${k}=${v}`).join('; ') } : {}, + body: form, + }); + const text = await res.text(); + return { status: res.status, json: JSON.parse(text), text }; + } + + get = (path: string, headers?: Record) => this.call('GET', path, undefined, headers); + post = (path: string, body: unknown = {}, headers?: Record) => this.call('POST', path, body, headers); + patch = (path: string, body: unknown, headers?: Record) => this.call('PATCH', path, body, headers); + del = (path: string, headers?: Record) => this.call('DELETE', path, undefined, headers); + + cookie(name: string) { + return this.cookies.get(name); + } +} + +export async function setUpAdmin(t: TestApp) { + const admin = new Client(t); + const res = await admin.post('/api/v1/setup', { displayName: 'Chris', username: 'Chris', password: 'correct-horse' }); + if (res.status !== 200) throw new Error(`setup failed: ${res.text}`); + return admin; +} diff --git a/server/test/ideas.test.ts b/server/test/ideas.test.ts new file mode 100644 index 0000000..e53ad4f --- /dev/null +++ b/server/test/ideas.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { Client, makeApp, setUpAdmin, type TestApp } from './helpers'; + +let t: TestApp; +afterEach(() => t.cleanup()); + +describe('ideas box', () => { + it('does not exist when IDEAS_ENABLED is off', async () => { + t = await makeApp({ IDEAS_ENABLED: 'false' }); + const admin = await setUpAdmin(t); + expect((await admin.get('/api/v1/config')).json.ideasEnabled).toBe(false); + expect((await admin.post('/api/v1/ideas', { title: 'Hello' })).status).toBe(404); + expect((await admin.get('/api/v1/ideas')).status).toBe(404); + }); + + it('takes ideas from anyone signed in and lets an admin triage and export them', async () => { + t = await makeApp({ IDEAS_ENABLED: 'true' }); + const admin = await setUpAdmin(t); + const added = await admin.post('/api/v1/people', { displayName: 'Sam', username: 'sam', access: 'read', password: 'sams-password' }); + expect(added.status).toBe(200); + const sam = new Client(t); + await sam.post('/api/v1/auth/login', { username: 'sam', password: 'sams-password' }); + + expect((await new Client(t).post('/api/v1/ideas', { title: 'Anonymous' })).status).toBe(401); + expect((await sam.post('/api/v1/ideas', { title: ' ' })).status).toBe(400); + + const idea = await sam.post('/api/v1/ideas', { + title: 'Show a coffee total', + description: 'How much did we spend on coffee this year?', + path: '/insights', + }); + expect(idea.status).toBe(200); + expect(idea.json).toMatchObject({ status: 'new', author: 'Sam', appVersion: '20261007.01', path: '/insights' }); + + expect((await sam.get('/api/v1/ideas')).status).toBe(403); + const list = (await admin.get('/api/v1/ideas')).json; + expect(list).toHaveLength(1); + + const planned = await admin.patch(`/api/v1/ideas/${idea.json.id}`, { status: 'planned', adminNote: 'Milestone 2' }); + expect(planned.json).toMatchObject({ status: 'planned', adminNote: 'Milestone 2' }); + expect((await admin.get('/api/v1/ideas?status=new')).json).toHaveLength(0); + + const md = await admin.get('/api/v1/ideas/export.md'); + expect(md.headers.get('content-type')).toContain('text/markdown'); + expect(md.text).toContain(`## #${idea.json.id}: Show a coffee total`); + expect(md.text).toContain('- Status: planned'); + expect(md.text).toContain('How much did we spend on coffee this year?'); + }); +}); diff --git a/server/test/import.test.ts b/server/test/import.test.ts new file mode 100644 index 0000000..8f2ab74 --- /dev/null +++ b/server/test/import.test.ts @@ -0,0 +1,268 @@ +import { readFileSync } from 'node:fs'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { cleanMerchant } from '../src/import/merchant'; +import { parseCents, parseDate } from '../src/import/parse'; +import { makeApp, setUpAdmin, type Client, type TestApp } from './helpers'; + +// All fixture files are made up, shaped like the real exports' masked profiles. +const fixture = (name: string) => readFileSync(new URL(`./fixtures/synthetic/${name}`, import.meta.url), 'utf8'); +const CHASE = 'Chase9876_Activity_20261007.csv'; +const DISCOVER = 'Discover-Statement-20261007.csv'; + +let t: TestApp; +let admin: Client; +let ids: { checking: number; savings: number; discover: number; chase: number }; + +async function addAccount(body: Record) { + const res = await admin.post('/api/v1/accounts', body); + expect(res.status).toBe(200); + return res.json.id as number; +} + +async function importFile(name: string, accountId?: number) { + const preview = await admin.upload('/api/v1/imports/files', name, fixture(name)); + expect(preview.status).toBe(200); + const target = accountId ?? preview.json.account?.id; + const done = await admin.post('/api/v1/imports', { stagingId: preview.json.stagingId, accountId: target }); + expect(done.status).toBe(200); + return { preview: preview.json, summary: done.json }; +} + +const categoryId = async (name: string) => { + const groups = (await admin.get('/api/v1/categories')).json as { categories: { id: number; name: string }[] }[]; + return groups.flatMap((g) => g.categories).find((c) => c.name === name)!.id; +}; + +beforeEach(async () => { + t = await makeApp(); + admin = await setUpAdmin(t); + ids = { + checking: await addAccount({ name: 'Checking', kind: 'checking', institution: 'Bank of America', last4: '1234' }), + savings: await addAccount({ name: 'Savings', kind: 'savings', institution: 'Bank of America', last4: '5678' }), + discover: await addAccount({ name: 'Discover', kind: 'credit_card', institution: 'Discover' }), + chase: await addAccount({ name: 'Prime Visa', kind: 'credit_card', institution: 'Chase', last4: '9876' }), + }; +}); +afterEach(() => t.cleanup()); + +describe('reading statements', () => { + it('parses money and dates the way banks write them', () => { + expect(parseCents('1,234.56')).toBe(123456); + expect(parseCents('-$5.00')).toBe(-500); + expect(parseCents('(12.50)')).toBe(-1250); + expect(parseCents('')).toBeNull(); + expect(parseDate('09/03/2026')).toBe('2026-09-03'); + expect(parseDate('2026-09-03')).toBe('2026-09-03'); + expect(parseDate('9/3/26')).toBe('2026-09-03'); + expect(parseDate('02/30/2026')).toBeNull(); + }); + + it('cleans merchant names', () => { + expect(cleanMerchant('KROGER #123 CHARLOTTE NC').merchant).toBe('Kroger'); + expect(cleanMerchant('AMAZON MKTPL*AB12CD3E4').merchant).toBe('Amazon'); + expect(cleanMerchant('JOES DINER #12 CHARLOTTE NC').merchant).toBe('Joes Diner'); + expect(cleanMerchant('BLUE MOON BOOKS #9 ASHEVILLE NC').merchant).toBe('Blue Moon Books'); + expect(cleanMerchant('DUKE ENERGY DES:BILL PAY ID:XXXXX77777 INDN:JANE DOE').merchant).toBe('Duke Energy'); + expect(cleanMerchant('SQ *CORNER BAKERY 8823 CHARLOTTE NC').merchant).toBe('Corner Bakery'); + expect(cleanMerchant('Town of Riverside NC Bill Payment').merchant).toBe('Town of Riverside NC'); + expect(cleanMerchant('Zelle payment to Jordan Lee Conf# h3k2l1m9').merchant).toBe('Zelle to Jordan Lee'); + expect(cleanMerchant('Zelle payment from Pat Doe Conf# XyZ987abc').merchant).toBe('Zelle from Pat Doe'); + expect(cleanMerchant('Online Banking payment to CRD 4321 Confirmation# 1234567890')).toEqual({ merchant: 'Payment to card 4321', mentionsLast4: '4321' }); + expect(cleanMerchant('WAKE PARKS - NC123 RALEIGH NC 27601').merchant).toBe('Wake Parks'); + expect(cleanMerchant('BLUE RIDGE OUTFITTERS ID1234567890ABC').merchant).toBe('Blue Ridge Outfitters'); + expect(cleanMerchant('7-ELEVEN 34567 CHARLOTTE NC').merchant).toBe('7-Eleven'); + expect(cleanMerchant('ACME CORP DES:PAYROLL ID:XXXXX12345 INDN:DOE,JANE CO ID:XXXXX12345 PPD').merchant).toBe('Acme Corp'); + expect(cleanMerchant('CITY UTILITIES DES:UTIL PYMT ID:XXXXX123 INDN:Jane Doe CO ID:XXXXX WEB').merchant).toBe('City Utilities'); + const transfer = cleanMerchant('Online Banking transfer to SAV 5678 Confirmation# XXXXX12345'); + expect(transfer).toEqual({ merchant: 'Transfer to savings', mentionsLast4: '5678' }); + }); + + it('recognizes each bank and gets money in and out the right way round', async () => { + const discover = (await admin.upload('/api/v1/imports/files', DISCOVER, fixture(DISCOVER))).json; + expect(discover.layout.key).toBe('discover-card'); + expect(discover.counts).toMatchObject({ rows: 8, new: 8 }); + expect(discover.newRows[0]).toMatchObject({ merchant: 'Kroger', amountCents: -5420 }); + + const chase = (await admin.upload('/api/v1/imports/files', CHASE, fixture(CHASE))).json; + expect(chase.layout.key).toBe('chase-card'); + expect(chase.account).toMatchObject({ id: ids.chase, confidence: 'certain' }); // from the file name + expect(chase.newRows.find((r: { merchant: string; amountCents: number }) => r.amountCents === 1850)?.merchant).toBe('Amazon'); // a return + + const checking = (await admin.upload('/api/v1/imports/files', 'stmt.csv', fixture('bofa-checking.csv'))).json; + expect(checking.layout.key).toBe('bofa-deposit'); + expect(checking.counts).toMatchObject({ rows: 5, new: 5 }); // the "Beginning balance" line isn't a transaction + expect(checking.account).toBeNull(); // two Bank of America accounts: Spendlight asks + }); +}); + +describe('importing', () => { + it('skips duplicates by count, so identical charges on one day both survive', async () => { + const first = await importFile(DISCOVER, ids.discover); + expect(first.summary.addedCount).toBe(8); + const coffees = (await admin.get(`/api/v1/transactions?accountId=${ids.discover}&q=starbucks`)).json; + expect(coffees.total).toBe(2); + + const again = (await admin.upload('/api/v1/imports/files', DISCOVER, fixture(DISCOVER))).json; + expect(again.account).toMatchObject({ id: ids.discover, confidence: 'certain' }); // recognized by its transactions + expect(again.counts).toMatchObject({ new: 0, duplicates: 8 }); + + // A longer statement overlapping the first: only the extra line is new. + const longer = fixture(DISCOVER) + '09/25/2026,09/25/2026,STARBUCKS #4567 CHARLOTTE NC,5.45,Restaurants\r\n'; + const overlap = (await admin.upload('/api/v1/imports/files', DISCOVER, longer)).json; + expect(overlap.counts).toMatchObject({ new: 1, duplicates: 8 }); + }); + + it('pairs card payments and transfers between accounts, and keeps them out of spending', async () => { + await importFile('bofa-checking.csv', ids.checking); + await importFile('bofa-savings.csv', ids.savings); + await importFile(DISCOVER, ids.discover); + const chase = await importFile(CHASE); + expect(chase.summary.transferCount).toBe(1); + + const checking = (await admin.get(`/api/v1/transactions?accountId=${ids.checking}`)).json.items as any[]; + const toDiscover = checking.find((x) => x.amountCents === -50000); + const toSavings = checking.find((x) => x.amountCents === -30000); + const toChase = checking.find((x) => x.amountCents === -15000); + expect(toDiscover).toMatchObject({ needsReview: false, transferPeer: { accountName: 'Discover' } }); + expect(toSavings).toMatchObject({ needsReview: false, transferPeer: { accountName: 'Savings' } }); + expect(toChase).toMatchObject({ needsReview: false, transferPeer: { accountName: 'Prime Visa' } }); + expect(toDiscover.categoryId).toBe(await categoryId('Credit Card Payment')); + expect(toSavings.categoryId).toBe(await categoryId('Transfer Between Accounts')); + + // Spending totals leave transfers out. + const sums = (await admin.get(`/api/v1/transactions?accountId=${ids.checking}`)).json.sums; + expect(sums).toEqual({ inCents: 300000, outCents: -12000 }); + }); + + it('pre-fills categories from the bank and from known merchants', async () => { + await importFile(DISCOVER, ids.discover); + const review = (await admin.get('/api/v1/review')).json as any[]; + const by = (m: string) => review.find((r) => r.merchant === m); + expect(by('Kroger').categoryId).toBe(await categoryId('Groceries')); + expect(by('Shell').categoryId).toBe(await categoryId('Fuel')); + expect(by('Netflix').categoryId).toBe(await categoryId('Streaming and Subscriptions')); + expect(by('Blue Moon Books').categoryId).toBe(await categoryId('General Merchandise')); // the bank's hint + expect(by('Kroger').suggestions.length).toBeGreaterThanOrEqual(1); + }); + + it('learns from your picks: three agreeing picks make the next one confident', async () => { + const dining = await categoryId('Dining Out'); + const coffee = await categoryId('Coffee and Snacks'); + await importFile(DISCOVER, ids.discover); + const diner = ((await admin.get('/api/v1/review')).json as any[]).find((r) => r.merchant === 'Joes Diner'); + const filed = await admin.patch(`/api/v1/transactions/${diner.id}`, { categoryId: dining }); + expect(filed.json.similar).toBeNull(); + + // Coffee: file one; the prompt offers the other identical one. + const coffees = ((await admin.get('/api/v1/review')).json as any[]).filter((r) => r.merchant === 'Starbucks'); + const firstCoffee = await admin.patch(`/api/v1/transactions/${coffees[0].id}`, { categoryId: coffee }); + expect(firstCoffee.json.similar).toEqual({ merchant: 'Starbucks', count: 1, categoryId: coffee }); + const yes = await admin.post('/api/v1/transactions/bulk', { merchant: 'Starbucks', categoryId: coffee, remember: true }); + expect(yes.json.updated).toBe(1); + + // Next month, the rule files Starbucks confidently. + const next = fixture(DISCOVER).replace(/09\/03\/2026/g, '10/03/2026'); + const preview = (await admin.upload('/api/v1/imports/files', DISCOVER, next)).json; + await admin.post('/api/v1/imports', { stagingId: preview.stagingId, accountId: ids.discover }); + const october = ((await admin.get('/api/v1/review')).json as any[]).filter((r) => r.merchant === 'Starbucks' && r.date === '2026-10-03'); + expect(october).toHaveLength(2); + expect(october.every((r) => r.categoryId === coffee && r.confident)).toBe(true); + + const accepted = await admin.post('/api/v1/transactions/accept', { ids: october.map((r) => r.id) }); + expect(accepted.json.accepted).toBe(2); + }); + + it('undoes a whole import and puts the other half of a transfer back in Review', async () => { + await importFile('bofa-checking.csv', ids.checking); + const discover = await importFile(DISCOVER, ids.discover); + const del = await admin.del(`/api/v1/imports/${discover.summary.id}`); + expect(del.json.status).toBe('undone'); + expect((await admin.get(`/api/v1/transactions?accountId=${ids.discover}`)).json.total).toBe(0); + const payment = ((await admin.get(`/api/v1/transactions?accountId=${ids.checking}`)).json.items as any[]).find( + (x) => x.amountCents === -50000, + ); + expect(payment).toMatchObject({ transferPeer: null, needsReview: true }); + }); + + it('keeps balances: running balance from the bank, and an entered card balance plus new charges', async () => { + await importFile('bofa-checking.csv', ids.checking); + let accounts = (await admin.get('/api/v1/accounts')).json as any[]; + expect(accounts.find((a) => a.id === ids.checking)).toMatchObject({ balanceCents: 393000, balanceConfirmedOn: '2026-09-20' }); + + await admin.post(`/api/v1/accounts/${ids.discover}/balance`, { balanceCents: -100000, asOf: '2026-09-10' }); + await importFile(DISCOVER, ids.discover); + accounts = (await admin.get('/api/v1/accounts')).json as any[]; + // Owed $1,000 on Sep 10; then +$500 payment Sep 11 and charges of 47.10 + 17.99 + 22.00 after it. + expect(accounts.find((a) => a.id === ids.discover).balanceCents).toBe(-100000 + 50000 - 4710 - 1799 - 2200); + }); + + it('splits a purchase, adds tags and notes, and records cash by hand', async () => { + await importFile(CHASE); + const amazon = ((await admin.get(`/api/v1/transactions?accountId=${ids.chase}&q=amazon`)).json.items as any[]).find( + (x) => x.amountCents === -11245, + ); + const household = await categoryId('Household Supplies'); + const clothing = await categoryId('Clothing'); + const bad = await admin.call('PUT', `/api/v1/transactions/${amazon.id}/split`, { + parts: [ + { categoryId: household, amountCents: -5000 }, + { categoryId: clothing, amountCents: -5000 }, + ], + }); + expect(bad.json.error.code).toBe('split_total'); + const split = await admin.call('PUT', `/api/v1/transactions/${amazon.id}/split`, { + parts: [ + { categoryId: household, amountCents: -6245, note: 'Paper towels' }, + { categoryId: clothing, amountCents: -5000 }, + ], + }); + expect(split.json).toMatchObject({ isSplit: true, needsReview: false, categoryId: null }); + expect((await admin.get(`/api/v1/transactions?categoryId=${clothing}`)).json.total).toBe(1); + + const tagged = await admin.patch(`/api/v1/transactions/${amazon.id}`, { tags: ['Back to school', 'back to school', 'Kids'], note: 'For Sam' }); + expect(tagged.json.transaction).toMatchObject({ tags: ['Back to school', 'Kids'], note: 'For Sam' }); + expect((await admin.get('/api/v1/transactions?tag=kids')).json.total).toBe(1); + + const cash = await addAccount({ name: 'Cash', kind: 'cash' }); + const farmers = await admin.post('/api/v1/transactions', { + accountId: cash, + date: '2026-09-20', + amountCents: -2400, + merchant: 'Farmers market', + categoryId: await categoryId('Groceries'), + }); + expect(farmers.json).toMatchObject({ source: 'manual', needsReview: false }); + expect((await admin.del(`/api/v1/transactions/${amazon.id}`)).json.error.code).toBe('from_statement'); + expect((await admin.del(`/api/v1/transactions/${farmers.json.id}`)).status).toBe(200); + }); + + it('asks how to read an unknown file once, then remembers the layout', async () => { + const cash = await addAccount({ name: 'Credit union', kind: 'checking', institution: 'Local CU' }); + const first = (await admin.upload('/api/v1/imports/files', 'export.csv', fixture('unknown-bank.csv'))).json; + expect(first.layout).toBeNull(); + expect(first.headerRow).toEqual(['Posting Date', 'Payee Name', 'Withdrawal', 'Deposit', 'Balance']); + expect(first.suggestedMap).toMatchObject({ date: 0, description: 1, debit: 2, credit: 3, balance: 4 }); + + const mapping = { ...first.suggestedMap, dateOrder: 'YMD' }; + const mapped = (await admin.post('/api/v1/imports/preview', { stagingId: first.stagingId, accountId: cash, mapping })).json; + expect(mapped.counts).toMatchObject({ rows: 3, new: 3 }); + expect(mapped.newRows.map((r: { amountCents: number }) => r.amountCents)).toEqual([-1250, 120000, -825]); + await admin.post('/api/v1/imports', { stagingId: first.stagingId, accountId: cash, mapping }); + + const second = (await admin.upload('/api/v1/imports/files', 'export (1).csv', fixture('unknown-bank.csv'))).json; + expect(second.layout).not.toBeNull(); + expect(second.account).toMatchObject({ id: cash, confidence: 'certain' }); + expect(second.counts).toMatchObject({ new: 0, duplicates: 3 }); + }); + + it('lets read-only people look but not import or change anything', async () => { + const sam = await admin.post('/api/v1/people', { displayName: 'Sam', username: 'sam', access: 'read', password: 'sams-password' }); + expect(sam.status).toBe(200); + const { Client } = await import('./helpers'); + const reader = new Client(t); + await reader.post('/api/v1/auth/login', { username: 'sam', password: 'sams-password' }); + expect((await reader.get('/api/v1/accounts')).status).toBe(200); + expect((await reader.upload('/api/v1/imports/files', DISCOVER, fixture(DISCOVER))).status).toBe(403); + expect((await reader.post('/api/v1/accounts', { name: 'X', kind: 'cash' })).status).toBe(403); + }); +}); diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..6658073 --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2023"], + "types": ["node"] + }, + "include": ["src", "test", "drizzle.config.ts"] +} diff --git a/server/vitest.config.ts b/server/vitest.config.ts new file mode 100644 index 0000000..581038e --- /dev/null +++ b/server/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['test/**/*.test.ts'], + testTimeout: 20_000, + }, +}); diff --git a/shared/package.json b/shared/package.json new file mode 100644 index 0000000..ee5c0bd --- /dev/null +++ b/shared/package.json @@ -0,0 +1,11 @@ +{ + "name": "@spendlight/shared", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc -p tsconfig.json" + } +} diff --git a/shared/src/index.ts b/shared/src/index.ts new file mode 100644 index 0000000..8cf0129 --- /dev/null +++ b/shared/src/index.ts @@ -0,0 +1,255 @@ +// Types and small helpers shared by the server and the web app. + +export type Access = 'read' | 'full'; +export type Theme = 'system' | 'light' | 'dark'; +export type IdeaStatus = 'new' | 'planned' | 'done' | 'declined'; +export type BackupKind = 'nightly' | 'manual' | 'pre-upgrade' | 'pre-restore'; + +export const IDEA_STATUSES: readonly IdeaStatus[] = ['new', 'planned', 'done', 'declined']; + +export interface PublicConfig { + version: string; + ideasEnabled: boolean; + needsSetup: boolean; +} + +export interface Me { + id: number; + username: string; + displayName: string; + access: Access; + isAdmin: boolean; + theme: Theme; + mustChangePassword: boolean; + hasPasskey: boolean; +} + +export interface Person { + id: number; + username: string; + displayName: string; + access: Access; + isAdmin: boolean; + mustChangePassword: boolean; + createdAt: string; + lastActiveAt: string | null; +} + +export interface Device { + id: string; + deviceLabel: string; + createdAt: string; + lastSeenAt: string; + current: boolean; +} + +export interface PasskeyInfo { + id: string; + name: string; + createdAt: string; + lastUsedAt: string | null; +} + +export interface BackupInfo { + name: string; + kind: BackupKind; + size: number; + createdAt: string; +} + +export interface BackupOverview { + backups: BackupInfo[]; + nightlyAt: string; + keepDaily: number; + keepMonthly: number; +} + +export interface Idea { + id: number; + title: string; + description: string; + status: IdeaStatus; + adminNote: string | null; + path: string | null; + appVersion: string | null; + userAgent: string | null; + author: string | null; + createdAt: string; + updatedAt: string; +} + +export interface ApiErrorBody { + error: { code: string; message: string }; +} + +/** Versions look like 20261007.01: the build date, then that day's build counter. */ +export const VERSION_PATTERN = /^(\d{4})(\d{2})(\d{2})\.(\d{2,})$/; + +/** The calendar date a version was built, or null for development builds. */ +export function versionDate(version: string): Date | null { + const m = VERSION_PATTERN.exec(version); + if (!m) return null; + return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])); +} + +export const ACCESS_LABEL: Record = { + read: 'Read-only', + full: 'Full access', +}; + +// --------------------------------------------------------------------------- +// Milestone 1: money in. Amounts are whole cents; money out is negative. + +export type AccountKind = 'checking' | 'savings' | 'credit_card' | 'cash' | 'loan' | 'mortgage' | 'heloc'; +export type CategoryKind = 'expense' | 'income' | 'transfer'; + +export const ACCOUNT_KIND_LABEL: Record = { + checking: 'Checking', + savings: 'Savings', + credit_card: 'Credit card', + cash: 'Cash', + loan: 'Loan', + mortgage: 'Mortgage', + heloc: 'Home equity line', +}; + +/** Account kinds where the balance is money owed rather than money held. */ +export const LIABILITY_KINDS: readonly AccountKind[] = ['credit_card', 'loan', 'mortgage', 'heloc']; + +/** Twelve hues tuned to read well in both light and dark themes. */ +export const PALETTE = ['indigo', 'sky', 'teal', 'emerald', 'lime', 'amber', 'orange', 'rose', 'pink', 'violet', 'cyan', 'slate'] as const; +export type PaletteColor = (typeof PALETTE)[number]; + +export interface Account { + id: number; + name: string; + kind: AccountKind; + institution: string | null; + last4: string | null; + color: PaletteColor; + status: 'open' | 'retired'; + /** Current balance: money held is positive, money owed is negative. */ + balanceCents: number; + balanceConfirmedOn: string | null; + transactionCount: number; + lastImportAt: string | null; + lastTransactionDate: string | null; +} + +export interface Category { + id: number; + groupId: number; + groupName: string; + name: string; + kind: CategoryKind; + icon: string; + color: PaletteColor; + archived: boolean; +} + +export interface CategoryGroup { + id: number; + name: string; + categories: Category[]; +} + +export interface Split { + id: number; + categoryId: number; + amountCents: number; + note: string | null; +} + +export interface Transaction { + id: number; + accountId: number; + date: string; + amountCents: number; + description: string; + merchant: string; + categoryId: number | null; + needsReview: boolean; + suggestedBy: string | null; + isSplit: boolean; + splits: Split[]; + note: string | null; + tags: string[]; + transferPeer: { id: number; accountId: number; accountName: string } | null; + source: 'import' | 'manual'; + bankCategory: string | null; + importId: number | null; +} + +export interface ReviewItem extends Transaction { + /** Up to three likely categories, best first. */ + suggestions: number[]; + /** True when Spendlight is sure: a rule, or the last 3 picks agreed. */ + confident: boolean; + /** Set for card payments and transfers with no matching half in another account. */ + unpairedTransfer: boolean; +} + +export interface TransactionPage { + items: Transaction[]; + total: number; + sums: { inCents: number; outCents: number }; +} + +export interface ImportColumnMap { + date: number; + description: number; + /** One signed amount column... */ + amount?: number; + /** ...or separate columns for money out and money in. */ + debit?: number; + credit?: number; + postedDate?: number; + category?: number; + balance?: number; + /** Whether a positive number in `amount` is money spent (most credit cards) or money received. */ + positiveIs: 'out' | 'in'; + dateOrder: 'MDY' | 'DMY' | 'YMD'; +} + +export interface ImportPreview { + stagingId: string; + fileName: string; + layout: { key: string; name: string } | null; + /** When the layout is unknown: the first rows, for the mapping pop-up. */ + sampleRows: string[][]; + headerRow: string[] | null; + suggestedMap: ImportColumnMap | null; + account: { id: number; confidence: 'certain' | 'likely'; reason: string } | null; + dateFrom: string | null; + dateTo: string | null; + counts: { rows: number; new: number; duplicates: number; possibleDuplicates: number; skipped: number }; + newRows: { date: string; merchant: string; amountCents: number }[]; + possibleDuplicates: { key: string; date: string; merchant: string; amountCents: number; existingDate: string }[]; + /** Account details the file itself suggests, for creating a new account. */ + accountHint: { kind: AccountKind; institution: string | null; last4: string | null; name: string } | null; +} + +export interface ImportSummary { + id: number; + accountId: number; + accountName: string; + fileName: string; + layoutKey: string; + dateFrom: string | null; + dateTo: string | null; + addedCount: number; + duplicateCount: number; + skippedCount: number; + transferCount: number; + toReview: number; + status: 'committed' | 'undone'; + createdAt: string; +} + +export function formatCents(cents: number, options: { sign?: boolean; whole?: boolean } = {}): string { + const value = Math.abs(cents) / 100; + const digits = options.whole ? 0 : 2; + const text = value.toLocaleString('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: digits, maximumFractionDigits: digits }); + if (cents < 0) return `−${text}`; + return options.sign && cents > 0 ? `+${text}` : text; +} diff --git a/shared/tsconfig.json b/shared/tsconfig.json new file mode 100644 index 0000000..3e24639 --- /dev/null +++ b/shared/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2023"] + }, + "include": ["src"] +} diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..075bbda --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "esModuleInterop": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true + } +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..9380dcb --- /dev/null +++ b/web/index.html @@ -0,0 +1,23 @@ + + + + + + Spendlight + + + + + + + + + + + + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..1e4a11e --- /dev/null +++ b/web/package.json @@ -0,0 +1,36 @@ +{ + "name": "@spendlight/web", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -p tsconfig.json && vite build", + "typecheck": "tsc -p tsconfig.json", + "icons": "node scripts/make-icons.mjs" + }, + "dependencies": { + "@fontsource-variable/inter": "^5.3.0", + "@simplewebauthn/browser": "^14.0.0", + "@spendlight/shared": "*", + "@tanstack/react-query": "^5.104.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.52.0", + "radix-ui": "^1.7.0", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "react-router": "^8.4.0", + "sonner": "^2.0.8", + "tailwind-merge": "^3.7.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^6.1.2", + "sharp": "^0.35.5", + "tailwindcss": "^4.3.3", + "typescript": "~6.0.2", + "vite": "^8.3.3" + } +} diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 0000000..ab6f4d8 --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/web/public/icons/apple-touch-icon.png b/web/public/icons/apple-touch-icon.png new file mode 100644 index 0000000..1e989d6 Binary files /dev/null and b/web/public/icons/apple-touch-icon.png differ diff --git a/web/public/icons/icon-192.png b/web/public/icons/icon-192.png new file mode 100644 index 0000000..9f25e30 Binary files /dev/null and b/web/public/icons/icon-192.png differ diff --git a/web/public/icons/icon-512.png b/web/public/icons/icon-512.png new file mode 100644 index 0000000..5c009b6 Binary files /dev/null and b/web/public/icons/icon-512.png differ diff --git a/web/public/icons/icon-maskable-512.png b/web/public/icons/icon-maskable-512.png new file mode 100644 index 0000000..8dda832 Binary files /dev/null and b/web/public/icons/icon-maskable-512.png differ diff --git a/web/public/manifest.webmanifest b/web/public/manifest.webmanifest new file mode 100644 index 0000000..cd89ada --- /dev/null +++ b/web/public/manifest.webmanifest @@ -0,0 +1,16 @@ +{ + "name": "Spendlight", + "short_name": "Spendlight", + "description": "Your household's money, in the spotlight.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "any", + "background_color": "#0B1020", + "theme_color": "#0B1020", + "icons": [ + { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }, + { "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ] +} diff --git a/web/public/sw.js b/web/public/sw.js new file mode 100644 index 0000000..b3be51b --- /dev/null +++ b/web/public/sw.js @@ -0,0 +1,57 @@ +// Spendlight service worker. Makes the home-screen app open instantly and +// show its shell offline. It never touches /api: household data is always +// fetched fresh and never stored here. + +const SHELL = 'spendlight-shell'; +const ASSETS = 'spendlight-assets'; +const MAX_ASSETS = 80; + +self.addEventListener('install', () => self.skipWaiting()); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => Promise.all(keys.filter((k) => k !== SHELL && k !== ASSETS).map((k) => caches.delete(k)))) + .then(() => self.clients.claim()), + ); +}); + +self.addEventListener('fetch', (event) => { + const request = event.request; + const url = new URL(request.url); + if (request.method !== 'GET' || url.origin !== self.location.origin || url.pathname.startsWith('/api/')) return; + + // Pages: always try the network first so a new version shows up at once. + if (request.mode === 'navigate') { + event.respondWith( + fetch(request) + .then((response) => { + if (response.ok) { + const copy = response.clone(); + caches.open(SHELL).then((cache) => cache.put('/', copy)); + } + return response; + }) + .catch(() => caches.match('/').then((cached) => cached || Response.error())), + ); + return; + } + + // Versioned files never change, so a cached copy is always right. + if (url.pathname.startsWith('/assets/')) { + event.respondWith( + caches.open(ASSETS).then(async (cache) => { + const cached = await cache.match(request); + if (cached) return cached; + const response = await fetch(request); + if (response.ok) { + await cache.put(request, response.clone()); + const keys = await cache.keys(); + if (keys.length > MAX_ASSETS) await Promise.all(keys.slice(0, keys.length - MAX_ASSETS).map((k) => cache.delete(k))); + } + return response; + }), + ); + } +}); diff --git a/web/public/theme-init.js b/web/public/theme-init.js new file mode 100644 index 0000000..373e603 --- /dev/null +++ b/web/public/theme-init.js @@ -0,0 +1,9 @@ +// Runs before the page paints, so dark mode never flashes white. +(function () { + try { + var choice = localStorage.getItem('sl-theme') || 'system'; + var dark = choice === 'dark' || (choice === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches); + document.documentElement.classList.toggle('dark', dark); + document.documentElement.style.colorScheme = dark ? 'dark' : 'light'; + } catch (e) {} +})(); diff --git a/web/scripts/make-icons.mjs b/web/scripts/make-icons.mjs new file mode 100644 index 0000000..418339d --- /dev/null +++ b/web/scripts/make-icons.mjs @@ -0,0 +1,19 @@ +// Render the app icons from public/favicon.svg. Run with: npm run icons +import sharp from 'sharp'; +import { readFileSync } from 'node:fs'; + +const svg = readFileSync(new URL('../public/favicon.svg', import.meta.url)); +const out = (name) => new URL(`../public/icons/${name}`, import.meta.url).pathname; +const night = { r: 11, g: 16, b: 32, alpha: 1 }; + +await sharp(svg, { density: 384 }).resize(192, 192).png().toFile(out('icon-192.png')); +await sharp(svg, { density: 384 }).resize(512, 512).png().toFile(out('icon-512.png')); +// iOS adds its own rounded corners and dislikes transparency: full-bleed square. +await sharp(svg, { density: 384 }).resize(180, 180).flatten({ background: night }).png().toFile(out('apple-touch-icon.png')); +// Maskable: the mark inside the 80% safe zone on a full-bleed background. +const inner = await sharp(svg, { density: 384 }).resize(360, 360).png().toBuffer(); +await sharp({ create: { width: 512, height: 512, channels: 4, background: night } }) + .composite([{ input: inner, gravity: 'center' }]) + .png() + .toFile(out('icon-maskable-512.png')); +console.log('Icons written to public/icons/'); diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx new file mode 100644 index 0000000..c24a9c6 --- /dev/null +++ b/web/src/components/AppShell.tsx @@ -0,0 +1,230 @@ +import { Ellipsis, Upload } from 'lucide-react'; +import { Suspense, useEffect, useState } from 'react'; +import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router'; +import { toast } from 'sonner'; +import { useReviewCount } from '@/lib/data'; +import { queueFiles } from '@/lib/pendingFiles'; +import { useConfig, useCurrentUser } from '@/lib/session'; +import { cn } from '@/lib/utils'; +import { APP_VERSION } from '@/lib/version'; +import { LogoMark, VersionTag } from './brand'; +import { IdeaButton } from './IdeaDialog'; +import { NAV, SETTINGS_NAV, type NavItem } from './nav'; +import { Button } from './ui/button'; +import { Skeleton, Tip } from './ui/misc'; +import { UserMenu } from './UserMenu'; + +function pageTitle(pathname: string): string { + if (pathname === '/') return 'Home'; + if (pathname.startsWith('/settings')) return 'Settings'; + if (pathname.startsWith('/more')) return 'More'; + if (pathname.startsWith('/import')) return 'Import'; + return NAV.find((n) => n.to !== '/' && pathname.startsWith(n.to))?.label ?? 'Spendlight'; +} + +function CountBadge({ count, className }: { count: number | undefined; className?: string }) { + if (!count) return null; + return ( + + {count > 999 ? '999+' : count} + + ); +} + +function SideLink({ item, badge }: { item: NavItem; badge?: number }) { + const Icon = item.icon; + return ( + + cn( + 'flex h-11 items-center gap-3 rounded-xl px-3 text-[14px] font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground md:justify-center lg:justify-start', + isActive && 'bg-accent text-accent-foreground hover:bg-accent hover:text-accent-foreground', + ) + } + > + + + + + {item.label} + + + ); +} + +function Sidebar() { + const { data: toReview } = useReviewCount(); + return ( + + ); +} + +function PhoneTabBar() { + const { pathname } = useLocation(); + const { data: toReview } = useReviewCount(); + const inMore = pathname.startsWith('/more') || pathname.startsWith('/settings') || pathname.startsWith('/import') || NAV.some((n) => !n.phone && pathname.startsWith(n.to)); + const tab = 'flex flex-col items-center gap-1 pt-2 pb-1 text-[11px] font-medium text-muted-foreground transition-colors'; + return ( + + ); +} + +/** When the server is running a newer version than these screens, offer a reload. */ +function UpdateNotice() { + const { data } = useConfig(); + useEffect(() => { + if (!data || data.version === 'dev' || APP_VERSION === 'dev' || data.version === APP_VERSION) return; + toast('A new version of Spendlight is ready', { + id: 'update', + description: `Version ${data.version}`, + duration: Infinity, + action: { label: 'Reload', onClick: () => window.location.reload() }, + }); + }, [data]); + return null; +} + +export function PageSkeleton() { + return ( +
+ + + +
+ ); +} + +/** Drop statement files anywhere on a big screen to start importing them. */ +function useFileDrop(enabled: boolean) { + const navigate = useNavigate(); + const [over, setOver] = useState(false); + useEffect(() => { + if (!enabled) return; + let depth = 0; + const hasFiles = (e: DragEvent) => !!e.dataTransfer?.types.includes('Files'); + const enter = (e: DragEvent) => { + if (!hasFiles(e)) return; + depth++; + setOver(true); + }; + const leave = (e: DragEvent) => { + if (!hasFiles(e)) return; + depth = Math.max(0, depth - 1); + if (!depth) setOver(false); + }; + const overHandler = (e: DragEvent) => { + if (hasFiles(e)) e.preventDefault(); + }; + const drop = (e: DragEvent) => { + if (!hasFiles(e)) return; + e.preventDefault(); + depth = 0; + setOver(false); + const files = [...(e.dataTransfer?.files ?? [])]; + if (files.length && !window.location.pathname.startsWith('/import')) { + queueFiles(files); + navigate('/import'); + } + }; + window.addEventListener('dragenter', enter); + window.addEventListener('dragleave', leave); + window.addEventListener('dragover', overHandler); + window.addEventListener('drop', drop); + return () => { + window.removeEventListener('dragenter', enter); + window.removeEventListener('dragleave', leave); + window.removeEventListener('dragover', overHandler); + window.removeEventListener('drop', drop); + }; + }, [enabled, navigate]); + return over; +} + +export function AppShell() { + const { data: config } = useConfig(); + const me = useCurrentUser(); + const { pathname } = useLocation(); + const dropping = useFileDrop(me.access === 'full' && !pathname.startsWith('/import')); + + useEffect(() => { + window.scrollTo(0, 0); + }, [pathname]); + + return ( +
+ +
+
+
+ + + +

{pageTitle(pathname)}

+ {me.access === 'full' ? ( + + + + ) : null} + {config?.ideasEnabled ? : null} + +
+
+
+ }> + + +
+
+ + + {dropping ? ( +
+
+ +

Drop to import

+

Statements from any of your banks

+
+
+ ) : null} +
+ ); +} diff --git a/web/src/components/AuthLayout.tsx b/web/src/components/AuthLayout.tsx new file mode 100644 index 0000000..b74587c --- /dev/null +++ b/web/src/components/AuthLayout.tsx @@ -0,0 +1,33 @@ +import type { ReactNode } from 'react'; +import { useConfig } from '@/lib/session'; +import { LogoMark, VersionTag } from './brand'; + +/** The calm, centered frame for setup, sign-in and the first password change. */ +export function AuthLayout({ title, subtitle, children }: { title: string; subtitle?: ReactNode; children: ReactNode }) { + const { data } = useConfig(); + return ( +
+
+
+
+ +

{title}

+ {subtitle ?

{subtitle}

: null} +
+
{children}
+
+
+ +
+
+ ); +} + +export function FormError({ message }: { message: string | null }) { + if (!message) return null; + return ( +

+ {message} +

+ ); +} diff --git a/web/src/components/CategoryPicker.tsx b/web/src/components/CategoryPicker.tsx new file mode 100644 index 0000000..c4cd2f2 --- /dev/null +++ b/web/src/components/CategoryPicker.tsx @@ -0,0 +1,202 @@ +import type { Category } from '@spendlight/shared'; +import { Check, Search } from 'lucide-react'; +import { Dialog as DialogPrimitive, Popover } from 'radix-ui'; +import { useMemo, useRef, useState, type ReactNode } from 'react'; +import { recentCategories, rememberCategory, useCategories } from '@/lib/data'; +import { useIsPhone } from '@/lib/useMediaQuery'; +import { cn } from '@/lib/utils'; +import { CategoryIcon } from './money'; + +// The one category picker used everywhere: a flat, searchable list. Suggested +// and recent picks sit on top, then every category A to Z with its group as a +// small label. Phones get a full-height sheet; bigger screens a popover. + +interface PickerProps { + value: number | null; + suggestions?: number[]; + onPick: (category: Category) => void; + children: ReactNode; + /** Hide transfer categories (for splits, which are always spending or income). */ + noTransfers?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; +} + +function PickerList({ + value, + suggestions = [], + onPick, + noTransfers, + autoFocus, +}: Omit & { autoFocus: boolean }) { + const { all, byId } = useCategories(); + const [query, setQuery] = useState(''); + const [active, setActive] = useState(0); + const listRef = useRef(null); + + const sections = useMemo(() => { + const usable = all.filter((c) => !c.archived && !(noTransfers && c.kind === 'transfer')); + const q = query.trim().toLowerCase(); + if (q) { + const matches = usable + .filter((c) => c.name.toLowerCase().includes(q) || c.groupName.toLowerCase().includes(q)) + .sort((a, b) => Number(!a.name.toLowerCase().startsWith(q)) - Number(!b.name.toLowerCase().startsWith(q)) || a.name.localeCompare(b.name)); + return [{ title: `${matches.length} match${matches.length === 1 ? '' : 'es'}`, items: matches }]; + } + const pick = (ids: number[]) => ids.map((id) => byId.get(id)).filter((c): c is Category => !!c && usable.includes(c)); + const suggested = pick(suggestions); + const recent = pick(recentCategories()).filter((c) => !suggested.includes(c)).slice(0, 4); + return [ + ...(suggested.length ? [{ title: 'Suggested', items: suggested }] : []), + ...(recent.length ? [{ title: 'Recent', items: recent }] : []), + { title: 'All categories', items: [...usable].sort((a, b) => a.name.localeCompare(b.name)) }, + ]; + }, [all, byId, query, suggestions, noTransfers]); + + const flat = sections.flatMap((s) => s.items); + const choose = (c: Category) => { + rememberCategory(c.id); + onPick(c); + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + const next = Math.max(0, Math.min(flat.length - 1, active + (e.key === 'ArrowDown' ? 1 : -1))); + setActive(next); + listRef.current?.querySelector(`[data-index="${next}"]`)?.scrollIntoView({ block: 'nearest' }); + } else if (e.key === 'Enter' && flat[active]) { + e.preventDefault(); + choose(flat[active]!); + } + }; + + let index = -1; + return ( +
+
+ + { + setQuery(e.target.value); + setActive(0); + }} + autoFocus={autoFocus} + placeholder="Search categories" + aria-label="Search categories" + className="h-11 w-full rounded-xl bg-muted pl-9 pr-3 text-[16px] outline-none placeholder:text-muted-foreground/80 focus-visible:ring-2 focus-visible:ring-ring/30 sm:h-10 sm:text-sm" + /> +
+
+ {sections.map((section) => ( +
+

{section.title}

+ {section.items.map((c) => { + index++; + const i = index; + return ( + + ); + })} + {section.items.length === 0 ?

Nothing matches that.

: null} +
+ ))} +
+
+ ); +} + +export function CategoryPicker({ children, open: controlledOpen, onOpenChange, ...props }: PickerProps) { + const phone = useIsPhone(); + const [uncontrolled, setUncontrolled] = useState(false); + const open = controlledOpen ?? uncontrolled; + const setOpen = onOpenChange ?? setUncontrolled; + const pick = (c: Category) => { + setOpen(false); + props.onPick(c); + }; + + if (phone) { + return ( + + {children} + + + +
+ Choose a category + Pick the category for this transaction + + + + + ); + } + + return ( + + {children} + + + + + + + ); +} + +/** A tappable chip showing a category (or "Choose category"), opening the picker. */ +export function CategoryButton({ + categoryId, + suggestions, + onPick, + className, + placeholder = 'Choose category', +}: { + categoryId: number | null; + suggestions?: number[]; + onPick: (category: Category) => void; + className?: string; + placeholder?: string; +}) { + const { byId } = useCategories(); + const c = categoryId ? byId.get(categoryId) : undefined; + return ( + + + + ); +} diff --git a/web/src/components/IdeaDialog.tsx b/web/src/components/IdeaDialog.tsx new file mode 100644 index 0000000..571dc95 --- /dev/null +++ b/web/src/components/IdeaDialog.tsx @@ -0,0 +1,82 @@ +import type { Idea } from '@spendlight/shared'; +import { useMutation } from '@tanstack/react-query'; +import { Lightbulb } from 'lucide-react'; +import { useState, type FormEvent } from 'react'; +import { useLocation } from 'react-router'; +import { toast } from 'sonner'; +import { api, errorMessage } from '@/lib/api'; +import { Button } from './ui/button'; +import { Dialog, DialogContent, DialogTrigger } from './ui/dialog'; +import { Field, Input, Textarea } from './ui/form'; +import { Tip } from './ui/misc'; + +/** The lightbulb at the top right: share an idea in a few seconds. */ +export function IdeaButton() { + const [open, setOpen] = useState(false); + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const location = useLocation(); + + const save = useMutation({ + mutationFn: () => + api('/ideas', { method: 'POST', body: { title, description, path: location.pathname } }), + onSuccess: () => { + toast.success('Thanks! Your idea is saved.', { description: 'It’s on the list to review.' }); + setTitle(''); + setDescription(''); + setOpen(false); + }, + onError: (error) => toast.error(errorMessage(error)), + }); + + const submit = (e: FormEvent) => { + e.preventDefault(); + if (title.trim()) save.mutate(); + }; + + return ( + + + + + + + +
+ + {(id) => ( + setTitle(e.target.value)} + maxLength={120} + placeholder="For example: show our coffee total for the year" + autoFocus + required + /> + )} + + + {(id) => ( +