From c3cf0f3eb87fa2a7255973ab87c79d48361d01c7 Mon Sep 17 00:00:00 2001 From: Chris Olson Date: Wed, 7 Oct 2026 19:52:07 -0400 Subject: [PATCH] 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 --- .dockerignore | 10 + .env.example | 28 + .githooks/pre-commit | 14 + .gitignore | 21 + .npmrc | 5 + Dockerfile | 57 + README.md | 7 + VERSION | 1 + compose.yaml | 34 + deploy/nginx.conf.example | 31 + deploy/spendlight-cli.sh | 5 + package-lock.json | 6846 +++++++++++++++++ package.json | 22 + samples/README.md | 18 + scripts/build.sh | 18 + scripts/deploy.sh | 18 + scripts/dev.sh | 24 + scripts/next-version.sh | 19 + scripts/profile-samples.mjs | 155 + server/build.mjs | 19 + server/drizzle.config.ts | 7 + server/drizzle/0000_foundation.sql | 77 + server/drizzle/0001_money_in.sql | 145 + server/drizzle/meta/0000_snapshot.json | 533 ++ server/drizzle/meta/0001_snapshot.json | 1548 ++++ server/drizzle/meta/_journal.json | 20 + server/package.json | 35 + server/src/accounts/balance.ts | 20 + server/src/app.ts | 107 + server/src/audit.ts | 24 + server/src/auth/guards.ts | 57 + server/src/auth/password.ts | 35 + server/src/auth/ratelimit.ts | 75 + server/src/auth/sessions.ts | 135 + server/src/backups/backup.ts | 162 + server/src/categories/seed.ts | 132 + server/src/categorize/known.ts | 231 + server/src/categorize/suggest.ts | 207 + server/src/cli.ts | 90 + server/src/config.ts | 59 + server/src/context.ts | 46 + server/src/db/client.ts | 55 + server/src/db/schema.ts | 291 + server/src/docs.ts | 50 + server/src/http.ts | 59 + server/src/ideas/markdown.ts | 18 + server/src/ideas/queries.ts | 30 + server/src/import/layouts.ts | 199 + server/src/import/merchant.ts | 109 + server/src/import/parse.ts | 72 + server/src/import/service.ts | 534 ++ server/src/import/transfers.ts | 89 + server/src/index.ts | 37 + server/src/log.ts | 15 + server/src/paths.ts | 6 + server/src/routes/accounts.ts | 167 + server/src/routes/auth.ts | 175 + server/src/routes/backups.ts | 76 + server/src/routes/devices.ts | 82 + server/src/routes/ideas.ts | 110 + server/src/routes/imports.ts | 120 + server/src/routes/passkeys.ts | 246 + server/src/routes/people.ts | 202 + server/src/routes/system.ts | 49 + server/src/routes/transactions.ts | 603 ++ server/src/schemas.ts | 256 + server/src/static.ts | 63 + server/src/transactions/dto.ts | 111 + server/src/users.ts | 42 + server/test/auth.test.ts | 161 + server/test/backups.test.ts | 96 + .../synthetic/Chase9876_Activity_20261007.csv | 6 + .../synthetic/Discover-Statement-20261007.csv | 9 + .../test/fixtures/synthetic/bofa-checking.csv | 13 + .../test/fixtures/synthetic/bofa-savings.csv | 10 + .../test/fixtures/synthetic/unknown-bank.csv | 4 + server/test/helpers.ts | 104 + server/test/ideas.test.ts | 49 + server/test/import.test.ts | 268 + server/tsconfig.json | 8 + server/vitest.config.ts | 9 + shared/package.json | 11 + shared/src/index.ts | 255 + shared/tsconfig.json | 7 + tsconfig.base.json | 17 + web/index.html | 23 + web/package.json | 36 + web/public/favicon.svg | 18 + web/public/icons/apple-touch-icon.png | Bin 0 -> 10123 bytes web/public/icons/icon-192.png | Bin 0 -> 11466 bytes web/public/icons/icon-512.png | Bin 0 -> 48784 bytes web/public/icons/icon-maskable-512.png | Bin 0 -> 32696 bytes web/public/manifest.webmanifest | 16 + web/public/sw.js | 57 + web/public/theme-init.js | 9 + web/scripts/make-icons.mjs | 19 + web/src/components/AppShell.tsx | 230 + web/src/components/AuthLayout.tsx | 33 + web/src/components/CategoryPicker.tsx | 202 + web/src/components/IdeaDialog.tsx | 82 + web/src/components/MappingDialog.tsx | 170 + web/src/components/RequireAuth.tsx | 101 + web/src/components/RouteError.tsx | 25 + web/src/components/TransactionSheet.tsx | 364 + web/src/components/UserMenu.tsx | 71 + web/src/components/brand.tsx | 49 + web/src/components/money.tsx | 152 + web/src/components/nav.ts | 21 + web/src/components/page.tsx | 63 + web/src/components/ui/button.tsx | 51 + web/src/components/ui/card.tsx | 26 + web/src/components/ui/dialog.tsx | 104 + web/src/components/ui/form.tsx | 64 + web/src/components/ui/menu.tsx | 42 + web/src/components/ui/misc.tsx | 90 + web/src/lib/api.ts | 39 + web/src/lib/data.ts | 50 + web/src/lib/pendingFiles.ts | 20 + web/src/lib/session.ts | 57 + web/src/lib/theme.ts | 38 + web/src/lib/useMediaQuery.ts | 15 + web/src/lib/useSetTheme.ts | 15 + web/src/lib/utils.ts | 64 + web/src/lib/version.ts | 2 + web/src/main.tsx | 35 + web/src/pages/AccountsPage.tsx | 453 ++ web/src/pages/ActivityPage.tsx | 362 + web/src/pages/HomePage.tsx | 196 + web/src/pages/ImportPage.tsx | 432 ++ web/src/pages/MorePage.tsx | 38 + web/src/pages/NotFoundPage.tsx | 16 + web/src/pages/ReviewPage.tsx | 383 + web/src/pages/SetupPage.tsx | 74 + web/src/pages/SignInPage.tsx | 104 + web/src/pages/Upcoming.tsx | 40 + web/src/pages/settings/AboutPage.tsx | 60 + web/src/pages/settings/BackupsPage.tsx | 88 + web/src/pages/settings/IdeasPage.tsx | 144 + web/src/pages/settings/PeoplePage.tsx | 257 + web/src/pages/settings/ProfilePage.tsx | 88 + web/src/pages/settings/SecurityPage.tsx | 217 + web/src/pages/settings/SettingsLayout.tsx | 45 + web/src/router.tsx | 74 + web/src/styles.css | 158 + web/src/vite-env.d.ts | 9 + web/tsconfig.json | 12 + web/vite.config.ts | 20 + 147 files changed, 21713 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100755 .githooks/pre-commit create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 VERSION create mode 100644 compose.yaml create mode 100644 deploy/nginx.conf.example create mode 100755 deploy/spendlight-cli.sh create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 samples/README.md create mode 100755 scripts/build.sh create mode 100755 scripts/deploy.sh create mode 100755 scripts/dev.sh create mode 100755 scripts/next-version.sh create mode 100644 scripts/profile-samples.mjs create mode 100644 server/build.mjs create mode 100644 server/drizzle.config.ts create mode 100644 server/drizzle/0000_foundation.sql create mode 100644 server/drizzle/0001_money_in.sql create mode 100644 server/drizzle/meta/0000_snapshot.json create mode 100644 server/drizzle/meta/0001_snapshot.json create mode 100644 server/drizzle/meta/_journal.json create mode 100644 server/package.json create mode 100644 server/src/accounts/balance.ts create mode 100644 server/src/app.ts create mode 100644 server/src/audit.ts create mode 100644 server/src/auth/guards.ts create mode 100644 server/src/auth/password.ts create mode 100644 server/src/auth/ratelimit.ts create mode 100644 server/src/auth/sessions.ts create mode 100644 server/src/backups/backup.ts create mode 100644 server/src/categories/seed.ts create mode 100644 server/src/categorize/known.ts create mode 100644 server/src/categorize/suggest.ts create mode 100644 server/src/cli.ts create mode 100644 server/src/config.ts create mode 100644 server/src/context.ts create mode 100644 server/src/db/client.ts create mode 100644 server/src/db/schema.ts create mode 100644 server/src/docs.ts create mode 100644 server/src/http.ts create mode 100644 server/src/ideas/markdown.ts create mode 100644 server/src/ideas/queries.ts create mode 100644 server/src/import/layouts.ts create mode 100644 server/src/import/merchant.ts create mode 100644 server/src/import/parse.ts create mode 100644 server/src/import/service.ts create mode 100644 server/src/import/transfers.ts create mode 100644 server/src/index.ts create mode 100644 server/src/log.ts create mode 100644 server/src/paths.ts create mode 100644 server/src/routes/accounts.ts create mode 100644 server/src/routes/auth.ts create mode 100644 server/src/routes/backups.ts create mode 100644 server/src/routes/devices.ts create mode 100644 server/src/routes/ideas.ts create mode 100644 server/src/routes/imports.ts create mode 100644 server/src/routes/passkeys.ts create mode 100644 server/src/routes/people.ts create mode 100644 server/src/routes/system.ts create mode 100644 server/src/routes/transactions.ts create mode 100644 server/src/schemas.ts create mode 100644 server/src/static.ts create mode 100644 server/src/transactions/dto.ts create mode 100644 server/src/users.ts create mode 100644 server/test/auth.test.ts create mode 100644 server/test/backups.test.ts create mode 100644 server/test/fixtures/synthetic/Chase9876_Activity_20261007.csv create mode 100644 server/test/fixtures/synthetic/Discover-Statement-20261007.csv create mode 100644 server/test/fixtures/synthetic/bofa-checking.csv create mode 100644 server/test/fixtures/synthetic/bofa-savings.csv create mode 100644 server/test/fixtures/synthetic/unknown-bank.csv create mode 100644 server/test/helpers.ts create mode 100644 server/test/ideas.test.ts create mode 100644 server/test/import.test.ts create mode 100644 server/tsconfig.json create mode 100644 server/vitest.config.ts create mode 100644 shared/package.json create mode 100644 shared/src/index.ts create mode 100644 shared/tsconfig.json create mode 100644 tsconfig.base.json create mode 100644 web/index.html create mode 100644 web/package.json create mode 100644 web/public/favicon.svg create mode 100644 web/public/icons/apple-touch-icon.png create mode 100644 web/public/icons/icon-192.png create mode 100644 web/public/icons/icon-512.png create mode 100644 web/public/icons/icon-maskable-512.png create mode 100644 web/public/manifest.webmanifest create mode 100644 web/public/sw.js create mode 100644 web/public/theme-init.js create mode 100644 web/scripts/make-icons.mjs create mode 100644 web/src/components/AppShell.tsx create mode 100644 web/src/components/AuthLayout.tsx create mode 100644 web/src/components/CategoryPicker.tsx create mode 100644 web/src/components/IdeaDialog.tsx create mode 100644 web/src/components/MappingDialog.tsx create mode 100644 web/src/components/RequireAuth.tsx create mode 100644 web/src/components/RouteError.tsx create mode 100644 web/src/components/TransactionSheet.tsx create mode 100644 web/src/components/UserMenu.tsx create mode 100644 web/src/components/brand.tsx create mode 100644 web/src/components/money.tsx create mode 100644 web/src/components/nav.ts create mode 100644 web/src/components/page.tsx create mode 100644 web/src/components/ui/button.tsx create mode 100644 web/src/components/ui/card.tsx create mode 100644 web/src/components/ui/dialog.tsx create mode 100644 web/src/components/ui/form.tsx create mode 100644 web/src/components/ui/menu.tsx create mode 100644 web/src/components/ui/misc.tsx create mode 100644 web/src/lib/api.ts create mode 100644 web/src/lib/data.ts create mode 100644 web/src/lib/pendingFiles.ts create mode 100644 web/src/lib/session.ts create mode 100644 web/src/lib/theme.ts create mode 100644 web/src/lib/useMediaQuery.ts create mode 100644 web/src/lib/useSetTheme.ts create mode 100644 web/src/lib/utils.ts create mode 100644 web/src/lib/version.ts create mode 100644 web/src/main.tsx create mode 100644 web/src/pages/AccountsPage.tsx create mode 100644 web/src/pages/ActivityPage.tsx create mode 100644 web/src/pages/HomePage.tsx create mode 100644 web/src/pages/ImportPage.tsx create mode 100644 web/src/pages/MorePage.tsx create mode 100644 web/src/pages/NotFoundPage.tsx create mode 100644 web/src/pages/ReviewPage.tsx create mode 100644 web/src/pages/SetupPage.tsx create mode 100644 web/src/pages/SignInPage.tsx create mode 100644 web/src/pages/Upcoming.tsx create mode 100644 web/src/pages/settings/AboutPage.tsx create mode 100644 web/src/pages/settings/BackupsPage.tsx create mode 100644 web/src/pages/settings/IdeasPage.tsx create mode 100644 web/src/pages/settings/PeoplePage.tsx create mode 100644 web/src/pages/settings/ProfilePage.tsx create mode 100644 web/src/pages/settings/SecurityPage.tsx create mode 100644 web/src/pages/settings/SettingsLayout.tsx create mode 100644 web/src/router.tsx create mode 100644 web/src/styles.css create mode 100644 web/src/vite-env.d.ts create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts 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 0000000000000000000000000000000000000000..1e989d600564fa80ab00b7b2c4d69875a691240e GIT binary patch literal 10123 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 e_jhbQ_%1kMDiE_VQMA1$>zs%6Tyq5lWq$+MaO literal 0 HcmV?d00001 diff --git a/web/public/icons/icon-maskable-512.png b/web/public/icons/icon-maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..8dda8325e3a0bee2429f8deb73bdda39ff6f4abb GIT binary patch literal 32696 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 literal 0 HcmV?d00001 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) => ( +