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 <cursoragent@cursor.com>
This commit is contained in:
parent
a9d16d2e4d
commit
1cc2e28423
86 changed files with 1413 additions and 565 deletions
77
.convoys/migrate-neon-to-homelab.md
Normal file
77
.convoys/migrate-neon-to-homelab.md
Normal file
|
|
@ -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 |
|
||||
11
.dockerignore
Normal file
11
.dockerignore
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
node_modules
|
||||
.next
|
||||
.git
|
||||
.convoys
|
||||
.cursor
|
||||
tests
|
||||
test
|
||||
*.md
|
||||
.env*
|
||||
!.env.example
|
||||
.dockerignore
|
||||
52
.github/workflows/preview-smoke.yml
vendored
52
.github/workflows/preview-smoke.yml
vendored
|
|
@ -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()
|
||||
|
|
|
|||
35
.github/workflows/visual-diff.yml
vendored
35
.github/workflows/visual-diff.yml
vendored
|
|
@ -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()
|
||||
|
|
|
|||
30
Dockerfile
Normal file
30
Dockerfile
Normal file
|
|
@ -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"]
|
||||
89
docs/DOKPLOY_DEPLOY.md
Normal file
89
docs/DOKPLOY_DEPLOY.md
Normal file
|
|
@ -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
|
||||
```
|
||||
135
docs/HOMELAB_DATABASE.md
Normal file
135
docs/HOMELAB_DATABASE.md
Normal file
|
|
@ -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 |
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { sql } from '@vercel/postgres';
|
||||
import { sql } from './sql.js';
|
||||
|
||||
import {
|
||||
cardNumbersEquivalent,
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { sql } from '@vercel/postgres';
|
||||
import { sql } from '../sql.js';
|
||||
|
||||
import {
|
||||
fetchPokemonSetCards,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { sql } from '@vercel/postgres';
|
||||
import { sql } from '../sql.js';
|
||||
|
||||
import { cardNumbersEquivalent, normalizeCardNumber } from '../card-number-utils.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';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { sql } from '@vercel/postgres';
|
||||
import { sql } from './sql.js';
|
||||
|
||||
import { findPrintingByCollectorNumber } from './card-number-utils.js';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { sql } from '@vercel/postgres';
|
||||
import { sql } from './sql.js';
|
||||
|
||||
import { formatEmbeddingForPg } from './card-embed.js';
|
||||
|
||||
|
|
|
|||
106
lib/object-storage.js
Normal file
106
lib/object-storage.js
Normal file
|
|
@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
74
lib/sql.js
Normal file
74
lib/sql.js
Normal file
|
|
@ -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<string, unknown>[], 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
|
|||
796
package-lock.json
generated
796
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
12
package.json
12
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",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 "<value>". ' +
|
||||
'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
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { sql } from '@vercel/postgres';
|
||||
import { sql } from '../lib/sql.js';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
// Load environment variables
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
99
scripts/migrate-neon-to-homelab.js
Normal file
99
scripts/migrate-neon-to-homelab.js
Normal file
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: [] })),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
31
test/lib/sql.test.js
Normal file
31
test/lib/sql.test.js
Normal file
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
18
vercel.json
18
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
|
||||
}
|
||||
}
|
||||
}
|
||||
{}
|
||||
Loading…
Reference in a new issue