Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
2 changes: 1 addition & 1 deletion ARCHITECTURE_ESSENTIALS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
12 changes: 10 additions & 2 deletions app/api/demo/[action]/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";

Expand Down Expand Up @@ -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 },
Expand All @@ -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) });
}
Expand Down
1 change: 1 addition & 0 deletions components/vault/DemoBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export function DemoBanner() {
setMessage("Back to the start.");
return done();
},
onError: (e) => setMessage(e.message),
});
return (
<aside className="mt-2 flex flex-wrap items-center justify-between gap-3 rounded-md bg-brass-soft/60 px-4 py-3" aria-label="Demo mode">
Expand Down
19 changes: 19 additions & 0 deletions lib/server/demo.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import * as demoRoute from "@/app/api/demo/[action]/route";
import { DEMO_LIMITS } from "./demoLimits";
import { rateLimits } from "../db/schema";
import * as fundQuoteRoute from "@/app/api/fund/quote/route";
import * as vaultRoute from "@/app/api/vault/route";
import { planTx, pauseTx, recoverTokenTx, resumeTx, withdrawTx } from "../client/ownerTx";
Expand Down Expand Up @@ -138,6 +140,23 @@ describe("demo routes", () => {
expect((await demoRoute.POST(req("/api/demo/nope", { cookie }), action("nope"))).status).toBe(404);
});

it("caps fast-forward per demo, with a message that says what to do (K13.6)", async () => {
const cookie = cookieOf(await demoRoute.POST(req("/api/demo/start"), action("start")));
for (let i = 0; i < DEMO_LIMITS.fastForwardPerDemo; i++) {
expect((await demoRoute.POST(req("/api/demo/fast-forward", { cookie }), action("fast-forward"))).status).toBe(200);
}
const over = await demoRoute.POST(req("/api/demo/fast-forward", { cookie }), action("fast-forward"));
expect(over.status).toBe(429);
expect((await over.json()).error).toMatch(/Start a new demo/);
});

it("caps new demos across every visitor once the hourly total is reached (K13.6)", async () => {
await db.insert(rateLimits).values({ key: "demo-start:all", windowStart: new Date(), count: DEMO_LIMITS.startAllHour });
const res = await demoRoute.POST(req("/api/demo/start"), action("start"));
expect(res.status).toBe(429);
expect((await res.json()).error).toMatch(/busy/);
});

it("rejects a session whose demo flag doesn't match the user", async () => {
const u = await seedDemoUser(db);
const forged = sessionCookie({ userId: u.id, accountId: u.nearAccountId, demo: false }).split(";")[0];
Expand Down
18 changes: 18 additions & 0 deletions lib/server/demoLimits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Caps for the demo routes (K13.6). Counted in Postgres by `rateLimit`, so they hold across every
* serverless instance. A demo user lives for a day (worker/cleanupDemo.ts).
* Roadmap: K13.6
*/

export const MINUTE = 60_000;
export const HOUR = 60 * MINUTE;
export const DAY = 24 * HOUR;

export const DEMO_LIMITS = {
startPerIpMinute: 10,
startPerIpHour: 30,
startAllHour: 600,
fastForwardPerDemo: 12,
resetPerDemo: 20,
ownerTxPerDemoHour: 60,
} as const;
11 changes: 9 additions & 2 deletions lib/server/rateLimit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,14 @@ import type { Db } from "../db/client";
import { rateLimits } from "../db/schema";
import { HttpError } from "./http";

export async function rateLimit(db: Db, key: string, limit: number, windowMs: number, now = Date.now()): Promise<void> {
export async function rateLimit(
db: Db,
key: string,
limit: number,
windowMs: number,
now = Date.now(),
message = "Too many requests, try again shortly",
): Promise<void> {
const at = new Date(now);
const windowOpenedBefore = new Date(now - windowMs);
const expired = sql`${rateLimits.windowStart} <= ${windowOpenedBefore.toISOString()}::timestamptz`;
Expand All @@ -25,7 +32,7 @@ export async function rateLimit(db: Db, key: string, limit: number, windowMs: nu
},
})
.returning({ count: rateLimits.count });
if ((row?.count ?? 0) > limit) throw new HttpError(429, "Too many requests, try again shortly");
if ((row?.count ?? 0) > limit) throw new HttpError(429, message);
}

/** Drops counters whose window is long over (run daily with the demo cleanup). */
Expand Down
2 changes: 1 addition & 1 deletion project_structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ lib/
├─ db/ # schema.ts, client.ts (PGlite/Postgres + migrate()), queries/ (users, fundingAttempts, buys), seed-demo.ts
├─ client/ # browser-only: api.ts (typed fetch + DTOs), hooks.ts (TanStack Query), ownerTx.ts (vault txs for the wallet), format.ts
└─ server/ # SERVER ONLY: env.ts, session.ts (NEP-413 challenge + HMAC cookies), adapters.ts (getServices seam), context.ts (current user/vault),
# http.ts (JSON, body parsing, error mapping), rateLimit.ts, redact.ts (scrub secrets from logs), routes.test.ts + demo.test.ts (API tests), testing/ (server-only stub for vitest)
# http.ts (JSON, body parsing, error mapping), rateLimit.ts (shared counters in Postgres), demoLimits.ts (demo caps), cronAuth.ts, redact.ts (scrub secrets from logs), routes.test.ts + demo.test.ts (API tests), testing/ (server-only stub for vitest)
```

## 6. `worker/`
Expand Down
Loading