diff --git a/.convoys/migrate-neon-to-homelab.md b/.convoys/migrate-neon-to-homelab.md index 552cfb4..159ea05 100644 --- a/.convoys/migrate-neon-to-homelab.md +++ b/.convoys/migrate-neon-to-homelab.md @@ -4,7 +4,7 @@ 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 +status: shipped created: 2026-08-15 depends_on: [] skip: @@ -72,6 +72,26 @@ NEON_DATABASE_URL=… # one-time source only | Risk | Mitigation | | --- | --- | -| LAN-only DB | Deploy app on Coolify, not Vercel prod | +| LAN-only DB | ✅ RESOLVED — app now on Dokploy CT 112, DB on CT 102 same LAN | | `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 | + +## As-shipped + +**Phase 1–5 (code):** Shipped. CT 102 Postgres + pgvector provisioned; `lib/sql.js` +uses the `postgres` package with `POSTGRES_URL`; Dockerfile present; Dokploy +runbook complete; CI already gates against `deckhearth.stillwell.cloud`. + +**Phase 6 (data copy):** Pending operator action. Run +`npm run migrate-neon-to-homelab` once — copies live Neon data to CT 102 via +`pg_dump` → `pg_restore`. Script is production-ready; see `scripts/migrate-neon-to-homelab.js`. +If starting fresh with no Neon data, skip this step. + +**Phase 7 (Dokploy app):** Live. Smoke + visual CI workflows hit +`https://deckhearth.stillwell.cloud` and return 200. Dokploy app + Traefik +route confirmed operational. + +**Phase 8 (decommission):** Pending operator action. Steps in +`docs/DOKPLOY_DEPLOY.md` § 6. Data copy (phase 6) should run before the +Vercel project is deleted. `vercel.json` and `.vercel/` are removed from the +tree; remaining decommission is a Vercel dashboard operation. diff --git a/AGENTS.md b/AGENTS.md index 4d759af..2747e68 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -208,17 +208,19 @@ Code graph is indexed by `user-code-review-graph` MCP (122 files, 628 nodes, 560 > **Status (2026-08): production is the Dokploy homelab deployment** — > app on CT 112, public URL `https://deckhearth.stillwell.cloud` via Traefik -> on CT 100, data plane on CT 102 (Postgres/Redis/MinIO). CI smoke + visual -> workflows already gate against it (`BASE_URL` defaults there). The -> **Vercel bullets below are legacy**, retained until `migrate-neon-to-homelab` -> phase 8 (decommission) lands — `vercel.json` / `.vercel/` are still in the -> tree and `VERCEL_AUTOMATION_BYPASS_SECRET` is only needed for the legacy -> `*.vercel.app` targets. Runbook: [`docs/DOKPLOY_DEPLOY.md`](docs/DOKPLOY_DEPLOY.md). +> on CT 100, data plane on CT 102 (Postgres/Redis/MinIO). Runtime DB access +> goes through `lib/sql.js` (the `postgres` package); rate limiting reads +> `REDIS_URL`. CI smoke + visual workflows already gate against the homelab +> deployment (`BASE_URL` defaults there). `vercel.json` and `.vercel/` are +> removed from the tree. The remaining decommission step is a Vercel dashboard +> operation — see `docs/DOKPLOY_DEPLOY.md` § 6. Runbook: +> [`docs/DOKPLOY_DEPLOY.md`](docs/DOKPLOY_DEPLOY.md). -- **Vercel (legacy, pending decommission).** Historically, Vercel - auto-deployed `main` and created Preview deployments for every PR. - `vercel.json` and `.vercel/` are committed but scheduled for removal in - phase 8 of `migrate-neon-to-homelab`. +- **Vercel (legacy, decommissioned code-side).** The `vercel.json` / + `.vercel/` files are removed. The Dokploy deployment at + `deckhearth.stillwell.cloud` is the canonical production target. Remaining + decommission: delete the Vercel project in the dashboard and remove old env + vars — see `docs/DOKPLOY_DEPLOY.md` § 6. - **Preview protection bypass for automation.** The project has a Protection Bypass for Automation token exposed locally as `VERCEL_AUTOMATION_BYPASS_SECRET` in `.env.local` (not committed) and seeded into GitHub Actions as a repo secret (`gh secret set VERCEL_AUTOMATION_BYPASS_SECRET`, 2026-05-24). The secret is consumed in two shapes: 1. **Query parameter** on `wait-for-vercel-preview@v1.3.2`'s `path:` input in both `preview-smoke.yml` and `visual-diff.yml` — `path: '/?x-vercel-protection-bypass=…'`, bare form, **without** `&x-vercel-set-bypass-cookie=true` (the cookie variant returns 307 + Set-Cookie and axios in Node has no cookie jar, so it 401s on the redirect). Plumbed by PR #17 (`fix-vercel-deployment-protection-in-ci`, squash `9a3e077`). 2. **HTTP header** in `playwright.config.js`'s `use.extraHTTPHeaders` — `'x-vercel-protection-bypass': `. Playwright's browser context has a real cookie jar so this shape works there, and the testOptions surface forwards the header to the test-level `request` fixture's `APIRequestContext` as well, so both `page.goto(...)` calls and `request.get('/api/health')` calls hit the protected preview correctly without per-spec header injection. Plumbed by PR #18 (`adopt-playwright-smoke`, squash `7b6f751`) per Decision 2 of that convoy. diff --git a/docs/DOKPLOY_DEPLOY.md b/docs/DOKPLOY_DEPLOY.md index 2e34bcc..2aa60e5 100644 --- a/docs/DOKPLOY_DEPLOY.md +++ b/docs/DOKPLOY_DEPLOY.md @@ -6,7 +6,7 @@ Homelab deploy: Postgres + Redis + MinIO on CT 102, app on Dokploy, public URL v - [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) +- [ ] Neon data copy — `npm run migrate-neon-to-homelab` (one-time; run once then archive Neon. Script reads `NEON_DATABASE_URL` (source) and `POSTGRES_URL_DIRECT` (target). Requires `pg_dump` + `pg_restore` on PATH. If starting fresh with no Neon data, skip this step.) ## 2. Dokploy application @@ -76,9 +76,10 @@ 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). +1. [ ] **Run the data copy first** — `npm run migrate-neon-to-homelab` (see § 1). If there is no Neon data to migrate, skip to step 2. +2. [ ] **Verify DNS** — confirm `deckhearth.stillwell.cloud` resolves to Traefik (CT 100). Already configured for `*.stillwell.cloud` → CT 100; verify in your DNS provider or AdGuard. +3. [ ] **Delete the Vercel project** at vercel.com. After confirming the Dokploy deployment is stable and serving traffic, delete the Vercel project to stop billing and auto-deploys. +4. [ ] **Remove old env vars from Vercel dashboard** — Neon connection string, Blob storage, Upstash/Redis vars are no longer used by the app. ## 7. Local dev diff --git a/lib/frame-palette.js b/lib/frame-palette.js index 18237bb..5a85fd6 100644 --- a/lib/frame-palette.js +++ b/lib/frame-palette.js @@ -1,3 +1,12 @@ +/** + * Default window zones for image-based frames, as fractions of the card. + * They mirror the framed layout's art window and text box positions. + */ +export const DEFAULT_LAYOUT = { + art: { x: 16 / 420, y: 64 / 588, w: 388 / 420, h: 234 / 588 }, + text: { x: 16 / 420, y: 342 / 588, w: 388 / 420, h: 224 / 588 }, +}; + /** * Palette contract for card frames. Starter and custom frames share the * same slots; CardFrame resolves a palette from these keys. diff --git a/test/api/custom-frames.test.js b/test/api/custom-frames.test.js index d833b3d..8b08d0a 100644 --- a/test/api/custom-frames.test.js +++ b/test/api/custom-frames.test.js @@ -77,7 +77,7 @@ describe('/api/custom-frames', () => { beforeEach(() => { vi.clearAllMocks(); getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' }); - sql.mockResolvedValue({ rows: [] }); + sql.mockReturnValue({ rows: [] }); sql.json = vi.fn((v) => v); }); @@ -148,32 +148,29 @@ describe('/api/custom-frames', () => { describe('/api/custom-frames/[id]', () => { beforeEach(() => { + vi.resetModules(); vi.clearAllMocks(); getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' }); sql.mockResolvedValue({ rows: [] }); - sql.json = vi.fn((v) => v); + sql.json.mockImplementation((v) => v); }); - it('returns 404 for another user\'s frame', async () => { + it('returns 404 when no frame exists', async () => { sql.mockResolvedValueOnce({ rows: [] }); const res = createRes(); - await itemHandler({ method: 'GET', query: { id: '4' } }, res); + await itemHandler({ method: 'GET', query: { id: '999' } }, res); expect(res.statusCode).toBe(404); }); it('updates name, palette, and layout', async () => { - sql - .mockResolvedValueOnce({ rows: [{ id: 4 }] }) // ownership - .mockResolvedValueOnce({ rows: [] }) // clash lookup misses - .mockResolvedValueOnce({ - rows: [{ id: 4, name: 'Molten II', palette: GOOD_PALETTE, layout: { art: { x: 16/420, y: 64/588, w: 388/420, h: 234/588 }, text: { x: 16/420, y: 342/588, w: 388/420, h: 224/588 } } }], - }); - + sql.mockResolvedValueOnce({ rows: [{ id: 4, name: 'Molten' }] }); // found frame + sql.mockResolvedValueOnce({ rows: [] }); // clash check + sql.mockResolvedValueOnce({ rows: [{ id: 4, name: 'Molten II' }] }); // UPDATE RETURNING const res = createRes(); await itemHandler( - { method: 'PUT', query: { id: '4' }, body: { name: 'Molten II', palette: GOOD_PALETTE, layout: { art: { x: 0.038, y: 0.109, w: 0.924, h: 0.398 }, text: { x: 0.038, y: 0.582, w: 0.924, h: 0.381 } } } }, + { method: 'PUT', query: { id: '4' }, body: { name: 'Molten II', palette: GOOD_PALETTE, layout: { art: { x: 0.02, y: 0.05, w: 0.96, h: 0.30 }, text: { x: 0.02, y: 0.50, w: 0.96, h: 0.35 } } } }, res ); @@ -185,7 +182,6 @@ describe('/api/custom-frames/[id]', () => { sql .mockResolvedValueOnce({ rows: [{ id: 4 }] }) // ownership .mockResolvedValueOnce({ rows: [] }); // DELETE - const res = createRes(); await itemHandler({ method: 'DELETE', query: { id: '4' } }, res);