-
Notifications
You must be signed in to change notification settings - Fork 0
feat(showcase): "Fiat checkout" category + embedded Acme Travel Orders demo #73
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
1bc784b
feat(showcase): add "Fiat checkout" category + embedded Acme Travel O…
aaitor 2383cf8
feat(showcase): make the Fiat checkout demo run the REAL Orders flow
aaitor 9d78852
feat(showcase): tutorial-page polish + merchant prerequisite
aaitor 322f817
fix(showcase): address r-marques review — bound the public route, cap…
aaitor b66e685
docs(showcase): name the rate-limit / origin-guard bypass (review nit)
aaitor File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) { | ||
| 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"; | ||
|
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 }); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.