diff --git a/.env.example b/.env.example index 48becc9..0bd9c5d 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,8 @@ ONECLICK_BASE_URL=https://1click.chaindefuser.com ONECLICK_JWT= # Empty → embedded PGlite DATABASE_URL= +# Hosted previews only: their own database. Preview/Development deployments never use DATABASE_URL. +PREVIEW_DATABASE_URL= SESSION_SECRET= PUBLIC_APP_URL=http://localhost:3000 # Optional: wallet network for the browser (defaults to NEAR's main network) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0c833c3..196534e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -280,7 +280,7 @@ PUBLIC_APP_URL= CRON_SECRET= # ≥ 16 chars; Vercel sends it to the cron route ``` -**Hosting (K9.1, decided 2026-11-05):** web on Vercel (project `stack-savings`, Node 22), Postgres on Neon (via the Vercel integration, pooled `DATABASE_URL`). `pnpm vercel-build` runs `drizzle-kit migrate` before `next build`; migrations SQL is traced into the API functions; `getDb()` refuses to start hosted without `DATABASE_URL`. `.vercelignore` keeps contracts and local data out of the upload. The worker needs a long-running host; it is only needed for real mode. +**Hosting (K9.1, decided 2026-11-05):** web on Vercel (project `stack-savings`, Node 22), Postgres on Neon (via the Vercel integration, pooled `DATABASE_URL`). `pnpm vercel-build` (`scripts/vercel-build.mjs`) runs `drizzle-kit migrate` before `next build` on production builds only. The Neon integration also gives Preview and Development the production `DATABASE_URL`; `databaseUrlFor()` ignores it there and uses only `PREVIEW_DATABASE_URL` (K13.5). `pnpm demo` clears `DATABASE_URL`, so a pulled `.env.local` can't point the demo at production; migrations SQL is traced into the API functions; `getDb()` refuses to start hosted without `DATABASE_URL`. `.vercelignore` keeps contracts and local data out of the upload. The worker needs a long-running host; it is only needed for real mode. --- diff --git a/ARCHITECTURE_ESSENTIALS.md b/ARCHITECTURE_ESSENTIALS.md index 793dd7d..cc98fad 100644 --- a/ARCHITECTURE_ESSENTIALS.md +++ b/ARCHITECTURE_ESSENTIALS.md @@ -102,4 +102,4 @@ Add an admin withdraw path · use an unverified quote · show unconfirmed status `NEAR_RPC_URL FACTORY_ACCOUNT_ID USDC_CONTRACT_ID KEEPER_ACCOUNT_ID KEEPER_PRIVATE_KEY ONECLICK_BASE_URL ONECLICK_JWT DATABASE_URL SESSION_SECRET PUBLIC_APP_URL CRON_SECRET` ## Hosting -Vercel (web, `pnpm vercel-build` migrates first) + Neon Postgres + daily cron `/api/cron/cleanup-demo`. Hosted demo: https://stack-savings.vercel.app/demo. Worker needs a long-running host (real mode only). +Vercel (web; `pnpm vercel-build` migrates only on production builds, and Preview/Development never use production's `DATABASE_URL`, only `PREVIEW_DATABASE_URL`) + Neon Postgres + daily cron `/api/cron/cleanup-demo`. Hosted demo: https://stack-savings.vercel.app/demo. Worker needs a long-running host (real mode only). diff --git a/README.md b/README.md index e0ecc76..04a7e7f 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ Other protections: real quotes are refused unless their signature verifies (fail Being honest about what is and isn't live: -- **Pre-launch review (2026-11-08):** a security and reliability review found nine issues; they are fixed one at a time and tracked as ROADMAP Phase 13. Fixed so far: a buy whose deposit never reaches NEAR Intents now expires after 30 minutes instead of blocking the plan forever; when NEAR gives no clear result for a buy (a timeout after sending), it stays in flight until NEAR Intents confirms it, instead of being shown as failed with nothing spent; signing out now ends the session on every device, so a copied session cookie stops working. +- **Pre-launch review (2026-11-08):** a security and reliability review found nine issues; they are fixed one at a time and tracked as ROADMAP Phase 13. Fixed so far: a buy whose deposit never reaches NEAR Intents now expires after 30 minutes instead of blocking the plan forever; when NEAR gives no clear result for a buy (a timeout after sending), it stays in flight until NEAR Intents confirms it, instead of being shown as failed with nothing spent; signing out now ends the session on every device, so a copied session cookie stops working; preview deployments and `pnpm demo` can no longer read or migrate the production database. - **Contracts are built and tested but not deployed yet** (ROADMAP K1.13). Real mode needs deployed contracts, funded keeper/factory accounts and a 1Click partner JWT. - **Quote signatures:** real 1Click quotes work without a partner key, but `verifyQuoteSignature()` in the newest SDK (0.1.26) returns false on dry and real quotes (tested 2026-11-05). Real mode refuses unverified quotes, so real funding and buys stay closed until 1Click confirms the current signing key or hash (ROADMAP K3.5). The demo is not affected. diff --git a/ROADMAP.md b/ROADMAP.md index 83089a7..c6231e2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -27,8 +27,8 @@ | 10. README & public repo | Nov 12–13 | 100% | ✅ done (hosted URL added) | | 11. Launch: video, post, submission | Nov 13–14 | 57% | in progress (video, post and submission need the human) | | 12. After submission | Nov 14–25 | 0% | not started | -| 13. Launch review fixes | Nov 8–13 | 33% | in progress (pre-launch security and reliability review) | -| **Overall (MVP)** | | **~77%** | | +| 13. Launch review fixes | Nov 8–13 | 44% | in progress (pre-launch security and reliability review) | +| **Overall (MVP)** | | **~79%** | | Phase % = done tasks ÷ non-dropped tasks in that phase. Overall = average of all 15 rows (D + phases 0–13; Phase 13 was added on 2026-11-08, which lowered the overall %). (Earlier entries over-estimated the overall %; recalculated 2026-11-04.) @@ -213,8 +213,8 @@ From the pre-launch security and reliability review (2026-11-08), ranked with au - [x] **K13.1** [#127](https://github.com/MFrancis-dev/stack/issues/127) Expire buys whose deposit never reaches 1Click · 2026-11-08: `pollBuys` marks a buy `expired` when 1Click still shows `PENDING_DEPOSIT` 30 min after it (quote deadline 10 min + margin); buys 1Click has seen are never expired; 3 tests - [x] **K13.2** [#128](https://github.com/MFrancis-dev/stack/issues/128) Unclear `execute_buy` errors must not be recorded as failed (a timeout after broadcast showed "Nothing spent" while USDC left) · 2026-11-08: the NEAR adapter raises `BuyOutcomeUnknownError` unless the error is a clear failure (contract refusal, failed action, rejected transaction); the scheduler keeps that buy `sent` with a note and alerts; K13.1's expiry settles it if nothing arrived; 3 tests - [ ] **K13.3** [#129](https://github.com/MFrancis-dev/stack/issues/129) ⛔ Vault creation spends the factory's NEAR for anyone who signs in (~1.5 NEAR per vault): owner pays storage, or create after the first deposit -- [x] **K13.4** [#130](https://github.com/MFrancis-dev/stack/issues/130) Make sessions revocable (sign-out only cleared the cookie) · 2026-11-08: sessions carry an issued-at time; sign-out sets `users.sessions_valid_after` (migration 0003) and every older session is rejected, real and demo; test signs in, signs out, checks the copied cookie fails and a new sign-in works -- [ ] **K13.5** [#131](https://github.com/MFrancis-dev/stack/issues/131) Keep preview and development off the production database +- [x] **K13.4** [#130](https://github.com/MFrancis-dev/stack/issues/130) Make sessions revocable (sign-out only cleared the cookie) · 2026-11-08 (with a CI fix: retry the `cargo-near` download, then build it from crates.io, after GitHub releases kept answering 504): sessions carry an issued-at time; sign-out sets `users.sessions_valid_after` (migration 0003) and every older session is rejected, real and demo; test signs in, signs out, checks the copied cookie fails and a new sign-in works +- [x] **K13.5** [#131](https://github.com/MFrancis-dev/stack/issues/131) Keep preview and development off the production database · 2026-11-08: `scripts/vercel-build.mjs` migrates only on production builds; `databaseUrlFor()` ignores `DATABASE_URL` on Preview/Development (uses `PREVIEW_DATABASE_URL` or refuses to start); `pnpm demo` clears `DATABASE_URL` (checked: Next keeps the empty value over `.env.local`); 3 tests - [ ] **K13.6** [#132](https://github.com/MFrancis-dev/stack/issues/132) Cap demo starts and fast-forwards in the database - [ ] **K13.7** [#133](https://github.com/MFrancis-dev/stack/issues/133) Rate limits shared across serverless instances - [ ] **K13.8** [#134](https://github.com/MFrancis-dev/stack/issues/134) Bad ids and malformed cookies return 500 @@ -234,6 +234,7 @@ From the pre-launch security and reliability review (2026-11-08), ranked with au ## Update log One line per contribution, newest first: `YYYY-MM-DD · who/agent · what changed · tasks` +- 2026-11-08 · agent · K13.5 previews and `pnpm demo` can't touch the production database (migrate on production builds only, `databaseUrlFor`, `PREVIEW_DATABASE_URL`); README, ARCHITECTURE, ESSENTIALS, `.env.example` updated · K13.5 - 2026-11-08 · agent · K13.4 revocable sessions: issued-at in the token, sign-out revokes every older session (`sessions_valid_after`, migration 0003); README status, ARCHITECTURE §4.1/§5, ESSENTIALS updated · K13.4 - 2026-11-08 · agent · K13.2 unclear `execute_buy` results stay in flight (`BuyOutcomeUnknownError`, `isClearTxFailure`) instead of being marked failed; README status and ARCHITECTURE §4.4 updated · K13.2 - 2026-11-08 · agent · Pre-launch review: new Phase 13 (9 tasks, issues #127–#135); K13.1 buys whose deposit never reaches 1Click expire after 30 min; README status updated · K13.1 diff --git a/lib/db/client.ts b/lib/db/client.ts index 92927c9..85a666d 100644 --- a/lib/db/client.ts +++ b/lib/db/client.ts @@ -42,12 +42,28 @@ export function createDb({ databaseUrl, pgliteDataDir = DEFAULT_PGLITE_DIR }: Db let shared: Db | undefined; +/** + * Which database this process may use (K13.5). On Vercel only Production uses `DATABASE_URL`: the + * Neon integration hands the production URL to Preview and Development too, and a preview must + * never read or write production data, so those use `PREVIEW_DATABASE_URL` or nothing. + */ +export function databaseUrlFor(env: Record): string | undefined { + if (env.VERCEL && env.VERCEL_ENV !== "production") return env.PREVIEW_DATABASE_URL || undefined; + return env.DATABASE_URL || undefined; +} + /** Process-wide client for the app and worker. Tests should call `createDb` directly. */ export function getDb(): Db { if (!shared) { - const databaseUrl = process.env.DATABASE_URL || undefined; + const databaseUrl = databaseUrlFor(process.env); // Hosted platforms have a read-only or throwaway disk: the embedded database would lose data or crash. - if (!databaseUrl && process.env.VERCEL) throw new Error("DATABASE_URL is required when hosted (embedded PGlite is for local and CI only)"); + if (!databaseUrl && process.env.VERCEL) { + throw new Error( + process.env.VERCEL_ENV === "production" + ? "DATABASE_URL is required when hosted (embedded PGlite is for local and CI only)" + : "This preview has no database of its own: set PREVIEW_DATABASE_URL (it never uses production's)", + ); + } shared = createDb({ databaseUrl }); } return shared; diff --git a/lib/db/databaseUrl.test.ts b/lib/db/databaseUrl.test.ts new file mode 100644 index 0000000..a91ae31 --- /dev/null +++ b/lib/db/databaseUrl.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { databaseUrlFor } from "./client"; + +// Roadmap: K13.5 +const PROD = "postgres://prod"; +const PREVIEW = "postgres://preview"; + +describe("databaseUrlFor", () => { + it("uses DATABASE_URL in production on Vercel, and locally", () => { + expect(databaseUrlFor({ VERCEL: "1", VERCEL_ENV: "production", DATABASE_URL: PROD })).toBe(PROD); + expect(databaseUrlFor({ DATABASE_URL: PROD })).toBe(PROD); + }); + + it("never gives a preview or development deployment the production database", () => { + expect(databaseUrlFor({ VERCEL: "1", VERCEL_ENV: "preview", DATABASE_URL: PROD })).toBeUndefined(); + expect(databaseUrlFor({ VERCEL: "1", VERCEL_ENV: "development", DATABASE_URL: PROD })).toBeUndefined(); + expect(databaseUrlFor({ VERCEL: "1", VERCEL_ENV: "preview", DATABASE_URL: PROD, PREVIEW_DATABASE_URL: PREVIEW })).toBe(PREVIEW); + }); + + it("falls back to the embedded database when nothing is set locally", () => { + expect(databaseUrlFor({ DATABASE_URL: "" })).toBeUndefined(); + }); +}); diff --git a/package.json b/package.json index 897cb89..9fe6145 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,10 @@ "scripts": { "dev": "next dev", "build": "next build", - "vercel-build": "drizzle-kit migrate && next build", + "vercel-build": "node scripts/vercel-build.mjs", "start": "next start", "worker": "tsx --conditions=react-server worker/index.ts", - "demo": "tsx --conditions=react-server scripts/seed-demo.ts && next dev", + "demo": "DATABASE_URL= tsx --conditions=react-server scripts/seed-demo.ts && DATABASE_URL= next dev", "typecheck": "tsc --noEmit", "lint": "eslint .", "format": "prettier --write .", diff --git a/scripts/vercel-build.mjs b/scripts/vercel-build.mjs new file mode 100644 index 0000000..2b921bd --- /dev/null +++ b/scripts/vercel-build.mjs @@ -0,0 +1,11 @@ +// Vercel build (K13.5): only production builds migrate the database. Preview and Development builds +// get the production DATABASE_URL from the Neon integration too, and must never change it. +import { execSync } from "node:child_process"; + +const env = process.env.VERCEL_ENV ?? "local"; +if (env === "production") { + execSync("drizzle-kit migrate", { stdio: "inherit" }); +} else { + console.log(`Skipping database migrations on a ${env} build: only production migrates its database.`); +} +execSync("next build", { stdio: "inherit" });