Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions showcase/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# ---------------------------------------------------------------------------
# "Fiat checkout" demo (tutorial: fiat-checkout-chat) — SERVER-SIDE ONLY.
# The gallery's "See it run" panel makes a real POST /orders and embeds the
# hosted Stripe checkout. These power app/api/orders/route.ts; none is
# NEXT_PUBLIC_*, so the org key never reaches the browser.
#
# Leave them unset and the rest of the showcase works fine — only the Fiat
# checkout panel needs them (it shows a "start the Orders backend" notice
# otherwise). While Orders is unshipped (epic #3238) point these at a local
# stack; in production, at the sandbox Orders deployment.
# ---------------------------------------------------------------------------

# The organization's Nevermined API key (SECRET). Authorizes creating orders.
NVM_ORDER_API_KEY=

# The Orders API the merchant backend calls.
NVM_API_BASE_URL=http://localhost:3001

# Origin of the hosted checkout the panel iframes; also the value the panel's
# success listener checks event.origin against.
NVM_EMBED_BASE_URL=http://localhost:4250
144 changes: 144 additions & 0 deletions showcase/app/api/orders/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { NextResponse } from "next/server";
import { getTutorial } from "@/content/tutorials";

// Merchant backend for the "Fiat checkout" showcase demo — the ONLY place the
// organization's Nevermined API key is used. The browser panel (FiatRunPanel)
// calls THIS route with just a packageId; we look up the price server-side and
// create the Order on the org's behalf. The key never reaches the browser bundle.
//
// Local dev points at the local Orders stack; in production, at the sandbox
// Orders API. Configure via env (see .env.example):
// NVM_ORDER_API_KEY the org's (sandbox) API key — SECRET, server-only
// NVM_API_BASE_URL the Orders API base (default http://localhost:3001)

const NVM_API_BASE_URL = process.env.NVM_API_BASE_URL ?? "http://localhost:3001";
const NVM_ORDER_API_KEY = process.env.NVM_ORDER_API_KEY;

// Single source of truth for prices: the fiat tutorial's own package list
// (content/tutorials.ts). The client sends only a packageId and we look the
// amount up here, so a tampered client can't name its own amount, and the
// displayed price (formatted from the same amountMinor) can't drift from the
// charged one. Built with a null prototype so the lookup is a real allowlist.
const CATALOG: Record<string, { amountMinor: number; description: string }> = Object.create(null);
{
const fiat = getTutorial("fiat-checkout-chat");
if (fiat && fiat.run.kind === "fiat") {
for (const p of fiat.run.packages) CATALOG[p.id] = { amountMinor: p.amountMinor, description: p.name };
}
}

// Fail-fast guard at import: every amount must be in the Orders API's window.
for (const id of Object.keys(CATALOG)) {
const a = CATALOG[id].amountMinor;
if (!Number.isInteger(a) || a < 100 || a > 99_999_999) {
throw new Error(`Fiat catalog "${id}" amountMinor out of range: ${a}`);
}
}

// ponytail: in-memory per-IP limiter — this is a PUBLIC route that spends the
// org's identity (creates real Orders), so it must be bounded. Ceiling: state is
// per-instance and resets on restart; a multi-instance deploy wants a shared
// store (Redis), but for a demo this turns "unbounded" into "bounded".
const HITS = new Map<string, number[]>();
const RL_WINDOW_MS = 10 * 60_000;
const RL_MAX = 8;
function rateLimited(ip: string): boolean {
const now = Date.now();
const recent = (HITS.get(ip) ?? []).filter((t) => now - t < RL_WINDOW_MS);
if (recent.length >= RL_MAX) {
HITS.set(ip, recent);
return true;
}
recent.push(now);
HITS.set(ip, recent);
return false;
}

export async function POST(req: Request) {
Comment thread
aaitor marked this conversation as resolved.
if (!NVM_ORDER_API_KEY) {
return NextResponse.json(
{ error: "Server missing NVM_ORDER_API_KEY — set it to run the live demo (see .env.example)." },
{ status: 503 },
);
}

// NOTE on the bound below: both halves have a deliberate, demo-grade bypass. The
// same-origin check is conditional — a request with no `Origin` header (curl,
// server-to-server) skips it — and `x-forwarded-for` is caller-supplied unless a
// trusted proxy overwrites it, so a direct caller can spoof the rate-limit key.
// Behind the showcase's own ingress both hold; for a hardened public deployment,
// put this route behind the platform's WAF / rate-limiter and treat the in-route
// guards as a backstop, not the primary control.
const origin = req.headers.get("origin");
const host = req.headers.get("host");
if (origin && host) {
let ok = false;
try {
ok = new URL(origin).host === host;
} catch {
ok = false;
}
if (!ok) return NextResponse.json({ error: "Cross-origin requests are not allowed." }, { status: 403 });
}

// Rate limit per client IP (bounds scripted abuse that skips the Origin header).
const ip = req.headers.get("x-forwarded-for")?.split(",")[0].trim() || "unknown";
Comment thread
aaitor marked this conversation as resolved.
if (rateLimited(ip)) {
return NextResponse.json({ error: "Too many orders from this client — slow down." }, { status: 429 });
}

let packageId: unknown;
try {
({ packageId } = await req.json());
} catch {
return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 });
}
// Object.hasOwn — a real own-property check, so "constructor"/"__proto__"/etc.
// don't slip past the allowlist into an authenticated upstream call.
if (typeof packageId !== "string" || !Object.hasOwn(CATALOG, packageId)) {
return NextResponse.json({ error: `Unknown package: ${String(packageId)}` }, { status: 400 });
}

const pkg = CATALOG[packageId];
let res: Response;
try {
res = await fetch(`${NVM_API_BASE_URL}/api/v1/orders`, {
method: "POST",
headers: {
Authorization: `Bearer ${NVM_ORDER_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
amountMinor: pkg.amountMinor, // price is OURS, never the client's
currency: "usd",
description: pkg.description,
buyerRef: "showcase-fiat-checkout",
}),
signal: AbortSignal.timeout(15_000), // don't hang the caller on a stalled backend
});
} catch (err) {
console.error(`[showcase/orders] Orders API unreachable: ${err instanceof Error ? err.message : err}`);
return NextResponse.json({ error: "Orders backend unreachable or timed out." }, { status: 502 });
}

if (!res.ok) {
const detail = await res.text().catch(() => "");
console.error(`[showcase/orders] Nevermined API ${res.status}: ${detail}`);
return NextResponse.json(
{ error: `Orders API returned ${res.status}. Is the Orders backend running?` },
{ status: 502 },
);
}

// A 200 without an orderId (renamed/wrapped field) would otherwise become a
// 200 {} the client can't detect — route it into the 502 it already handles.
const { orderId } = await res.json().catch(() => ({}) as { orderId?: unknown });
if (typeof orderId !== "string" || !orderId) {
console.error("[showcase/orders] Orders API returned 200 with no orderId");
return NextResponse.json({ error: "Orders API returned no orderId." }, { status: 502 });
}

// Only orderId is forwarded — the hosted checkout fetches the rest itself via
// the no-auth GET /orders/:id, so clientSecret never touches the browser.
return NextResponse.json({ orderId });
}
8 changes: 7 additions & 1 deletion showcase/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,12 @@ button { font-family: inherit; }
.sb-cat .glyph.x402 { background: linear-gradient(140deg, #12b3a6, #0b7d75); }
.sb-cat .glyph.mcp { background: linear-gradient(140deg, #7c6bff, #5a49df); }
.sb-cat .glyph.langchain { background: linear-gradient(140deg, #1ec46f, #0f9a52); }
.sb-cat .glyph.orders { background: linear-gradient(140deg, #4f7cff, #2f54eb); }

/* Fiat checkout — the embedded hosted Stripe iframe inside the run panel */
.fiat-checkout { border: 1px solid var(--border); border-radius: 12px; overflow: hidden; background: var(--panel); }
.fiat-checkout .fiat-cap { padding: 9px 13px; font-size: 12px; color: var(--ink-soft); border-bottom: 1px solid var(--border); font-family: var(--mono); }
.fiat-checkout iframe { width: 100%; height: 600px; border: 0; display: block; }
.sb-cat .cat-count { margin-left: auto; font-family: var(--mono); font-size: 11px; color: var(--faint);
font-weight: 400; }
.sb-cat .chev { color: var(--faint); transition: transform 0.18s ease; }
Expand Down Expand Up @@ -269,7 +275,7 @@ ul.learn li .b { width: 8px; height: 8px; border-radius: 2px; background: var(--
code.ic { font-family: var(--mono); font-size: 0.85em; color: var(--accent-ink); background: var(--accent-wash);
padding: 1px 5px; border-radius: 4px; }

.flow { display: flex; align-items: stretch; gap: 8px; margin: 4px 0; flex-wrap: wrap; }
.flow { display: flex; align-items: stretch; gap: 8px; margin: 24px 0 8px; flex-wrap: wrap; }
.flow .step { flex: 1; min-width: 96px; background: var(--panel); border: 1px solid var(--border); border-radius: 11px;
padding: 13px 12px; text-align: center; }
.flow .step b { font-family: var(--mono); font-size: 12.5px; color: var(--ink); font-weight: 500; display: block; }
Expand Down
28 changes: 21 additions & 7 deletions showcase/app/t/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { repoUrl, repoFileUrl } from "@/lib/repo";
import LiveRunPanel from "@/components/LiveRunPanel";
import RecapPanel from "@/components/RecapPanel";
import DiscoverPanel from "@/components/DiscoverPanel";
import FiatRunPanel from "@/components/FiatRunPanel";
import CodeBlock from "@/components/CodeBlock";
import { ArrowRight, ArrowLeft, GitHub, External } from "@/components/icons";

Expand Down Expand Up @@ -67,6 +68,15 @@ export default async function TutorialPage({ params }: { params: Promise<{ slug:
</div>

<div className="content">
{/* Tech stack badges — shown up front, before the walkthrough */}
<div className="chips" style={{ marginBottom: "22px" }}>
{t.tech.stack.map((s) => (
<span key={s} className="schip">
{s}
</span>
))}
</div>

{/* 1 — Learn */}
<section className="block" id="learn">
<div className="h2">
Expand Down Expand Up @@ -125,13 +135,6 @@ export default async function TutorialPage({ params }: { params: Promise<{ slug:
<div className="h2">
<span className="num">3</span> Under the hood
</div>
<div className="chips" style={{ marginBottom: "18px" }}>
{t.tech.stack.map((s) => (
<span key={s} className="schip">
{s}
</span>
))}
</div>
{t.tech.groups?.length ? (
t.tech.groups.map((g, gi) => (
<div key={gi} className="subblock">
Expand Down Expand Up @@ -184,6 +187,17 @@ export default async function TutorialPage({ params }: { params: Promise<{ slug:
</div>
{t.run.kind === "live" ? (
<LiveRunPanel slug={t.slug} run={t.run} title={t.title} />
) : t.run.kind === "fiat" ? (
// Default to localhost only in dev. In production the var is required;
// "" makes the panel show a "checkout not configured" notice rather than
// silently pointing the iframe (and the origin check) at localhost.
<FiatRunPanel
run={t.run}
embedBase={
process.env.NVM_EMBED_BASE_URL ??
(process.env.NODE_ENV === "production" ? "" : "http://localhost:4250")
}
/>
) : t.run.kind === "discover" ? (
<DiscoverPanel run={t.run} />
) : (
Expand Down
2 changes: 2 additions & 0 deletions showcase/components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
Signal,
Plug,
Layers,
Card,
Book,
Globe,
Discord,
Expand Down Expand Up @@ -48,6 +49,7 @@ const GLYPH: Record<Protocol, React.ReactNode> = {
mpp: <Signal size={13} />,
mcp: <Plug size={13} />,
langchain: <LinkIcon size={13} />,
orders: <Card size={13} />,
};

export default function AppShell({
Expand Down
6 changes: 6 additions & 0 deletions showcase/components/CodeBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { useMemo, useState } from "react";
import Prism from "prismjs";
import "prismjs/components/prism-python";
import "prismjs/components/prism-typescript";
// jsx/tsx must load after typescript (tsx extends both jsx and typescript) so
// the chat.tsx sample — JSX + TS — highlights fully instead of rendering plain.
import "prismjs/components/prism-jsx";
import "prismjs/components/prism-tsx";
import "prismjs/components/prism-json";
import "prismjs/components/prism-bash";
import { Copy, Check } from "./icons";
Expand All @@ -13,6 +17,8 @@ const LANG_MAP: Record<string, string> = {
py: "python",
typescript: "typescript",
ts: "typescript",
tsx: "tsx",
jsx: "jsx",
json: "json",
bash: "bash",
sh: "bash",
Expand Down
Loading
Loading