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 @@ -147,7 +147,7 @@ The keeper chooses `deposit_address`. A stolen keeper key could redirect at most
| `GET /api/vault` | session | `get_state()` + `ft_balance_of` + next run |
| `GET /api/tokens` | public | Cached token list |
| `POST /api/fund/quote` | session, rate-limited | `{originAsset, amount, refundTo, dry}` → preview or FundingAttempt |
| `POST /api/fund/:id/submit-tx` · `GET /api/fund/:id/status` | session | |
| `POST /api/fund/:id/submit-tx` · `GET /api/fund/:id/status` | session | `:id` must be a UUID, else 404 (`uuidParam`, K13.8) |
| `POST /api/plan/preview` | session | Dry buy quote → expected cost % for a buy size |
| `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 |
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; demo starts, fast-forwards and resets are capped so a script can't fill the 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; 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.

- **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
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 | 67% | in progress (pre-launch security and reliability review) |
| **Overall (MVP)** | | **~80%** | |
| 13. Launch review fixes | Nov 8–13 | 78% | in progress (pre-launch security and reliability review) |
| **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 @@ -217,7 +217,7 @@ From the pre-launch security and reliability review (2026-11-08), ranked with au
- [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
- [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
- [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

---
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.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
- 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
Expand Down
4 changes: 2 additions & 2 deletions app/api/fund/[id]/status/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@
import { FUNDING_TERMINAL, fundingStatusFromOneClick, type FundingStatus } from "@/lib/core/status";
import { getFundingAttempt, updateFundingStatus } from "@/lib/db/queries/fundingAttempts";
import { requireUser } from "@/lib/server/context";
import { handle, HttpError, json } from "@/lib/server/http";
import { handle, HttpError, json, uuidParam } from "@/lib/server/http";

export const GET = handle(async (req: Request, ctx: { params: Promise<{ id: string }> }) => {
const { id } = await ctx.params;
const id = uuidParam((await ctx.params).id);
const { services, user } = await requireUser(req);
const attempt = await getFundingAttempt(services.db, id, user.id);
if (!attempt) throw new HttpError(404, "Not found");
Expand Down
4 changes: 2 additions & 2 deletions app/api/fund/[id]/submit-tx/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
import { z } from "zod";
import { getFundingAttempt, updateFundingStatus } from "@/lib/db/queries/fundingAttempts";
import { requireUser } from "@/lib/server/context";
import { handle, HttpError, json, parseBody } from "@/lib/server/http";
import { handle, HttpError, json, parseBody, uuidParam } from "@/lib/server/http";

const input = z.object({ txHash: z.string().trim().min(8).max(200) });

export const POST = handle(async (req: Request, ctx: { params: Promise<{ id: string }> }) => {
const { id } = await ctx.params;
const id = uuidParam((await ctx.params).id);
const { services, user } = await requireUser(req);
const { txHash } = await parseBody(req, input);
const attempt = await getFundingAttempt(services.db, id, user.id);
Expand Down
8 changes: 8 additions & 0 deletions lib/server/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ export async function parseBody<S extends z.ZodTypeAny>(req: Request, schema: S)
return r.data;
}

const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

/** A path id for a uuid column. Anything else is 404, not a database error (K13.8). */
export function uuidParam(id: string): string {
if (!UUID.test(id)) throw new HttpError(404, "Not found");
return id;
}

export function clientIp(req: Request): string {
return req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || req.headers.get("x-real-ip") || "local";
}
Expand Down
10 changes: 10 additions & 0 deletions lib/server/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,16 @@ describe("session (K4.1)", () => {
await expect(signIn()).rejects.toThrow();
});

it("treats a malformed session cookie as signed out, not a server error (K13.8)", async () => {
expect((await vaultRoute.GET(req("/api/vault", { cookie: "stack_session=%E0%A4%A" }))).status).toBe(401);
});

it("answers 404, not 500, for funding ids that aren't UUIDs (K13.8)", async () => {
const cookie = await signIn();
expect((await fundStatusRoute.GET(req("/api/fund/not-a-uuid/status", { cookie }), params("not-a-uuid"))).status).toBe(404);
expect((await submitTxRoute.POST(req("/api/fund/1;drop/submit-tx", { method: "POST", cookie, body: { txHash: "5xYzTxHash123" } }), params("1;drop"))).status).toBe(404);
});

it("requires a session for user routes", async () => {
expect((await vaultRoute.GET(req("/api/vault"))).status).toBe(401);
expect((await onboardRoute.POST(req("/api/onboard", { method: "POST", cookie: "stack_session=forged.token" }))).status).toBe(401);
Expand Down
7 changes: 6 additions & 1 deletion lib/server/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,12 @@ export function readCookie(req: Request, name: string): string | undefined {
const header = req.headers.get("cookie") ?? "";
for (const part of header.split(";")) {
const [k, ...v] = part.trim().split("=");
if (k === name) return decodeURIComponent(v.join("="));
if (k !== name) continue;
try {
return decodeURIComponent(v.join("="));
} catch {
return undefined; // a malformed cookie reads as signed out, not as a server error (K13.8)
}
}
return undefined;
}
Expand Down
Loading