From 1cc2e28423efda5d8462c9de049724950d0c730b Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Sat, 15 Aug 2026 09:32:13 -0500 Subject: [PATCH] Migrate Deck Hearth off Vercel/Neon to homelab Dokploy stack. Replace @vercel/postgres, Blob, and Upstash with lib/sql.js, MinIO object storage, and CT 102 Redis rate limits. Add Dockerfile for Dokploy deploy, homelab runbooks, Neon data-copy helper, and point CI smoke/visual at the homelab URL instead of Vercel previews. Co-authored-by: Cursor --- .convoys/migrate-neon-to-homelab.md | 77 ++ .dockerignore | 11 + .github/workflows/preview-smoke.yml | 52 +- .github/workflows/visual-diff.yml | 35 +- Dockerfile | 30 + docs/DOKPLOY_DEPLOY.md | 89 ++ docs/HOMELAB_DATABASE.md | 135 +++ lib/card-catalog-match.js | 2 +- lib/card-embed.js | 38 +- lib/card-import/bulk-sync.js | 2 +- lib/card-import/discover.js | 2 +- lib/card-import/lorcana-bulk.js | 2 +- lib/card-import/mtg.js | 2 +- lib/card-import/pokemon-bulk.js | 2 +- lib/card-import/pokemon.js | 2 +- lib/card-import/reconcile-submissions.js | 2 +- lib/card-import/sync-catalog.js | 2 +- lib/card-text-match.js | 2 +- lib/card-visual-match.js | 2 +- lib/object-storage.js | 106 +++ lib/permission-middleware.js | 2 +- lib/rate-limit.js | 85 +- lib/sql.js | 74 ++ next.config.js | 2 + package-lock.json | 796 +++++++++++------- package.json | 12 +- pages/api/admin/card-submissions.js | 2 +- pages/api/admin/index.js | 2 +- pages/api/auth-utils.js | 2 +- pages/api/auth/login.js | 2 +- pages/api/auth/register.js | 2 +- pages/api/auth/verify.js | 2 +- pages/api/cards/[id].js | 2 +- pages/api/cards/[id]/collections.js | 2 +- pages/api/cards/[id]/decks.js | 2 +- pages/api/cards/[id]/favorite.js | 2 +- pages/api/cards/[id]/ownership.js | 2 +- pages/api/cards/batch-ownership.js | 2 +- pages/api/cards/owned.js | 2 +- pages/api/cards/search.js | 2 +- pages/api/collections.js | 2 +- pages/api/collections/[identifier].js | 2 +- .../api/collections/[identifier]/activity.js | 2 +- pages/api/collections/[identifier]/cards.js | 2 +- .../collections/[identifier]/permissions.js | 2 +- .../collections/[identifier]/thumbnails.js | 2 +- pages/api/community/collections.js | 2 +- pages/api/cron/sync-catalog.js | 2 +- pages/api/decks.js | 2 +- pages/api/decks/[id].js | 2 +- pages/api/decks/[id]/cards.js | 2 +- pages/api/favorites.js | 2 +- pages/api/invite/accept.js | 2 +- pages/api/invite/decline.js | 2 +- pages/api/public/collections.js | 2 +- pages/api/scan/upload-image.js | 2 +- pages/api/user-cards.js | 2 +- pages/api/user/avatar.js | 10 +- pages/api/user/avatar/generate.js | 13 +- pages/api/user/delete.js | 6 +- pages/api/user/password.js | 2 +- pages/api/user/profile.js | 2 +- pages/api/user/settings.js | 2 +- pages/api/user/stats.js | 2 +- pages/api/users/search.js | 2 +- playwright.config.js | 41 +- scripts/add-collaboration-features.js | 2 +- scripts/add-favorites-system.js | 2 +- scripts/add-image-column.js | 2 +- scripts/add-user-profile-columns.js | 2 +- scripts/backfill-card-embeddings.js | 10 +- scripts/create-sample-cards.js | 2 +- scripts/create-test-users.js | 2 +- scripts/demote-admin-to-user.js | 2 +- scripts/list-users.js | 2 +- scripts/migrate-neon-to-homelab.js | 99 +++ scripts/promote-user-to-admin.js | 2 +- scripts/reset-db.js | 2 +- scripts/setup-neon-db.js | 35 +- test/api/scan/upload-image.test.js | 6 +- test/api/user-cards.test.js | 4 +- test/lib/card-embed.test.js | 41 +- test/lib/card-import-sync-catalog.test.js | 2 +- test/lib/permission-middleware.test.js | 4 +- test/lib/sql.test.js | 31 + vercel.json | 18 +- 86 files changed, 1413 insertions(+), 565 deletions(-) create mode 100644 .convoys/migrate-neon-to-homelab.md create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docs/DOKPLOY_DEPLOY.md create mode 100644 docs/HOMELAB_DATABASE.md create mode 100644 lib/object-storage.js create mode 100644 lib/sql.js create mode 100644 scripts/migrate-neon-to-homelab.js create mode 100644 test/lib/sql.test.js diff --git a/.convoys/migrate-neon-to-homelab.md b/.convoys/migrate-neon-to-homelab.md new file mode 100644 index 0000000..552cfb4 --- /dev/null +++ b/.convoys/migrate-neon-to-homelab.md @@ -0,0 +1,77 @@ +--- +name: migrate-neon-to-homelab +classification: infra +success_metric: | + Deck Hearth runs on CT 102 Postgres + MinIO + Redis, app on Dokploy (CT 112); + Neon and Vercel hosting decommissioned; Vercel AI Gateway retained. +status: in_progress +created: 2026-08-15 +depends_on: [] +skip: + - ia + - ui-design + - ux + - visual + - a11y + - design + - flag +--- + +# Convoy: migrate-neon-to-homelab + +Move Deck Hearth from Neon (free tier full ~490 MB) to **CT 102 Postgres** +on the axiom homelab. Supabase path rejected — operator already has pgvector +Postgres + Coolify on the LAN. + +## Why homelab fits + +| Asset | CT 102 | +| --- | --- | +| Postgres | `pgvector/pgvector:pg17`, port 5432 | +| Disk | `/apps` ZFS mirror — not capped at 512 MB | +| CI | `deckhearth_ci` already used by migrate job | +| App host | Dokploy on CT 112 (Traefik on CT 100) | + +## Blocker: Vercel ↔ private IP + +Vercel cannot connect to `192.168.68.102`. Production cutover requires +**Dokploy deploy** (`deckhearth.stillwell.cloud`) on CT 112. + +## Phases + +| Phase | Work | Owner | Status | +| --- | --- | --- | --- | +| 1 | `axiom-server`: `ct102/init/03-deckhearth.sql` | operator | ready | +| 2 | `lib/sql.js` + import swap off `@vercel/postgres` | code | done | +| 3 | `docs/HOMELAB_DATABASE.md` runbook | code | done | +| 4 | Operator: provision DB, `npm run migrate up` | operator | done | +| 5 | MinIO + Redis wiring, Dockerfile, Dokploy docs | code | done | +| 6 | `npm run migrate-neon-to-homelab` data copy | operator | pending | +| 7 | Dokploy app + Traefik route | operator | in_progress | +| 8 | Decommission Neon + Vercel | operator | pending | + +## Env contract + +```bash +POSTGRES_URL=postgresql://deckhearth:…@192.168.68.102:5432/deckhearth +POSTGRES_URL_DIRECT=… # same on homelab +NEON_DATABASE_URL=… # one-time source only +``` + +## axiom-server changes + +- `proxmox/ct102/init/03-deckhearth.sql` — `deckhearth` + pgvector + `deckhearth_ci` +- `.cursor/rules/ct102-databases.mdc` — table row + +## tcg-vault changes + +- `lib/sql.js`, `scripts/migrate-neon-to-homelab.js` +- `docs/HOMELAB_DATABASE.md` + +## Risks + +| Risk | Mitigation | +| --- | --- | +| LAN-only DB | Deploy app on Coolify, not Vercel prod | +| `deckhearth_ci` password drift | Match `HOMELAB_CI_POSTGRES_PASSWORD` GitHub secret | +| Init SQL on live CT 102 | Manual `docker exec psql` apply, not initdb.d replay | diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3753eaa --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +node_modules +.next +.git +.convoys +.cursor +tests +test +*.md +.env* +!.env.example +.dockerignore diff --git a/.github/workflows/preview-smoke.yml b/.github/workflows/preview-smoke.yml index fb911c7..5298c5c 100644 --- a/.github/workflows/preview-smoke.yml +++ b/.github/workflows/preview-smoke.yml @@ -1,14 +1,8 @@ name: Preview smoke -# Waits for Vercel's per-PR Preview deployment to be ready, then runs -# Playwright smoke against its URL. -# -# Vercel's GitHub integration auto-deploys every push and posts a Deployment -# to the GitHub API once ready. We wait on that Deployment so we always hit -# the canonical preview URL Vercel just published. -# -# REQUIRES: @playwright/test installed. Until then, this workflow will fail -# on `npx playwright install`. See .convoys/ for the testing convoy. +# Runs Playwright smoke against the homelab deployment (Dokploy on CT 112, +# routed via Traefik at deckhearth.stillwell.cloud). Until per-PR preview +# deploy exists on Dokploy, smoke validates the live homelab app after merge. on: pull_request: @@ -29,17 +23,9 @@ concurrency: group: preview-smoke-${{ github.event.pull_request.number }} cancel-in-progress: true -# Default workflow token is read-only on this repo. The `wait-for-vercel-preview` -# action needs `deployments: read` to query Vercel's GitHub Deployment status, -# plus `statuses: read` because some Vercel deployments use commit statuses -# instead of the Deployments API. `pull-requests: read` lets it correlate the -# deployment back to this PR. Without these, the action 403s on the Checks API -# and the smoke job fails before Playwright even starts. permissions: contents: read - deployments: read pull-requests: read - statuses: read jobs: gate: @@ -79,20 +65,21 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Wait for Vercel Preview deployment - id: vercel - uses: patrickedqvist/wait-for-vercel-preview@v1.3.2 - with: - token: ${{ secrets.GITHUB_TOKEN }} - max_timeout: 120 - # NOTE: do NOT add `&x-vercel-set-bypass-cookie=true` here. Vercel - # responds to that with a 307 + Set-Cookie (`_vercel_jwt`), but - # axios in Node has no cookie jar — the cookie is dropped before - # the followup request, which then 401s. For this one-shot - # healthcheck the bare bypass query is enough; the cookie variant - # belongs in the future Playwright config where the browser does - # have a cookie jar. - path: /?x-vercel-protection-bypass=${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} + - name: Wait for homelab app health + run: | + BASE_URL="${SMOKE_BASE_URL:-https://deckhearth.stillwell.cloud}" + for i in $(seq 1 30); do + if curl -fsS "${BASE_URL}/api/health" >/dev/null; then + echo "Homelab app healthy at ${BASE_URL}" + exit 0 + fi + echo "Waiting for ${BASE_URL}/api/health (${i}/30)…" + sleep 10 + done + echo "::error::Homelab app did not become healthy in time" + exit 1 + env: + SMOKE_BASE_URL: ${{ vars.SMOKE_BASE_URL }} - uses: actions/setup-node@v4 with: @@ -136,8 +123,7 @@ jobs: - name: Run smoke tests run: npx playwright test --project=smoke env: - BASE_URL: ${{ steps.vercel.outputs.url }} - VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} + BASE_URL: ${{ vars.SMOKE_BASE_URL || 'https://deckhearth.stillwell.cloud' }} - name: Upload Playwright report on failure if: failure() diff --git a/.github/workflows/visual-diff.yml b/.github/workflows/visual-diff.yml index d86923d..617da81 100644 --- a/.github/workflows/visual-diff.yml +++ b/.github/workflows/visual-diff.yml @@ -1,7 +1,6 @@ name: Visual diff -# Same as preview-smoke — waits for Vercel's Preview deployment, then captures -# Playwright screenshots against it. UI-paths-only trigger to keep cost down. +# Same as preview-smoke — hits the homelab deployment at deckhearth.stillwell.cloud. # Paths are tcg-vault-specific (pages router, JS). on: @@ -30,15 +29,9 @@ concurrency: group: visual-diff-${{ github.event.pull_request.number }} cancel-in-progress: true -# `deployments: read` + `statuses: read` are required by wait-for-vercel-preview -# (see preview-smoke.yml for context). `pull-requests: write` is required by the -# final github-script step that posts the "Visual Diff" comment back to the PR; -# without it the API returns 403 even though the screenshots upload fine. permissions: contents: read - deployments: read pull-requests: write - statuses: read jobs: gate: @@ -73,14 +66,21 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Wait for Vercel Preview deployment - id: vercel - uses: patrickedqvist/wait-for-vercel-preview@v1.3.2 - with: - token: ${{ secrets.GITHUB_TOKEN }} - max_timeout: 120 - # See preview-smoke.yml for the no-`set-bypass-cookie` rationale. - path: /?x-vercel-protection-bypass=${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} + - name: Wait for homelab app health + run: | + BASE_URL="${SMOKE_BASE_URL:-https://deckhearth.stillwell.cloud}" + for i in $(seq 1 30); do + if curl -fsS "${BASE_URL}/api/health" >/dev/null; then + echo "Homelab app healthy at ${BASE_URL}" + exit 0 + fi + echo "Waiting for ${BASE_URL}/api/health (${i}/30)…" + sleep 10 + done + echo "::error::Homelab app did not become healthy in time" + exit 1 + env: + SMOKE_BASE_URL: ${{ vars.SMOKE_BASE_URL }} - uses: actions/setup-node@v4 with: @@ -136,8 +136,7 @@ jobs: - name: Capture screenshots (PR) run: npx playwright test --project=visual --update-snapshots=none env: - BASE_URL: ${{ steps.vercel.outputs.url }} - VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} + BASE_URL: ${{ vars.SMOKE_BASE_URL || 'https://deckhearth.stillwell.cloud' }} - name: Upload screenshots + diffs if: always() diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4b32917 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +FROM node:20-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +# npm ci is stricter than local macOS npm; install keeps Linux Docker builds reliable. +RUN npm install + +FROM node:20-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +ENV NEXT_TELEMETRY_DISABLED=1 +# Next.js evaluates API routes at build time; auth-secret throws without JWT_SECRET. +ENV JWT_SECRET=build-time-placeholder-replaced-at-runtime +RUN npm run build + +FROM node:20-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 + +RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/docs/DOKPLOY_DEPLOY.md b/docs/DOKPLOY_DEPLOY.md new file mode 100644 index 0000000..6f1ccd5 --- /dev/null +++ b/docs/DOKPLOY_DEPLOY.md @@ -0,0 +1,89 @@ +# Deck Hearth on Dokploy (CT 112) + +Homelab deploy: Postgres + Redis + MinIO on CT 102, app on Dokploy, public URL via CT 100 Traefik → CT 112. + +## 1. Prerequisites (CT 102) + +- [x] `deckhearth` Postgres — `provision-deckhearth.sh` +- [x] MinIO bucket — `provision-deckhearth-minio.sh` (axiom-server) +- [ ] Neon data copy — `npm run migrate-neon-to-homelab` (optional) + +## 2. Dokploy application + +**Dashboard:** https://dokploy.stillwell.cloud + +| Setting | Value | +| --- | --- | +| Source | Git (Forgejo/GitHub) → this repo | +| Build type | **Dockerfile** | +| Dockerfile path | `Dockerfile` (repo root — must be pushed to GitHub) | +| Branch | homelab migration branch until merged to `main` | +| Domain in Dokploy | `http://deckhearth.stillwell.cloud` (use `http://`, not `https://`) | +| Port | `3000` | + +## 3. Environment variables (Dokploy → Environment) + +```bash +# Database (CT 102) +POSTGRES_URL=postgresql://deckhearth:YOUR_PASSWORD@192.168.68.102:5432/deckhearth + +# Redis rate limits (CT 102, DB index 5) +REDIS_URL=redis://:YOUR_REDIS_PASSWORD@192.168.68.102:6379/5 + +# MinIO object storage (CT 102) +S3_ENDPOINT=http://192.168.68.102:9000 +S3_ACCESS_KEY_ID=deckhearth-app +S3_SECRET_ACCESS_KEY=YOUR_MINIO_SECRET +S3_BUCKET=deckhearth +S3_PUBLIC_BASE_URL=https://cdn.stillwell.cloud/deckhearth +S3_REGION=us-east-1 + +# Auth +JWT_SECRET=openssl rand -base64 32 + +# AI (kept on Vercel AI Gateway) +AI_GATEWAY_API_KEY=vck_… + +# Email (external — Resend until self-hosted) +RESEND_API_KEY=re_… + +# Cron (n8n on CT 105 hits this weekly) +CRON_SECRET=openssl rand -base64 24 + +# Optional +NODE_ENV=production +``` + +## 4. Traefik (CT 100) + +Route added in `axiom-server/proxmox/ct100/traefik/config/dynamic/ct112-dokploy.yml`: + +- Host: `deckhearth.stillwell.cloud` +- Backend: `http://192.168.68.112:80` (Dokploy Traefik) + +After editing locally: `./proxmox/scripts/sync.sh push-traefik` + +## 5. Catalog sync cron (replaces Vercel Cron) + +Vercel `vercel.json` crons are removed. Schedule in **n8n** (CT 105) or cron: + +```bash +curl -fsS -H "Authorization: Bearer $CRON_SECRET" \ + https://deckhearth.stillwell.cloud/api/cron/sync-catalog +``` + +Weekly: `0 6 * * 1` (Mondays 06:00). + +## 6. Decommission Vercel + +1. Point DNS / AdGuard rewrite for `deckhearth.stillwell.cloud` at Traefik (already `*.stillwell.cloud` → CT 100). +2. Disable Vercel project auto-deploy or delete project after Coolify/Dokploy is stable. +3. Remove unused env vars from Vercel dashboard (Neon, Blob, Upstash). + +## 7. Local dev + +Same env vars in `.env.local` with homelab URLs (LAN required). + +```bash +npm run dev +``` diff --git a/docs/HOMELAB_DATABASE.md b/docs/HOMELAB_DATABASE.md new file mode 100644 index 0000000..b5813a5 --- /dev/null +++ b/docs/HOMELAB_DATABASE.md @@ -0,0 +1,135 @@ +# Homelab Postgres (CT 102) — Deck Hearth + +Move Deck Hearth off Neon onto the shared **pgvector** Postgres on CT 102 +(`192.168.68.102:5432`). No Supabase or Neon bill — storage lives on your +`/apps` ZFS pool. + +**Homelab reference:** `axiom-server/proxmox/AGENT-DEPLOY.md` § Shared Services (CT 102) + +## Important: Vercel vs homelab + +Vercel serverless **cannot reach** `192.168.68.102` on your LAN. Options: + +| Hosting | DB on CT 102? | +| --- | --- | +| **Dokploy on CT 112** (recommended) | Yes — app and DB on the same network | +| **Local dev** (Mac on LAN) | Yes | +| **Vercel production** | No — unless you add a tunnel (not recommended for Postgres) | + +Plan: run production at `deckhearth.stillwell.cloud` via Dokploy (CT 112), +with `POSTGRES_URL` pointing at CT 102. + +CI already uses CT 102 today (`deckhearth_ci` user, migrate job in `.github/workflows/ci.yml`). + +--- + +## Phase 1 — Provision `deckhearth` on CT 102 + +Init SQL lives in the **axiom-server** repo: + +`proxmox/ct102/init/03-deckhearth.sql` + +On CT 102 (once), apply manually — initdb.d only runs on first Postgres boot: + +```bash +# From axiom-server workspace on your Mac: +./proxmox/scripts/sync.sh push 102 +./proxmox/scripts/sync.sh exec 102 "docker exec -i postgres psql -U postgres" < proxmox/ct102/init/03-deckhearth.sql +``` + +**Before running:** edit `03-deckhearth.sql` and set `deckhearth_ci` password to match +GitHub secret `HOMELAB_CI_POSTGRES_PASSWORD` (or rotate both together). + +Verify: + +```bash +./proxmox/scripts/sync.sh exec 102 "docker exec postgres psql -U postgres -c '\\l deckhearth'" +./proxmox/scripts/sync.sh exec 102 "docker exec postgres psql -U deckhearth -d deckhearth -c 'CREATE EXTENSION IF NOT EXISTS vector;'" +``` + +--- + +## Phase 2 — `.env.local` (Deck Hearth repo) + +Homelab uses one direct URL (no pooler): + +```bash +POSTGRES_URL=postgresql://deckhearth:YOUR_PASSWORD@192.168.68.102:5432/deckhearth +POSTGRES_URL_DIRECT=postgresql://deckhearth:YOUR_PASSWORD@192.168.68.102:5432/deckhearth + +# One-time Neon source for data copy (keep until migration done) +NEON_DATABASE_URL=postgresql://…@…neon.tech/…?sslmode=require +``` + +Apply schema on the **empty** homelab database: + +```bash +npm run migrate up +npm run setup-db # seeds admin@deckhearth.com if ADMIN_INITIAL_PASSWORD set +``` + +--- + +## Phase 3 — Copy data Neon → CT 102 + +Requires **PostgreSQL 17** `pg_dump` / `pg_restore`. Homebrew PG 14 will fail with a version mismatch — use the `postgres:17` Docker image: + +```bash +set -a && source .env.local && set +a +docker run --rm \ + -e "SOURCE=${NEON_DATABASE_URL}" \ + -e "TARGET=${POSTGRES_URL_DIRECT}" \ + -v /tmp/deckhearth-pg-migrate:/dump \ + postgres:17 bash -c ' + pg_dump "$SOURCE" --format=custom --data-only --no-owner --no-acl -f /dump/neon-data.dump + pg_restore --dbname "$TARGET" --data-only --no-owner --no-acl /dump/neon-data.dump + ' +``` + +Or `npm run migrate-neon-to-homelab` if local `pg_dump` is v17+. + +Copies **data only** (schema from Phase 2 migrations). Harmless restore errors: `neon_auth.*`, legacy RBAC tables, duplicate `pgmigrations` rows. + +--- + +## Phase 4 — Deploy app on Dokploy (CT 112) + +1. Dokploy (CT 112) → New Application → Git repo — see [`DOKPLOY_DEPLOY.md`](./DOKPLOY_DEPLOY.md) +2. Domain: `http://deckhearth.stillwell.cloud` (Traefik on CT 100 → CT 112) +3. Environment variables: + +| Variable | Value | +| --- | --- | +| `POSTGRES_URL` | `postgresql://deckhearth:…@192.168.68.102:5432/deckhearth` | +| `REDIS_URL` | `redis://:…@192.168.68.102:6379/5` | +| `S3_*` | MinIO on CT 102 — see `DOKPLOY_DEPLOY.md` | +| `JWT_SECRET` | (openssl rand -base64 32) | +| `AI_GATEWAY_API_KEY` | Vercel AI Gateway (embeddings + vision) | +| `RESEND_API_KEY` | email (external until self-hosted) | +| `CRON_SECRET` | n8n weekly catalog sync | + +4. Deploy and smoke-test login, search, scanner. + +--- + +## Phase 5 — Decommission Neon + Vercel + +After Dokploy prod is healthy for 24–48 h, pause/delete the Neon project and disable the Vercel deployment. + +--- + +## Code changes (this convoy) + +- `lib/sql.js` — generic Postgres client (`postgres` npm package); LAN = no SSL +- All routes import `lib/sql.js` instead of `@vercel/postgres` +- `npm run migrate` uses `POSTGRES_URL_DIRECT` +- `npm run migrate-neon-to-homelab` — data copy helper + +## Troubleshooting + +| Symptom | Fix | +| --- | --- | +| `connection refused` to 192.168.68.102 | Mac not on LAN / VPN; CT 102 down | +| `SSL required` against homelab | Fixed in `lib/sql.js` for private IPs | +| `type "vector" does not exist` | Run `CREATE EXTENSION vector` in `deckhearth` DB | +| Vercel preview still on Neon | Retired — CI smoke targets homelab URL | diff --git a/lib/card-catalog-match.js b/lib/card-catalog-match.js index ba54d65..3d1320d 100644 --- a/lib/card-catalog-match.js +++ b/lib/card-catalog-match.js @@ -1,4 +1,4 @@ -import { sql } from '@vercel/postgres'; +import { sql } from './sql.js'; import { cardNumbersEquivalent, diff --git a/lib/card-embed.js b/lib/card-embed.js index 80cacc2..0d03d73 100644 --- a/lib/card-embed.js +++ b/lib/card-embed.js @@ -26,16 +26,25 @@ function parseEmbeddingResponse(data) { return embedding.map((value) => Number(value)); } +function isValidEmbedInput(value) { + if (typeof value !== 'string' || !value.trim()) { + return false; + } + + if (value.startsWith('data:')) { + return value.includes(','); + } + + return value.startsWith('http://') || value.startsWith('https://'); +} + function buildEmbedInput(value) { if (typeof value === 'string') { return value; } if (value?.imageUrl) { - return { - type: 'image_url', - image_url: { url: value.imageUrl }, - }; + return value.imageUrl; } if (value?.imageDataUrl) { @@ -45,9 +54,24 @@ function buildEmbedInput(value) { throw new EmbedApiError('Invalid embed input'); } +function normalizeEmbeddingDimensions(embedding) { + if (embedding.length === EMBED_DIMENSION) { + return embedding; + } + + if (embedding.length > EMBED_DIMENSION) { + // Gateway ignores matryoshka `dimensions` for multimodal — truncate to catalog width. + return embedding.slice(0, EMBED_DIMENSION); + } + + throw new EmbedApiError( + `Embedding API returned ${embedding.length} dimensions; expected ${EMBED_DIMENSION}` + ); +} + /** * Embed image content via Vercel AI Gateway (server-only). - * Accepts a JPEG data URL or HTTPS image URL object. + * Accepts a JPEG data URL string, HTTPS image URL string, or { imageUrl } / { imageDataUrl }. */ export async function embedCardImage(input) { const apiKey = process.env.AI_GATEWAY_API_KEY; @@ -56,7 +80,7 @@ export async function embedCardImage(input) { } const payloadInput = buildEmbedInput(input); - if (typeof payloadInput === 'string' && !payloadInput.includes(',')) { + if (!isValidEmbedInput(payloadInput)) { throw new EmbedApiError('Invalid image data format'); } @@ -81,7 +105,7 @@ export async function embedCardImage(input) { }); } - return parseEmbeddingResponse(await response.json()); + return normalizeEmbeddingDimensions(parseEmbeddingResponse(await response.json())); } /** Format a float array for pgvector tagged-template queries. */ diff --git a/lib/card-import/bulk-sync.js b/lib/card-import/bulk-sync.js index 6374e6e..61f3dd8 100644 --- a/lib/card-import/bulk-sync.js +++ b/lib/card-import/bulk-sync.js @@ -8,7 +8,7 @@ * Also refreshes prices, legalities, and edhrec_rank on existing cards. */ -import { sql } from '@vercel/postgres'; +import { sql } from '../sql.js'; const BATCH_SIZE = 100; const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/lib/card-import/discover.js b/lib/card-import/discover.js index ddc5bbd..5e276ee 100644 --- a/lib/card-import/discover.js +++ b/lib/card-import/discover.js @@ -1,4 +1,4 @@ -import { sql } from '@vercel/postgres'; +import { sql } from '../sql.js'; const EXCLUDED_MTG_SET_TYPES = new Set(['token', 'memorabilia', 'funny', 'treasure_chest']); diff --git a/lib/card-import/lorcana-bulk.js b/lib/card-import/lorcana-bulk.js index b1aa169..33d3085 100644 --- a/lib/card-import/lorcana-bulk.js +++ b/lib/card-import/lorcana-bulk.js @@ -4,7 +4,7 @@ * Single fetch returns all cards (~2.3k). Upserts keyed on scryfall_id (stores Unique_ID). */ -import { sql as vercelSql } from '@vercel/postgres'; +import { sql as vercelSql } from '../sql.js'; const BATCH_SIZE = 100; const BULK_URL = 'https://api.lorcana-api.com/bulk/cards'; diff --git a/lib/card-import/mtg.js b/lib/card-import/mtg.js index 8a49a6c..1b20b9f 100644 --- a/lib/card-import/mtg.js +++ b/lib/card-import/mtg.js @@ -1,4 +1,4 @@ -import { sql } from '@vercel/postgres'; +import { sql } from '../sql.js'; const SCRYFALL_DELAY_MS = 100; const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/lib/card-import/pokemon-bulk.js b/lib/card-import/pokemon-bulk.js index 0608352..6f2596c 100644 --- a/lib/card-import/pokemon-bulk.js +++ b/lib/card-import/pokemon-bulk.js @@ -5,7 +5,7 @@ * (keyed on scryfall_id = external card id). Populates legalities from set metadata. */ -import { sql } from '@vercel/postgres'; +import { sql } from '../sql.js'; import { fetchPokemonSetCards, diff --git a/lib/card-import/pokemon.js b/lib/card-import/pokemon.js index 53ad6b1..ea35bb1 100644 --- a/lib/card-import/pokemon.js +++ b/lib/card-import/pokemon.js @@ -1,4 +1,4 @@ -import { sql } from '@vercel/postgres'; +import { sql } from '../sql.js'; import { fetchPokemonSetCards, diff --git a/lib/card-import/reconcile-submissions.js b/lib/card-import/reconcile-submissions.js index 9e6048a..2597e4c 100644 --- a/lib/card-import/reconcile-submissions.js +++ b/lib/card-import/reconcile-submissions.js @@ -1,4 +1,4 @@ -import { sql } from '@vercel/postgres'; +import { sql } from '../sql.js'; import { cardNumbersEquivalent, normalizeCardNumber } from '../card-number-utils.js'; diff --git a/lib/card-import/sync-catalog.js b/lib/card-import/sync-catalog.js index 196b29c..6722069 100644 --- a/lib/card-import/sync-catalog.js +++ b/lib/card-import/sync-catalog.js @@ -1,4 +1,4 @@ -import { sql } from '@vercel/postgres'; +import { sql } from '../sql.js'; import { runBulkMtgSync } from './bulk-sync.js'; import { runBulkLorcanaSync } from './lorcana-bulk.js'; diff --git a/lib/card-text-match.js b/lib/card-text-match.js index 6483a86..736c028 100644 --- a/lib/card-text-match.js +++ b/lib/card-text-match.js @@ -1,4 +1,4 @@ -import { sql } from '@vercel/postgres'; +import { sql } from './sql.js'; import { findPrintingByCollectorNumber } from './card-number-utils.js'; diff --git a/lib/card-visual-match.js b/lib/card-visual-match.js index d400648..9b84f22 100644 --- a/lib/card-visual-match.js +++ b/lib/card-visual-match.js @@ -1,4 +1,4 @@ -import { sql } from '@vercel/postgres'; +import { sql } from './sql.js'; import { formatEmbeddingForPg } from './card-embed.js'; diff --git a/lib/object-storage.js b/lib/object-storage.js new file mode 100644 index 0000000..b783659 --- /dev/null +++ b/lib/object-storage.js @@ -0,0 +1,106 @@ +import { + DeleteObjectCommand, + PutObjectCommand, + S3Client, +} from '@aws-sdk/client-s3'; + +/** + * MinIO / S3-compatible object storage (CT 102). + * + * Env: + * S3_ENDPOINT — e.g. http://192.168.68.102:9000 + * S3_ACCESS_KEY_ID — app-scoped MinIO user + * S3_SECRET_ACCESS_KEY + * S3_BUCKET — e.g. deckhearth + * S3_PUBLIC_BASE_URL — e.g. https://cdn.stillwell.cloud/deckhearth + * S3_REGION — optional, default us-east-1 + */ + +let client = null; + +function requireEnv(name) { + const value = process.env[name]; + if (!value?.trim()) { + throw new Error(`${name} is not configured`); + } + return value.trim(); +} + +function getClient() { + if (!client) { + client = new S3Client({ + endpoint: requireEnv('S3_ENDPOINT'), + region: process.env.S3_REGION || 'us-east-1', + credentials: { + accessKeyId: requireEnv('S3_ACCESS_KEY_ID'), + secretAccessKey: requireEnv('S3_SECRET_ACCESS_KEY'), + }, + forcePathStyle: true, + }); + } + return client; +} + +function getBucket() { + return requireEnv('S3_BUCKET'); +} + +function publicUrlForKey(key) { + const base = requireEnv('S3_PUBLIC_BASE_URL').replace(/\/$/, ''); + return `${base}/${key.replace(/^\//, '')}`; +} + +/** Extract object key from a stored public URL or pass through raw keys. */ +export function objectKeyFromReference(reference) { + if (!reference || typeof reference !== 'string') { + throw new Error('Invalid object reference'); + } + if (!reference.startsWith('http')) { + return reference.replace(/^\//, ''); + } + + const base = process.env.S3_PUBLIC_BASE_URL?.replace(/\/$/, ''); + if (base && reference.startsWith(`${base}/`)) { + return reference.slice(base.length + 1); + } + + try { + const url = new URL(reference); + const parts = url.pathname.split('/').filter(Boolean); + const bucket = getBucket(); + if (parts[0] === bucket) { + return parts.slice(1).join('/'); + } + return parts.join('/'); + } catch { + throw new Error('Could not parse object URL for deletion'); + } +} + +/** + * Drop-in replacement for `@vercel/blob` put (access ignored — bucket policy controls visibility). + * @returns {Promise<{ url: string }>} + */ +export async function put(key, body, { contentType } = {}) { + const objectKey = key.replace(/^\//, ''); + await getClient().send( + new PutObjectCommand({ + Bucket: getBucket(), + Key: objectKey, + Body: body, + ContentType: contentType, + }) + ); + return { url: publicUrlForKey(objectKey) }; +} + +/** Delete by object key or public URL (legacy blob URLs in DB). */ +export async function del(urlOrKey) { + const objectKey = objectKeyFromReference(urlOrKey); + await getClient().send( + new DeleteObjectCommand({ + Bucket: getBucket(), + Key: objectKey, + }) + ); +} diff --git a/lib/permission-middleware.js b/lib/permission-middleware.js index e8502b9..2cdb035 100644 --- a/lib/permission-middleware.js +++ b/lib/permission-middleware.js @@ -1,4 +1,4 @@ -import { sql } from '@vercel/postgres'; +import { sql } from './sql.js'; import jwt from 'jsonwebtoken'; import { JWT_SECRET } from './auth-secret.js'; diff --git a/lib/rate-limit.js b/lib/rate-limit.js index c41a95a..3e857fd 100644 --- a/lib/rate-limit.js +++ b/lib/rate-limit.js @@ -1,44 +1,33 @@ -import { Ratelimit } from '@upstash/ratelimit'; -import { Redis } from '@upstash/redis'; +import Redis from 'ioredis'; +import { RateLimiterRedis } from 'rate-limiter-flexible'; -// Per-class limiter configuration. Distinct Redis prefix per class is -// REQUIRED — without it, a search-class hit would consume the auth-class -// budget for the same identifier. `slidingWindow` chosen across all -// classes to match Brief 4's existing algorithm; switching to -// `tokenBucket` per-class would be its own convoy. const LIMITER_CONFIG = { - auth: { limit: 5, window: '15 m', prefix: 'deckhearth:auth' }, - search: { limit: 60, window: '1 m', prefix: 'deckhearth:search' }, - upload: { limit: 10, window: '1 h', prefix: 'deckhearth:upload' }, - generate: { limit: 5, window: '1 h', prefix: 'deckhearth:generate' }, - import: { limit: 5, window: '1 h', prefix: 'deckhearth:import' }, - scan: { limit: 5, window: '1 m', prefix: 'deckhearth:scan' }, + auth: { limit: 5, durationSec: 15 * 60, prefix: 'deckhearth:auth' }, + search: { limit: 60, durationSec: 60, prefix: 'deckhearth:search' }, + upload: { limit: 10, durationSec: 60 * 60, prefix: 'deckhearth:upload' }, + generate: { limit: 5, durationSec: 60 * 60, prefix: 'deckhearth:generate' }, + import: { limit: 5, durationSec: 60 * 60, prefix: 'deckhearth:import' }, + scan: { limit: 5, durationSec: 60, prefix: 'deckhearth:scan' }, }; -// Lazy singleton. Module-load init would throw in environments without -// Upstash env vars (local dev pre-onboarding, tests that transitively -// import the auth handlers, Vercel build-time bundling). Defer -// construction until the first request actually arrives. let cached = null; function init() { - // Env-var names match Vercel's Upstash Marketplace integration, which - // auto-provisions KV_REST_API_URL and KV_REST_API_TOKEN. See - // https://upstash.com/docs/redis/howto/vercelintegration. Single-source- - // of-truth — do NOT alias to UPSTASH_REDIS_REST_*. - const url = process.env.KV_REST_API_URL; - const token = process.env.KV_REST_API_TOKEN; - - if (url && token) { - const redis = new Redis({ url, token }); + const url = process.env.REDIS_URL; + if (url) { + const storeClient = new Redis(url, { + maxRetriesPerRequest: 1, + enableOfflineQueue: false, + }); const instances = new Map(); for (const [name, cfg] of Object.entries(LIMITER_CONFIG)) { instances.set( name, - new Ratelimit({ - redis, - limiter: Ratelimit.slidingWindow(cfg.limit, cfg.window), - prefix: cfg.prefix, + new RateLimiterRedis({ + storeClient, + keyPrefix: cfg.prefix, + points: cfg.limit, + duration: cfg.durationSec, }) ); } @@ -46,16 +35,12 @@ function init() { } if (process.env.NODE_ENV === 'production') { - // Fail-closed in production. A single failed login is a better outcome - // than silently disabling brute-force protection on the live site. throw new Error( - '[rate-limit] Upstash not configured. Set KV_REST_API_URL and KV_REST_API_TOKEN in the deployment environment (auto-provisioned by the Vercel Upstash Marketplace integration) before serving auth traffic.' + '[rate-limit] REDIS_URL is not configured. Set REDIS_URL to the homelab Redis URL (CT 102) before serving traffic.' ); } - console.warn( - '[rate-limit] KV_REST_API_URL / KV_REST_API_TOKEN not set — rate limiting disabled (dev/test only)' - ); + console.warn('[rate-limit] REDIS_URL not set — rate limiting disabled (dev/test only)'); return { mode: 'noop' }; } @@ -65,12 +50,6 @@ function extractIpIdentifier(req) { return firstHop || req.socket?.remoteAddress || 'anonymous'; } -// THROWS on missing userId. Per-user limiters MUST sit AFTER the auth -// check in the handler body — silently falling back to IP here would -// convert a per-user limit into a per-IP limit, locking out other -// household members for one user's behavior. The throw surfaces the -// misordering immediately during development rather than at first -// production incident. function extractUserIdentifier(userId) { if ( userId === null || @@ -100,13 +79,21 @@ async function check(className, identifier) { } try { - const { success, remaining, reset } = await limiter.limit(identifier); - return { allowed: success, remaining, reset }; - } catch (err) { - // Fail-open on Upstash outage. A hard outage at the rate-limit backend - // should not lock the entire user base out. Brute-force protection - // lives behind defense-in-depth (Vercel firewall, etc.). - console.error('[rate-limit]', err); + const result = await limiter.consume(identifier); + return { + allowed: true, + remaining: result.remainingPoints, + reset: Date.now() + result.msBeforeNext, + }; + } catch (rej) { + if (rej && typeof rej.msBeforeNext === 'number') { + return { + allowed: false, + remaining: 0, + reset: Date.now() + rej.msBeforeNext, + }; + } + console.error('[rate-limit]', rej); return { allowed: true, remaining: Infinity, reset: 0 }; } } diff --git a/lib/sql.js b/lib/sql.js new file mode 100644 index 0000000..2817f96 --- /dev/null +++ b/lib/sql.js @@ -0,0 +1,74 @@ +import postgres from 'postgres'; + +/** + * Canonical Postgres client for Deck Hearth. + * + * Supports: + * - Homelab CT 102 (direct LAN — `postgresql://deckhearth@192.168.68.102:5432/deckhearth`) + * - Supabase pooler (transaction mode, port 6543) if ever used + * + * Env: + * POSTGRES_URL — runtime (required) + * POSTGRES_URL_DIRECT — migrations (`npm run migrate`); on homelab, same as POSTGRES_URL + * + * API matches the former @vercel/postgres shape: `{ rows, rowCount }`. + */ + +function resolveDatabaseUrl() { + const url = process.env.POSTGRES_URL || process.env.DATABASE_URL; + if (!url) { + throw new Error('POSTGRES_URL is not set'); + } + return url; +} + +function resolveClientOptions(url) { + const isSupabasePooler = + url.includes('pgbouncer=true') || /:6543\//.test(url) || url.includes(':6543?'); + const isPrivateLan = /(?:^|@)(?:localhost|127\.0\.0\.1|192\.168\.|10\.|172\.(?:1[6-9]|2\d|3[01])\.)/.test( + url + ); + + return { + ssl: isPrivateLan ? false : 'require', + max: 1, + idle_timeout: 20, + connect_timeout: 15, + prepare: isSupabasePooler ? false : true, + }; +} + +/** @type {import('postgres').Sql | null} */ +let client = null; + +function getClient() { + if (!client) { + const url = resolveDatabaseUrl(); + client = postgres(url, resolveClientOptions(url)); + } + return client; +} + +/** + * Tagged-template SQL helper. + * @param {TemplateStringsArray} strings + * @param {...unknown} values + * @returns {Promise<{ rows: Record[], rowCount: number }>} + */ +export async function sql(strings, ...values) { + const pg = getClient(); + const result = await pg(strings, ...values); + const rows = Array.from(result); + return { + rows, + rowCount: typeof result.count === 'number' ? result.count : rows.length, + }; +} + +/** Close the pooled client (tests / long-running scripts). */ +export async function closeSqlPool() { + if (client) { + await client.end({ timeout: 5 }); + client = null; + } +} diff --git a/next.config.js b/next.config.js index 3d10b15..1ca97a7 100644 --- a/next.config.js +++ b/next.config.js @@ -1,10 +1,12 @@ /** @type {import('next').NextConfig} */ const nextConfig = { + output: 'standalone', images: { remotePatterns: [ { protocol: 'https', hostname: 'api.scryfall.com' }, { protocol: 'https', hostname: 'images.pokemontcg.io' }, { protocol: 'https', hostname: 'lorcana-api.com' }, + { protocol: 'https', hostname: 'cdn.stillwell.cloud' }, ], }, }; diff --git a/package-lock.json b/package-lock.json index 846be6d..5958184 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,16 +8,16 @@ "name": "deck-hearth", "version": "0.1.0", "dependencies": { + "@aws-sdk/client-s3": "^3.888.0", "@neondatabase/serverless": "^1.1.0", - "@upstash/ratelimit": "^2.0.8", - "@upstash/redis": "^1.38.0", - "@vercel/blob": "^1.1.1", - "@vercel/postgres": "^0.10.0", "bcryptjs": "^3.0.2", "dotenv": "^17.2.1", + "ioredis": "^5.7.0", "jsonwebtoken": "^9.0.2", "next": "^16.2.6", "node-fetch": "^3.3.2", + "postgres": "^3.4.7", + "rate-limiter-flexible": "^7.2.0", "react": "^18.3.1", "react-dom": "^18.3.1", "resend": "^4.7.0", @@ -103,6 +103,314 @@ "dev": true, "license": "MIT" }, + "node_modules/@aws-sdk/checksums": { + "version": "3.1000.28", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.28.tgz", + "integrity": "sha512-VCpnmyHQ1IH49ni3LXnQj7DPr7rmcJmzYeiCkYdCcfgNtkvOj38cdcL9lapBWoItZWFACJPFJlymqC7/gem3Gw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1111.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1111.0.tgz", + "integrity": "sha512-VnLT6aSTN8tWl/NsXUysXNZor7wQBp9CRwufo7kt8cwGXvHLZ0S/cV1K9WFcREGboVYSo3NGQ3ZvU7LRidh2aQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/checksums": "^3.1000.28", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/credential-provider-node": "^3.972.80", + "@aws-sdk/middleware-sdk-s3": "^3.972.74", + "@aws-sdk/signature-v4-multi-region": "^3.996.45", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.977.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.8.tgz", + "integrity": "sha512-7+Kcrkvrk9lM/m7jRhHpT4jCdvzGHsuaSRbF8TdzzkY1mRzp/Ogwf9c7H29k4gGhey0BBWhCWr16+t0J61gwmg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.4", + "@aws-sdk/xml-builder": "^3.972.39", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.31.1", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.69", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.69.tgz", + "integrity": "sha512-AreCFzcB4kH2HF9031Ot0jSJr3KXvRg6e8uDeub20JEVdZU3Bv0sTq1plc7VsT3KiqutlzH7l0j50UcCWHUioA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.71", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.71.tgz", + "integrity": "sha512-A8ObcqVmDMnk4F9NozZ7JwmUu9Q4xyBJkmyq1C5U+wNM9ht9J7+EuuyabsLWXZnOoTqFaJuYBYTKf5CTipkEjA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.14.tgz", + "integrity": "sha512-7c+Wti2LsERNWMfm7ySz3/6RPopFW3Nmn7s63Xpcq6R/tRuY5hpvkHA2xVgi5ukJbvok9l0IDtVEvqTtg+X7dw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/credential-provider-env": "^3.972.69", + "@aws-sdk/credential-provider-http": "^3.972.71", + "@aws-sdk/credential-provider-login": "^3.972.76", + "@aws-sdk/credential-provider-process": "^3.972.69", + "@aws-sdk/credential-provider-sso": "^3.973.13", + "@aws-sdk/credential-provider-web-identity": "^3.972.75", + "@aws-sdk/nested-clients": "^3.997.43", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.76", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.76.tgz", + "integrity": "sha512-LVixwOnEJfrrfKHeZjBA8pIMTZjNDq8ak8VpcoWUuCJDrSnBNU8POJksULMgvN089P0MXtQYH2Zs627/MK1K0g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/nested-clients": "^3.997.43", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.80", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.80.tgz", + "integrity": "sha512-bE2qh8ww4iClO1jHsBXdOE8FUgzDbdxbyorNjSCoPSkQd51k3jODItuPZfuwcLHZqDXsH+bI4AMHhqtuyR7mSg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.69", + "@aws-sdk/credential-provider-http": "^3.972.71", + "@aws-sdk/credential-provider-ini": "^3.973.14", + "@aws-sdk/credential-provider-process": "^3.972.69", + "@aws-sdk/credential-provider-sso": "^3.973.13", + "@aws-sdk/credential-provider-web-identity": "^3.972.75", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.69", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.69.tgz", + "integrity": "sha512-9kpTNdZTrcqXTfhxM7fgl9Z68ek3Fu5oe3Yf+A/pJGibEqpgZxz2tSY7SinmyCIU2PJ+ygY4FPoBBnLpocMtrQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.13.tgz", + "integrity": "sha512-Oc81qauMPzUoTnAS2YKpNwY6sY/LUyQTEeaf6yP197WMxkEBQfcKLR1MFpD7+pNTubXnfkH6gwpji+Gc7iyD2Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/nested-clients": "^3.997.43", + "@aws-sdk/token-providers": "3.1111.0", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.75", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.75.tgz", + "integrity": "sha512-YPN6uoGDgjjjeVFZrcOeCJqmB6zpXoeeNgIjqe+DexJaWqdjVfCCe+VAZwli9Z2h8KhFW8oxkO39emQ1tyz/Mw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/nested-clients": "^3.997.43", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.74", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.74.tgz", + "integrity": "sha512-2lzoV2z2QO5KJZYGOCnIZ1WVQgzMECvwuzr1xb034a++8QW4U4eGrmC2u4yg1xvNv4TLL/Uv5DLyuAiw0b9z7Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/signature-v4-multi-region": "^3.996.45", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.43.tgz", + "integrity": "sha512-bit+VpqWNyi3wHxFoTsTliNXimCSL2r2OeDTm7ZrG+YsTZ2D7ofDJ6r/t9PVBn80i6/v0X2h9Tgw6QP2MAKfPw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/signature-v4-multi-region": "^3.996.45", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.45", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.45.tgz", + "integrity": "sha512-bBuyztukzXq6plzFGHAWiQt0QXo+HL8b8lX5cFTzkez/74PtS1c0qPFCIVuHkyoT+miH2qOjAcm1/yoro2ESPA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.4", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1111.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1111.0.tgz", + "integrity": "sha512-JfljgoVtl+s3Qy21n9a7Z48uCQaOXcN74KJ3TEQfPoB293GrXFSt6HSQJF1sTZ8c/5QedEvd3NjJQMO4u9qa5A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/nested-clients": "^3.997.43", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.4.tgz", + "integrity": "sha512-dSFDNG00MEz0/xl5gxL62giLd1iYyJsTxZ1I1DOj6lC+bbgLB4TRsYClJg3b62dhXT1uATzsTNXPnC+33EJV3A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.39.tgz", + "integrity": "sha512-FTti8DS5MMWXNUWiRwXAJeYS+0GHHiMy0+7XOhcwk63ILHmfS2UFy2z/HNpZCSOJJ3P3dnWY6hfYNW3DF0nXUA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -1199,15 +1507,6 @@ } } }, - "node_modules/@fastify/busboy": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", - "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", - "license": "MIT", - "engines": { - "node": ">=14" - } - }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -1740,6 +2039,12 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@ioredis/commands": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz", + "integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==", + "license": "MIT" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -2466,6 +2771,87 @@ "url": "https://ko-fi.com/killymxi" } }, + "node_modules/@smithy/core": { + "version": "3.33.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.0.tgz", + "integrity": "sha512-uKbkxgqLyepQDZoq8aRSdUqD1ID//rOqG96ixBhp++O7vBtmwYM6fwldGhr9HJP0iYrdc7GP/AlgzPWEZIrNRg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.0.tgz", + "integrity": "sha512-2jsPi+7Zv2hSzD9IXR9D7DTqSn7mv4XalzRm+bESh53jiaUS3NKEUbpQFTJP0HhQy9qzZvluxQ3yS24zdRrqsA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.7.0.tgz", + "integrity": "sha512-W/exA8T0LEzCQtJ02w4IzaEQPIspgarqZprb7W8FwnYiDowgCrjl2fTQ6FvuSSUnJORuepBF81abmBJwqh+0XQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.11.0.tgz", + "integrity": "sha512-ssHIZsadPUA3lGdnoByxfnjtb9xPYQLvdfJRLKIwxOoa6tO1suG4sLFSsgd7D/CsvYd8QbBIuKTImuJha5l6aQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.0", + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.0.tgz", + "integrity": "sha512-hCynhm22wMJ8wTF9crcwu8mxggtUrSLLJgDcGUvYFBqpofxycYJCGKOMYg4xtPPFtgNiDJSYmhsWLTrcU/g59Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.17.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.17.0.tgz", + "integrity": "sha512-Aw4joiM0ZdErpo39lCj8phT2lxoiKZV+KZzBxnnQhWVtU2Is/WffQSL04uUWRcXUse9Ln8vXZK6V/FwqRVnQpg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -2580,12 +2966,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/node": { - "version": "16.18.126", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.126.tgz", - "integrity": "sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw==", - "license": "MIT" - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.59.4", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz", @@ -3124,146 +3504,6 @@ "win32" ] }, - "node_modules/@upstash/core-analytics": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/@upstash/core-analytics/-/core-analytics-0.0.10.tgz", - "integrity": "sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ==", - "license": "MIT", - "dependencies": { - "@upstash/redis": "^1.28.3" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@upstash/ratelimit": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@upstash/ratelimit/-/ratelimit-2.0.8.tgz", - "integrity": "sha512-YSTMBJ1YIxsoPkUMX/P4DDks/xV5YYCswWMamU8ZIfK9ly6ppjRnVOyBhMDXBmzjODm4UQKcxsJPvaeFAijp5w==", - "license": "MIT", - "dependencies": { - "@upstash/core-analytics": "^0.0.10" - }, - "peerDependencies": { - "@upstash/redis": "^1.34.3" - } - }, - "node_modules/@upstash/redis": { - "version": "1.38.0", - "resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.38.0.tgz", - "integrity": "sha512-wu+dZBptlLy0+MCUEoHmzrY/TnmgDey3+c7EbIGwrLqAvkP8yi5MWZHYGIFtAygmL4Bkz2TdFu+eU0vFPncIcg==", - "license": "MIT", - "dependencies": { - "uncrypto": "^0.1.3" - } - }, - "node_modules/@vercel/blob": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@vercel/blob/-/blob-1.1.1.tgz", - "integrity": "sha512-heiJGj2qt5qTv6yiShH9f6KRAoZGj+lz61GQ+lBRL4lhvUmKI9A51KYlQTnsUd9ymdFlKHBlvmPeG+yGz2Qsbg==", - "license": "Apache-2.0", - "dependencies": { - "async-retry": "^1.3.3", - "is-buffer": "^2.0.5", - "is-node-process": "^1.2.0", - "throttleit": "^2.1.0", - "undici": "^5.28.4" - }, - "engines": { - "node": ">=16.14" - } - }, - "node_modules/@vercel/postgres": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@vercel/postgres/-/postgres-0.10.0.tgz", - "integrity": "sha512-fSD23DxGND40IzSkXjcFcxr53t3Tiym59Is0jSYIFpG4/0f0KO9SGtcp1sXiebvPaGe7N/tU05cH4yt2S6/IPg==", - "license": "Apache-2.0", - "dependencies": { - "@neondatabase/serverless": "^0.9.3", - "bufferutil": "^4.0.8", - "ws": "^8.17.1" - }, - "engines": { - "node": ">=18.14" - } - }, - "node_modules/@vercel/postgres/node_modules/@neondatabase/serverless": { - "version": "0.9.5", - "resolved": "https://registry.npmjs.org/@neondatabase/serverless/-/serverless-0.9.5.tgz", - "integrity": "sha512-siFas6gItqv6wD/pZnvdu34wEqgG3nSE6zWZdq5j2DEsa+VvX8i/5HXJOo06qrw5axPXn+lGCxeR+NLaSPIXug==", - "license": "MIT", - "dependencies": { - "@types/pg": "8.11.6" - } - }, - "node_modules/@vercel/postgres/node_modules/@types/pg": { - "version": "8.11.6", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.6.tgz", - "integrity": "sha512-/2WmmBXHLsfRqzfHW7BNZ8SbYzE8OSk7i3WjFYvfgRHj7S1xj+16Je5fUKv3lVdVzk/zn9TXOqf+avFCFIE0yQ==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "pg-protocol": "*", - "pg-types": "^4.0.1" - } - }, - "node_modules/@vercel/postgres/node_modules/pg-types": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.0.2.tgz", - "integrity": "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==", - "license": "MIT", - "dependencies": { - "pg-int8": "1.0.1", - "pg-numeric": "1.0.2", - "postgres-array": "~3.0.1", - "postgres-bytea": "~3.0.0", - "postgres-date": "~2.1.0", - "postgres-interval": "^3.0.0", - "postgres-range": "^1.1.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@vercel/postgres/node_modules/postgres-array": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", - "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/@vercel/postgres/node_modules/postgres-bytea": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz", - "integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==", - "license": "MIT", - "dependencies": { - "obuf": "~1.1.2" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/@vercel/postgres/node_modules/postgres-date": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz", - "integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==", - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/@vercel/postgres/node_modules/postgres-interval": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-3.0.0.tgz", - "integrity": "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==", - "license": "MIT", - "engines": { - "node": ">=12" - } - }, "node_modules/@vitest/expect": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", @@ -3677,15 +3917,6 @@ "node": ">= 0.4" } }, - "node_modules/async-retry": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", - "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", - "license": "MIT", - "dependencies": { - "retry": "0.13.1" - } - }, "node_modules/autoprefixer": { "version": "10.4.21", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", @@ -3817,6 +4048,12 @@ "integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==", "license": "MIT" }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -3880,19 +4117,6 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, - "node_modules/bufferutil": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.9.tgz", - "integrity": "sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -4136,6 +4360,15 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz", + "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -4310,7 +4543,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4393,6 +4625,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -6011,6 +6252,28 @@ "node": ">= 0.4" } }, + "node_modules/ioredis": { + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz", + "integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.10.0", + "cluster-key-slot": "1.1.1", + "debug": "4.4.3", + "denque": "2.1.0", + "redis-errors": "1.2.0", + "redis-parser": "3.0.0", + "standard-as-callback": "2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -6095,29 +6358,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-buffer": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", - "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/is-bun-module": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", @@ -6286,12 +6526,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-node-process": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", - "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", - "license": "MIT" - }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -7157,17 +7391,6 @@ "url": "https://opencollective.com/node-fetch" } }, - "node_modules/node-gyp-build": { - "version": "4.8.4", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", - "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", - "license": "MIT", - "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" - } - }, "node_modules/node-pg-migrate": { "version": "8.0.4", "resolved": "https://registry.npmjs.org/node-pg-migrate/-/node-pg-migrate-8.0.4.tgz", @@ -7471,12 +7694,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/obuf": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", - "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", - "license": "MIT" - }, "node_modules/opencollective-postinstall": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz", @@ -7730,20 +7947,12 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "dev": true, "license": "ISC", "engines": { "node": ">=4.0.0" } }, - "node_modules/pg-numeric": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/pg-numeric/-/pg-numeric-1.0.2.tgz", - "integrity": "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==", - "license": "ISC", - "engines": { - "node": ">=4" - } - }, "node_modules/pg-pool": { "version": "3.14.0", "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", @@ -7758,6 +7967,7 @@ "version": "1.14.0", "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==", + "dev": true, "license": "MIT" }, "node_modules/pg-types": { @@ -8033,6 +8243,19 @@ "dev": true, "license": "MIT" }, + "node_modules/postgres": { + "version": "3.4.9", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz", + "integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==", + "license": "Unlicense", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -8076,12 +8299,6 @@ "node": ">=0.10.0" } }, - "node_modules/postgres-range": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/postgres-range/-/postgres-range-1.1.4.tgz", - "integrity": "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==", - "license": "MIT" - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -8185,6 +8402,12 @@ ], "license": "MIT" }, + "node_modules/rate-limiter-flexible": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/rate-limiter-flexible/-/rate-limiter-flexible-7.4.0.tgz", + "integrity": "sha512-IJopePGO6HnMWVdeLCihnxXZ0WCW0mxXiU5LE3bZ00GHESsCaAvgD8hN/ATIJeZhnrVdU5cfRyS1uV63Vmc4zg==", + "license": "ISC" + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -8255,6 +8478,27 @@ "node": ">=8.10.0" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -8378,15 +8622,6 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, - "node_modules/retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -8841,6 +9076,12 @@ "dev": true, "license": "MIT" }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -9351,18 +9592,6 @@ "node": ">=0.8" } }, - "node_modules/throttleit": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz", - "integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -9683,24 +9912,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/uncrypto": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", - "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", - "license": "MIT" - }, - "node_modules/undici": { - "version": "5.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", - "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", - "license": "MIT", - "dependencies": { - "@fastify/busboy": "^2.0.0" - }, - "engines": { - "node": ">=14.0" - } - }, "node_modules/unrs-resolver": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", @@ -10277,27 +10488,6 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/package.json b/package.json index ebf185b..b2f236e 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "build": "next build", "start": "next start", "lint": "eslint .", - "migrate": "node-pg-migrate --database-url-var POSTGRES_URL --envPath .env.local --migrations-dir migrations --verbose", + "migrate": "node-pg-migrate --database-url-var POSTGRES_URL_DIRECT --envPath .env.local --migrations-dir migrations --verbose", + "migrate:neon-var": "node-pg-migrate --database-url-var POSTGRES_URL --envPath .env.local --migrations-dir migrations --verbose", "setup-db": "node scripts/setup-neon-db.js", "import-popular": "node scripts/import-popular-sets.js", "import-all": "node scripts/bulk-import-all.js", @@ -16,6 +17,7 @@ "bulk-import-lorcana": "node --env-file=.env.local scripts/bulk-import-lorcana.js", "bulk-import-pokemon": "node --env-file=.env.local scripts/bulk-import-pokemon.js", "backfill-embeddings": "node --env-file=.env.local scripts/backfill-card-embeddings.js", + "migrate-neon-to-homelab": "node --env-file=.env.local scripts/migrate-neon-to-homelab.js", "import-tags": "node --env-file=.env.local scripts/import-scryfall-tags.js", "test": "vitest", "test:run": "vitest run", @@ -24,16 +26,16 @@ "test:visual:update": "playwright test --project=visual --update-snapshots" }, "dependencies": { + "@aws-sdk/client-s3": "^3.888.0", "@neondatabase/serverless": "^1.1.0", - "@upstash/ratelimit": "^2.0.8", - "@upstash/redis": "^1.38.0", - "@vercel/blob": "^1.1.1", - "@vercel/postgres": "^0.10.0", "bcryptjs": "^3.0.2", "dotenv": "^17.2.1", + "ioredis": "^5.7.0", "jsonwebtoken": "^9.0.2", "next": "^16.2.6", "node-fetch": "^3.3.2", + "postgres": "^3.4.7", + "rate-limiter-flexible": "^7.2.0", "react": "^18.3.1", "react-dom": "^18.3.1", "resend": "^4.7.0", diff --git a/pages/api/admin/card-submissions.js b/pages/api/admin/card-submissions.js index cf977ad..998e032 100644 --- a/pages/api/admin/card-submissions.js +++ b/pages/api/admin/card-submissions.js @@ -1,4 +1,4 @@ -import { sql } from '@vercel/postgres'; +import { sql } from '../../../lib/sql.js'; import { withAdmin } from '../../../lib/permission-middleware'; export default withAdmin(async function handler(req, res, user) { diff --git a/pages/api/admin/index.js b/pages/api/admin/index.js index 28e1305..77aece1 100644 --- a/pages/api/admin/index.js +++ b/pages/api/admin/index.js @@ -1,4 +1,4 @@ -import { sql } from '@vercel/postgres'; +import { sql } from '../../../lib/sql.js'; import { verifyToken, isAdmin } from '../auth-utils.js'; // Rate limiting for external APIs diff --git a/pages/api/auth-utils.js b/pages/api/auth-utils.js index 30eed94..28be70c 100644 --- a/pages/api/auth-utils.js +++ b/pages/api/auth-utils.js @@ -1,5 +1,5 @@ import jwt from 'jsonwebtoken'; -import { sql } from '@vercel/postgres'; +import { sql } from '../../lib/sql.js'; import { JWT_SECRET, JWT_TOKEN_TTL } from '../../lib/auth-secret.js'; export async function hashPassword(password) { diff --git a/pages/api/auth/login.js b/pages/api/auth/login.js index ccb2964..7d20397 100644 --- a/pages/api/auth/login.js +++ b/pages/api/auth/login.js @@ -1,5 +1,5 @@ import bcrypt from 'bcryptjs'; -import { sql } from '@vercel/postgres'; +import { sql } from '../../../lib/sql.js'; import { generateToken } from '../auth-utils.js'; import { checkAuthRateLimit } from '../../../lib/rate-limit.js'; diff --git a/pages/api/auth/register.js b/pages/api/auth/register.js index c382a5d..343bcff 100644 --- a/pages/api/auth/register.js +++ b/pages/api/auth/register.js @@ -1,5 +1,5 @@ import bcrypt from 'bcryptjs'; -import { sql } from '@vercel/postgres'; +import { sql } from '../../../lib/sql.js'; import { generateUniqueSlug } from '../../../lib/slug-utils.js'; import { SYSTEM_COLLECTION_DB_NAME, VOCAB } from '../../../lib/collection-vocabulary.js'; import { generateToken } from '../auth-utils.js'; diff --git a/pages/api/auth/verify.js b/pages/api/auth/verify.js index 4f34209..f691da1 100644 --- a/pages/api/auth/verify.js +++ b/pages/api/auth/verify.js @@ -1,4 +1,4 @@ -import { sql } from '@vercel/postgres'; +import { sql } from '../../../lib/sql.js'; import jwt from 'jsonwebtoken'; import { JWT_SECRET } from '../../../lib/auth-secret.js'; diff --git a/pages/api/cards/[id].js b/pages/api/cards/[id].js index 84246a9..ce81c7e 100644 --- a/pages/api/cards/[id].js +++ b/pages/api/cards/[id].js @@ -1,4 +1,4 @@ -import { sql } from '@vercel/postgres'; +import { sql } from '../../../lib/sql.js'; export default async function handler(req, res) { const { id } = req.query; diff --git a/pages/api/cards/[id]/collections.js b/pages/api/cards/[id]/collections.js index 15b7ec5..2b3e31f 100644 --- a/pages/api/cards/[id]/collections.js +++ b/pages/api/cards/[id]/collections.js @@ -1,4 +1,4 @@ -import { sql } from '@vercel/postgres'; +import { sql } from '../../../../lib/sql.js'; import { getUserFromRequest } from '../../../../lib/permission-middleware'; export default async function handler(req, res) { diff --git a/pages/api/cards/[id]/decks.js b/pages/api/cards/[id]/decks.js index 8518974..2a5b136 100644 --- a/pages/api/cards/[id]/decks.js +++ b/pages/api/cards/[id]/decks.js @@ -1,4 +1,4 @@ -import { sql } from '@vercel/postgres'; +import { sql } from '../../../../lib/sql.js'; import { getUserFromRequest } from '../../../../lib/permission-middleware'; export default async function handler(req, res) { diff --git a/pages/api/cards/[id]/favorite.js b/pages/api/cards/[id]/favorite.js index 0778010..2e9de3f 100644 --- a/pages/api/cards/[id]/favorite.js +++ b/pages/api/cards/[id]/favorite.js @@ -1,4 +1,4 @@ -import { sql } from '@vercel/postgres'; +import { sql } from '../../../../lib/sql.js'; import { getUserFromRequest } from '../../../../lib/permission-middleware'; export default async function handler(req, res) { diff --git a/pages/api/cards/[id]/ownership.js b/pages/api/cards/[id]/ownership.js index c1980be..f17290e 100644 --- a/pages/api/cards/[id]/ownership.js +++ b/pages/api/cards/[id]/ownership.js @@ -1,4 +1,4 @@ -import { sql } from '@vercel/postgres'; +import { sql } from '../../../../lib/sql.js'; import { getUserFromRequest } from '../../../../lib/permission-middleware'; export default async function handler(req, res) { diff --git a/pages/api/cards/batch-ownership.js b/pages/api/cards/batch-ownership.js index 6689713..94a1601 100644 --- a/pages/api/cards/batch-ownership.js +++ b/pages/api/cards/batch-ownership.js @@ -1,4 +1,4 @@ -import { sql } from '@vercel/postgres'; +import { sql } from '../../../lib/sql.js'; import { getUserFromRequest } from '../../../lib/permission-middleware'; export default async function handler(req, res) { diff --git a/pages/api/cards/owned.js b/pages/api/cards/owned.js index c1ea65d..054c422 100644 --- a/pages/api/cards/owned.js +++ b/pages/api/cards/owned.js @@ -1,4 +1,4 @@ -import { sql } from '@vercel/postgres'; +import { sql } from '../../../lib/sql.js'; import { getUserFromRequest } from '../../../lib/permission-middleware'; export default async function handler(req, res) { diff --git a/pages/api/cards/search.js b/pages/api/cards/search.js index eb9da8c..2cb1eb5 100644 --- a/pages/api/cards/search.js +++ b/pages/api/cards/search.js @@ -1,4 +1,4 @@ -import { sql } from '@vercel/postgres'; +import { sql } from '../../../lib/sql.js'; import { checkSearchRateLimit } from '../../../lib/rate-limit.js'; export default async function handler(req, res) { diff --git a/pages/api/collections.js b/pages/api/collections.js index 0bfb9a2..b36bff7 100644 --- a/pages/api/collections.js +++ b/pages/api/collections.js @@ -1,4 +1,4 @@ -import { sql } from '@vercel/postgres'; +import { sql } from '../../lib/sql.js'; import { getUserFromRequest, logCollectionActivity } from '../../lib/permission-middleware'; import { generateUniqueSlug } from '../../lib/slug-utils'; diff --git a/pages/api/collections/[identifier].js b/pages/api/collections/[identifier].js index 377094c..05790d6 100644 --- a/pages/api/collections/[identifier].js +++ b/pages/api/collections/[identifier].js @@ -1,4 +1,4 @@ -import { sql } from '@vercel/postgres'; +import { sql } from '../../../lib/sql.js'; import { getUserFromRequest } from '../../../lib/permission-middleware'; import { isValidSlug } from '../../../lib/slug-utils'; diff --git a/pages/api/collections/[identifier]/activity.js b/pages/api/collections/[identifier]/activity.js index 2f22a2c..b66c2f6 100644 --- a/pages/api/collections/[identifier]/activity.js +++ b/pages/api/collections/[identifier]/activity.js @@ -1,4 +1,4 @@ -import { sql } from '@vercel/postgres'; +import { sql } from '../../../../lib/sql.js'; import { getUserFromRequest } from '../../../../lib/permission-middleware'; import { isValidSlug } from '../../../../lib/slug-utils'; diff --git a/pages/api/collections/[identifier]/cards.js b/pages/api/collections/[identifier]/cards.js index 8e758e5..b3a4f55 100644 --- a/pages/api/collections/[identifier]/cards.js +++ b/pages/api/collections/[identifier]/cards.js @@ -1,4 +1,4 @@ -import { sql } from '@vercel/postgres'; +import { sql } from '../../../../lib/sql.js'; import { getUserFromRequest, logCollectionActivity } from '../../../../lib/permission-middleware'; import { isValidSlug } from '../../../../lib/slug-utils'; diff --git a/pages/api/collections/[identifier]/permissions.js b/pages/api/collections/[identifier]/permissions.js index 2051949..3be6de5 100644 --- a/pages/api/collections/[identifier]/permissions.js +++ b/pages/api/collections/[identifier]/permissions.js @@ -1,4 +1,4 @@ -import { sql } from '@vercel/postgres'; +import { sql } from '../../../../lib/sql.js'; import { getUserFromRequest } from '../../../../lib/permission-middleware'; import { isValidSlug } from '../../../../lib/slug-utils'; diff --git a/pages/api/collections/[identifier]/thumbnails.js b/pages/api/collections/[identifier]/thumbnails.js index 6307011..afd4a4b 100644 --- a/pages/api/collections/[identifier]/thumbnails.js +++ b/pages/api/collections/[identifier]/thumbnails.js @@ -1,4 +1,4 @@ -import { sql } from '@vercel/postgres'; +import { sql } from '../../../../lib/sql.js'; import { getUserFromRequest } from '../../../../lib/permission-middleware'; import { isValidSlug } from '../../../../lib/slug-utils'; diff --git a/pages/api/community/collections.js b/pages/api/community/collections.js index d8bf422..ab9a4f9 100644 --- a/pages/api/community/collections.js +++ b/pages/api/community/collections.js @@ -1,4 +1,4 @@ -import { sql } from '@vercel/postgres'; +import { sql } from '../../../lib/sql.js'; import { getUserFromRequest } from '../../../lib/permission-middleware'; export default async function handler(req, res) { diff --git a/pages/api/cron/sync-catalog.js b/pages/api/cron/sync-catalog.js index 6766324..ea30e06 100644 --- a/pages/api/cron/sync-catalog.js +++ b/pages/api/cron/sync-catalog.js @@ -4,7 +4,7 @@ function authorizeCron(req) { const secret = process.env.CRON_SECRET; if (!secret) { - if (process.env.NODE_ENV === 'production' || process.env.VERCEL_ENV === 'production') { + if (process.env.NODE_ENV === 'production') { return { ok: false, status: 503, error: 'CRON_SECRET is not configured' }; } console.warn('[GET /api/cron/sync-catalog] CRON_SECRET unset — allowing in dev'); diff --git a/pages/api/decks.js b/pages/api/decks.js index d6cec3b..8999b9b 100644 --- a/pages/api/decks.js +++ b/pages/api/decks.js @@ -1,4 +1,4 @@ -import { sql } from '@vercel/postgres'; +import { sql } from '../../lib/sql.js'; import { getUserFromRequest } from '../../lib/permission-middleware'; export default async function handler(req, res) { diff --git a/pages/api/decks/[id].js b/pages/api/decks/[id].js index 7be90e2..c6ffa48 100644 --- a/pages/api/decks/[id].js +++ b/pages/api/decks/[id].js @@ -1,4 +1,4 @@ -import { sql } from '@vercel/postgres'; +import { sql } from '../../../lib/sql.js'; import { getUserFromRequest } from '../../../lib/permission-middleware'; export default async function handler(req, res) { diff --git a/pages/api/decks/[id]/cards.js b/pages/api/decks/[id]/cards.js index 245c0ec..edf7d29 100644 --- a/pages/api/decks/[id]/cards.js +++ b/pages/api/decks/[id]/cards.js @@ -1,4 +1,4 @@ -import { sql } from '@vercel/postgres'; +import { sql } from '../../../../lib/sql.js'; import { getUserFromRequest } from '../../../../lib/permission-middleware'; export default async function handler(req, res) { diff --git a/pages/api/favorites.js b/pages/api/favorites.js index 36db095..606e67c 100644 --- a/pages/api/favorites.js +++ b/pages/api/favorites.js @@ -1,4 +1,4 @@ -import { sql } from '@vercel/postgres'; +import { sql } from '../../lib/sql.js'; import jwt from 'jsonwebtoken'; import { JWT_SECRET } from '../../lib/auth-secret.js'; diff --git a/pages/api/invite/accept.js b/pages/api/invite/accept.js index a3710d2..d421cdf 100644 --- a/pages/api/invite/accept.js +++ b/pages/api/invite/accept.js @@ -1,4 +1,4 @@ -import { sql } from '@vercel/postgres'; +import { sql } from '../../../lib/sql.js'; export default async function handler(req, res) { if (req.method !== 'POST') { diff --git a/pages/api/invite/decline.js b/pages/api/invite/decline.js index 5910b73..7796ad3 100644 --- a/pages/api/invite/decline.js +++ b/pages/api/invite/decline.js @@ -1,4 +1,4 @@ -import { sql } from '@vercel/postgres'; +import { sql } from '../../../lib/sql.js'; export default async function handler(req, res) { if (req.method !== 'POST') { diff --git a/pages/api/public/collections.js b/pages/api/public/collections.js index 5f07408..e65c5b3 100644 --- a/pages/api/public/collections.js +++ b/pages/api/public/collections.js @@ -1,4 +1,4 @@ -import { sql } from '@vercel/postgres'; +import { sql } from '../../../lib/sql.js'; export default async function handler(req, res) { if (req.method !== 'GET') { diff --git a/pages/api/scan/upload-image.js b/pages/api/scan/upload-image.js index 43e1f08..69bb90f 100644 --- a/pages/api/scan/upload-image.js +++ b/pages/api/scan/upload-image.js @@ -1,5 +1,5 @@ import { randomUUID } from 'crypto'; -import { put } from '@vercel/blob'; +import { put } from '../../../lib/object-storage.js'; import { getUserFromRequest } from '../../../lib/permission-middleware'; import { checkUploadRateLimit } from '../../../lib/rate-limit.js'; diff --git a/pages/api/user-cards.js b/pages/api/user-cards.js index eb579d6..1ff6b50 100644 --- a/pages/api/user-cards.js +++ b/pages/api/user-cards.js @@ -1,4 +1,4 @@ -import { sql } from '@vercel/postgres'; +import { sql } from '../../lib/sql.js'; import { getUserFromRequest } from '../../lib/permission-middleware'; export default async function handler(req, res) { diff --git a/pages/api/user/avatar.js b/pages/api/user/avatar.js index 3ebb31c..90109b8 100644 --- a/pages/api/user/avatar.js +++ b/pages/api/user/avatar.js @@ -1,5 +1,5 @@ -import { put, del } from '@vercel/blob'; -import { sql } from '@vercel/postgres'; +import { put, del } from '../../../lib/object-storage.js'; +import { sql } from '../../../lib/sql.js'; import { getUserFromRequest } from '../../../lib/permission-middleware'; import { checkUploadRateLimit } from '../../../lib/rate-limit.js'; @@ -62,7 +62,7 @@ export default async function handler(req, res) { const fileExtension = file.type.split('/')[1]; const filename = `avatars/${user.userId}-${Date.now()}.${fileExtension}`; - // Upload to Vercel Blob + // Upload to MinIO const blob = await put(filename, file.buffer, { access: 'public', contentType: file.type, @@ -181,7 +181,7 @@ async function parseMultipartFormData(req) { } /** - * Delete old avatar from Vercel Blob and database + * Delete old avatar from object storage and database */ async function deleteOldAvatar(userId) { try { @@ -194,7 +194,7 @@ async function deleteOldAvatar(userId) { if (avatarResult.rows.length > 0) { const avatar = avatarResult.rows[0]; - // Delete from Vercel Blob + // Delete from object storage try { await del(avatar.file_path); } catch (blobError) { diff --git a/pages/api/user/avatar/generate.js b/pages/api/user/avatar/generate.js index 03030d7..3212f8d 100644 --- a/pages/api/user/avatar/generate.js +++ b/pages/api/user/avatar/generate.js @@ -1,5 +1,5 @@ -import { put } from '@vercel/blob'; -import { sql } from '@vercel/postgres'; +import { put, del } from '../../../../lib/object-storage.js'; +import { sql } from '../../../../lib/sql.js'; import { getUserFromRequest } from '../../../../lib/permission-middleware'; import { checkGenerateRateLimit } from '../../../../lib/rate-limit.js'; @@ -55,9 +55,8 @@ export default async function handler(req, res) { // Generate unique filename const filename = `avatars/generated-${user.userId}-${Date.now()}.svg`; - // Upload to Vercel Blob + // Upload to MinIO const blob = await put(filename, avatarBuffer, { - access: 'public', contentType: 'image/svg+xml', }); @@ -104,7 +103,7 @@ function getInitials(userData) { } /** - * Delete old avatar from Vercel Blob and database + * Delete old avatar from object storage and database */ async function deleteOldAvatar(userId) { try { @@ -117,12 +116,10 @@ async function deleteOldAvatar(userId) { if (avatarResult.rows.length > 0) { const avatar = avatarResult.rows[0]; - // Delete from Vercel Blob try { - const { del } = await import('@vercel/blob'); await del(avatar.file_path); } catch (blobError) { - console.warn('Failed to delete blob file:', blobError); + console.warn('Failed to delete object storage file:', blobError); // Continue anyway - the database record should still be cleaned up } diff --git a/pages/api/user/delete.js b/pages/api/user/delete.js index a682717..3a387f7 100644 --- a/pages/api/user/delete.js +++ b/pages/api/user/delete.js @@ -1,5 +1,5 @@ -import { del } from '@vercel/blob'; -import { sql } from '@vercel/postgres'; +import { del } from '../../../lib/object-storage.js'; +import { sql } from '../../../lib/sql.js'; import { getUserFromRequest } from '../../../lib/permission-middleware'; export default async function handler(req, res) { @@ -25,7 +25,7 @@ export default async function handler(req, res) { // Start transaction-like cleanup console.log(`Starting account deletion for user ${user.userId}`); - // 1. Delete user avatars from Vercel Blob + // 1. Delete user avatars from object storage const avatarsResult = await sql` SELECT file_path FROM user_avatars WHERE user_id = ${user.userId} AND is_active = true diff --git a/pages/api/user/password.js b/pages/api/user/password.js index e434a8b..34a5c02 100644 --- a/pages/api/user/password.js +++ b/pages/api/user/password.js @@ -1,4 +1,4 @@ -import { sql } from '@vercel/postgres'; +import { sql } from '../../../lib/sql.js'; import bcrypt from 'bcryptjs'; import { getUserFromRequest } from '../../../lib/permission-middleware'; diff --git a/pages/api/user/profile.js b/pages/api/user/profile.js index c61a5f6..8cfc05e 100644 --- a/pages/api/user/profile.js +++ b/pages/api/user/profile.js @@ -1,4 +1,4 @@ -import { sql } from '@vercel/postgres'; +import { sql } from '../../../lib/sql.js'; import { getUserFromRequest } from '../../../lib/permission-middleware'; export default async function handler(req, res) { diff --git a/pages/api/user/settings.js b/pages/api/user/settings.js index 48b1daf..df0509c 100644 --- a/pages/api/user/settings.js +++ b/pages/api/user/settings.js @@ -1,4 +1,4 @@ -import { sql } from '@vercel/postgres'; +import { sql } from '../../../lib/sql.js'; import { getUserFromRequest } from '../../../lib/permission-middleware'; export default async function handler(req, res) { diff --git a/pages/api/user/stats.js b/pages/api/user/stats.js index b592c27..b388e6d 100644 --- a/pages/api/user/stats.js +++ b/pages/api/user/stats.js @@ -1,4 +1,4 @@ -import { sql } from '@vercel/postgres'; +import { sql } from '../../../lib/sql.js'; import { getUserFromRequest } from '../../../lib/permission-middleware'; export default async function handler(req, res) { diff --git a/pages/api/users/search.js b/pages/api/users/search.js index 5847fa6..67f8125 100644 --- a/pages/api/users/search.js +++ b/pages/api/users/search.js @@ -1,4 +1,4 @@ -import { sql } from '@vercel/postgres'; +import { sql } from '../../../lib/sql.js'; import jwt from 'jsonwebtoken'; import { JWT_SECRET } from '../../../lib/auth-secret.js'; import { checkSearchRateLimit } from '../../../lib/rate-limit.js'; diff --git a/playwright.config.js b/playwright.config.js index b7acb4b..9fb123b 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -1,46 +1,25 @@ -// Playwright config for the post-PR-17 `Preview smoke` and -// `Screenshot diff` workflows. ESM per the repo's -// `"type": "module"` setting in package.json. Companion docs: -// `.convoys/adopt-playwright-smoke.md` (Decisions 1-6), -// AGENTS.md § 7 (Vercel preview bypass conventions). +// Playwright config for smoke + visual workflows against the homelab +// deployment (or localhost in dev). ESM per package.json `"type": "module"`. import { defineConfig } from '@playwright/test'; const BASE_URL = process.env.BASE_URL ?? 'http://localhost:3000'; const BYPASS_SECRET = process.env.VERCEL_AUTOMATION_BYPASS_SECRET; - -// `CI === 'true'` is the canonical GitHub Actions signal (set by -// the runner). Playwright config has no Next.js context, so -// `NODE_ENV` is not reliable here. Mirrors the rate-limit.js -// fail-closed pattern documented in AGENTS.md Gotcha #12. const IS_CI = process.env.CI === 'true'; +const USES_VERCEL_PREVIEW = BASE_URL.includes('vercel.app'); -if (IS_CI && !BYPASS_SECRET) { - // Fail loud in CI per Decision 2. The workflow's `env:` block - // (preview-smoke.yml line 101, visual-diff.yml line 85) maps - // `secrets.VERCEL_AUTOMATION_BYPASS_SECRET` into the process - // env; if it's empty here, the secret is unseeded or the - // workflow YAML drift broke the mapping. +if (IS_CI && USES_VERCEL_PREVIEW && !BYPASS_SECRET) { throw new Error( - 'VERCEL_AUTOMATION_BYPASS_SECRET is required in CI to reach ' + - 'Vercel-Protection-protected preview deployments. ' + - 'Reseed via: gh secret set VERCEL_AUTOMATION_BYPASS_SECRET --body "". ' + - 'See AGENTS.md § 7 for the full plumbing context.' + 'VERCEL_AUTOMATION_BYPASS_SECRET is required when BASE_URL targets a ' + + 'Vercel preview (*.vercel.app). Homelab smoke uses deckhearth.stillwell.cloud ' + + 'and does not need this secret.' ); } -if (!BYPASS_SECRET && !IS_CI) { - // Dev fallback per Decision 2 — warn once at config load, - // proceed without the header. Local runs target localhost - // (no preview protection) or a non-protected URL. - // (No eslint-disable for `no-console` here: the active config - // does not enable `no-console` for repo-root config files; - // adding the directive here trips `eslint-comments/no-unused-disable`.) +if (USES_VERCEL_PREVIEW && !BYPASS_SECRET && !IS_CI) { console.warn( '[playwright.config] VERCEL_AUTOMATION_BYPASS_SECRET unset — ' + - 'running without the Vercel bypass header. Targets a non-protected ' + - 'URL (e.g. http://localhost:3000). Hitting a protected preview without ' + - 'this header will return Vercel\'s SSO challenge page.' + 'protected Vercel previews may return the SSO challenge page.' ); } @@ -104,7 +83,7 @@ export default defineConfig({ // spec body. If a future hotfix shows the header NOT // propagating to APIRequestContext, see Risk R1 in the // convoy file's Architecture section. - extraHTTPHeaders: BYPASS_SECRET + extraHTTPHeaders: USES_VERCEL_PREVIEW && BYPASS_SECRET ? { 'x-vercel-protection-bypass': BYPASS_SECRET } : undefined, // Trace OFF this convoy. Enabling it would land the bypass diff --git a/scripts/add-collaboration-features.js b/scripts/add-collaboration-features.js index 6ae125e..5341d2d 100755 --- a/scripts/add-collaboration-features.js +++ b/scripts/add-collaboration-features.js @@ -1,7 +1,7 @@ #!/usr/bin/env node import { config } from 'dotenv'; -import { sql } from '@vercel/postgres'; +import { sql } from '../lib/sql.js'; // Load environment variables config({ path: '.env.local' }); diff --git a/scripts/add-favorites-system.js b/scripts/add-favorites-system.js index 4093dac..18eb1da 100644 --- a/scripts/add-favorites-system.js +++ b/scripts/add-favorites-system.js @@ -1,6 +1,6 @@ #!/usr/bin/env node import { config } from 'dotenv'; -import { sql } from '@vercel/postgres'; +import { sql } from '../lib/sql.js'; config({ path: '.env.local' }); async function addFavoritesSystem() { diff --git a/scripts/add-image-column.js b/scripts/add-image-column.js index a76c774..0d03352 100755 --- a/scripts/add-image-column.js +++ b/scripts/add-image-column.js @@ -1,7 +1,7 @@ #!/usr/bin/env node import { config } from 'dotenv'; -import { sql } from '@vercel/postgres'; +import { sql } from '../lib/sql.js'; // Load environment variables config({ path: '.env.local' }); diff --git a/scripts/add-user-profile-columns.js b/scripts/add-user-profile-columns.js index 89456d3..088adca 100644 --- a/scripts/add-user-profile-columns.js +++ b/scripts/add-user-profile-columns.js @@ -1,4 +1,4 @@ -import { sql } from '@vercel/postgres'; +import { sql } from '../lib/sql.js'; import dotenv from 'dotenv'; // Load environment variables diff --git a/scripts/backfill-card-embeddings.js b/scripts/backfill-card-embeddings.js index 4f95980..d3442bd 100644 --- a/scripts/backfill-card-embeddings.js +++ b/scripts/backfill-card-embeddings.js @@ -14,8 +14,7 @@ * DRY_RUN — "true" to list candidates only */ -import { neon } from '@neondatabase/serverless'; - +import { sql } from '../lib/sql.js'; import { embedCardImage, formatEmbeddingForPg } from '../lib/card-embed.js'; if (!process.env.POSTGRES_URL) { @@ -28,7 +27,6 @@ if (!process.env.AI_GATEWAY_API_KEY) { process.exit(1); } -const sql = neon(process.env.POSTGRES_URL, { fullResults: false }); const BATCH_SIZE = Number(process.env.BATCH_SIZE || 25); const SLEEP_MS = Number(process.env.SLEEP_MS || 250); const FORCE = process.env.FORCE === '1'; @@ -41,7 +39,7 @@ function sleep(ms) { async function fetchCandidates(lastId) { if (FORCE) { - return sql` + const { rows } = await sql` SELECT id, name, image_url FROM cards WHERE id > ${lastId} @@ -50,9 +48,10 @@ async function fetchCandidates(lastId) { ORDER BY id LIMIT ${BATCH_SIZE} `; + return rows; } - return sql` + const { rows } = await sql` SELECT id, name, image_url FROM cards WHERE id > ${lastId} @@ -62,6 +61,7 @@ async function fetchCandidates(lastId) { ORDER BY id LIMIT ${BATCH_SIZE} `; + return rows; } async function main() { diff --git a/scripts/create-sample-cards.js b/scripts/create-sample-cards.js index 13d51e9..dabd398 100755 --- a/scripts/create-sample-cards.js +++ b/scripts/create-sample-cards.js @@ -1,7 +1,7 @@ #!/usr/bin/env node import { config } from 'dotenv'; -import { sql } from '@vercel/postgres'; +import { sql } from '../lib/sql.js'; // Load environment variables config({ path: '.env.local' }); diff --git a/scripts/create-test-users.js b/scripts/create-test-users.js index abb26cb..b61a4cc 100755 --- a/scripts/create-test-users.js +++ b/scripts/create-test-users.js @@ -22,7 +22,7 @@ */ import { config } from 'dotenv'; -import { sql } from '@vercel/postgres'; +import { sql } from '../lib/sql.js'; import bcrypt from 'bcryptjs'; config({ path: '.env.local' }); diff --git a/scripts/demote-admin-to-user.js b/scripts/demote-admin-to-user.js index 6afbd1e..7fc8e80 100755 --- a/scripts/demote-admin-to-user.js +++ b/scripts/demote-admin-to-user.js @@ -1,7 +1,7 @@ #!/usr/bin/env node import { config } from 'dotenv'; -import { sql } from '@vercel/postgres'; +import { sql } from '../lib/sql.js'; // Load environment variables config({ path: '.env.local' }); diff --git a/scripts/list-users.js b/scripts/list-users.js index 81ba26e..dca3248 100755 --- a/scripts/list-users.js +++ b/scripts/list-users.js @@ -1,7 +1,7 @@ #!/usr/bin/env node import { config } from 'dotenv'; -import { sql } from '@vercel/postgres'; +import { sql } from '../lib/sql.js'; // Load environment variables config({ path: '.env.local' }); diff --git a/scripts/migrate-neon-to-homelab.js b/scripts/migrate-neon-to-homelab.js new file mode 100644 index 0000000..0130f65 --- /dev/null +++ b/scripts/migrate-neon-to-homelab.js @@ -0,0 +1,99 @@ +#!/usr/bin/env node +/** + * Copy Deck Hearth data from Neon → homelab Postgres (CT 102). + * + * Run AFTER: + * 1. `deckhearth` database exists on CT 102 (see docs/HOMELAB_DATABASE.md) + * 2. `npm run migrate up` on the empty homelab database + * + * Env: + * NEON_DATABASE_URL — Neon direct connection (source) + * POSTGRES_URL_DIRECT — homelab deckhearth URL (target), e.g. + * postgresql://deckhearth:…@192.168.68.102:5432/deckhearth + * SKIP_CONFIRM=1 — skip interactive prompt + * + * Requires `pg_dump`, `pg_restore`, and `psql` on PATH. + */ + +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import readline from 'node:readline/promises'; + +function requireEnv(name) { + const value = process.env[name]; + if (!value?.trim()) { + console.error(`❌ ${name} is required`); + process.exit(1); + } + return value.trim(); +} + +function run(cmd, args) { + console.log(`\n→ ${cmd} ${args.join(' ')}`); + const result = spawnSync(cmd, args, { stdio: 'inherit' }); + if (result.status !== 0) { + throw new Error(`${cmd} exited with code ${result.status}`); + } +} + +async function confirm(message) { + if (process.env.SKIP_CONFIRM === '1') return; + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const answer = await rl.question(`${message} Type "yes" to continue: `); + rl.close(); + if (answer.trim().toLowerCase() !== 'yes') { + console.log('Aborted.'); + process.exit(0); + } +} + +async function main() { + const sourceUrl = requireEnv('NEON_DATABASE_URL'); + const targetUrl = requireEnv('POSTGRES_URL_DIRECT'); + + console.log('Neon → homelab (CT 102) data migration'); + console.log('Target must already have schema from npm run migrate up'); + + await confirm('\nThis copies DATA ONLY into homelab Postgres.\n'); + + const tempDir = mkdtempSync(join(tmpdir(), 'deckhearth-pg-')); + const dumpPath = join(tempDir, 'neon-data.dump'); + + try { + run('pg_dump', [ + sourceUrl, + '--format=custom', + '--data-only', + '--no-owner', + '--no-acl', + '--verbose', + '--file', + dumpPath, + ]); + + run('pg_restore', [ + '--dbname', + targetUrl, + '--data-only', + '--no-owner', + '--no-acl', + '--verbose', + '--disable-triggers', + dumpPath, + ]); + + run('psql', [targetUrl, '-c', 'SELECT pg_size_pretty(pg_database_size(current_database())) AS db_size;']); + + console.log('\n✅ Data copy finished.'); + console.log('Next: set POSTGRES_URL to the homelab URL and deploy (Coolify on CT 107).'); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +} + +main().catch((error) => { + console.error(error.message || error); + process.exit(1); +}); diff --git a/scripts/promote-user-to-admin.js b/scripts/promote-user-to-admin.js index 235401d..070f6a2 100755 --- a/scripts/promote-user-to-admin.js +++ b/scripts/promote-user-to-admin.js @@ -1,7 +1,7 @@ #!/usr/bin/env node import { config } from 'dotenv'; -import { sql } from '@vercel/postgres'; +import { sql } from '../lib/sql.js'; // Load environment variables config({ path: '.env.local' }); diff --git a/scripts/reset-db.js b/scripts/reset-db.js index db5a468..3cc88dc 100644 --- a/scripts/reset-db.js +++ b/scripts/reset-db.js @@ -20,7 +20,7 @@ import dotenv from 'dotenv'; dotenv.config({ path: '.env.local' }); -import { sql } from '@vercel/postgres'; +import { sql } from '../lib/sql.js'; import bcrypt from 'bcryptjs'; async function resetDatabase() { diff --git a/scripts/setup-neon-db.js b/scripts/setup-neon-db.js index c55d7ff..d2e9e31 100644 --- a/scripts/setup-neon-db.js +++ b/scripts/setup-neon-db.js @@ -1,25 +1,26 @@ #!/usr/bin/env node /** - * First-time / re-onboarding setup for the Deck Hearth Neon database. + * First-time / re-onboarding setup for the Deck Hearth Postgres database. * - * Pipeline (post-`migration-tool` convoy, 2026-05-26): + * Pipeline: * 1. Validate `ADMIN_INITIAL_PASSWORD` is set (fail loud BEFORE touching the DB). - * 2. Spawn `npm run migrate up` to apply every pending migration under - * `migrations/`. The initial backfill migration (1779853647564_initial-schema) - * uses `CREATE TABLE IF NOT EXISTS` and is idempotent against fresh or - * pre-existing envs. + * 2. Spawn `npm run migrate up` (uses POSTGRES_URL_DIRECT) to apply migrations. * 3. Seed the admin user with `ON CONFLICT (email) DO NOTHING`. * - * Make sure you have `POSTGRES_URL` set in `.env.local`. See README § - * "First-time admin setup" for the operator runbook. + * Env: + * POSTGRES_URL / POSTGRES_URL_DIRECT — homelab: + * postgresql://deckhearth:…@192.168.68.102:5432/deckhearth + * See docs/HOMELAB_DATABASE.md + * + * Legacy script name kept as setup-neon-db.js until a follow-up rename lands. */ import dotenv from 'dotenv'; dotenv.config({ path: '.env.local' }); import { spawn } from 'node:child_process'; -import { sql } from '@vercel/postgres'; +import { sql } from '../lib/sql.js'; import bcrypt from 'bcryptjs'; function runMigrations() { @@ -58,18 +59,26 @@ async function setupNeonDatabase() { process.exit(1); } - if (!process.env.POSTGRES_URL) { + if (!process.env.POSTGRES_URL && !process.env.POSTGRES_URL_DIRECT) { console.error( - '❌ POSTGRES_URL environment variable is not set.\n' + - ' Set it in .env.local (Neon connection string) before running setup.\n' + '❌ POSTGRES_URL or POSTGRES_URL_DIRECT must be set.\n' + + ' Use POSTGRES_URL (homelab CT 102 deckhearth URL).\n' + + ' See docs/HOMELAB_DATABASE.md\n' ); process.exit(1); } + if (!process.env.POSTGRES_URL_DIRECT) { + process.env.POSTGRES_URL_DIRECT = process.env.POSTGRES_URL; + } + if (!process.env.POSTGRES_URL) { + process.env.POSTGRES_URL = process.env.POSTGRES_URL_DIRECT; + } + try { await runMigrations(); - console.log('✅ Connecting to Neon database to seed admin user...'); + console.log('✅ Connecting to Postgres to seed admin user...'); const hashedPassword = await bcrypt.hash(adminPassword, 12); diff --git a/test/api/scan/upload-image.test.js b/test/api/scan/upload-image.test.js index c2c88fe..525c27c 100644 --- a/test/api/scan/upload-image.test.js +++ b/test/api/scan/upload-image.test.js @@ -4,7 +4,7 @@ vi.mock('crypto', () => ({ randomUUID: vi.fn(() => 'test-uuid'), })); -vi.mock('@vercel/blob', () => ({ +vi.mock('../../../lib/object-storage.js', () => ({ put: vi.fn(), })); @@ -16,7 +16,7 @@ vi.mock('../../../lib/rate-limit.js', () => ({ checkUploadRateLimit: vi.fn(), })); -import { put } from '@vercel/blob'; +import { put } from '../../../lib/object-storage.js'; import { getUserFromRequest } from '../../../lib/permission-middleware.js'; import { checkUploadRateLimit } from '../../../lib/rate-limit.js'; import handler from '../../../pages/api/scan/upload-image.js'; @@ -98,7 +98,7 @@ describe('POST /api/scan/upload-image', () => { expect(put).toHaveBeenCalledWith( 'scans/7/test-uuid.jpg', expect.any(Buffer), - expect.objectContaining({ access: 'public', contentType: 'image/jpeg' }) + expect.objectContaining({ contentType: 'image/jpeg' }) ); }); }); diff --git a/test/api/user-cards.test.js b/test/api/user-cards.test.js index c21c84c..1716eb7 100644 --- a/test/api/user-cards.test.js +++ b/test/api/user-cards.test.js @@ -1,11 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -vi.mock('@vercel/postgres', () => ({ sql: vi.fn() })); +vi.mock('../../lib/sql.js', () => ({ sql: vi.fn() })); vi.mock('../../lib/permission-middleware.js', () => ({ getUserFromRequest: vi.fn(), })); -import { sql } from '@vercel/postgres'; +import { sql } from '../../lib/sql.js'; import { getUserFromRequest } from '../../lib/permission-middleware.js'; import handler from '../../pages/api/user-cards.js'; diff --git a/test/lib/card-embed.test.js b/test/lib/card-embed.test.js index b894493..0dace95 100644 --- a/test/lib/card-embed.test.js +++ b/test/lib/card-embed.test.js @@ -21,15 +21,52 @@ describe('embedCardImage', () => { it('returns embedding values from the gateway response', async () => { process.env.AI_GATEWAY_API_KEY = 'test-key'; + const vector = Array.from({ length: 1024 }, (_, index) => index / 1024); vi.stubGlobal( 'fetch', vi.fn(async () => ({ ok: true, - json: async () => ({ data: [{ embedding: [0.5, 0.25] }] }), + json: async () => ({ data: [{ embedding: vector }] }), })) ); const embedding = await embedCardImage('data:image/jpeg;base64,abc'); - expect(embedding).toEqual([0.5, 0.25]); + expect(embedding).toEqual(vector); + }); + + it('sends catalog image URLs as plain strings (not OpenAI image_url objects)', async () => { + process.env.AI_GATEWAY_API_KEY = 'test-key'; + const fetchMock = vi.fn(async () => ({ + ok: true, + json: async () => ({ + data: [{ embedding: Array.from({ length: 1024 }, () => 0.1) }], + }), + })); + vi.stubGlobal('fetch', fetchMock); + + await embedCardImage({ imageUrl: 'https://cards.scryfall.io/normal/front/a.jpg' }); + + const [, requestInit] = fetchMock.mock.calls[0]; + expect(JSON.parse(requestInit.body)).toMatchObject({ + input: 'https://cards.scryfall.io/normal/front/a.jpg', + }); + }); + + it('truncates oversized gateway vectors to the catalog dimension', async () => { + process.env.AI_GATEWAY_API_KEY = 'test-key'; + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: true, + json: async () => ({ + data: [{ embedding: Array.from({ length: 1536 }, (_, index) => index / 1536) }], + }), + })) + ); + + const embedding = await embedCardImage('data:image/jpeg;base64,abc'); + expect(embedding).toHaveLength(1024); + expect(embedding[0]).toBe(0); + expect(embedding[1023]).toBeCloseTo(1023 / 1536, 5); }); }); diff --git a/test/lib/card-import-sync-catalog.test.js b/test/lib/card-import-sync-catalog.test.js index 8420839..e8cb903 100644 --- a/test/lib/card-import-sync-catalog.test.js +++ b/test/lib/card-import-sync-catalog.test.js @@ -17,7 +17,7 @@ vi.mock('../../lib/card-import/lorcana-bulk.js', () => ({ runBulkLorcanaSync: vi.fn(), })); -vi.mock('@vercel/postgres', () => ({ +vi.mock('../../lib/sql.js', () => ({ sql: vi.fn(() => Promise.resolve({ rows: [] })), })); diff --git a/test/lib/permission-middleware.test.js b/test/lib/permission-middleware.test.js index ab0d859..f0a2c3d 100644 --- a/test/lib/permission-middleware.test.js +++ b/test/lib/permission-middleware.test.js @@ -1,9 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import jwt from 'jsonwebtoken'; -vi.mock('@vercel/postgres', () => ({ sql: vi.fn() })); +vi.mock('../../lib/sql.js', () => ({ sql: vi.fn() })); -import { sql } from '@vercel/postgres'; +import { sql } from '../../lib/sql.js'; import { JWT_SECRET } from '../../lib/auth-secret.js'; import { getUserFromRequest, withAdmin } from '../../lib/permission-middleware.js'; diff --git a/test/lib/sql.test.js b/test/lib/sql.test.js new file mode 100644 index 0000000..61c82fe --- /dev/null +++ b/test/lib/sql.test.js @@ -0,0 +1,31 @@ +import { describe, expect, it, vi, afterEach } from 'vitest'; + +describe('lib/sql.js', () => { + afterEach(() => { + vi.resetModules(); + vi.unstubAllGlobals(); + delete process.env.POSTGRES_URL; + delete process.env.DATABASE_URL; + }); + + it('throws when POSTGRES_URL is unset', async () => { + const { sql } = await import('../../lib/sql.js'); + await expect(sql`SELECT 1`).rejects.toThrow('POSTGRES_URL is not set'); + }); + + it('returns rows in the vercel/postgres shape', async () => { + process.env.POSTGRES_URL = 'postgresql://example.com/postgres'; + const mockQuery = vi.fn(async () => { + const result = [{ id: 1 }]; + result.count = 1; + return result; + }); + vi.doMock('postgres', () => ({ + default: vi.fn(() => mockQuery), + })); + + const { sql } = await import('../../lib/sql.js'); + const response = await sql`SELECT id FROM users WHERE id = ${1}`; + expect(response).toEqual({ rows: [{ id: 1 }], rowCount: 1 }); + }); +}); diff --git a/vercel.json b/vercel.json index 258f564..9e26dfe 100644 --- a/vercel.json +++ b/vercel.json @@ -1,17 +1 @@ -{ - "framework": "nextjs", - "crons": [ - { - "path": "/api/cron/sync-catalog", - "schedule": "0 6 * * 1" - } - ], - "functions": { - "pages/api/cron/sync-catalog.js": { - "maxDuration": 300 - }, - "pages/api/admin/sync-catalog.js": { - "maxDuration": 300 - } - } -} +{} \ No newline at end of file