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:
Randall Stillwell 2026-08-15 09:32:13 -05:00
parent a9d16d2e4d
commit 1cc2e28423
86 changed files with 1413 additions and 565 deletions

View 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
View file

@ -0,0 +1,11 @@
node_modules
.next
.git
.convoys
.cursor
tests
test
*.md
.env*
!.env.example
.dockerignore

View file

@ -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()

View file

@ -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
View 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
View 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
View 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 2448 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 |

View file

@ -1,4 +1,4 @@
import { sql } from '@vercel/postgres';
import { sql } from './sql.js';
import {
cardNumbersEquivalent,

View file

@ -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. */

View file

@ -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));

View file

@ -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']);

View file

@ -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';

View file

@ -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));

View file

@ -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,

View file

@ -1,4 +1,4 @@
import { sql } from '@vercel/postgres';
import { sql } from '../sql.js';
import {
fetchPokemonSetCards,

View file

@ -1,4 +1,4 @@
import { sql } from '@vercel/postgres';
import { sql } from '../sql.js';
import { cardNumbersEquivalent, normalizeCardNumber } from '../card-number-utils.js';

View file

@ -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';

View file

@ -1,4 +1,4 @@
import { sql } from '@vercel/postgres';
import { sql } from './sql.js';
import { findPrintingByCollectorNumber } from './card-number-utils.js';

View file

@ -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
View 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,
})
);
}

View file

@ -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';

View file

@ -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
View 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;
}
}

View file

@ -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
View file

@ -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",

View file

@ -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",

View file

@ -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) {

View file

@ -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

View file

@ -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) {

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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;

View file

@ -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) {

View file

@ -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) {

View file

@ -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) {

View file

@ -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) {

View file

@ -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) {

View file

@ -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) {

View file

@ -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) {

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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) {

View file

@ -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');

View file

@ -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) {

View file

@ -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) {

View file

@ -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) {

View file

@ -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';

View file

@ -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') {

View file

@ -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') {

View file

@ -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') {

View file

@ -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';

View file

@ -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) {

View file

@ -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) {

View file

@ -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
}

View file

@ -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

View file

@ -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';

View file

@ -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) {

View file

@ -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) {

View file

@ -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) {

View file

@ -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';

View file

@ -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

View file

@ -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' });

View file

@ -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() {

View file

@ -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' });

View file

@ -1,4 +1,4 @@
import { sql } from '@vercel/postgres';
import { sql } from '../lib/sql.js';
import dotenv from 'dotenv';
// Load environment variables

View file

@ -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() {

View file

@ -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' });

View file

@ -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' });

View file

@ -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' });

View file

@ -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' });

View 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);
});

View file

@ -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' });

View file

@ -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() {

View file

@ -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);

View file

@ -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' })
);
});
});

View file

@ -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';

View file

@ -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);
});
});

View file

@ -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: [] })),
}));

View file

@ -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
View 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 });
});
});

View file

@ -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
}
}
}
{}