Skip to content

Commit 5497734

Browse files
DylanMerigaudclaude
andcommitted
Add per-IP rate limiting + social preview (OG) image
Rate limiting (lib/ratelimit.ts): /api/run now rate-limits each IP via Upstash (8 runs / 10 min) before any model call, so the public demo can't be used to drain the Anthropic budget. Mirrors the sibling ai-invoice-parser repo: sliding window, accepts UPSTASH_ or Vercel-KV naming, and FAILS OPEN with a logged warning if Redis isn't configured (local dev stays fully functional). Returns a friendly 429 + retry-after. Documented in .env.example. Social preview (app/opengraph-image.tsx): a branded 1200×630 OG card rendered by next/og (no external service) so the demo link unfurls properly in Slack / email / X / LinkedIn instead of a bare title. Added openGraph + twitter metadata and metadataBase in the layout. Verified: typecheck · knip · 82 tests · sanity · build all green; OG renders a valid 1200×630 PNG; e2e (3/3) still passes with the rate-limit guard in place. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 855e7f9 commit 5497734

7 files changed

Lines changed: 319 additions & 4 deletions

File tree

.env.example

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,19 @@ DATABASE_URL=
2424
# Direct string (port 5432):
2525
# postgresql://postgres.<ref>:<password>@<region>.pooler.supabase.com:5432/postgres
2626
DIRECT_DATABASE_URL=
27+
28+
# ─── Redis (optional — per-IP rate limiting) ───────────────────────────────
29+
# If a URL + token are set, /api/run rate-limits each IP (8 runs / 10 min) so a
30+
# public visitor can't drain the Anthropic budget. If they're missing, rate
31+
# limiting FAILS OPEN (a warning is logged) and the app still runs locally.
32+
#
33+
# "Redis on Vercel" is Upstash via the Marketplace (Vercel's first-party KV was
34+
# deprecated). The code accepts EITHER naming — nothing to rename:
35+
# • Provisioned at https://console.upstash.com/redis → use the UPSTASH_ vars
36+
# • Provisioned via the Vercel dashboard (Storage tab) → Vercel auto-injects
37+
# KV_REST_API_URL / KV_REST_API_TOKEN; nothing to set by hand.
38+
UPSTASH_REDIS_REST_URL=
39+
UPSTASH_REDIS_REST_TOKEN=
40+
# Vercel Marketplace naming also works as a fallback:
41+
# KV_REST_API_URL=
42+
# KV_REST_API_TOKEN=

app/api/run/route.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { mastra } from "@/src/mastra";
22
import { loadRunBundle } from "@/db/client";
33
import { toTraceEvent, pipelineErrorEvent, type TraceEvent } from "@/lib/trace";
44
import { ndjsonLine } from "@/lib/ndjson";
5+
import { checkRateLimit, clientIpFrom } from "@/lib/ratelimit";
56
import { RunRequest, STREAM_CONTENT_TYPE, type StreamDone } from "@/lib/api-types";
67

78
/**
@@ -36,6 +37,23 @@ function line(obj: unknown): Uint8Array {
3637
}
3738

3839
export async function POST(request: Request): Promise<Response> {
40+
// 0. Rate-limit by IP first — before any work or model calls. The demo is
41+
// public and each run spends Anthropic tokens, so this caps abuse. Fails
42+
// open if Redis isn't configured (see lib/ratelimit.ts).
43+
const ip = clientIpFrom(request.headers);
44+
const verdict = await checkRateLimit(ip);
45+
if (!verdict.ok) {
46+
return Response.json(
47+
{
48+
error: `You've hit the demo limit (8 runs / 10 min). Try again in about ${Math.max(
49+
1,
50+
Math.ceil(verdict.retryAfterSeconds / 60),
51+
)} minute(s).`,
52+
},
53+
{ status: 429, headers: { "retry-after": String(verdict.retryAfterSeconds) } },
54+
);
55+
}
56+
3957
// 1. Parse + validate the request body.
4058
let id: string;
4159
let decision: "approve" | "reject" | undefined;

app/layout.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,28 @@ import { GeistSans } from "geist/font/sans";
33
import { GeistMono } from "geist/font/mono";
44
import "./globals.css";
55

6+
const SITE_URL = "https://ledgerloop-eta.vercel.app";
7+
const DESCRIPTION =
8+
"A multi-agent finance-ops demo: cooperating agents run invoice intake → 2/3-way matching → approval routing → reconciliation, with the live agent execution trace streamed as it happens and a real human-in-the-loop on caught mismatches. Built with Mastra.";
9+
610
export const metadata: Metadata = {
11+
metadataBase: new URL(SITE_URL),
712
title: "ledgerloop — multi-agent procure-to-pay",
8-
description:
9-
"A multi-agent finance-ops demo: cooperating agents run invoice intake → 2/3-way matching → approval routing → reconciliation, with the live agent execution trace streamed as it happens. Built with Mastra.",
13+
description: DESCRIPTION,
14+
// opengraph-image.tsx is picked up automatically; the blocks below complete
15+
// the unfurl (title / description / large-image card) for Slack, X, LinkedIn.
16+
openGraph: {
17+
type: "website",
18+
url: SITE_URL,
19+
siteName: "ledgerloop",
20+
title: "ledgerloop — multi-agent procure-to-pay",
21+
description: DESCRIPTION,
22+
},
23+
twitter: {
24+
card: "summary_large_image",
25+
title: "ledgerloop — multi-agent procure-to-pay",
26+
description: DESCRIPTION,
27+
},
1028
};
1129

1230
export default function RootLayout({

app/opengraph-image.tsx

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { ImageResponse } from "next/og";
2+
3+
/**
4+
* The social preview card (1200×630) shown when the demo link is shared in
5+
* Slack / email / X / LinkedIn. Rendered at build time by next/og — no external
6+
* service. Mirrors the in-app fintech palette (cool canvas + indigo accent) so
7+
* the unfurl looks like a deliberate product, not a bare title.
8+
*/
9+
10+
export const runtime = "nodejs";
11+
export const alt =
12+
"ledgerloop — a multi-agent procure-to-pay pipeline: intake, matching, approval, reconciliation";
13+
export const size = { width: 1200, height: 630 };
14+
export const contentType = "image/png";
15+
16+
const INK = "#0A0A0A";
17+
const MUTED = "#6B7280";
18+
const ACCENT = "#4F46E5";
19+
const LINE = "#E5E7EB";
20+
21+
export default function OpengraphImage() {
22+
const stages = ["Intake", "Matching", "Approval", "Reconciliation"];
23+
24+
return new ImageResponse(
25+
(
26+
<div
27+
style={{
28+
width: "100%",
29+
height: "100%",
30+
display: "flex",
31+
flexDirection: "column",
32+
justifyContent: "space-between",
33+
background: "#FAFAFA",
34+
padding: "72px 80px",
35+
fontFamily: "sans-serif",
36+
}}
37+
>
38+
{/* Top: wordmark + a small accent tag */}
39+
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
40+
<div
41+
style={{
42+
width: 18,
43+
height: 18,
44+
borderRadius: 5,
45+
background: ACCENT,
46+
}}
47+
/>
48+
<div style={{ fontSize: 30, fontWeight: 700, color: INK, letterSpacing: -0.5 }}>
49+
ledgerloop
50+
</div>
51+
</div>
52+
53+
{/* Middle: headline + subline */}
54+
<div style={{ display: "flex", flexDirection: "column", gap: 22 }}>
55+
<div
56+
style={{
57+
fontSize: 60,
58+
fontWeight: 700,
59+
color: INK,
60+
lineHeight: 1.08,
61+
letterSpacing: -1.5,
62+
maxWidth: 980,
63+
}}
64+
>
65+
A multi-agent procure-to-pay pipeline
66+
</div>
67+
<div style={{ fontSize: 27, color: MUTED, lineHeight: 1.4, maxWidth: 940 }}>
68+
Four cooperating AI agents match, route, and reconcile each invoice —
69+
with the live execution trace streamed and a real human-in-the-loop on
70+
caught mismatches.
71+
</div>
72+
</div>
73+
74+
{/* Stage flow */}
75+
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
76+
{stages.map((label, i) => (
77+
<div key={label} style={{ display: "flex", alignItems: "center", gap: 14 }}>
78+
<div
79+
style={{
80+
display: "flex",
81+
alignItems: "center",
82+
gap: 12,
83+
background: "#FFFFFF",
84+
border: `1px solid ${LINE}`,
85+
borderRadius: 999,
86+
padding: "12px 22px",
87+
fontSize: 24,
88+
color: INK,
89+
}}
90+
>
91+
<div
92+
style={{
93+
display: "flex",
94+
alignItems: "center",
95+
justifyContent: "center",
96+
width: 26,
97+
height: 26,
98+
borderRadius: 999,
99+
background: "#EEF2FF",
100+
color: ACCENT,
101+
fontSize: 15,
102+
fontWeight: 700,
103+
}}
104+
>
105+
{i + 1}
106+
</div>
107+
{label}
108+
</div>
109+
{i < stages.length - 1 && (
110+
<div style={{ fontSize: 26, color: LINE }}></div>
111+
)}
112+
</div>
113+
))}
114+
</div>
115+
116+
{/* Footer: stack + author */}
117+
<div
118+
style={{
119+
display: "flex",
120+
justifyContent: "space-between",
121+
alignItems: "center",
122+
fontSize: 22,
123+
color: MUTED,
124+
borderTop: `1px solid ${LINE}`,
125+
paddingTop: 28,
126+
}}
127+
>
128+
<div>Mastra · Next.js · Claude Haiku · Supabase</div>
129+
<div style={{ color: INK }}>Dylan Mérigaud</div>
130+
</div>
131+
</div>
132+
),
133+
{ ...size },
134+
);
135+
}

lib/ratelimit.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { Ratelimit } from "@upstash/ratelimit";
2+
import { Redis } from "@upstash/redis";
3+
4+
/**
5+
* Per-IP rate limiting via Upstash, for the public "Run pipeline" endpoint.
6+
* Configured for 8 runs per 10 minutes — enough to explore every seeded scenario
7+
* (clean / price / quantity / duplicate, plus an approve/reject) without leaving
8+
* the door open for a bot to drain the Anthropic budget.
9+
*
10+
* Design choice (mirrors the sibling ai-invoice-parser repo): if the Upstash env
11+
* vars are absent (e.g. local dev without a Redis instance), we FAIL OPEN — the
12+
* limiter is disabled and a one-time warning is logged. The app stays fully
13+
* functional; only the abuse guard is off. In production on Vercel you set the
14+
* two env vars and the guard engages.
15+
*/
16+
17+
const WINDOW = "10 m" as const;
18+
const LIMIT = 8;
19+
20+
export type RateVerdict =
21+
| { ok: true; remaining: number | null }
22+
| { ok: false; limit: number; reset: number; retryAfterSeconds: number };
23+
24+
let limiter: Ratelimit | null = null;
25+
let initialized = false;
26+
27+
function getLimiter(): Ratelimit | null {
28+
if (initialized) return limiter;
29+
initialized = true;
30+
31+
// Accept either naming convention so it works however you provision Redis:
32+
// • Upstash directly → UPSTASH_REDIS_REST_URL / _TOKEN
33+
// • Vercel Marketplace → KV_REST_API_URL / _TOKEN (Vercel's Upstash add-on,
34+
// which keeps the legacy "KV" prefix)
35+
const url = process.env.UPSTASH_REDIS_REST_URL ?? process.env.KV_REST_API_URL;
36+
const token =
37+
process.env.UPSTASH_REDIS_REST_TOKEN ?? process.env.KV_REST_API_TOKEN;
38+
39+
if (!url || !token) {
40+
console.warn(
41+
"[ratelimit] No Redis credentials found " +
42+
"(UPSTASH_REDIS_REST_URL/_TOKEN or KV_REST_API_URL/_TOKEN) — " +
43+
"rate limiting is DISABLED (failing open).",
44+
);
45+
limiter = null;
46+
return limiter;
47+
}
48+
49+
limiter = new Ratelimit({
50+
redis: new Redis({ url, token }),
51+
limiter: Ratelimit.slidingWindow(LIMIT, WINDOW),
52+
prefix: "ledgerloop",
53+
analytics: false,
54+
});
55+
return limiter;
56+
}
57+
58+
/** Check (and consume) one unit of the rate budget for the given IP. */
59+
export async function checkRateLimit(ip: string): Promise<RateVerdict> {
60+
const rl = getLimiter();
61+
if (!rl) {
62+
// Failing open: always allow.
63+
return { ok: true, remaining: null };
64+
}
65+
66+
try {
67+
const { success, limit, reset, remaining } = await rl.limit(ip);
68+
if (success) {
69+
return { ok: true, remaining };
70+
}
71+
const retryAfterSeconds = Math.max(0, Math.ceil((reset - Date.now()) / 1000));
72+
return { ok: false, limit, reset, retryAfterSeconds };
73+
} catch (err) {
74+
// If Redis itself errors, don't take the whole endpoint down — fail open but
75+
// log it so the operator notices.
76+
console.error("[ratelimit] Upstash error, failing open:", err);
77+
return { ok: true, remaining: null };
78+
}
79+
}
80+
81+
/** Best-effort client IP extraction from proxy headers (Vercel-friendly). */
82+
export function clientIpFrom(headers: Headers): string {
83+
const forwarded = headers.get("x-forwarded-for");
84+
if (forwarded) {
85+
const first = forwarded.split(",")[0]?.trim();
86+
if (first) return first;
87+
}
88+
return headers.get("x-real-ip") ?? "anonymous";
89+
}

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
},
2020
"dependencies": {
2121
"@mastra/core": "1.42.0",
22+
"@upstash/ratelimit": "2.0.8",
23+
"@upstash/redis": "1.38.0",
2224
"clsx": "2.1.1",
2325
"drizzle-orm": "0.45.2",
2426
"geist": "1.7.2",

0 commit comments

Comments
 (0)