Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
106b684
feat(backend): crypto sub-agent with multi-currency support
FireTable Jun 25, 2026
465fee6
feat(frontend): wagmi web3 + crypto cards + wallet picker
FireTable Jun 25, 2026
58fdd5c
test(crypto): backend tools + frontend card + router regression
FireTable Jun 25, 2026
dcfa976
fix(crypto): use Decimal for amount math, surface validation errors
FireTable Jun 26, 2026
a0224fc
fix(decimal): handle null/undefined/NaN in format + divide helpers
FireTable Jun 26, 2026
221c56d
feat(frontend): downgrade wagmi → 2.x + use RainbowKit for wallet picker
FireTable Jun 26, 2026
fcf242f
feat(crypto): self-custody DEX swap via CoW + Alchemy
FireTable Jun 27, 2026
a8a3121
chore: drop unused CoW / Alchemy scaffolding
FireTable Jun 28, 2026
496a3b1
feat(crypto): split confirm into atomic tools + simulated Mock Coin swap
FireTable Jun 28, 2026
c197973
feat(ui): add AddressOrHash truncated address / hash with copy
FireTable Jun 28, 2026
7e69fb2
test(e2e): add Playwright harness for the 3-card trade flow
FireTable Jun 28, 2026
56fabd2
feat(frontend): wrap toolkit renders in ToolFallback chrome
FireTable Jun 28, 2026
dc06261
feat(crypto): explicit confirm flow for connect-wallet card
FireTable Jun 28, 2026
16e9ae8
style(e2e): prettier-wrap signatures + trailing newlines
FireTable Jun 28, 2026
982854d
chore: drop menu wallet icon + price-card rank spacing
FireTable Jun 28, 2026
502ae28
chore(frontend): merge weather-widget directory into weather
FireTable Jun 28, 2026
bc8dd6e
chore(frontend): drop no-op render on geocode_location
FireTable Jun 28, 2026
8a44e17
chore(ui): drop icon prefix from ask-location button
FireTable Jun 28, 2026
baa74d0
feat(crypto): hard-block investment advice in CRYPTO_AGENT_PROMPT
FireTable Jun 28, 2026
f489fa8
docs: catalog tools + sync crypto interrupt examples
FireTable Jun 28, 2026
72075a0
docs(CLAUDE): no-icon rule + sync crypto sub-agent architecture
FireTable Jun 28, 2026
f56df2c
chore(ui): drop top-level my-2 from tool-ui cards
FireTable Jun 28, 2026
59d00bb
chore(ui): resize tool-card-skeleton to inline row
FireTable Jun 28, 2026
35efb18
refactor(backend): rename searchWeb/fetchUrl to snake_case
FireTable Jun 28, 2026
885f4be
feat(ui): add breathing border glow on tool-call interrupt
FireTable Jun 28, 2026
e9466c8
docs: sync crypto docs to post-Mock-Coin flow
FireTable Jun 28, 2026
367fef5
refactor(crypto): drop CoW references from tool descriptions and comm…
FireTable Jun 28, 2026
6b734a5
feat(crypto): add get_NFT_holdings tool + NFT gallery card
FireTable Jun 28, 2026
61c1ec6
feat(crypto): NFT gallery — grouping, video, persistence, lightbox
FireTable Jun 28, 2026
a986276
style(crypto): tighten NFT gallery header spacing + lightbox image ratio
FireTable Jun 28, 2026
110180b
docs: sync NFT gallery entries across APIS, TOOLS, CLAUDE, README
FireTable Jun 28, 2026
a66f571
feat(api): gate LangGraph + Alchemy proxies behind withAuth
FireTable Jun 28, 2026
4f55bff
chore(env): commit .env.test so BETTER_AUTH_SECRET ships with the repo
FireTable Jun 28, 2026
478d78d
docs: document the withAuth rule + catch-all proxy runtime change
FireTable Jun 28, 2026
ab7e777
style(crypto): accept-swap drain bar uses warm→bright gradient
FireTable Jun 28, 2026
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
30 changes: 29 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,32 @@ RESEND_FROM_EMAIL=onboarding@resend.dev
# Comma-separated API keys. The pool picks one at random per request;
# 401/403 responses remove that key from the pool and retry.
# Get free keys at https://jina.ai/reader after sign-up.
JINA_API_KEYS=
JINA_API_KEYS=

# Alchemy RPC — https://dashboard.alchemy.com/
# Server-only. Used by app/api/alchemy/[...path] to proxy JSON-RPC
# requests. Never reaches the browser (no NEXT_PUBLIC_ prefix).
ALCHEMY_API_KEY=
# Optional. Comma-separated Alchemy network slugs the proxy will REJECT
# (turn-off list). The proxy accepts every network in the static catalog
# (lib/alchemy/networks.ts) by default — set this to disable specific
# chains in production (e.g. testnets). Leave empty to enable all.
ALCHEMY_DISABLED_NETWORKS=

# Crypto real-swap — feature flag for routing `place_crypto_order`
# through a live DEX path. When unset / "false" (default), the card
# stays in SIMULATED mode (hardcoded Mock Coin balance, no signing,
# no on-chain broadcast). Set to "true" only after wiring a real DEX
# router + signer (currently dormant — wagmi hooks live in the React
# tree, so a server-only toggle won't reach them). Exposed to the
# browser (NEXT_PUBLIC_) on purpose.
NEXT_PUBLIC_CRYPTO_REAL_SWAP=

# WalletConnect / Reown projectId — https://dashboard.reown.com
# Bundled into the browser (NEXT_PUBLIC_) on purpose: it's a public
# dapp identifier, not a secret (Reown docs: "this is a public
# identifier"). The free tier covers 100k connections / month. Required
# for WalletConnect-based wallets (binanceWallet, bitgetWallet) to
# expose their mobile-QR fallback; injected wallets (MetaMask, Coinbase,
# Rainbow, Safe) work without it.
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=
13 changes: 13 additions & 0 deletions .env.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Postgres — used by vitest under NODE_ENV=test.
# @next/env reads this file when NODE_ENV=test (along with .env.test.local).
DATABASE_URL_TEST=postgresql://FireTable@localhost:5432/langgraph_app_test

# Jina pool — fake keys for unit tests. The pool rotation / failover logic
# is exercised against `vi.stubGlobal("fetch", ...)`, so no real network.
JINA_API_KEYS=jina_test_key_1,jina_test_key_2

# Better Auth — fake secret for unit tests. Required for the auth module
# to load; withAuth HOC tests mock getSession() directly so the secret
# value is never validated against real signatures.
BETTER_AUTH_SECRET=test_secret_aabbccddeeff00112233445566778899
BETTER_AUTH_URL=http://localhost:3000
11 changes: 11 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,22 @@ yarn-error.log*

# env files (can opt-in for committing if needed)
.env*
!.env.test
!.env.example

# vercel
.vercel

# local visual verification screenshots
/.verify
# ad-hoc screenshots/logs dropped at repo root during manual testing
/*.png
/*.jpg
/*.jpeg
/*.log
/test-results/
/playwright-report/

# typescript
*.tsbuildinfo
next-env.d.ts
Expand Down
98 changes: 85 additions & 13 deletions CLAUDE.md

Large diffs are not rendered by default.

10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ A self-hostable chat app (this repo: `langgraph-app`) that streams tokens from a
- **Type-safe DB layer**: Drizzle ORM + Zod validators, derived from the same schema source.
- **TDD-tested**: Vitest with a separate test database.
- **User accounts**: email + password (with email verification), GitHub and Google sign-in, 7-day persistent sessions, and per-user thread isolation. See [docs/AUTH.md](docs/AUTH.md) for the operator guide.
- **Tool-using agent**: the `agent` node is bound to `searchWeb` (Jina Search) and `fetchUrl` (Jina Reader) — the model can research topics and read pages mid-conversation. Tools run unconditionally; write-side tools added later will hang their own `interruptBefore` hook. See [docs/APIS.md](docs/APIS.md) for the contract.
- **Tool-using agent**: the `agent` node is bound to `search_web` (Jina Search) and `fetch_url` (Jina Reader) — the model can research topics and read pages mid-conversation. Tools run unconditionally; write-side tools added later will hang their own `interruptBefore` hook. See [docs/APIS.md](docs/APIS.md) for the contract.
- **Crypto sub-agent**: price, NFT holdings (5-chain gallery via Alchemy Portfolio), and a simulated swap flow against an auto-funded Mock Coin balance — see [docs/TOOLS.md](docs/TOOLS.md) and [docs/INTERRUPT.md](docs/INTERRUPT.md) for the per-card contract.

## Tech stack

Expand Down Expand Up @@ -115,8 +116,8 @@ backend/
model.ts ChatOpenAI singletons (with / without thinking)
checkpointer.ts PostgresSaver (Postgres checkpoint tables)
tool/ LangChain tools bound to the agent
web-search.ts searchWeb — Jina Search (s.jina.ai/{query})
web-fetch.ts fetchUrl — Jina Reader (r.jina.ai/{url})
web-search.ts search_web — Jina Search (s.jina.ai/{query})
web-fetch.ts fetch_url — Jina Reader (r.jina.ai/{url})
node/
call-model-node.ts "agent" node — appends AI reply
rename-thread-node.ts "renameThread" node — generates + persists title
Expand Down Expand Up @@ -210,6 +211,7 @@ Test database stays isolated from dev — never put production-like data in `lan
| `OPENAI_MODEL` | backend agent | optional (default `gpt-4o-mini`) |
| `OPENAI_BASE_URL` | backend agent | optional (OpenAI-compatible gateway) |
| `JINA_API_KEYS` | web-search + web-fetch | yes (comma-separated; one per Jina account) |
| `ALCHEMY_API_KEY` | NFT gallery + portfolio | yes (server-only; powers `get_NFT_holdings`) |
| `LANGGRAPH_API_URL` | Next.js proxy | optional (default `http://localhost:2024`) |
| `LANGCHAIN_API_KEY` | Next.js proxy → LangGraph | optional (leave blank locally) |
| `NEXT_PUBLIC_LANGGRAPH_ASSISTANT_ID` | browser runtime | optional (default `agent`) |
Expand All @@ -231,6 +233,8 @@ Test database stays isolated from dev — never put production-like data in `lan
## Documentation

- [`docs/APIS.md`](docs/APIS.md) — HTTP endpoint reference. Update whenever a route under `app/api/` changes.
- [`docs/TOOLS.md`](docs/TOOLS.md) — LangGraph tool inventory and frontend card wiring. Update whenever a tool or card is added/removed/rerouted.
- [`docs/INTERRUPT.md`](docs/INTERRUPT.md) — interrupt-driven tool flows (ask_location, connect_wallet, place_crypto_order, get_order_status) — the two runtime paths the cards can take.
- [`docs/AUTH.md`](docs/AUTH.md) — operator guide for the auth layer: env vars, OAuth app setup, Resend, troubleshooting.
- [`docs/DB.md`](docs/DB.md) — database schema (Better Auth + `threads`), ownership model, indexes. Source of truth: `db/migrations/0000_*.sql`.

Expand Down
109 changes: 65 additions & 44 deletions app/api/[..._path]/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,22 @@
import { type NextRequest, NextResponse } from "next/server";

export const runtime = "edge";
export const runtime = "nodejs";

import { withAuth } from "@/lib/auth/with-auth";

// ponytail: edge catch-all proxy to LANGGRAPH_API_URL. The browser
// sends `ANY /api/<rest>`; we forward to `${LANGGRAPH_API_URL}/<rest>`
// with `x-api-key: LANGCHAIN_API_KEY`. We also forward the user's
// cookie + Authorization so LangGraph can identify the calling thread.
//
// Auth: gated behind a Better Auth session via withAuth. The previous
// build accepted anonymous traffic — any website's JS could create /
// list / delete threads through this proxy. withAuth uses next/headers
// which works on edge as of Next 14.
//
// SSE: most calls are streaming runs (text/event-stream). The response
// body MUST stay a ReadableStream — buffering it to a string would
// break the stream. We do not touch res.body before returning.
function getCorsHeaders() {
return {
"Access-Control-Allow-Origin": "*",
Expand All @@ -10,57 +25,63 @@ function getCorsHeaders() {
};
}

async function handleRequest(req: NextRequest, method: string) {
try {
const path = req.nextUrl.pathname.replace(/^\/?api\//, "");
const url = new URL(req.url);
const searchParams = new URLSearchParams(url.search);
searchParams.delete("_path");
searchParams.delete("nxtP_path");
const queryString = searchParams.toString() ? `?${searchParams.toString()}` : "";
async function proxyRequest(req: NextRequest | Request): Promise<Response> {
// Next.js always passes a NextRequest here, but withAuth's generic
// typing widens it to Request — narrow at the call site so we keep
// nextUrl access in this function.
const nextReq = req as NextRequest;
const path = nextReq.nextUrl.pathname.replace(/^\/?api\//, "");
const url = new URL(nextReq.url);
const searchParams = new URLSearchParams(url.search);
searchParams.delete("_path");
searchParams.delete("nxtP_path");
const queryString = searchParams.toString() ? `?${searchParams.toString()}` : "";

const options: RequestInit = {
method,
headers: {
"x-api-key": process.env.LANGCHAIN_API_KEY || "",
},
signal: req.signal,
};
const upstreamHeaders: Record<string, string> = {
"x-api-key": process.env.LANGCHAIN_API_KEY || "",
};
// Forward the user's session cookie / Authorization so LangGraph can
// identify the calling thread. We deliberately do NOT forward every
// header — only the two LangGraph consults.
const cookie = nextReq.headers.get("cookie");
if (cookie) upstreamHeaders.cookie = cookie;
const authorization = nextReq.headers.get("authorization");
if (authorization) upstreamHeaders.authorization = authorization;

if (["POST", "PUT", "PATCH"].includes(method)) {
options.body = await req.text();
}
const options: RequestInit = {
method: nextReq.method,
headers: upstreamHeaders,
signal: nextReq.signal,
};

const res = await fetch(`${process.env.LANGGRAPH_API_URL}/${path}${queryString}`, options);
if (["POST", "PUT", "PATCH"].includes(nextReq.method)) {
options.body = await nextReq.text();
}

const headers = new Headers(res.headers);
headers.delete("content-encoding");
headers.delete("content-length");
headers.delete("transfer-encoding");
const corsHeaders = getCorsHeaders();
for (const [key, value] of Object.entries(corsHeaders)) {
headers.set(key, value);
}
const res = await fetch(`${process.env.LANGGRAPH_API_URL}/${path}${queryString}`, options);

return new NextResponse(res.body, {
status: res.status,
statusText: res.statusText,
headers,
});
} catch (e: unknown) {
if (e instanceof Error) {
const typedError = e as Error & { status?: number };
return NextResponse.json({ error: typedError.message }, { status: typedError.status ?? 500 });
}
return NextResponse.json({ error: "Unknown error" }, { status: 500 });
const headers = new Headers(res.headers);
headers.delete("content-encoding");
headers.delete("content-length");
headers.delete("transfer-encoding");
for (const [key, value] of Object.entries(getCorsHeaders())) {
headers.set(key, value);
}

return new NextResponse(res.body, {
status: res.status,
statusText: res.statusText,
headers,
});
}

export const GET = (req: NextRequest) => handleRequest(req, "GET");
export const POST = (req: NextRequest) => handleRequest(req, "POST");
export const PUT = (req: NextRequest) => handleRequest(req, "PUT");
export const PATCH = (req: NextRequest) => handleRequest(req, "PATCH");
export const DELETE = (req: NextRequest) => handleRequest(req, "DELETE");
const authedProxy = withAuth<{ path: string[] }>(async (req) => proxyRequest(req));

export const GET = authedProxy;
export const POST = authedProxy;
export const PUT = authedProxy;
export const PATCH = authedProxy;
export const DELETE = authedProxy;
export const OPTIONS = () =>
new NextResponse(null, {
status: 204,
Expand Down
94 changes: 94 additions & 0 deletions app/api/alchemy/[...path]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { NextResponse } from "next/server";

// Runtime: nodejs (not edge). Alchemy proxy itself only forwards HTTP
// and could run on edge, but withAuth reads the session row from
// Postgres through drizzle/postgres-js, which needs the `net` module
// from Node — edge throws `Failed to get session` if it tries.

import { parseNetworkList, resolveAllowlist } from "@/lib/alchemy/networks";
import { withAuth } from "@/lib/auth/with-auth";

function getCorsHeaders() {
return {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
};
}

// ponytail: this is a thin Alchemy proxy. The browser sends
// `POST /api/alchemy/<network>` with a JSON-RPC body; we forward it
// to `https://<network>.g.alchemy.com/v2/<key>` using the server-only
// ALCHEMY_API_KEY. The key never enters the client bundle. The proxy
// rejects any slug that isn't in the static catalog (so the URL can't
// be tricked into calling arbitrary hosts); the optional
// `ALCHEMY_DISABLED_NETWORKS` denylist further restricts that.
//
// The `/api/alchemy/portfolio/<endpoint>` namespace is a separate
// branch that forwards to the global Portfolio API
// (`https://api.g.alchemy.com/data/v1/<key>/assets/<endpoint>`).
// It's not gated by the network allowlist — Portfolio API takes the
// list of networks in its body, so the allowlist wouldn't even apply.
async function handle(
req: Request,
ctx: { params: { path: string[] } },
) {
try {
const { path } = ctx.params;
const first = path?.[0];
const method = req.method === "POST" ? "POST" : "GET";

const apiKey = process.env.ALCHEMY_API_KEY;
if (!apiKey) {
return NextResponse.json({ error: "Alchemy RPC not configured" }, { status: 500 });
}

let upstreamUrl: string;

if (first === "portfolio") {
const endpoint = path.slice(1).join("/");
if (!endpoint) {
return NextResponse.json({ error: "portfolio endpoint is required" }, { status: 400 });
}
upstreamUrl = `https://api.g.alchemy.com/data/v1/${apiKey}/assets/${endpoint}`;
} else {
if (!first) {
return NextResponse.json({ error: "network is required" }, { status: 400 });
}
const allowlist = resolveAllowlist(
parseNetworkList(process.env.ALCHEMY_DISABLED_NETWORKS ?? ""),
);
if (!allowlist.includes(first)) {
return NextResponse.json({ error: `network '${first}' is not allowed` }, { status: 400 });
}
upstreamUrl = `https://${first}.g.alchemy.com/v2/${apiKey}`;
}

const init: RequestInit = { method, signal: req.signal };
if (method === "POST") {
init.headers = { "Content-Type": "application/json" };
init.body = await req.text();
}

const res = await fetch(upstreamUrl, init);

const headers = new Headers(res.headers);
headers.delete("content-encoding");
headers.delete("content-length");
headers.delete("transfer-encoding");
for (const [k, v] of Object.entries(getCorsHeaders())) headers.set(k, v);

return new NextResponse(res.body, {
status: res.status,
statusText: res.statusText,
headers,
});
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : "Unknown error";
return NextResponse.json({ error: msg }, { status: 500 });
}
}

export const POST = withAuth<{ path: string[] }>(handle);
export const GET = withAuth<{ path: string[] }>(handle);
export const OPTIONS = () => new NextResponse(null, { status: 204, headers: getCorsHeaders() });
21 changes: 21 additions & 0 deletions app/api/alchemy/status/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";

import { withAuth } from "@/lib/auth/with-auth";

// ponytail: just reports whether ALCHEMY_API_KEY is set — never the
// value. The frontend uses this for the "🔑 configured / ⚠ not set"
// status badge on the Alchemy admin page.
//
// Auth: gated behind a Better Auth session check via withAuth. The
// /status endpoint tells the browser whether a key is set; previously
// any anonymous visitor could enumerate it. We deliberately do NOT
// expose the key value, so the only thing an authenticated user can
// learn is `{ configured: true | false }` — useful for the admin badge,
// useless for an attacker.
//
// Runtime: nodejs (not edge) so withAuth can hit Postgres through
// drizzle/postgres-js to read the session row.
export const GET = withAuth(() => {
const key = process.env.ALCHEMY_API_KEY;
return NextResponse.json({ configured: Boolean(key && key.length > 0) });
});
10 changes: 10 additions & 0 deletions app/assistant.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,16 @@ export function Assistant() {
label: "",
prompt: "What’s the weather like at today?",
},
{
title: "I want to buy 2 ETH",
label: "",
prompt: "I want to buy 2 ETH",
},
{
title: "Show me my NFTs",
label: "",
prompt: "Show me my NFTs",
}
]),
});

Expand Down
Loading