From 385dafaefd5859e437f9c7b6c68b4ed15b8e4558 Mon Sep 17 00:00:00 2001 From: Allison Muyideen Opeyemi Date: Wed, 4 Nov 2026 20:28:30 +0100 Subject: [PATCH] Phase 4: API routes with NEP-413 sessions - Sign-in: challenge nonce in a signed cookie, NEP-413 verification (full-access key), HMAC-signed HTTP-only session; page middleware - Routes: onboard (keeper creates the vault once), vault, tokens, funding quote (dry preview or stored attempt), submit-tx, status refreshed from 1Click on read, plan cost preview, buys, CSV export - getServices() seam so tests and demo mode inject their own adapters; clear 503 when real mode isn't configured; rate limits - 16 route tests on the real handlers (84 TS tests total) --- ARCHITECTURE.md | 7 +- ARCHITECTURE_ESSENTIALS.md | 6 + ROADMAP.md | 21 +- app/api/buys/route.ts | 33 ++- app/api/export.csv/route.ts | 75 ++++++- app/api/fund/[id]/status/route.ts | 43 +++- app/api/fund/[id]/submit-tx/route.ts | 27 ++- app/api/fund/quote/route.ts | 52 ++++- app/api/onboard/route.ts | 27 ++- app/api/plan/preview/route.ts | 65 +++++- app/api/session/route.ts | 48 +++- app/api/tokens/route.ts | 15 +- app/api/vault/route.ts | 27 ++- lib/core/adapters.ts | 3 +- lib/core/csv.test.ts | 15 ++ lib/core/csv.ts | 18 ++ lib/near/index.ts | 3 +- lib/server/adapters.ts | 67 +++++- lib/server/context.ts | 31 +++ lib/server/http.ts | 59 +++++ lib/server/rateLimit.ts | 25 ++- lib/server/routes.test.ts | 295 +++++++++++++++++++++++++ lib/server/session.ts | 113 +++++++++- lib/server/testing/server-only-stub.ts | 2 + middleware.ts | 17 +- project_structure.md | 5 +- vitest.config.ts | 7 +- 27 files changed, 993 insertions(+), 113 deletions(-) create mode 100644 lib/core/csv.test.ts create mode 100644 lib/core/csv.ts create mode 100644 lib/server/context.ts create mode 100644 lib/server/http.ts create mode 100644 lib/server/routes.test.ts create mode 100644 lib/server/testing/server-only-stub.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index af15fdd..24d664d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -140,7 +140,9 @@ The keeper chooses `deposit_address`. A stolen keeper key could redirect at most ### 4.1 API routes | Route | Auth | Purpose | |---|---|---| -| `POST /api/session` | NEP-413 | Sign in | +| `GET /api/session` | public | Challenge: 32-byte nonce + message + recipient (app host), in a signed 5-min cookie | +| `POST /api/session` | NEP-413 | Verify signature (full-access key, matching nonce/message/recipient) → upsert user → HMAC-signed HTTP-only session cookie (7 days) | +| `DELETE /api/session` | – | Sign out | | `POST /api/onboard` | session | Keeper `create_vault_for(owner)` (once) | | `GET /api/vault` | session | `get_state()` + `ft_balance_of` + next run | | `GET /api/tokens` | public | Cached token list | @@ -153,6 +155,9 @@ The keeper chooses `deposit_address`. A stolen keeper key could redirect at most Owner contract calls (`set_plan`, `resume`, `pause`, `withdraw`, `recover_token`) are wallet transactions built in the browser; no server involvement. +All routes go through `lib/server/adapters.ts#getServices()` (DB, 1Click, NEAR, token list). Real mode builds it from env; tests and demo mode inject their own. A missing real-mode dependency returns **503** with what to set. Errors: 400 validation, 401 session, 404 not yours, 409 no vault yet, 429 rate limit, 502 1Click. +Sessions: `SESSION_SECRET` (≥ 32 chars) is required in production; locally a per-process secret is used. + ### 4.2 1Click requests Funding (`EXACT_INPUT`): origin = user's asset, destination = USDC on NEAR, recipient = vault, refundTo = user's origin address, `recipientType: DESTINATION_CHAIN`, `refundType: ORIGIN_CHAIN`. diff --git a/ARCHITECTURE_ESSENTIALS.md b/ARCHITECTURE_ESSENTIALS.md index a64d95e..5a30f17 100644 --- a/ARCHITECTURE_ESSENTIALS.md +++ b/ARCHITECTURE_ESSENTIALS.md @@ -72,6 +72,12 @@ Rust near-sdk =5.30.0 + cargo-near 0.22 + near-workspaces =0.24.0 · Next.js 15. `users` (near_account_id, vault_account_id, is_demo) · `funding_attempts` (deposit_address uniq, quote_raw, signature, status) · `buys` (quote_ref uniq, cost_bps, deposit_address, status) On-chain vault = truth for plan + balance. +## API +- Every route uses `getServices()` (lib/server/adapters.ts); tests/demo inject fakes via `setServicesForTest` +- Sign-in: GET /api/session challenge → wallet `signMessage` → POST verifies (full-access key) → signed HTTP-only cookie +- Statuses: 400 · 401 · 404 (not yours) · 409 (no vault) · 429 · 502 (1Click) · 503 (real mode not configured) +- Funding status refreshes from 1Click on read; plan preview refunds to the user's own account before a vault exists + ## Worker `pollFunding` 5 s · `scheduleBuys` 60 s · `pollBuys` 10 s · `expireQuotes` 60 s · alert on low keeper gas diff --git a/ROADMAP.md b/ROADMAP.md index 59d626e..7b02531 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -18,7 +18,7 @@ | 1. Contracts | Nov 5–7 | 92% | in progress (K1.13 deploy is 👤) | | 2. Core logic (`lib/core`) | Nov 6–7 | 100% | ✅ done | | 3. Integrations (1Click, NEAR, DB) | Nov 7–8 | 80% | in progress (K3.5 blocked on K0.8) | -| 4. API routes | Nov 8–9 | 0% | not started | +| 4. API routes | Nov 8–9 | 100% | ✅ done | | 5. Worker (scheduler) | Nov 9–10 | 0% | not started | | 6. UI | Nov 9–11 | 0% | not started | | 7. Demo mode & dry-run | Nov 11 | 0% | not started | @@ -27,7 +27,7 @@ | 10. README & public repo | Nov 12–13 | 14% | in progress | | 11. Launch: video, post, submission | Nov 13–14 | 0% | not started | | 12. After submission | Nov 14–25 | 0% | not started | -| **Overall (MVP)** | | **~33%** | | +| **Overall (MVP)** | | **~40%** | | Phase % = done tasks ÷ non-dropped tasks in that phase. Overall = average of all 14 rows (D + phases 0–12). (Earlier entries over-estimated the overall %; recalculated 2026-11-04.) @@ -119,13 +119,13 @@ Format: `- [task id] what blocks it · who can unblock · since date` ## Phase 4 — API routes (Nov 8–9) -- [ ] **K4.1** [#43](https://github.com/MFrancis-dev/stack/issues/43) Session + `middleware.ts` -- [ ] **K4.2** [#44](https://github.com/MFrancis-dev/stack/issues/44) `POST /api/onboard` (keeper `create_vault_for`, once) -- [ ] **K4.3** [#45](https://github.com/MFrancis-dev/stack/issues/45) `GET /api/vault`, `GET /api/tokens` -- [ ] **K4.4** [#46](https://github.com/MFrancis-dev/stack/issues/46) Funding: quote (dry/real, rate limit), submit-tx, status -- [ ] **K4.5** [#47](https://github.com/MFrancis-dev/stack/issues/47) `POST /api/plan/preview` (cost % for a buy size) · 2026-11-04: dry quotes need an existing `refundTo` → use the owner's account before the vault exists -- [ ] **K4.6** [#48](https://github.com/MFrancis-dev/stack/issues/48) `GET /api/buys`, `GET /api/export.csv` -- [ ] **K4.7** [#49](https://github.com/MFrancis-dev/stack/issues/49) Route tests (happy + validation + auth) +- [x] **K4.1** [#43](https://github.com/MFrancis-dev/stack/issues/43) Session + `middleware.ts` · 2026-11-04: NEP-413 challenge (signed 5-min cookie) + verify + HMAC session cookie; middleware redirects signed-out page visits +- [x] **K4.2** [#44](https://github.com/MFrancis-dev/stack/issues/44) `POST /api/onboard` (keeper `create_vault_for`, once) · 2026-11-04: keeper `create_vault_for` once per user (findVault first), rate-limited; 503 with guidance when not configured +- [x] **K4.3** [#45](https://github.com/MFrancis-dev/stack/issues/45) `GET /api/vault`, `GET /api/tokens` · 2026-11-04: `GET /api/vault` (state + USDC balance), `GET /api/tokens` (public, cached) +- [x] **K4.4** [#46](https://github.com/MFrancis-dev/stack/issues/46) Funding: quote (dry/real, rate limit), submit-tx, status · 2026-11-04: dry preview (nothing stored) / real attempt with deposit address; submit-tx; status refreshed from 1Click on read incl. expiry; 20/min rate limit +- [x] **K4.5** [#47](https://github.com/MFrancis-dev/stack/issues/47) `POST /api/plan/preview` (cost % for a buy size) · 2026-11-04: dry quotes need an existing `refundTo` → use the owner's account before the vault exists · 2026-11-04: dry buy quote → cost bps + suggested minimum; example recipients; refund to the user's account before a vault exists +- [x] **K4.6** [#48](https://github.com/MFrancis-dev/stack/issues/48) `GET /api/buys`, `GET /api/export.csv` · 2026-11-04: `GET /api/buys`; CSV export (UTC, USD value per buy, formula-safe) +- [x] **K4.7** [#49](https://github.com/MFrancis-dev/stack/issues/49) Route tests (happy + validation + auth) · 2026-11-04: 16 route tests on real handlers (in-memory PGlite, fake 1Click/NEAR, real NEP-413 signature); 84 TS tests total ## Phase 5 — Worker (Nov 9–10) @@ -163,7 +163,7 @@ Format: `- [task id] what blocks it · who can unblock · since date` - [ ] **K8.4** [#72](https://github.com/MFrancis-dev/stack/issues/72) Balance below buy amount (remainder only if it passes the cost check) - [ ] **K8.5** [#73](https://github.com/MFrancis-dev/stack/issues/73) Funding underpaid from an exchange: warning + status - [ ] **K8.6** [#74](https://github.com/MFrancis-dev/stack/issues/74) 1Click down / no route → `no_quote` -- [ ] **K8.7** [#75](https://github.com/MFrancis-dev/stack/issues/75) Rate limits; logs without secrets +- [~] **K8.7** [#75](https://github.com/MFrancis-dev/stack/issues/75) Rate limits; logs without secrets · 2026-11-04: in-memory rate limits on quote, preview and onboard routes; log hygiene still to review - [ ] **K8.8** [#76](https://github.com/MFrancis-dev/stack/issues/76) Re-run all contract invariant tests against the deployed code hash ## Phase 9 — Deploy & real run (Nov 12) @@ -216,6 +216,7 @@ Format: `- [task id] what blocks it · who can unblock · since date` ## Update log One line per contribution, newest first: `YYYY-MM-DD · who/agent · what changed · tasks` +- 2026-11-04 · agent · Phase 4 API: NEP-413 sessions, onboard, vault, tokens, funding (dry/real/status/submit), plan preview, buys, CSV; services seam for tests/demo; 84 TS tests · K4.1–K4.7, K8.7 - 2026-11-04 · agent · Phase 3 integrations: 1Click layer (fail-closed signatures), NEAR bindings + NEP-413 sign-in, first DB migration + queries; 66 TS tests; new K3.5 (signature verification) blocked on K0.8 · K3.1–K3.5 - 2026-11-04 · agent · Phase 2 core logic: money, cost, planMatch, address (checksums), assets, schemas; 33 TS tests · K2.1–K2.6 - 2026-11-04 · agent · Rewrote `main` history (approved force-push) to drop tool attribution lines from commit messages; every commit's files unchanged · — diff --git a/app/api/buys/route.ts b/app/api/buys/route.ts index 96511e0..00c8b13 100644 --- a/app/api/buys/route.ts +++ b/app/api/buys/route.ts @@ -1,11 +1,30 @@ -import { NextResponse } from "next/server"; - /** - * GET /api/buys — buy history. + * GET /api/buys — the signed-in user's buys, including skipped ones with their reason. * Roadmap: K4.6 · Spec: ARCHITECTURE.md §4.1 - * Status: scaffold — not implemented yet. */ -export async function GET() { - return NextResponse.json({ error: "Not implemented (K4.6)" }, { status: 501 }); -} +import { listBuysByUser } from "@/lib/db/queries/buys"; +import { requireUser } from "@/lib/server/context"; +import { handle, json } from "@/lib/server/http"; + +export const GET = handle(async (req: Request) => { + const { services, user } = await requireUser(req); + const rows = await listBuysByUser(services.db, user.id); + return json({ + buys: rows.map((b) => ({ + id: b.id, + quoteRef: b.quoteRef, + status: b.status, + amountIn: b.amountIn, + targetAsset: b.targetAsset, + destinationAddress: b.destinationAddress, + amountOut: b.amountOut, + amountInUsd: b.amountInUsd, + costBps: b.costBps, + nearTxHash: b.nearTxHash, + destinationTxHash: b.destinationTxHash, + createdAt: b.createdAt.toISOString(), + completedAt: b.completedAt?.toISOString() ?? null, + })), + }); +}); diff --git a/app/api/export.csv/route.ts b/app/api/export.csv/route.ts index 1bff29a..dd162fc 100644 --- a/app/api/export.csv/route.ts +++ b/app/api/export.csv/route.ts @@ -1,11 +1,70 @@ -import { NextResponse } from "next/server"; - /** - * GET /api/export.csv — CSV with USD value per buy. - * Roadmap: K4.6 · Spec: ARCHITECTURE.md §4.1 - * Status: scaffold — not implemented yet. + * GET /api/export.csv — deposits and buys with the USD value of each buy (cost basis for tax, K-E20). + * Times in UTC. Roadmap: K4.6 · Spec: ARCHITECTURE.md §4.1 */ -export async function GET() { - return NextResponse.json({ error: "Not implemented (K4.6)" }, { status: 501 }); -} +import { targetByAssetId, USDC_DECIMALS } from "@/lib/core/assets"; +import { toCsv, type CsvValue } from "@/lib/core/csv"; +import { formatAmount } from "@/lib/core/money"; +import { listBuysByUser } from "@/lib/db/queries/buys"; +import { listFundingByUser } from "@/lib/db/queries/fundingAttempts"; +import { requireUser } from "@/lib/server/context"; +import { handle } from "@/lib/server/http"; + +const HEADER = [ + "date_utc", + "type", + "status", + "amount_in", + "asset_in", + "amount_out", + "asset_out", + "usd_value_in", + "cost_bps", + "deposit_address", + "near_tx", + "destination_tx", +] as const; + +export const GET = handle(async (req: Request) => { + const { services, user } = await requireUser(req); + const [funding, buys, tokens] = await Promise.all([ + listFundingByUser(services.db, user.id, 1_000), + listBuysByUser(services.db, user.id, 10_000), + services.listTokens().catch(() => []), + ]); + const decimalsOf = (assetId: string) => tokens.find((t) => t.assetId === assetId)?.decimals; + const fmt = (amount: string | null, decimals: number | undefined) => (amount && decimals !== undefined ? formatAmount(amount, decimals) : amount); + + const rows: { at: Date; row: CsvValue[] }[] = [ + ...funding.map((f) => ({ + at: f.createdAt, + row: [f.createdAt.toISOString(), "deposit", f.status, fmt(f.amountIn, decimalsOf(f.originAsset)), f.originAsset, fmt(f.expectedOut, USDC_DECIMALS), "USDC", null, null, f.depositAddress, f.depositTxHash, null], + })), + ...buys.map((b) => ({ + at: b.createdAt, + row: [ + b.createdAt.toISOString(), + "buy", + b.status, + fmt(b.amountIn, USDC_DECIMALS), + "USDC", + fmt(b.amountOut, targetByAssetId(b.targetAsset)?.decimals), + targetByAssetId(b.targetAsset)?.symbol ?? b.targetAsset, + b.amountInUsd, + b.costBps, + b.depositAddress, + b.nearTxHash, + b.destinationTxHash, + ], + })), + ].sort((a, b) => a.at.getTime() - b.at.getTime()); + + return new Response(toCsv(HEADER, rows.map((r) => r.row)), { + headers: { + "content-type": "text/csv; charset=utf-8", + "content-disposition": 'attachment; filename="stack-history.csv"', + "cache-control": "no-store", + }, + }); +}); diff --git a/app/api/fund/[id]/status/route.ts b/app/api/fund/[id]/status/route.ts index bdaf2d7..1eb7caa 100644 --- a/app/api/fund/[id]/status/route.ts +++ b/app/api/fund/[id]/status/route.ts @@ -1,11 +1,38 @@ -import { NextResponse } from "next/server"; - /** - * GET /api/fund/:id/status — funding status. - * Roadmap: K4.4 · Spec: ARCHITECTURE.md §4.1 - * Status: scaffold — not implemented yet. + * GET /api/fund/:id/status — current funding status. Non-final attempts are refreshed from 1Click + * on read, so the page stays live even without the worker. Roadmap: K4.4 · Spec: ARCHITECTURE.md §4.3 */ -export async function GET() { - return NextResponse.json({ error: "Not implemented (K4.4)" }, { status: 501 }); -} +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"; + +export const GET = handle(async (req: Request, ctx: { params: Promise<{ id: string }> }) => { + const { id } = await ctx.params; + const { services, user } = await requireUser(req); + const attempt = await getFundingAttempt(services.db, id, user.id); + if (!attempt) throw new HttpError(404, "Not found"); + + let status = attempt.status as FundingStatus; + let oneclickStatus = attempt.oneclickStatus; + if (!FUNDING_TERMINAL.has(status)) { + const r = await services.status.getStatus(attempt.depositAddress); + oneclickStatus = r.status; + status = fundingStatusFromOneClick(r.status); + if (r.status === "PENDING_DEPOSIT" && attempt.quoteDeadline.getTime() < Date.now()) status = "expired"; + if (status !== attempt.status || oneclickStatus !== attempt.oneclickStatus) { + await updateFundingStatus(services.db, attempt.id, { status, oneclickStatus }); + } + } + return json({ + id: attempt.id, + status, + oneclickStatus, + depositAddress: attempt.depositAddress, + amountIn: attempt.amountIn, + expectedOut: attempt.expectedOut, + deadline: attempt.quoteDeadline.toISOString(), + depositTxHash: attempt.depositTxHash, + }); +}); diff --git a/app/api/fund/[id]/submit-tx/route.ts b/app/api/fund/[id]/submit-tx/route.ts index 060d4c6..3cfd06c 100644 --- a/app/api/fund/[id]/submit-tx/route.ts +++ b/app/api/fund/[id]/submit-tx/route.ts @@ -1,11 +1,22 @@ -import { NextResponse } from "next/server"; - /** - * POST /api/fund/:id/submit-tx — forward tx hash to 1Click. - * Roadmap: K4.4 · Spec: ARCHITECTURE.md §4.1 - * Status: scaffold — not implemented yet. + * POST /api/fund/:id/submit-tx — the user tells us (and 1Click) their deposit tx hash. Optional speed-up. + * Roadmap: K4.4 · Spec: ARCHITECTURE.md §4.2 */ -export async function POST() { - return NextResponse.json({ error: "Not implemented (K4.4)" }, { status: 501 }); -} +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"; + +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 { services, user } = await requireUser(req); + const { txHash } = await parseBody(req, input); + const attempt = await getFundingAttempt(services.db, id, user.id); + if (!attempt) throw new HttpError(404, "Not found"); + await services.status.submitDepositTx(attempt.depositAddress, txHash); + await updateFundingStatus(services.db, attempt.id, { status: attempt.status, depositTxHash: txHash }); + return json({ ok: true }); +}); diff --git a/app/api/fund/quote/route.ts b/app/api/fund/quote/route.ts index c0a178b..cb77640 100644 --- a/app/api/fund/quote/route.ts +++ b/app/api/fund/quote/route.ts @@ -1,11 +1,47 @@ -import { NextResponse } from "next/server"; - /** - * POST /api/fund/quote — dry preview or real funding attempt. - * Roadmap: K4.4 · Spec: ARCHITECTURE.md §4.1 - * Status: scaffold — not implemented yet. + * POST /api/fund/quote — any token on any chain → USDC into the user's vault. + * dry: preview only (nothing stored, no deposit address). Real: stores a funding attempt with the + * verified deposit address. Roadmap: K4.4 · Spec: ARCHITECTURE.md §4.2 */ -export async function POST() { - return NextResponse.json({ error: "Not implemented (K4.4)" }, { status: 501 }); -} +import { fundQuoteInput } from "@/lib/core/schemas"; +import { asBaseUnits } from "@/lib/core/money"; +import { insertFundingAttempt } from "@/lib/db/queries/fundingAttempts"; +import { requireVaultOwner } from "@/lib/server/context"; +import { clientIp, handle, HttpError, json, parseBody } from "@/lib/server/http"; +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); + const body = await parseBody(req, fundQuoteInput); + + const quote = await services.quotes.fundingQuote( + { originAsset: body.originAsset, amountIn: asBaseUnits(body.amountIn), refundTo: body.refundTo, vault: vaultAccountId }, + { dry: body.dry }, + ); + const preview = { + amountIn: quote.amountIn, + amountOut: quote.amountOut, + minAmountOut: quote.minAmountOut, + amountInUsd: quote.amountInUsd, + amountOutUsd: quote.amountOutUsd, + timeEstimateS: quote.timeEstimateS, + deadline: quote.deadline, + }; + if (body.dry) return json({ dry: true, quote: preview }); + + if (!quote.depositAddress) throw new HttpError(502, "1Click returned no deposit address"); + const attempt = await insertFundingAttempt(services.db, { + userId: user.id, + originAsset: body.originAsset, + amountIn: quote.amountIn, + expectedOut: quote.amountOut, + refundTo: body.refundTo, + depositAddress: quote.depositAddress, + quoteDeadline: new Date(quote.deadline), + quoteSignature: quote.signature, + quoteRaw: quote.raw as object, + }); + return json({ dry: false, quote: preview, attempt: { id: attempt.id, depositAddress: attempt.depositAddress, status: attempt.status } }, { status: 201 }); +}); diff --git a/app/api/onboard/route.ts b/app/api/onboard/route.ts index ea659bc..a900263 100644 --- a/app/api/onboard/route.ts +++ b/app/api/onboard/route.ts @@ -1,11 +1,22 @@ -import { NextResponse } from "next/server"; - /** - * POST /api/onboard — keeper create_vault_for(owner), once. - * Roadmap: K4.2 · Spec: ARCHITECTURE.md §4.1 - * Status: scaffold — not implemented yet. + * POST /api/onboard — the keeper creates the signed-in user's vault (once). Free for the user. + * A new vault is paused with no plan (I7). Roadmap: K4.2 · Spec: ARCHITECTURE.md §6 */ -export async function POST() { - return NextResponse.json({ error: "Not implemented (K4.2)" }, { status: 501 }); -} +import { setVault } from "@/lib/db/queries/users"; +import { requireVaultClient } from "@/lib/server/adapters"; +import { requireUser } from "@/lib/server/context"; +import { handle, json } from "@/lib/server/http"; +import { rateLimit } from "@/lib/server/rateLimit"; + +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); + const vaults = requireVaultClient(services); + const existing = await vaults.findVault(user.nearAccountId); + const vaultAccountId = existing ?? (await vaults.createVaultFor(user.nearAccountId)).vaultAccountId; + await setVault(services.db, user.id, vaultAccountId); + return json({ vaultAccountId, created: !existing }, { status: existing ? 200 : 201 }); +}); diff --git a/app/api/plan/preview/route.ts b/app/api/plan/preview/route.ts index 88563b3..8798512 100644 --- a/app/api/plan/preview/route.ts +++ b/app/api/plan/preview/route.ts @@ -1,11 +1,62 @@ -import { NextResponse } from "next/server"; +/** + * POST /api/plan/preview — dry buy quote for a buy size → expected cost %, so users see that small + * BTC buys are mostly fees before they commit. Roadmap: K4.5 · Spec: ARCHITECTURE.md §4.2, §12 + */ + +import { TARGET_ASSETS, USDC_DECIMALS } from "@/lib/core/assets"; +import { costBps } from "@/lib/core/cost"; +import { asBaseUnits, formatAmount } from "@/lib/core/money"; +import { planPreviewInput } from "@/lib/core/schemas"; +import type { VaultState } from "@/lib/core/types"; +import { requireUser } from "@/lib/server/context"; +import { handle, json, parseBody } from "@/lib/server/http"; +import { rateLimit } from "@/lib/server/rateLimit"; /** - * POST /api/plan/preview — dry buy quote → expected cost % for a buy size. - * Roadmap: K4.5 · Spec: ARCHITECTURE.md §4.1 - * Status: scaffold — not implemented yet. + * Cost doesn't depend on who receives the coins, so previews quote to well-known example addresses + * (BIP-173 test vector, EIP-55 example). Dry quotes never move funds. */ +const PREVIEW_RECIPIENT = { + btc: "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", + eth: "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed", +} as const; + +export const POST = handle(async (req: Request) => { + const { services, user } = await requireUser(req); + rateLimit(`plan-preview:${user.id}`, 30, 60_000); + const body = await parseBody(req, planPreviewInput); + const target = TARGET_ASSETS[body.target]; + const amountIn = asBaseUnits(body.amountPerRun); + + const vault: VaultState = { + owner: user.nearAccountId, + keeper: "", + usdc: "", + targetAsset: target.assetId, + destinationAddress: body.target === "near" ? user.nearAccountId : PREVIEW_RECIPIENT[body.target], + amountPerRun: amountIn, + intervalS: 86_400, + maxCostBps: 500, + paused: false, + lastRunAtS: 0, + nextRunAtS: null, + }; + // refundTo must be an existing account (ARCHITECTURE §12): the vault if it exists, else the user's own. + const quote = await services.quotes.buyQuote( + { vault, vaultAccountId: user.vaultAccountId ?? user.nearAccountId, amountIn }, + { dry: true }, + ); -export async function POST() { - return NextResponse.json({ error: "Not implemented (K4.5)" }, { status: 501 }); -} + const perRunUsd = Number(formatAmount(amountIn, USDC_DECIMALS)); + return json({ + target: body.target, + amountIn, + amountOut: quote.amountOut, + amountOutFormatted: formatAmount(quote.amountOut, target.decimals, 8), + minAmountOut: quote.minAmountOut, + costBps: costBps(quote), + timeEstimateS: quote.timeEstimateS, + suggestedMinPerRunUsd: target.suggestedMinPerRunUsd, + belowSuggestedMin: perRunUsd < target.suggestedMinPerRunUsd, + }); +}); diff --git a/app/api/session/route.ts b/app/api/session/route.ts index 2a72a5e..1d1c0c9 100644 --- a/app/api/session/route.ts +++ b/app/api/session/route.ts @@ -1,11 +1,43 @@ -import { NextResponse } from "next/server"; - /** - * POST /api/session — sign in with a NEP-413 signed message. - * Roadmap: K4.1 · Spec: ARCHITECTURE.md §4.1 - * Status: scaffold — not implemented yet. + * NEP-413 sign-in. GET issues a challenge (nonce in a signed cookie), POST verifies the wallet's + * signature and sets the session cookie, DELETE signs out. + * Roadmap: K4.1 · Spec: ARCHITECTURE.md §4.1, §6 */ -export async function POST() { - return NextResponse.json({ error: "Not implemented (K4.1)" }, { status: 501 }); -} +import { sessionInput } from "@/lib/core/schemas"; +import { upsertUser } from "@/lib/db/queries/users"; +import { verifySignIn } from "@/lib/near/nep413"; +import { getServices, requireNear } from "@/lib/server/adapters"; +import { handle, HttpError, json, parseBody } from "@/lib/server/http"; +import { CHALLENGE_COOKIE, clearCookie, newChallenge, readChallenge, SESSION_COOKIE, sessionCookie } from "@/lib/server/session"; + +export const GET = handle(async () => { + const { challenge, setCookie } = newChallenge(); + return json( + { nonce: challenge.nonce, message: challenge.message, recipient: challenge.recipient }, + { headers: { "set-cookie": setCookie, "cache-control": "no-store" } }, + ); +}); + +export const POST = handle(async (req: Request) => { + const body = await parseBody(req, sessionInput); + const challenge = readChallenge(req); + if (!challenge) throw new HttpError(401, "Sign-in request expired, try again"); + if (body.nonce !== challenge.nonce || body.message !== challenge.message) throw new HttpError(401, "Sign-in request doesn't match"); + + const services = await getServices(); + try { + await verifySignIn(body, challenge.recipient, requireNear(services)); + } catch (e) { + if (e instanceof HttpError) throw e; + throw new HttpError(401, "Signature check failed"); + } + + const user = await upsertUser(services.db, body.accountId); + const headers = new Headers(); + headers.append("set-cookie", sessionCookie({ userId: user.id, accountId: user.nearAccountId, demo: false })); + headers.append("set-cookie", clearCookie(CHALLENGE_COOKIE)); + return json({ accountId: user.nearAccountId, vaultAccountId: user.vaultAccountId }, { headers }); +}); + +export const DELETE = handle(async () => json({ ok: true }, { headers: { "set-cookie": clearCookie(SESSION_COOKIE) } })); diff --git a/app/api/tokens/route.ts b/app/api/tokens/route.ts index ae80345..7af6dc6 100644 --- a/app/api/tokens/route.ts +++ b/app/api/tokens/route.ts @@ -1,11 +1,12 @@ -import { NextResponse } from "next/server"; - /** - * GET /api/tokens — cached 1Click token list. + * GET /api/tokens — cached 1Click token list (what users can fund from). * Roadmap: K4.3 · Spec: ARCHITECTURE.md §4.1 - * Status: scaffold — not implemented yet. */ -export async function GET() { - return NextResponse.json({ error: "Not implemented (K4.3)" }, { status: 501 }); -} +import { getServices } from "@/lib/server/adapters"; +import { handle, json } from "@/lib/server/http"; + +export const GET = handle(async () => { + const tokens = await (await getServices()).listTokens(); + return json({ tokens }, { headers: { "cache-control": "public, max-age=300" } }); +}); diff --git a/app/api/vault/route.ts b/app/api/vault/route.ts index 41ba8df..6cfcdcd 100644 --- a/app/api/vault/route.ts +++ b/app/api/vault/route.ts @@ -1,11 +1,24 @@ -import { NextResponse } from "next/server"; - /** - * GET /api/vault — get_state + USDC balance + next run. + * GET /api/vault — on-chain plan + USDC balance for the signed-in user's vault. * Roadmap: K4.3 · Spec: ARCHITECTURE.md §4.1 - * Status: scaffold — not implemented yet. */ -export async function GET() { - return NextResponse.json({ error: "Not implemented (K4.3)" }, { status: 501 }); -} +import { USDC_DECIMALS } from "@/lib/core/assets"; +import { formatAmount } from "@/lib/core/money"; +import { requireVaultClient } from "@/lib/server/adapters"; +import { requireUser } from "@/lib/server/context"; +import { handle, json } from "@/lib/server/http"; + +export const GET = handle(async (req: Request) => { + const { services, user } = await requireUser(req); + if (!user.vaultAccountId) return json({ accountId: user.nearAccountId, vaultAccountId: null }); + const vaults = requireVaultClient(services); + const [state, balance] = await Promise.all([vaults.getState(user.vaultAccountId), vaults.usdcBalance(user.vaultAccountId)]); + return json({ + accountId: user.nearAccountId, + vaultAccountId: user.vaultAccountId, + state, + usdcBalance: balance, + usdcBalanceFormatted: formatAmount(balance, USDC_DECIMALS), + }); +}); diff --git a/lib/core/adapters.ts b/lib/core/adapters.ts index cccee36..915af81 100644 --- a/lib/core/adapters.ts +++ b/lib/core/adapters.ts @@ -1,7 +1,6 @@ /** * Interfaces swapped between real and demo implementations. * Roadmap: K2.6 · Spec: ARCHITECTURE.md §8 - * Status: scaffold — not implemented yet. */ import type { OneClickStatus } from "./status"; @@ -21,6 +20,8 @@ export interface StatusProvider { } export interface VaultClient { + /** The owner's vault per the factory, if one exists. */ + findVault(owner: string): Promise; getState(vaultAccountId: string): Promise; usdcBalance(vaultAccountId: string): Promise; createVaultFor(owner: string): Promise<{ vaultAccountId: string; txHash: string }>; diff --git a/lib/core/csv.test.ts b/lib/core/csv.test.ts new file mode 100644 index 0000000..4e71a00 --- /dev/null +++ b/lib/core/csv.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { toCsv } from "./csv"; + +// Roadmap: K4.6 +describe("csv", () => { + it("quotes commas, quotes and newlines; leaves numbers alone", () => { + expect(toCsv(["a", "b"], [["x,y", 'say "hi"'], ["line\nbreak", -5], [null, undefined]])).toBe( + 'a,b\r\n"x,y","say ""hi"""\r\n"line\nbreak",-5\r\n,\r\n', + ); + }); + + it("neutralises spreadsheet formulas", () => { + expect(toCsv(["a"], [["=HYPERLINK(1)"], ["@cmd"], ["-12.5"]])).toBe("a\r\n'=HYPERLINK(1)\r\n'@cmd\r\n-12.5\r\n"); + }); +}); diff --git a/lib/core/csv.ts b/lib/core/csv.ts new file mode 100644 index 0000000..9ab560a --- /dev/null +++ b/lib/core/csv.ts @@ -0,0 +1,18 @@ +/** + * Minimal RFC 4180 CSV writer (quotes fields containing commas, quotes or newlines). + * Roadmap: K4.6 + */ + +export type CsvValue = string | number | null | undefined; + +function field(v: CsvValue): string { + if (v === null || v === undefined) return ""; + const s = String(v); + // Neutralise spreadsheet formulas from user-controlled text. + const safe = /^[=+\-@]/.test(s) && !/^-?\d/.test(s) ? `'${s}` : s; + return /[",\r\n]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe; +} + +export function toCsv(header: readonly string[], rows: readonly CsvValue[][]): string { + return [header, ...rows].map((r) => r.map(field).join(",")).join("\r\n") + "\r\n"; +} diff --git a/lib/near/index.ts b/lib/near/index.ts index f432d6c..ac8e0a1 100644 --- a/lib/near/index.ts +++ b/lib/near/index.ts @@ -4,7 +4,7 @@ */ import type { VaultClient } from "../core/adapters"; -import { createVaultFor } from "./factory"; +import { createVaultFor, getVault } from "./factory"; import type { NearProvider } from "./rpc"; import { executeBuy, getUsdcBalance, getVaultState, type KeeperAccount } from "./vault"; @@ -15,6 +15,7 @@ export function nearVaultClient(deps: { usdcContractId: string; }): VaultClient { return { + findVault: (owner) => getVault(deps.provider, deps.factoryId, owner), getState: (vaultAccountId) => getVaultState(deps.provider, vaultAccountId), usdcBalance: (vaultAccountId) => getUsdcBalance(deps.provider, deps.usdcContractId, vaultAccountId), async createVaultFor(owner) { diff --git a/lib/server/adapters.ts b/lib/server/adapters.ts index 341e13b..663b0ff 100644 --- a/lib/server/adapters.ts +++ b/lib/server/adapters.ts @@ -1,9 +1,68 @@ import "server-only"; /** - * Chooses real vs demo adapters by session type. - * Roadmap: K7.1 - * Status: scaffold — not implemented yet. + * The one seam between routes and the outside world. Real mode wires 1Click, NEAR and the DB from + * env; tests (and demo mode, K7.1) swap in their own implementations with `setServicesForTest`. + * Roadmap: K4.x, K7.1 */ -export {}; +import type { QuoteProvider, StatusProvider, VaultClient } from "../core/adapters"; +import { getDb, type Db } from "../db/client"; +import { nearVaultClient } from "../near"; +import { createKeeper } from "../near/keeper"; +import { createProvider, type NearProvider } from "../near/rpc"; +import { configureOneClick, getTokens, oneClickQuotes, oneClickStatus } from "../oneclick"; +import type { TokenInfo } from "../oneclick/tokens"; +import { NotConfiguredError } from "./http"; + +export interface Services { + db: Db; + quotes: QuoteProvider; + status: StatusProvider; + listTokens: () => Promise; + /** null until NEAR_RPC_URL is set */ + near: NearProvider | null; + /** null until the keeper, factory and USDC ids are set */ + vault: VaultClient | null; +} + +let override: Services | null = null; +let real: Promise | null = null; + +export function setServicesForTest(s: Services | null): void { + override = s; +} + +async function buildReal(): Promise { + const e = process.env; + configureOneClick({ baseUrl: e.ONECLICK_BASE_URL || undefined, jwt: e.ONECLICK_JWT }); + const db = getDb(); + await db.migrate(); + const near = e.NEAR_RPC_URL ? createProvider(e.NEAR_RPC_URL) : null; + const vault = + near && e.KEEPER_ACCOUNT_ID && e.KEEPER_PRIVATE_KEY && e.FACTORY_ACCOUNT_ID && e.USDC_CONTRACT_ID + ? nearVaultClient({ + provider: near, + keeper: createKeeper({ accountId: e.KEEPER_ACCOUNT_ID, privateKey: e.KEEPER_PRIVATE_KEY, rpcUrl: e.NEAR_RPC_URL! }), + factoryId: e.FACTORY_ACCOUNT_ID, + usdcContractId: e.USDC_CONTRACT_ID, + }) + : null; + return { db, quotes: oneClickQuotes, status: oneClickStatus, listTokens: () => getTokens(), near, vault }; +} + +export async function getServices(): Promise { + if (override) return override; + real ??= buildReal(); + return real; +} + +export function requireVaultClient(s: Services): VaultClient { + if (!s.vault) throw new NotConfiguredError("NEAR keeper/factory (NEAR_RPC_URL, KEEPER_*, FACTORY_ACCOUNT_ID, USDC_CONTRACT_ID)"); + return s.vault; +} + +export function requireNear(s: Services): NearProvider { + if (!s.near) throw new NotConfiguredError("NEAR_RPC_URL"); + return s.near; +} diff --git a/lib/server/context.ts b/lib/server/context.ts new file mode 100644 index 0000000..c2652de --- /dev/null +++ b/lib/server/context.ts @@ -0,0 +1,31 @@ +import "server-only"; + +/** + * Per-request context: the signed-in user (and their vault). Roadmap: K4.x + */ + +import { getUserById } from "../db/queries/users"; +import type { User } from "../db/schema"; +import { getServices, type Services } from "./adapters"; +import { HttpError } from "./http"; +import { requireSession, type Session } from "./session"; + +export interface UserContext { + session: Session; + services: Services; + user: User; +} + +export async function requireUser(req: Request): Promise { + const session = requireSession(req); + const services = await getServices(); + const user = await getUserById(services.db, session.userId); + if (!user) throw new HttpError(401, "Sign in again"); + return { session, services, user }; +} + +export async function requireVaultOwner(req: Request): Promise { + const ctx = await requireUser(req); + if (!ctx.user.vaultAccountId) throw new HttpError(409, "Create your vault first"); + return { ...ctx, vaultAccountId: ctx.user.vaultAccountId }; +} diff --git a/lib/server/http.ts b/lib/server/http.ts new file mode 100644 index 0000000..78e34a1 --- /dev/null +++ b/lib/server/http.ts @@ -0,0 +1,59 @@ +/** + * Small HTTP helpers shared by route handlers: JSON responses, body parsing, error mapping. + * Roadmap: K4.x + */ + +import type { z } from "zod"; +import { OneClickError } from "../oneclick/client"; + +export class HttpError extends Error { + constructor( + readonly status: number, + message: string, + readonly details?: unknown, + ) { + super(message); + this.name = "HttpError"; + } +} + +/** Real-mode dependency missing (no keeper, RPC, factory…). Demo mode never hits this. */ +export class NotConfiguredError extends HttpError { + constructor(what: string) { + super(503, `Not configured: ${what}. Run \`pnpm check-env\`, or use demo mode.`); + } +} + +export function json(data: unknown, init: ResponseInit = {}): Response { + return Response.json(data, init); +} + +export async function parseBody(req: Request, schema: S): Promise> { + let body: unknown; + try { + body = await req.json(); + } catch { + throw new HttpError(400, "Body must be JSON"); + } + const r = schema.safeParse(body); + if (!r.success) throw new HttpError(400, "Invalid request", r.error.flatten()); + return r.data; +} + +export function clientIp(req: Request): string { + return req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || req.headers.get("x-real-ip") || "local"; +} + +/** Wraps a handler so every error becomes a JSON response with the right status. */ +export function handle(fn: (...args: A) => Promise): (...args: A) => Promise { + return async (...args: A) => { + try { + return await fn(...args); + } catch (e) { + if (e instanceof HttpError) return json({ error: e.message, details: e.details }, { status: e.status }); + if (e instanceof OneClickError) return json({ error: e.message, correlationId: e.correlationId }, { status: 502 }); + console.error("unhandled route error", e instanceof Error ? e.message : e); + return json({ error: "Something went wrong" }, { status: 500 }); + } + }; +} diff --git a/lib/server/rateLimit.ts b/lib/server/rateLimit.ts index 52dcc4a..e699dab 100644 --- a/lib/server/rateLimit.ts +++ b/lib/server/rateLimit.ts @@ -1,9 +1,22 @@ -import "server-only"; - /** - * Rate limits for quote routes. - * Roadmap: K8.7 - * Status: scaffold — not implemented yet. + * In-memory fixed-window rate limiter (single app instance). Roadmap: K4.4, K8.7 */ -export {}; +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"); +} + +/** For tests. */ +export function resetRateLimits(): void { + windows.clear(); +} diff --git a/lib/server/routes.test.ts b/lib/server/routes.test.ts new file mode 100644 index 0000000..e560125 --- /dev/null +++ b/lib/server/routes.test.ts @@ -0,0 +1,295 @@ +import { Account, KeyPair, KeyPairSigner } from "near-api-js"; +import { signMessage } from "near-api-js/nep413"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; +import * as buysRoute from "@/app/api/buys/route"; +import * as csvRoute from "@/app/api/export.csv/route"; +import * as fundQuoteRoute from "@/app/api/fund/quote/route"; +import * as fundStatusRoute from "@/app/api/fund/[id]/status/route"; +import * as submitTxRoute from "@/app/api/fund/[id]/submit-tx/route"; +import * as onboardRoute from "@/app/api/onboard/route"; +import * as planPreviewRoute from "@/app/api/plan/preview/route"; +import * as sessionRoute from "@/app/api/session/route"; +import * as tokensRoute from "@/app/api/tokens/route"; +import * as vaultRoute from "@/app/api/vault/route"; +import { USDC_ON_NEAR } from "../core/assets"; +import { asBaseUnits } from "../core/money"; +import type { OneClickStatus } from "../core/status"; +import type { QuoteSummary, VaultState } from "../core/types"; +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"; +const VAULT = "vabc0123456789def.factory.stack.near"; + +let db: Db; +let depositCounter = 0; +let oneClickStatus: OneClickStatus = "PENDING_DEPOSIT"; + +const quote = (over: Partial): QuoteSummary => ({ + dry: true, + originAsset: USDC_ON_NEAR, + refundTo: VAULT, + depositAddress: null, + amountIn: asBaseUnits("10000000"), + amountOut: asBaseUnits("9900000"), + minAmountOut: asBaseUnits("9800000"), + timeEstimateS: 30, + amountInUsd: "10.00", + amountOutUsd: "9.90", + recipient: VAULT, + destinationAsset: USDC_ON_NEAR, + deadline: new Date(Date.now() + 30 * 60_000).toISOString(), + signature: "ed25519:sig", + raw: { fake: true }, + ...over, +}); + +const vaultState: VaultState = { + owner: "alice.near", + keeper: "keeper.near", + usdc: "usdc.near", + targetAsset: null, + destinationAddress: null, + amountPerRun: asBaseUnits("0"), + intervalS: 0, + maxCostBps: 0, + paused: true, + lastRunAtS: 0, + nextRunAtS: null, +}; + +const fakes = { + fundingQuote: vi.fn(async (input: { refundTo: string; vault: string }, { dry }: { dry: boolean }) => + quote({ dry, refundTo: input.refundTo, recipient: input.vault, depositAddress: dry ? null : `dep-${++depositCounter}` }), + ), + buyQuote: vi.fn(async (input: { vault: VaultState; vaultAccountId: string; amountIn: string }) => + quote({ amountIn: asBaseUnits(input.amountIn), refundTo: input.vaultAccountId, recipient: input.vault.destinationAddress!, destinationAsset: input.vault.targetAsset!, amountInUsd: "10.00", amountOutUsd: "8.46" }), + ), + submitDepositTx: vi.fn(async () => {}), + createVaultFor: vi.fn(async () => ({ vaultAccountId: VAULT, txHash: "TX" })), +}; + +function services(over: Partial = {}): Services { + return { + db, + quotes: { fundingQuote: fakes.fundingQuote, buyQuote: fakes.buyQuote } as Services["quotes"], + status: { getStatus: async () => ({ status: oneClickStatus, raw: {} }), submitDepositTx: fakes.submitDepositTx }, + listTokens: async () => [{ assetId: "nep141:sol.omft.near", symbol: "SOL", blockchain: "sol", decimals: 9, priceUsd: 95 }], + near: { viewAccessKey: vi.fn(async () => ({ permission: "FullAccess" })), callFunction: vi.fn() } as never, + vault: { + findVault: async () => null, + createVaultFor: fakes.createVaultFor, + getState: async () => vaultState, + usdcBalance: async () => asBaseUnits("500000000"), + executeBuy: vi.fn(), + }, + ...over, + }; +} + +beforeEach(async () => { + db = createDb({ pgliteDataDir: "memory://" }); + await db.migrate(); + setServicesForTest(services()); + resetRateLimits(); + oneClickStatus = "PENDING_DEPOSIT"; + vi.clearAllMocks(); +}); +afterAll(() => setServicesForTest(null)); + +const cookiesOf = (res: Response) => res.headers.getSetCookie().map((c) => c.split(";")[0]!).filter((c) => !c.endsWith("=")); +const req = (path: string, init: { method?: string; cookie?: string; body?: unknown } = {}) => + new Request(`${BASE}${path}`, { + method: init.method ?? "GET", + headers: { "content-type": "application/json", ...(init.cookie ? { cookie: init.cookie } : {}) }, + body: init.body === undefined ? undefined : JSON.stringify(init.body), + }); +const params = (id: string) => ({ params: Promise.resolve({ id }) }); + +async function signIn(accountId = "alice.near"): Promise { + const challengeRes = await sessionRoute.GET(); + const challenge = (await challengeRes.json()) as { nonce: string; message: string; recipient: string }; + const challengeCookie = cookiesOf(challengeRes).join("; "); + const key = KeyPair.fromRandom("ed25519"); + const account = new Account(accountId, "http://127.0.0.1:1", new KeyPairSigner(key)); + const signed = await signMessage({ + signerAccount: account, + payload: { message: challenge.message, recipient: challenge.recipient, nonce: Uint8Array.from(Buffer.from(challenge.nonce, "base64")) }, + }); + const res = await sessionRoute.POST( + req("/api/session", { + method: "POST", + cookie: challengeCookie, + body: { accountId, publicKey: key.getPublicKey().toString(), signature: Buffer.from(signed.signature).toString("base64"), message: challenge.message, nonce: challenge.nonce }, + }), + ); + expect(res.status).toBe(200); + return cookiesOf(res).join("; "); +} + +describe("session (K4.1)", () => { + it("signs in with a valid NEP-413 signature and creates the user", async () => { + const cookie = await signIn(); + expect(cookie).toContain("stack_session="); + expect(await getUserByAccount(db, "alice.near")).toBeDefined(); + }); + + it("rejects a missing challenge, a swapped nonce and a non-full-access key", async () => { + const body = { accountId: "alice.near", publicKey: "ed25519:x", signature: "AA==", message: "Sign in to Stack", nonce: "AA==" }; + expect((await sessionRoute.POST(req("/api/session", { method: "POST", body }))).status).toBe(401); + + const ch = await sessionRoute.GET(); + const cookie = cookiesOf(ch).join("; "); + expect((await sessionRoute.POST(req("/api/session", { method: "POST", cookie, body }))).status).toBe(401); + + setServicesForTest(services({ near: { viewAccessKey: async () => ({ permission: { FunctionCall: {} } }), callFunction: vi.fn() } as never })); + await expect(signIn()).rejects.toThrow(); + }); + + 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); + }); + + it("signs out", async () => { + const res = await sessionRoute.DELETE(); + expect(res.headers.get("set-cookie")).toContain("Max-Age=0"); + }); +}); + +describe("onboard + vault (K4.2, K4.3)", () => { + it("creates the vault once and reports state + balance", async () => { + const cookie = await signIn(); + expect(await (await vaultRoute.GET(req("/api/vault", { cookie }))).json()).toMatchObject({ vaultAccountId: null }); + + const first = await onboardRoute.POST(req("/api/onboard", { method: "POST", cookie })); + expect(first.status).toBe(201); + const second = await onboardRoute.POST(req("/api/onboard", { method: "POST", cookie })); + expect(await second.json()).toMatchObject({ vaultAccountId: VAULT, created: false }); + expect(fakes.createVaultFor).toHaveBeenCalledTimes(1); + + const v = await (await vaultRoute.GET(req("/api/vault", { cookie }))).json(); + expect(v).toMatchObject({ vaultAccountId: VAULT, usdcBalanceFormatted: "500", state: { paused: true } }); + }); + + it("returns 503 with guidance when the keeper isn't configured", async () => { + setServicesForTest(services({ vault: null })); + const cookie = await signIn(); + const res = await onboardRoute.POST(req("/api/onboard", { method: "POST", cookie })); + expect(res.status).toBe(503); + expect((await res.json()).error).toMatch(/check-env/); + }); + + it("serves the token list publicly", async () => { + expect((await (await tokensRoute.GET()).json()).tokens[0].symbol).toBe("SOL"); + }); +}); + +describe("funding (K4.4)", () => { + async function onboarded() { + const cookie = await signIn(); + await onboardRoute.POST(req("/api/onboard", { method: "POST", cookie })); + return cookie; + } + const body = (dry: boolean) => ({ originAsset: "nep141:sol.omft.near", amountIn: "1000000000", refundTo: "So1Refund", dry }); + + it("needs a vault first", async () => { + const cookie = await signIn(); + expect((await fundQuoteRoute.POST(req("/api/fund/quote", { method: "POST", cookie, body: body(true) }))).status).toBe(409); + }); + + it("previews without storing, then stores a real attempt with its deposit address", async () => { + const cookie = await onboarded(); + const dry = await (await fundQuoteRoute.POST(req("/api/fund/quote", { method: "POST", cookie, body: body(true) }))).json(); + expect(dry).toMatchObject({ dry: true }); + expect(dry.attempt).toBeUndefined(); + + const res = await fundQuoteRoute.POST(req("/api/fund/quote", { method: "POST", cookie, body: body(false) })); + expect(res.status).toBe(201); + const real = await res.json(); + expect(real.attempt.depositAddress).toMatch(/^dep-/); + expect(fakes.fundingQuote).toHaveBeenLastCalledWith(expect.objectContaining({ vault: VAULT, refundTo: "So1Refund" }), { dry: false }); + }); + + it("validates the body and rate-limits", async () => { + const cookie = await onboarded(); + expect((await fundQuoteRoute.POST(req("/api/fund/quote", { method: "POST", cookie, body: { ...body(true), amountIn: "1.5" } }))).status).toBe(400); + let last = 0; + for (let i = 0; i < 21; i++) last = (await fundQuoteRoute.POST(req("/api/fund/quote", { method: "POST", cookie, body: body(true) }))).status; + expect(last).toBe(429); + }); + + it("refreshes status from 1Click, expires stale quotes, and scopes to the owner", async () => { + const cookie = await onboarded(); + const { attempt } = await (await fundQuoteRoute.POST(req("/api/fund/quote", { method: "POST", cookie, body: body(false) }))).json(); + + oneClickStatus = "SUCCESS"; + expect(await (await fundStatusRoute.GET(req(`/api/fund/${attempt.id}/status`, { cookie }), params(attempt.id))).json()).toMatchObject({ status: "credited" }); + + const other = await signIn("bob.near"); + expect((await fundStatusRoute.GET(req(`/api/fund/${attempt.id}/status`, { cookie: other }), params(attempt.id))).status).toBe(404); + }); + + it("marks a pending attempt past its deadline as expired", async () => { + const cookie = await onboarded(); + fakes.fundingQuote.mockImplementationOnce(async (input, { dry }) => + quote({ dry, refundTo: input.refundTo, recipient: input.vault, depositAddress: "dep-old", deadline: new Date(Date.now() - 60_000).toISOString() }), + ); + const { attempt } = await (await fundQuoteRoute.POST(req("/api/fund/quote", { method: "POST", cookie, body: body(false) }))).json(); + oneClickStatus = "PENDING_DEPOSIT"; + expect(await (await fundStatusRoute.GET(req(`/api/fund/${attempt.id}/status`, { cookie }), params(attempt.id))).json()).toMatchObject({ status: "expired" }); + }); + + it("forwards the deposit tx hash", async () => { + const cookie = await onboarded(); + const { attempt } = await (await fundQuoteRoute.POST(req("/api/fund/quote", { method: "POST", cookie, body: body(false) }))).json(); + const res = await submitTxRoute.POST(req(`/api/fund/${attempt.id}/submit-tx`, { method: "POST", cookie, body: { txHash: "5xYzTxHash123" } }), params(attempt.id)); + expect(res.status).toBe(200); + expect(fakes.submitDepositTx).toHaveBeenCalledWith(attempt.depositAddress, "5xYzTxHash123"); + }); +}); + +describe("plan preview (K4.5)", () => { + it("shows that a $10 BTC buy is mostly fees, refunding to the user's own account before a vault exists", async () => { + const cookie = await signIn(); + const res = await planPreviewRoute.POST(req("/api/plan/preview", { method: "POST", cookie, body: { target: "btc", amountPerRun: "10000000" } })); + const p = await res.json(); + expect(p).toMatchObject({ target: "btc", costBps: 1540, belowSuggestedMin: true, suggestedMinPerRunUsd: 100 }); + expect(fakes.buyQuote).toHaveBeenLastCalledWith(expect.objectContaining({ vaultAccountId: "alice.near" }), { dry: true }); + }); + + it("rejects unknown targets", async () => { + const cookie = await signIn(); + expect((await planPreviewRoute.POST(req("/api/plan/preview", { method: "POST", cookie, body: { target: "doge", amountPerRun: "1" } }))).status).toBe(400); + }); +}); + +describe("history (K4.6)", () => { + it("lists buys and exports a CSV with USD values", async () => { + const cookie = await signIn(); + const user = (await getUserByAccount(db, "alice.near"))!; + await insertBuy(db, { + userId: user.id, + quoteRef: "q1", + amountIn: asBaseUnits("100000000"), + targetAsset: "nep141:btc.omft.near", + destinationAddress: "bc1q", + amountOut: asBaseUnits("121000"), + amountInUsd: "99.96", + costBps: 161, + status: "success", + }); + const list = await (await buysRoute.GET(req("/api/buys", { cookie }))).json(); + expect(list.buys[0]).toMatchObject({ quoteRef: "q1", costBps: 161 }); + + const csv = await csvRoute.GET(req("/api/export.csv", { cookie })); + expect(csv.headers.get("content-type")).toContain("text/csv"); + const text = await csv.text(); + expect(text.split("\r\n")[0]).toBe("date_utc,type,status,amount_in,asset_in,amount_out,asset_out,usd_value_in,cost_bps,deposit_address,near_tx,destination_tx"); + expect(text).toContain(",buy,success,100,USDC,0.00121,BTC,99.96,161,"); + }); +}); diff --git a/lib/server/session.ts b/lib/server/session.ts index 76f348a..37df5f6 100644 --- a/lib/server/session.ts +++ b/lib/server/session.ts @@ -1,9 +1,114 @@ import "server-only"; /** - * Session cookie issue/verify after NEP-413 sign-in; demo sessions. - * Roadmap: K4.1 - * Status: scaffold — not implemented yet. + * Sessions after NEP-413 sign-in. Tokens are `base64url(json).base64url(hmac-sha256)` in HTTP-only + * cookies. A short-lived challenge cookie carries the server-issued 32-byte nonce. + * Roadmap: K4.1 · Spec: ARCHITECTURE.md §6 */ -export {}; +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; +import { HttpError } from "./http"; + +export const SESSION_COOKIE = "stack_session"; +export const CHALLENGE_COOKIE = "stack_challenge"; +const SESSION_TTL_S = 7 * 24 * 3600; +const CHALLENGE_TTL_S = 5 * 60; +export const SIGN_IN_MESSAGE = "Sign in to Stack"; + +export interface Session { + userId: string; + accountId: string; + demo: boolean; + exp: number; +} + +export interface Challenge { + nonce: string; + message: string; + recipient: string; + exp: number; +} + +let devSecret: Buffer | undefined; + +function secret(): Buffer { + const s = process.env.SESSION_SECRET; + if (s && s.length >= 32) return Buffer.from(s); + if (process.env.NODE_ENV === "production") throw new HttpError(500, "SESSION_SECRET is not set"); + // Local and demo mode: a per-process secret, so sessions reset on restart. Fine without real funds. + devSecret ??= randomBytes(32); + return devSecret; +} + +const b64url = (b: Buffer | string) => Buffer.from(b).toString("base64url"); + +export function signToken(payload: object): string { + const body = b64url(JSON.stringify(payload)); + return `${body}.${b64url(createHmac("sha256", secret()).update(body).digest())}`; +} + +export function verifyToken(token: string | undefined, now = Date.now()): T | null { + if (!token) return null; + const [body, mac] = token.split("."); + if (!body || !mac) return null; + const expected = createHmac("sha256", secret()).update(body).digest(); + const given = Buffer.from(mac, "base64url"); + if (given.length !== expected.length || !timingSafeEqual(given, expected)) return null; + try { + const data = JSON.parse(Buffer.from(body, "base64url").toString()) as T; + return data.exp * 1000 > now ? data : null; + } catch { + return null; + } +} + +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("=")); + } + return undefined; +} + +function cookie(name: string, value: string, maxAgeS: number): string { + const secure = process.env.NODE_ENV === "production" ? "; Secure" : ""; + return `${name}=${encodeURIComponent(value)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeS}${secure}`; +} + +export function clearCookie(name: string): string { + return cookie(name, "", 0); +} + +export function getSession(req: Request): Session | null { + return verifyToken(readCookie(req, SESSION_COOKIE)); +} + +export function requireSession(req: Request): Session { + const s = getSession(req); + if (!s) throw new HttpError(401, "Sign in first"); + return s; +} + +export function sessionCookie(data: Omit, now = Date.now()): string { + return cookie(SESSION_COOKIE, signToken({ ...data, exp: Math.floor(now / 1000) + SESSION_TTL_S }), SESSION_TTL_S); +} + +/** The NEP-413 `recipient`: this app's host, so a signature for another site can't be replayed here. */ +export function appRecipient(): string { + return new URL(process.env.PUBLIC_APP_URL || "http://localhost:3000").host; +} + +export function newChallenge(now = Date.now()): { challenge: Challenge; setCookie: string } { + const challenge: Challenge = { + nonce: randomBytes(32).toString("base64"), + message: SIGN_IN_MESSAGE, + recipient: appRecipient(), + exp: Math.floor(now / 1000) + CHALLENGE_TTL_S, + }; + return { challenge, setCookie: cookie(CHALLENGE_COOKIE, signToken(challenge), CHALLENGE_TTL_S) }; +} + +export function readChallenge(req: Request): Challenge | null { + return verifyToken(readCookie(req, CHALLENGE_COOKIE)); +} diff --git a/lib/server/testing/server-only-stub.ts b/lib/server/testing/server-only-stub.ts new file mode 100644 index 0000000..eed9103 --- /dev/null +++ b/lib/server/testing/server-only-stub.ts @@ -0,0 +1,2 @@ +// Empty stand-in for the "server-only" package under vitest (it throws outside React Server Components). +export {}; diff --git a/middleware.ts b/middleware.ts index c0c70f3..82057da 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,16 +1,19 @@ /** - * Redirects signed-out users away from (dashboard) routes. - * Roadmap: S3.1 / K4.1 · Spec: ARCHITECTURE.md (API routes) - * Status: scaffold — not implemented yet. + * Sends signed-out visitors of the app pages to the landing page. Only checks that a session cookie + * exists; API routes verify its signature (lib/server/session.ts). + * Roadmap: K4.1 · Spec: ARCHITECTURE.md §4.1 */ import { NextResponse, type NextRequest } from "next/server"; -export function middleware(_req: NextRequest) { - // TODO: check the session cookie; demo sessions are allowed. - return NextResponse.next(); +export function middleware(req: NextRequest) { + if (req.cookies.has("stack_session")) return NextResponse.next(); + const url = req.nextUrl.clone(); + url.pathname = "/"; + url.searchParams.set("next", req.nextUrl.pathname); + return NextResponse.redirect(url); } export const config = { - matcher: ["/dashboard/:path*", "/invoices/:path*", "/settings/:path*", "/fund/:path*", "/plan/:path*", "/history/:path*"], + matcher: ["/dashboard/:path*", "/fund/:path*", "/plan/:path*", "/history/:path*", "/settings/:path*"], }; diff --git a/project_structure.md b/project_structure.md index cf41c61..e2f01e6 100644 --- a/project_structure.md +++ b/project_structure.md @@ -90,11 +90,12 @@ components/ ## 5. `lib/` ``` lib/ -├─ core/ # pure: assets.ts (KD-6 ids), money.ts, status.ts, cost.ts (cost_bps), planMatch.ts, address.ts (checksums), adapters.ts, schemas.ts +├─ core/ # pure: csv.ts, assets.ts (KD-6 ids), money.ts, status.ts, cost.ts (cost_bps), planMatch.ts, address.ts (checksums), adapters.ts, schemas.ts ├─ oneclick/ # index.ts (QuoteProvider/StatusProvider), client.ts, tokens.ts, quote.ts (funding + buy, fail-closed verify), status.ts, demo.ts, __fixtures__/ (real dry quotes) ├─ near/ # index.ts (VaultClient), rpc.ts, nep413.ts, vault.ts (bindings), factory.ts, keeper.ts (server only), events.ts, demo.ts ├─ db/ # schema.ts, client.ts (PGlite/Postgres + migrate()), queries/ (users, fundingAttempts, buys), seed-demo.ts -└─ server/ # SERVER ONLY: env.ts, session.ts, adapters.ts, rateLimit.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, routes.test.ts (API tests), testing/ (server-only stub for vitest) ``` ## 6. `worker/` diff --git a/vitest.config.ts b/vitest.config.ts index 9a4a68c..e1cc70e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,7 +2,12 @@ import { defineConfig } from "vitest/config"; import path from "node:path"; export default defineConfig({ - resolve: { alias: { "@": path.resolve(import.meta.dirname, ".") } }, + resolve: { + alias: { + "@": path.resolve(import.meta.dirname, "."), + "server-only": path.resolve(import.meta.dirname, "lib/server/testing/server-only-stub.ts"), + }, + }, test: { include: ["**/*.test.ts"], exclude: ["node_modules", ".next", "e2e", "contracts"],