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 @@ -245,7 +245,7 @@ On-chain vault = truth for plan and balance. DB = history and metadata.
| Tampered quote | `verifyQuoteSignature()` |
| Admin rug | No admin path to funds (I5) |
| 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) |
| 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. CSP (K13.9): enforced `object-src 'none'; frame-ancestors 'none'`; full policy (`default-src 'self'`, scripts/styles self + inline, `connect-src 'self' https: wss:`, `base-uri`/`form-action 'self'`) as `Content-Security-Policy-Report-Only`, because wallets run in `srcdoc` sandbox frames that inherit it; enforce after a real-wallet sign-in shows no violations (K13.10) |
| CSRF on state-changing routes | Session cookie is `SameSite=Lax` + HTTP-only, so cross-site POSTs carry no session |
| 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 @@ -82,7 +82,7 @@ On-chain vault = truth for plan + balance.
`pollFunding` 5 s · `scheduleBuys` 60 s · `pollBuys` 10 s · `expireQuotes` 60 s · keeper gas alert 10 min · `cleanupDemo` 60 min (demo users > 24 h)
- Hosted (serverless): one pass of each loop per `GET /api/cron/tick` every 5 min (`worker/tick.ts`, GitHub workflow, off until `WORKER_ENABLED=true`)
- NEAR plans: the destination must be registered on `wrap.near` first (`/api/storage-check`, `wnearRegisterTx`)
- Log errors through `redact()`; security headers live in next.config.ts
- Log errors through `redact()`; security headers live in next.config.ts (CSP: two directives enforced, full policy report-only until a real-wallet test, K13.10)
- Every loop is `(deps) => Promise` (worker/deps.ts) — test one tick with fakes
- scheduleBuys order: due? → not in flight / not in 15-min backoff → amount = min(per-run, balance) → quote → cost → plan match → **insert row** → execute_buy → submit
- Every skip/refusal/failure writes `buys.note`; refusals and failures `log.alert`
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,13 @@ 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; demo starts, fast-forwards and resets are capped so a script can't fill the database; malformed ids and cookies get a 404 or 401 instead of a server error.
- **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; malformed ids and cookies get a 404 or 401 instead of a server error; a Content-Security-Policy is in place (partly enforced, partly report-only, see below).

- **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.
- **Content-Security-Policy:** `object-src 'none'` and `frame-ancestors 'none'` are enforced; the full policy runs in report-only mode, because wallets run in sandboxed frames that inherit it and wallet sign-in hasn't been tested with a real wallet yet (ROADMAP K13.10).
- 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`.

Expand Down
6 changes: 4 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 | 78% | in progress (pre-launch security and reliability review) |
| 13. Launch review fixes | Nov 8–13 | 80% | in progress (K13.3 ⛔ needs a decision; K13.10 needs a real wallet) |
| **Overall (MVP)** | | **~81%** | |

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 @@ -218,7 +218,8 @@ From the pre-launch security and reliability review (2026-11-08), ranked with au
- [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
- [x] **K13.8** [#134](https://github.com/MFrancis-dev/stack/issues/134) Bad ids and malformed cookies return 500 · 2026-11-08: `uuidParam` makes non-UUID funding ids a 404; `readCookie` treats a malformed cookie as signed out; 2 tests, both failed before the fix
- [ ] **K13.9** [#135](https://github.com/MFrancis-dev/stack/issues/135) Add a Content-Security-Policy
- [x] **K13.9** [#135](https://github.com/MFrancis-dev/stack/issues/135) Add a Content-Security-Policy · 2026-11-08: enforced `object-src 'none'; frame-ancestors 'none'`; full policy as report-only (wallets run in `srcdoc` sandbox frames that inherit it); production build checked on landing, demo, overview and Add funds: no violations; header test
- [ ] **K13.10** [#143](https://github.com/MFrancis-dev/stack/issues/143) 👤 Enforce the full CSP after a real-wallet sign-in (and one signed transaction) shows no report-only violations

---

Expand All @@ -234,6 +235,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.9 Content-Security-Policy (2 directives enforced, full policy report-only, no violations on our pages in a production build); new K13.10 to enforce it after a real-wallet test; README, ARCHITECTURE §7, ESSENTIALS updated · K13.9, K13.10
- 2026-11-08 · agent · K13.8 non-UUID ids → 404 and malformed cookies → signed out, instead of 500s; README, ARCHITECTURE §4.1 updated · K13.8
- 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
Expand Down
17 changes: 17 additions & 0 deletions lib/server/securityHeaders.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import nextConfig from "../../next.config";

// Roadmap: K8.7, K13.9
describe("security headers", () => {
it("enforces the CSP directives that can't break wallets, and reports the full policy", async () => {
const [rule] = await nextConfig.headers!();
const h = Object.fromEntries(rule!.headers.map((x) => [x.key, x.value]));
expect(h["Content-Security-Policy"]).toBe("object-src 'none'; frame-ancestors 'none'");
const reportOnly = h["Content-Security-Policy-Report-Only"]!;
for (const d of ["default-src 'self'", "object-src 'none'", "base-uri 'self'", "form-action 'self'", "frame-ancestors 'none'"]) {
expect(reportOnly).toContain(d);
}
expect(h["X-Frame-Options"]).toBe("DENY");
expect(h["X-Content-Type-Options"]).toBe("nosniff");
});
});
32 changes: 30 additions & 2 deletions next.config.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,43 @@
import type { NextConfig } from "next";
import path from "node:path";

/** Baseline security headers (K8.7). No CSP yet: the wallet connector loads wallet scripts in sandboxed iframes. */
const isProd = process.env.NODE_ENV === "production";

/**
* Content-Security-Policy (K13.9). Wallets run in `srcdoc` sandbox frames, which inherit this policy,
* and wallet sign-in can't be tested without a real wallet. So only directives that can't touch
* wallet code are enforced; the full policy is report-only (violations show in the browser console)
* until a real-wallet sign-in shows none, then it moves to the enforced header.
*/
const CSP_ENFORCED = ["object-src 'none'", "frame-ancestors 'none'"].join("; ");
const CSP_REPORT_ONLY = [
"default-src 'self'",
// Next.js inlines its bootstrap scripts; dev mode also needs eval for fast refresh.
`script-src 'self' 'unsafe-inline'${isProd ? "" : " 'unsafe-eval'"}`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"font-src 'self' data:",
"media-src 'self'",
// The wallet connector fetches its wallet list and talks to wallets and NEAR RPC over https/wss.
"connect-src 'self' https: wss:",
"frame-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
].join("; ");

/** Baseline security headers (K8.7, K13.9). */
const securityHeaders = [
{ key: "Content-Security-Policy", value: CSP_ENFORCED },
{ key: "Content-Security-Policy-Report-Only", value: CSP_REPORT_ONLY },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(), payment=()" },
// Wallet sign-in may use popups that need to talk back to this window.
{ key: "Cross-Origin-Opener-Policy", value: "same-origin-allow-popups" },
...(process.env.NODE_ENV === "production" ? [{ key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" }] : []),
...(isProd ? [{ key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" }] : []),
];

const nextConfig: NextConfig = {
Expand Down
Loading