From 8842fcfc444f1d0595f5bbc18b0a0e0aafc7fafe Mon Sep 17 00:00:00 2001 From: Allison Muyideen Opeyemi Date: Sun, 8 Nov 2026 10:15:36 +0100 Subject: [PATCH] Keep rate limits in Postgres so every serverless instance shares them Counters lived in each instance's memory, so on the hosted app the limits barely applied, and the map never shrank. Closes #133 --- ARCHITECTURE.md | 3 +- ARCHITECTURE_ESSENTIALS.md | 2 +- README.md | 6 +- ROADMAP.md | 7 +- app/api/demo/[action]/route.ts | 2 +- app/api/fund/quote/route.ts | 2 +- app/api/onboard/route.ts | 2 +- app/api/plan/preview/route.ts | 2 +- app/api/storage-check/route.ts | 2 +- drizzle/0004_rate_limits.sql | 5 + drizzle/meta/0004_snapshot.json | 488 ++++++++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + lib/db/schema.ts | 7 + lib/server/demo.test.ts | 2 - lib/server/rateLimit.test.ts | 42 +++ lib/server/rateLimit.ts | 41 ++- lib/server/routes.test.ts | 2 - worker/cleanupDemo.ts | 5 +- 18 files changed, 595 insertions(+), 32 deletions(-) create mode 100644 drizzle/0004_rate_limits.sql create mode 100644 drizzle/meta/0004_snapshot.json create mode 100644 lib/server/rateLimit.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 196534e..46fa31e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -221,6 +221,7 @@ buys (id uuid pk, user_id uuid fk, quote_ref text unique not null, amount_in num status text not null, -- skipped_cost|no_quote|sent|processing|success|refunded|failed|expired note text, -- why it was skipped, refused, failed or refunded (migration 0001) destination_tx_hash text, created_at timestamptz default now(), completed_at timestamptz) +rate_limits (key text pk, window_start timestamptz not null, count int not null) -- K13.7, shared fixed windows ``` On-chain vault = truth for plan and balance. DB = history and metadata. @@ -246,7 +247,7 @@ On-chain vault = truth for plan and balance. DB = history and metadata. | Secrets | `.env` only; logged errors pass through `redact()` (env secret values, URL credentials, private-key strings) | | Clickjacking, sniffing, leaky referrers | Security headers on every response: `X-Frame-Options: DENY`, `nosniff`, `strict-origin-when-cross-origin`, restrictive `Permissions-Policy`, `COOP: same-origin-allow-popups`, HSTS in production. No CSP yet (wallet connector iframes) | | CSRF on state-changing routes | Session cookie is `SameSite=Lax` + HTTP-only, so cross-site POSTs carry no session | -| Request floods | In-memory rate limits on quote, plan preview, onboard, storage check and demo start (single instance) | +| Request floods | Rate limits on quote, plan preview, onboard, storage check and demo start, kept in Postgres (`rate_limits`, one atomic upsert per request) so every serverless instance shares them; counters older than a day removed by the daily cleanup (K13.7) | --- diff --git a/ARCHITECTURE_ESSENTIALS.md b/ARCHITECTURE_ESSENTIALS.md index cc98fad..86c562f 100644 --- a/ARCHITECTURE_ESSENTIALS.md +++ b/ARCHITECTURE_ESSENTIALS.md @@ -69,7 +69,7 @@ Rust near-sdk =5.30.0 + cargo-near 0.22 + near-workspaces =0.24.0 · Next.js 15. | no route | – | no_quote | ## Data model (amounts numeric(78,0)) -`users` (near_account_id, vault_account_id, is_demo, sessions_valid_after) · `funding_attempts` (deposit_address uniq, quote_raw, signature, status) · `buys` (quote_ref uniq, cost_bps, deposit_address, status) +`users` (near_account_id, vault_account_id, is_demo, sessions_valid_after) · `funding_attempts` (deposit_address uniq, quote_raw, signature, status) · `buys` (quote_ref uniq, cost_bps, deposit_address, status) · `rate_limits` (key, window_start, count; shared by all instances) On-chain vault = truth for plan + balance. ## API diff --git a/README.md b/README.md index 04a7e7f..eb5b0fe 100644 --- a/README.md +++ b/README.md @@ -93,14 +93,14 @@ 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. +- **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. - **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. - **Fees without a partner key:** 1Click adds about 0.25%. A real $100 Bitcoin quote came to about 2.0%, against 1.75% in the fee estimates shown in the demo and on the landing page. - **Demo mode** uses live dry quotes when NEAR Intents is reachable and a fee model calibrated on real quotes otherwise; demo deposits complete on a timer. It never moves funds. - **No Content-Security-Policy yet** — the wallet connector loads wallets in sandboxed iframes. -- Rate limits are in-memory (per app instance). +- Rate limits live in Postgres, so all serverless instances share them (quote, plan preview, onboard, storage check, demo start). - **Hosting:** the web app runs on Vercel with a Neon Postgres database; demo cleanup runs as a daily Vercel Cron job. On the hosted app the worker runs as one pass every 5 minutes (`GET /api/cron/tick`, called by the `Worker tick` GitHub workflow). It stays off until real mode is configured: set the repository variable `WORKER_ENABLED=true` and the secret `CRON_SECRET`. ## Running for real @@ -117,7 +117,7 @@ Contracts: `pnpm contracts:build` (cargo-near 0.22 → `contracts/target/near/`) ## Tests ```bash -pnpm typecheck && pnpm lint && pnpm test # 127 unit + API route tests (in-memory database, fake 1Click/NEAR) +pnpm typecheck && pnpm lint && pnpm test # 146 unit + API route tests (in-memory database, fake 1Click/NEAR) pnpm e2e # Playwright judge journey on demo mode, desktop + phone E2E_BASE_URL=https://stack-savings.vercel.app pnpm e2e # same journey against the hosted demo pnpm contracts:test # 49 contract tests: 24 vault + 9 factory unit, 16 sandbox (I1–I7 and more) diff --git a/ROADMAP.md b/ROADMAP.md index c6231e2..a684828 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 | 44% | in progress (pre-launch security and reliability review) | -| **Overall (MVP)** | | **~79%** | | +| 13. Launch review fixes | Nov 8–13 | 56% | 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.) @@ -216,7 +216,7 @@ From the pre-launch security and reliability review (2026-11-08), ranked with au - [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 +- [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.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 - 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 diff --git a/app/api/demo/[action]/route.ts b/app/api/demo/[action]/route.ts index e832690..9b5db10 100644 --- a/app/api/demo/[action]/route.ts +++ b/app/api/demo/[action]/route.ts @@ -42,8 +42,8 @@ export const POST = handle(async (req: Request, ctx: { params: Promise<{ action: try { switch (action) { case "start": { - rateLimit(`demo-start:${clientIp(req)}`, 10, 60_000); const { db } = await getServices(); + await rateLimit(db, `demo-start:${clientIp(req)}`, 10, 60_000); const user = await seedDemoUser(db); return json( { accountId: user.nearAccountId, vaultAccountId: user.vaultAccountId }, diff --git a/app/api/fund/quote/route.ts b/app/api/fund/quote/route.ts index cb77640..1e23108 100644 --- a/app/api/fund/quote/route.ts +++ b/app/api/fund/quote/route.ts @@ -13,7 +13,7 @@ import { rateLimit } from "@/lib/server/rateLimit"; export const POST = handle(async (req: Request) => { const { services, user, vaultAccountId } = await requireVaultOwner(req); - rateLimit(`fund-quote:${user.id}:${clientIp(req)}`, 20, 60_000); + await rateLimit(services.db, `fund-quote:${user.id}:${clientIp(req)}`, 20, 60_000); const body = await parseBody(req, fundQuoteInput); const quote = await services.quotes.fundingQuote( diff --git a/app/api/onboard/route.ts b/app/api/onboard/route.ts index a900263..53de5e8 100644 --- a/app/api/onboard/route.ts +++ b/app/api/onboard/route.ts @@ -13,7 +13,7 @@ export const POST = handle(async (req: Request) => { const { services, user } = await requireUser(req); if (user.vaultAccountId) return json({ vaultAccountId: user.vaultAccountId, created: false }); - rateLimit(`onboard:${user.id}`, 3, 60 * 60 * 1000); + await rateLimit(services.db, `onboard:${user.id}`, 3, 60 * 60 * 1000); const vaults = requireVaultClient(services); const existing = await vaults.findVault(user.nearAccountId); const vaultAccountId = existing ?? (await vaults.createVaultFor(user.nearAccountId)).vaultAccountId; diff --git a/app/api/plan/preview/route.ts b/app/api/plan/preview/route.ts index 8798512..3815bbc 100644 --- a/app/api/plan/preview/route.ts +++ b/app/api/plan/preview/route.ts @@ -23,7 +23,7 @@ const PREVIEW_RECIPIENT = { export const POST = handle(async (req: Request) => { const { services, user } = await requireUser(req); - rateLimit(`plan-preview:${user.id}`, 30, 60_000); + await rateLimit(services.db, `plan-preview:${user.id}`, 30, 60_000); const body = await parseBody(req, planPreviewInput); const target = TARGET_ASSETS[body.target]; const amountIn = asBaseUnits(body.amountPerRun); diff --git a/app/api/storage-check/route.ts b/app/api/storage-check/route.ts index fb4f158..0ecd152 100644 --- a/app/api/storage-check/route.ts +++ b/app/api/storage-check/route.ts @@ -18,7 +18,7 @@ export const GET = handle(async (req: Request) => { const account = new URL(req.url).searchParams.get("account")?.trim() ?? ""; if (!isValidNearAccount(account)) throw new HttpError(400, "Not a valid NEAR account"); if (session.demo) return json({ account, registered: true }); - rateLimit(`storage-check:${user.id}`, 30, 60_000); + await rateLimit(services.db, `storage-check:${user.id}`, 30, 60_000); const balance = await view(requireNear(services), WNEAR_CONTRACT, "storage_balance_of", { account_id: account }); return json({ account, registered: balance !== null && balance !== undefined }); }); diff --git a/drizzle/0004_rate_limits.sql b/drizzle/0004_rate_limits.sql new file mode 100644 index 0000000..3467ca6 --- /dev/null +++ b/drizzle/0004_rate_limits.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS "rate_limits" ( + "key" text PRIMARY KEY NOT NULL, + "window_start" timestamp with time zone NOT NULL, + "count" integer NOT NULL +); diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..7107f07 --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -0,0 +1,488 @@ +{ + "id": "9e589971-13d9-48c2-b97b-af22dc074177", + "prevId": "074c2fd2-5b84-454f-bdd0-1bf6991de9cd", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.buys": { + "name": "buys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "quote_ref": { + "name": "quote_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_in": { + "name": "amount_in", + "type": "numeric(78, 0)", + "primaryKey": false, + "notNull": true + }, + "target_asset": { + "name": "target_asset", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "destination_address": { + "name": "destination_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cost_bps": { + "name": "cost_bps", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "amount_in_usd": { + "name": "amount_in_usd", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "amount_out": { + "name": "amount_out", + "type": "numeric(78, 0)", + "primaryKey": false, + "notNull": false + }, + "deposit_address": { + "name": "deposit_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quote_signature": { + "name": "quote_signature", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quote_raw": { + "name": "quote_raw", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "near_tx_hash": { + "name": "near_tx_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oneclick_status": { + "name": "oneclick_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "destination_tx_hash": { + "name": "destination_tx_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "buys_user_idx": { + "name": "buys_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "buys_user_id_users_id_fk": { + "name": "buys_user_id_users_id_fk", + "tableFrom": "buys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "buys_quote_ref_unique": { + "name": "buys_quote_ref_unique", + "nullsNotDistinct": false, + "columns": [ + "quote_ref" + ] + }, + "buys_deposit_address_unique": { + "name": "buys_deposit_address_unique", + "nullsNotDistinct": false, + "columns": [ + "deposit_address" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.demo_vaults": { + "name": "demo_vaults", + "schema": "", + "columns": { + "vault_account_id": { + "name": "vault_account_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "withdrawn": { + "name": "withdrawn", + "type": "numeric(78, 0)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + } + }, + "indexes": {}, + "foreignKeys": { + "demo_vaults_user_id_users_id_fk": { + "name": "demo_vaults_user_id_users_id_fk", + "tableFrom": "demo_vaults", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.funding_attempts": { + "name": "funding_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "origin_asset": { + "name": "origin_asset", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_in": { + "name": "amount_in", + "type": "numeric(78, 0)", + "primaryKey": false, + "notNull": true + }, + "expected_out": { + "name": "expected_out", + "type": "numeric(78, 0)", + "primaryKey": false, + "notNull": true + }, + "refund_to": { + "name": "refund_to", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deposit_address": { + "name": "deposit_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quote_deadline": { + "name": "quote_deadline", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "quote_signature": { + "name": "quote_signature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quote_raw": { + "name": "quote_raw", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "oneclick_status": { + "name": "oneclick_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'awaiting_deposit'" + }, + "deposit_tx_hash": { + "name": "deposit_tx_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "funding_attempts_user_idx": { + "name": "funding_attempts_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "funding_attempts_user_id_users_id_fk": { + "name": "funding_attempts_user_id_users_id_fk", + "tableFrom": "funding_attempts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "funding_attempts_deposit_address_unique": { + "name": "funding_attempts_deposit_address_unique", + "nullsNotDistinct": false, + "columns": [ + "deposit_address" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limits": { + "name": "rate_limits", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "window_start": { + "name": "window_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "near_account_id": { + "name": "near_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vault_account_id": { + "name": "vault_account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_demo": { + "name": "is_demo", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sessions_valid_after": { + "name": "sessions_valid_after", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_near_account_id_unique": { + "name": "users_near_account_id_unique", + "nullsNotDistinct": false, + "columns": [ + "near_account_id" + ] + }, + "users_vault_account_id_unique": { + "name": "users_vault_account_id_unique", + "nullsNotDistinct": false, + "columns": [ + "vault_account_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 2a5f345..fdf005e 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1794128399884, "tag": "0003_sessions_valid_after", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1794129268260, + "tag": "0004_rate_limits", + "breakpoints": true } ] } \ No newline at end of file diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 119649a..354f99f 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -88,3 +88,10 @@ export type User = typeof users.$inferSelect; export type FundingAttempt = typeof fundingAttempts.$inferSelect; export type Buy = typeof buys.$inferSelect; export type DemoVault = typeof demoVaults.$inferSelect; + +/** Rate-limit counters shared by every serverless instance (K13.7). One fixed window per key. */ +export const rateLimits = pgTable("rate_limits", { + key: text("key").primaryKey(), + windowStart: ts("window_start").notNull(), + count: integer("count").notNull(), +}); diff --git a/lib/server/demo.test.ts b/lib/server/demo.test.ts index 59eac50..3372c71 100644 --- a/lib/server/demo.test.ts +++ b/lib/server/demo.test.ts @@ -13,7 +13,6 @@ import { fastForward, seedDemoUser } from "../db/seed-demo"; import { applyOwnerTx, demoBalance, demoVaultClient, DemoRuleError } from "../near/demo"; import { demoStatusAt, modelCostBps } from "../oneclick/demo"; import { setServicesForTest, type Services } from "./adapters"; -import { resetRateLimits } from "./rateLimit"; import { sessionCookie } from "./session"; // Roadmap: K7.1–K7.4 @@ -21,7 +20,6 @@ let db: Db; beforeEach(async () => { db = createDb({ pgliteDataDir: "memory://" }); await db.migrate(); - resetRateLimits(); // Real-mode services that must never be touched by a demo session. const untouchable = () => { throw new Error("real service used in demo mode"); diff --git a/lib/server/rateLimit.test.ts b/lib/server/rateLimit.test.ts new file mode 100644 index 0000000..f6d3f01 --- /dev/null +++ b/lib/server/rateLimit.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { createDb, type Db } from "../db/client"; +import { deleteStaleRateLimits, rateLimit } from "./rateLimit"; + +// Roadmap: K13.7 +let db: Db; +const NOW = Date.parse("2026-11-08T12:00:00Z"); +beforeEach(async () => { + db = createDb({ pgliteDataDir: "memory://" }); + await db.migrate(); +}); + +const status = (p: Promise) => p.then(() => 200, (e: { status?: number }) => e.status ?? 500); + +describe("rateLimit (shared, in Postgres)", () => { + it("allows up to the limit in a window, then refuses with 429", async () => { + for (let i = 0; i < 3; i++) await rateLimit(db, "k", 3, 60_000, NOW + i); + expect(await status(rateLimit(db, "k", 3, 60_000, NOW + 10))).toBe(429); + }); + + it("starts a fresh window once the old one is over", async () => { + for (let i = 0; i < 4; i++) await status(rateLimit(db, "k", 3, 60_000, NOW)); + expect(await status(rateLimit(db, "k", 3, 60_000, NOW + 60_000))).toBe(200); + }); + + it("keeps separate counters per key", async () => { + for (let i = 0; i < 3; i++) await rateLimit(db, "a", 3, 60_000, NOW); + expect(await status(rateLimit(db, "b", 3, 60_000, NOW))).toBe(200); + }); + + it("counts every request even when they arrive at once (one atomic upsert each)", async () => { + const results = await Promise.all(Array.from({ length: 8 }, () => status(rateLimit(db, "burst", 5, 60_000, NOW)))); + expect(results.filter((s) => s === 200)).toHaveLength(5); + expect(results.filter((s) => s === 429)).toHaveLength(3); + }); + + it("cleanup drops counters whose window is long over", async () => { + await rateLimit(db, "old", 3, 60_000, NOW - 2 * 86_400_000); + await rateLimit(db, "new", 3, 60_000, NOW); + expect(await deleteStaleRateLimits(db, new Date(NOW - 86_400_000))).toBe(1); + }); +}); diff --git a/lib/server/rateLimit.ts b/lib/server/rateLimit.ts index e699dab..efc9fb1 100644 --- a/lib/server/rateLimit.ts +++ b/lib/server/rateLimit.ts @@ -1,22 +1,35 @@ /** - * In-memory fixed-window rate limiter (single app instance). Roadmap: K4.4, K8.7 + * Fixed-window rate limiter kept in Postgres, so every serverless instance shares the same counters + * (K13.7; it used to live in each instance's memory). One atomic upsert per request: a new window + * starts at 1, otherwise the count goes up by one, and the request is refused once it passes `limit`. + * Roadmap: K4.4, K8.7, K13.7 */ +import { lt, sql } from "drizzle-orm"; +import type { Db } from "../db/client"; +import { rateLimits } from "../db/schema"; import { HttpError } from "./http"; -const windows = new Map(); - -export function rateLimit(key: string, limit: number, windowMs: number, now = Date.now()): void { - const w = windows.get(key); - if (!w || now - w.start >= windowMs) { - windows.set(key, { start: now, count: 1 }); - return; - } - w.count += 1; - if (w.count > limit) throw new HttpError(429, "Too many requests, try again shortly"); +export async function rateLimit(db: Db, key: string, limit: number, windowMs: number, now = Date.now()): Promise { + const at = new Date(now); + const windowOpenedBefore = new Date(now - windowMs); + const expired = sql`${rateLimits.windowStart} <= ${windowOpenedBefore.toISOString()}::timestamptz`; + const [row] = await db + .insert(rateLimits) + .values({ key, windowStart: at, count: 1 }) + .onConflictDoUpdate({ + target: rateLimits.key, + set: { + windowStart: sql`case when ${expired} then ${at.toISOString()}::timestamptz else ${rateLimits.windowStart} end`, + count: sql`case when ${expired} then 1 else ${rateLimits.count} + 1 end`, + }, + }) + .returning({ count: rateLimits.count }); + if ((row?.count ?? 0) > limit) throw new HttpError(429, "Too many requests, try again shortly"); } -/** For tests. */ -export function resetRateLimits(): void { - windows.clear(); +/** Drops counters whose window is long over (run daily with the demo cleanup). */ +export async function deleteStaleRateLimits(db: Db, olderThan: Date): Promise { + const rows = await db.delete(rateLimits).where(lt(rateLimits.windowStart, olderThan)).returning({ key: rateLimits.key }); + return rows.length; } diff --git a/lib/server/routes.test.ts b/lib/server/routes.test.ts index 81b0982..7d9841d 100644 --- a/lib/server/routes.test.ts +++ b/lib/server/routes.test.ts @@ -21,7 +21,6 @@ import { createDb, type Db } from "../db/client"; import { insertBuy } from "../db/queries/buys"; import { getUserByAccount } from "../db/queries/users"; import { setServicesForTest, type Services } from "./adapters"; -import { resetRateLimits } from "./rateLimit"; // Roadmap: K4.7 — real handlers, in-memory PGlite, fake 1Click/NEAR const BASE = "http://localhost:3000"; @@ -97,7 +96,6 @@ beforeEach(async () => { db = createDb({ pgliteDataDir: "memory://" }); await db.migrate(); setServicesForTest(services()); - resetRateLimits(); oneClickStatus = "PENDING_DEPOSIT"; vi.clearAllMocks(); }); diff --git a/worker/cleanupDemo.ts b/worker/cleanupDemo.ts index e8539a8..c939c29 100644 --- a/worker/cleanupDemo.ts +++ b/worker/cleanupDemo.ts @@ -1,15 +1,18 @@ /** * Hourly: delete demo users older than a day (with their rows), so a hosted demo stays small. - * Roadmap: K8.9 + * Also drops rate-limit counters older than a day (K13.7). + * Roadmap: K8.9, K13.7 */ import { deleteStaleDemoUsers } from "../lib/db/queries/users"; +import { deleteStaleRateLimits } from "../lib/server/rateLimit"; import type { WorkerDeps } from "./deps"; export const DEMO_TTL_MS = 24 * 60 * 60 * 1000; export async function cleanupDemo(deps: Pick): Promise { const n = await deleteStaleDemoUsers(deps.db, new Date(deps.now() - DEMO_TTL_MS)); + await deleteStaleRateLimits(deps.db, new Date(deps.now() - DEMO_TTL_MS)); if (n > 0) deps.log.info("removed stale demo users", { count: n }); return n; }