diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 46fa31e..f27b3c9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -152,7 +152,7 @@ The keeper chooses `deposit_address`. A stolen keeper key could redirect at most | `GET /api/storage-check?account=` | session, rate-limited | Is the account registered on `wrap.near`? A NEAR plan can't be saved until it is (no refund otherwise); the UI offers a one-tap `storage_deposit` (0.00125 NEAR). Demo: always yes | | `GET /api/buys` | session | History | | `GET /api/export.csv` | session | CSV | -| `POST /api/demo/:action` | demo session | `fast-forward`, `reset` | +| `POST /api/demo/:action` | demo session | `fast-forward`, `reset`. Capped in Postgres (`lib/server/demoLimits.ts`, K13.6): new demos 10/min and 30/h per IP, 600/h overall; per demo 12 fast-forwards and 20 resets a day, 60 owner transactions an hour | | `GET /api/cron/cleanup-demo` | `Bearer $CRON_SECRET` | Vercel Cron (daily 03:00 UTC): same `cleanupDemo` as the worker loop, for hosts without the worker | | `GET /api/cron/tick` | `Bearer $CRON_SECRET` | One pass of every worker loop (`worker/tick.ts`), in order: pollFunding → pollBuys → expireQuotes → scheduleBuys → keeper gas. Called every 5 min by `.github/workflows/worker-tick.yml` when `WORKER_ENABLED=true`. Each loop isolated | diff --git a/ARCHITECTURE_ESSENTIALS.md b/ARCHITECTURE_ESSENTIALS.md index 86c562f..1afcd9e 100644 --- a/ARCHITECTURE_ESSENTIALS.md +++ b/ARCHITECTURE_ESSENTIALS.md @@ -90,7 +90,7 @@ On-chain vault = truth for plan + balance. ## Demo & dry-run `pnpm demo` = zero secrets, PGlite (verified from a fresh clone). `/demo` → per-visitor demo saver + demo session; Never moves funds. - Demo sessions use `servicesFor(session)` → demo quotes (live dry first, calibrated model fallback in `lib/core/feeModel.ts`, also used by the landing page's fee check), demo status (time-based), demo vault (`demo_vaults` + balance from rows) -- Owner actions in demo → `POST /api/demo/owner-tx`, same rules as the contract; `fast-forward` 30 days, `reset` +- Owner actions in demo → `POST /api/demo/owner-tx`, same rules as the contract; `fast-forward` 30 days, `reset`; every demo action capped in Postgres (`lib/server/demoLimits.ts`) - Real worker queries **exclude demo users**; `demo-` hashes never link to explorers - `pnpm e2e` = Playwright PRD §8 journey on demo mode, desktop + phone, in CI - Keep `@electric-sql/pglite` in `serverExternalPackages`; keep the dev session secret on `globalThis` diff --git a/README.md b/README.md index eb5b0fe..8e169de 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; preview deployments and `pnpm demo` can no longer read or migrate the production database; rate limits now live in Postgres and apply across all serverless instances. +- **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; rate limits now live in Postgres and apply across all serverless instances; demo starts, fast-forwards and resets are capped so a script can't fill the 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 a684828..2a3a674 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -27,7 +27,7 @@ | 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 | 56% | in progress (pre-launch security and reliability review) | +| 13. Launch review fixes | Nov 8–13 | 67% | in progress (pre-launch security and reliability review) | | **Overall (MVP)** | | **~80%** | | 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.) @@ -215,7 +215,7 @@ From the pre-launch security and reliability review (2026-11-08), ranked with au - [ ] **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 (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 +- [x] **K13.6** [#132](https://github.com/MFrancis-dev/stack/issues/132) Cap demo starts and fast-forwards in the database · 2026-11-08: on the shared limiter (K13.7): new demos 10/min and 30/h per IP plus 600/h overall; per demo 12 fast-forwards and 20 resets a day, 60 owner transactions an hour; messages say what to do; the demo banner now shows reset errors too; 2 tests - [x] **K13.7** [#133](https://github.com/MFrancis-dev/stack/issues/133) Rate limits shared across serverless instances · 2026-11-08: `rate_limits` table (migration 0004), one atomic `INSERT … ON CONFLICT` per request, all five limited routes switched; counters older than a day removed by `cleanupDemo`; 5 tests incl. a burst of 8 against a limit of 5 - [ ] **K13.8** [#134](https://github.com/MFrancis-dev/stack/issues/134) Bad ids and malformed cookies return 500 - [ ] **K13.9** [#135](https://github.com/MFrancis-dev/stack/issues/135) Add a Content-Security-Policy @@ -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.6 demo actions capped in Postgres (`lib/server/demoLimits.ts`); demo banner shows reset errors; README, ARCHITECTURE §4.1, ESSENTIALS updated · K13.6 - 2026-11-08 · agent · K13.7 rate limits moved from instance memory to Postgres (`rate_limits`, atomic upsert, daily cleanup); README, ARCHITECTURE §5/§7, ESSENTIALS updated · K13.7 - 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 diff --git a/app/api/demo/[action]/route.ts b/app/api/demo/[action]/route.ts index 9b5db10..fac0170 100644 --- a/app/api/demo/[action]/route.ts +++ b/app/api/demo/[action]/route.ts @@ -1,7 +1,8 @@ /** * Demo mode (K7.x). `start` creates a fresh demo saver and signs the visitor in as them; the rest need a * demo session: `reset`, `fast-forward` (30 days), `owner-tx` (apply a vault transaction without a wallet). - * Nothing here touches real services or funds. Roadmap: K7.1–K7.4 + * Nothing here touches real services or funds. Every action is capped in the database, so a script + * can't fill the hosted database (K13.6). Roadmap: K7.1–K7.4, K13.6 */ import { z } from "zod"; @@ -10,6 +11,7 @@ import { fastForward, resetDemoUser, seedDemoUser } from "@/lib/db/seed-demo"; import { applyOwnerTx, DemoRuleError } from "@/lib/near/demo"; import { getServices } from "@/lib/server/adapters"; import { clientIp, handle, HttpError, json, parseBody } from "@/lib/server/http"; +import { DAY, DEMO_LIMITS, HOUR, MINUTE } from "@/lib/server/demoLimits"; import { rateLimit } from "@/lib/server/rateLimit"; import { requireSession, sessionCookie, sessionRevoked } from "@/lib/server/session"; @@ -43,7 +45,10 @@ export const POST = handle(async (req: Request, ctx: { params: Promise<{ action: switch (action) { case "start": { const { db } = await getServices(); - await rateLimit(db, `demo-start:${clientIp(req)}`, 10, 60_000); + const ip = clientIp(req); + await rateLimit(db, `demo-start:${ip}`, DEMO_LIMITS.startPerIpMinute, MINUTE); + await rateLimit(db, `demo-start-hour:${ip}`, DEMO_LIMITS.startPerIpHour, HOUR); + await rateLimit(db, "demo-start:all", DEMO_LIMITS.startAllHour, HOUR, Date.now(), "The demo is busy right now. Try again in a few minutes."); const user = await seedDemoUser(db); return json( { accountId: user.nearAccountId, vaultAccountId: user.vaultAccountId }, @@ -52,15 +57,18 @@ export const POST = handle(async (req: Request, ctx: { params: Promise<{ action: } case "reset": { const { db, user } = await demoUser(req); + await rateLimit(db, `demo-reset:${user.id}`, DEMO_LIMITS.resetPerDemo, DAY, Date.now(), "This demo has been reset enough times. Start a new demo from the landing page."); await resetDemoUser(db, user); return json({ ok: true }); } case "fast-forward": { const { db, user } = await demoUser(req); + await rateLimit(db, `demo-ff:${user.id}`, DEMO_LIMITS.fastForwardPerDemo, DAY, Date.now(), "This demo has fast-forwarded as far as it goes. Start a new demo from the landing page."); return json(await fastForward(db, user, 30)); } case "owner-tx": { const { db, user } = await demoUser(req); + await rateLimit(db, `demo-tx:${user.id}`, DEMO_LIMITS.ownerTxPerDemoHour, HOUR); const { tx } = await parseBody(req, ownerTxInput); return json({ state: await applyOwnerTx(db, user.vaultAccountId!, tx) }); } diff --git a/components/vault/DemoBanner.tsx b/components/vault/DemoBanner.tsx index c0e039f..3a92d36 100644 --- a/components/vault/DemoBanner.tsx +++ b/components/vault/DemoBanner.tsx @@ -29,6 +29,7 @@ export function DemoBanner() { setMessage("Back to the start."); return done(); }, + onError: (e) => setMessage(e.message), }); return (