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
3 changes: 2 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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) |

---

Expand Down
2 changes: 1 addition & 1 deletion ARCHITECTURE_ESSENTIALS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
7 changes: 4 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.)

Expand Down Expand Up @@ -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

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.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
Expand Down
2 changes: 1 addition & 1 deletion app/api/demo/[action]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
2 changes: 1 addition & 1 deletion app/api/fund/quote/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion app/api/onboard/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion app/api/plan/preview/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion app/api/storage-check/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>(requireNear(services), WNEAR_CONTRACT, "storage_balance_of", { account_id: account });
return json({ account, registered: balance !== null && balance !== undefined });
});
5 changes: 5 additions & 0 deletions drizzle/0004_rate_limits.sql
Original file line number Diff line number Diff line change
@@ -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
);
Loading
Loading