diff --git a/showcase/.env.example b/showcase/.env.example new file mode 100644 index 00000000..aa1bc9cc --- /dev/null +++ b/showcase/.env.example @@ -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 diff --git a/showcase/app/api/orders/route.ts b/showcase/app/api/orders/route.ts new file mode 100644 index 00000000..95d09826 --- /dev/null +++ b/showcase/app/api/orders/route.ts @@ -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 = 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(); +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) { + 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"; + 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 }); +} diff --git a/showcase/app/globals.css b/showcase/app/globals.css index e1ce1190..536bec25 100644 --- a/showcase/app/globals.css +++ b/showcase/app/globals.css @@ -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; } @@ -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; } diff --git a/showcase/app/t/[slug]/page.tsx b/showcase/app/t/[slug]/page.tsx index 3519adfb..da2aad39 100644 --- a/showcase/app/t/[slug]/page.tsx +++ b/showcase/app/t/[slug]/page.tsx @@ -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"; @@ -67,6 +68,15 @@ export default async function TutorialPage({ params }: { params: Promise<{ slug:
+ {/* Tech stack badges — shown up front, before the walkthrough */} +
+ {t.tech.stack.map((s) => ( + + {s} + + ))} +
+ {/* 1 — Learn */}
@@ -125,13 +135,6 @@ export default async function TutorialPage({ params }: { params: Promise<{ slug:
3 Under the hood
-
- {t.tech.stack.map((s) => ( - - {s} - - ))} -
{t.tech.groups?.length ? ( t.tech.groups.map((g, gi) => (
@@ -184,6 +187,17 @@ export default async function TutorialPage({ params }: { params: Promise<{ slug:
{t.run.kind === "live" ? ( + ) : 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. + ) : t.run.kind === "discover" ? ( ) : ( diff --git a/showcase/components/AppShell.tsx b/showcase/components/AppShell.tsx index daf4c2ef..d13f8840 100644 --- a/showcase/components/AppShell.tsx +++ b/showcase/components/AppShell.tsx @@ -15,6 +15,7 @@ import { Signal, Plug, Layers, + Card, Book, Globe, Discord, @@ -48,6 +49,7 @@ const GLYPH: Record = { mpp: , mcp: , langchain: , + orders: , }; export default function AppShell({ diff --git a/showcase/components/CodeBlock.tsx b/showcase/components/CodeBlock.tsx index aeccf080..194dec14 100644 --- a/showcase/components/CodeBlock.tsx +++ b/showcase/components/CodeBlock.tsx @@ -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"; @@ -13,6 +17,8 @@ const LANG_MAP: Record = { py: "python", typescript: "typescript", ts: "typescript", + tsx: "tsx", + jsx: "jsx", json: "json", bash: "bash", sh: "bash", diff --git a/showcase/components/FiatRunPanel.tsx b/showcase/components/FiatRunPanel.tsx new file mode 100644 index 00000000..4f4f128d --- /dev/null +++ b/showcase/components/FiatRunPanel.tsx @@ -0,0 +1,210 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import type { FiatRun, FiatPackage } from "@/lib/types"; + +// The REAL Orders flow, embedded in the gallery: pick a trip → POST /api/orders +// (our server route holds the org key and calls the Nevermined Orders API) → +// mount the hosted Stripe checkout in an iframe → the iframe postMessages +// nvm:success and we show "booked". No account, no wallet — a card in the iframe. +// +// This needs a running Orders backend + NVM_ORDER_API_KEY (local stack now; the +// sandbox once Orders ships). If it's not reachable, the panel says so. + +const fmtUsd = (amountMinor: number) => + new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amountMinor / 100); + +const ORDER_TIMEOUT_MS = 15_000; + +type Item = + | { type: "msg"; role: "user" | "agent"; text: string } + | { type: "checkout"; orderId: string; pkg: FiatPackage } + | { type: "confirm"; pkg: FiatPackage; paymentIntent: string } + | { type: "notice"; text: string }; + +export default function FiatRunPanel({ run, embedBase }: { run: FiatRun; embedBase: string }) { + const [items, setItems] = useState([{ type: "msg", role: "agent", text: run.greeting }]); + const [picking, setPicking] = useState(true); + const [busy, setBusy] = useState(false); + const logRef = useRef(null); + const confirmed = useRef>(new Set()); + const orderPkg = useRef>(new Map()); + + // Normalize once: e.origin is a browser-normalized origin (no trailing slash), + // while embedBase is whatever an operator typed into NVM_EMBED_BASE_URL. Compare + // and build the iframe src off the SAME derived origin so a trailing slash (or a + // full URL with a path) can't silently break the origin check or the iframe URL. + // Empty string (unconfigured in production) → "" → the panel refuses to proceed. + const embedOrigin = (() => { + try { + return embedBase ? new URL(embedBase).origin : ""; + } catch { + return ""; + } + })(); + + useEffect(() => { + logRef.current?.scrollTo({ top: logRef.current.scrollHeight }); + }, [items]); + + // Trust nvm:success only from the embed origin, only our event, only version 1. + useEffect(() => { + function onMessage(e: MessageEvent) { + if (!embedOrigin || e.origin !== embedOrigin) return; + if (e.data?.type !== "nvm:success") return; + if (e.data?.version !== "1") return; + const { orderId, paymentIntent } = e.data.payload ?? {}; + if (!orderId || confirmed.current.has(orderId)) return; + const pkg = orderPkg.current.get(orderId); + if (!pkg) return; + confirmed.current.add(orderId); + // swap the (completed) checkout iframe for the confirmation + setItems((x) => [ + ...x.filter((it) => !(it.type === "checkout" && it.orderId === orderId)), + { type: "confirm", pkg, paymentIntent: paymentIntent ?? "" }, + ]); + } + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, [embedOrigin]); + + async function pick(pkg: FiatPackage) { + if (busy) return; + if (!embedOrigin) { + setItems((x) => [ + ...x, + { type: "notice", text: "Checkout isn't configured here (NVM_EMBED_BASE_URL is unset). Run it from the fiat-checkout-chat/ app — see the tutorial's README." }, + ]); + return; + } + setBusy(true); + setPicking(false); + setItems((x) => [ + ...x, + { type: "msg", role: "user", text: `I'd like to book the ${pkg.name}.` }, + { type: "msg", role: "agent", text: "Setting up your secure checkout…" }, + ]); + try { + const res = await fetch("/api/orders", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ packageId: pkg.id }), + signal: AbortSignal.timeout(ORDER_TIMEOUT_MS), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || typeof data.orderId !== "string") { + throw new Error(data.error ?? `order failed (${res.status})`); + } + orderPkg.current.set(data.orderId, pkg); + setItems((x) => [ + ...x.filter((it) => !(it.type === "msg" && it.text === "Setting up your secure checkout…")), + { + type: "msg", + role: "agent", + text: `Here's your secure checkout for the ${pkg.name} (${fmtUsd(pkg.amountMinor)}). Pay with the Stripe test card 4242 4242 4242 4242 — any future expiry / CVC / ZIP.`, + }, + { type: "checkout", orderId: data.orderId, pkg }, + ]); + } catch (err) { + const timedOut = err instanceof DOMException && err.name === "TimeoutError"; + setItems((x) => [ + ...x.filter((it) => !(it.type === "msg" && it.text === "Setting up your secure checkout…")), + { + type: "notice", + text: timedOut + ? "The Orders backend didn't respond in time. Make sure the local Nevermined Orders stack is running — see the tutorial's README." + : `Couldn't reach the Orders backend (${err instanceof Error ? err.message : "error"}). Start the local Nevermined Orders stack and set NVM_ORDER_API_KEY — see the tutorial's README.`, + }, + ]); + setPicking(true); + } finally { + setBusy(false); + } + } + + function reset() { + setItems([{ type: "msg", role: "agent", text: run.greeting }]); + setPicking(true); + } + + return ( + <> +
+
+
+ +
+ {items.map((it, i) => { + if (it.type === "msg") { + return ( +
+ {it.text} +
+ ); + } + if (it.type === "checkout") { + const src = + `${embedOrigin}/checkout/order/${it.orderId}` + + `?parentOrigin=${encodeURIComponent(typeof window !== "undefined" ? window.location.origin : "")}`; + return ( +
+
+ 🔒 Secure Stripe checkout · {it.pkg.name} · {fmtUsd(it.pkg.amountMinor)} +
+