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