feat(showcase): "Fiat checkout" category + embedded Acme Travel Orders demo - #73
Conversation
…rders demo Surfaces the fiat-checkout-chat tutorial in the showcase gallery the way the other tutorials appear (like the MPP demo): a new "Fiat checkout" category and a tutorial page with an embedded, interactive card-checkout panel. - New protocol "orders" (label "Fiat checkout") with a card glyph, placed after Catalog in the sidebar/index. - content/tutorials.ts: the fiat-checkout-chat entry — learn / how / code (the merchant backend + the verified postMessage listener) / files. - New FiatRun kind + FiatRunPanel: a self-contained, scripted Acme Travel chat (pick a trip → hosted card checkout → "booked"), reusing the existing runpanel CSS. Nothing charges a card in the deployed gallery; the real hosted Stripe iframe lives in the tutorial's own app (repoPath), noted in the panel. Build green (next build typechecks the content array); demo-agent self-check passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014PGfHZCGGTv9S3u2AxMkH8
Replaces the scripted card step with the actual flow: selecting a trip sends a real POST /api/v1/orders to the backend and embeds the hosted Stripe checkout, finalized by paying a test card in the iframe. - New showcase server route app/api/orders/route.ts — the merchant backend that holds the org key (server-only), owns the prices, and calls the Orders API. - FiatRunPanel now POSTs /api/orders, mounts the hosted Stripe iframe, and shows "booked" only after a verified nvm:success (origin + version === '1'). Falls back to a clear notice when the Orders backend isn't configured/reachable. - embedBase passed from server env (NVM_EMBED_BASE_URL); .env.example added. Local dev points at the local stack; production points at the sandbox Orders API. Verified e2e through the gallery panel: pick trip → real order → embedded Stripe → pay 4242 → nvm:success (v1) → booked. Build green; demo-agent self-check passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014PGfHZCGGTv9S3u2AxMkH8
Small UI/content fixes (page-template ones apply to every tutorial): - CodeBlock: load Prism jsx/tsx grammars and map tsx/jsx, so the chat.tsx (TSX) sample is syntax-highlighted instead of rendering plain. - How section: more breathing room between the paragraphs and the flow diagram (.flow top margin 4px → 24px). Applies to all tutorial pages. - Move the tech-stack badges up front — right before "1 · What you'll learn" — instead of inside section 3. Applies to all tutorial pages. - Fiat checkout content: state the merchant prerequisite — a Nevermined organization account with a Stripe account linked (Stripe Connect) — in the learn bullets and the How walkthrough. Build green; demo-agent self-check passes; verified in the browser. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014PGfHZCGGTv9S3u2AxMkH8
|
@r-marques ready for review when you get a chance — showcase-only, CI green. It surfaces the |
|
👀 Reviewing |
r-marques
left a comment
There was a problem hiding this comment.
🤖 Automated PR review — 🟡 Mergeable with nits
Adds a "Fiat checkout" showcase category plus an embedded Acme Travel demo: a client panel picks a package, a new server route creates the Nevermined Order with the org's key, and the hosted Stripe checkout is mounted in an iframe that reports back over postMessage. The security-relevant parts are right, and I traced each rather than taking the comments' word for it. One should-fix, both nits are drift/robustness rather than defects.
What I verified
- The key never reaches the browser.
NVM_ORDER_API_KEYis referenced in exactly one place that executes —route.ts:53. Every other hit undershowcase/is a comment, a doc string, or the user-facing "set it to run the demo" message. NoNEXT_PUBLIC_carries a secret, andpage.tsxhas no"use client", so readingNVM_EMBED_BASE_URLthere and passing it down is the correct boundary. - The client cannot name its own amount. Prices are server-owned; the request carries only
packageId, validated against the catalog. I checked all three amounts against the display strings incontent/tutorials.ts— 343795/1289900/875000 match $3,437.95/$12,899.00/$8,750.00 exactly. clientSecretis genuinely withheld — onlyorderIdis forwarded; the hosted checkout fetches the rest itself.- The
postMessagehandler is properly guarded — origin, thentype, thenversion === "1", then dedupe, and a check that theorderIdis one this panel created. A forgednvm:successfrom another frame gets nowhere. - Failure paths are handled — 503 on a missing key, 400 on bad JSON, 400 on an unknown package, 502 on a non-2xx with the upstream detail logged server-side and a generic message to the client.
The one gap is the success path: const { orderId } = await res.json() is unguarded, so a 200 with an unexpected shape yields 200 {}, and the panel then renders a checkout iframe at /checkout/order/undefined and waits forever — the catch never fires, so the friendly error never shows. Details and a one-line fix are inline.
What I reviewed
- Diff range
origin/main...HEAD— 10 files, +439/−10, head9d788529..env*skipped per the review contract,showcase/.env.exampleincluded. - Panel: spawned
code-reviewerandsilent-failure-hunter— neither delivered a report. Both reached idle without returning findings and a capped re-ask produced nothing, so every finding here is my own. I am not claiming panel coverage I did not get.
Verdict: 🟡 Mergeable with nits — 0 blockers, 1 should-fix, 2 nits.
🧭 Inline comments (4)
Each is posted on its line in Files changed.
- 🟡 SHOULD FIX — A 200 with an unexpected shape becomes a silent hang the UI presents as success —
showcase/app/api/orders/route.ts:75 - 💡 NIT — Normalize
embedBase— a trailing slash in the env silently breaks confirmation —showcase/components/FiatRunPanel.tsx:35 - 💡 NIT — The charged price and the displayed price are two constants held together by a comment —
showcase/app/api/orders/route.ts:18 - ✅ NICE — The secret-handling story holds up when you actually trace it —
showcase/app/api/orders/route.ts:53
r-marques
left a comment
There was a problem hiding this comment.
🟡 Approved with comments — no blockers; 1 should-fix, 2 nit(s) left inline. Auto-merge was disabled so these can be looked at first.
Inline review: #73 (review)
r-marques
left a comment
There was a problem hiding this comment.
🤖 Automated PR review (addendum) — 🔴 Blocked · withdrawing my approval above
I approved this PR earlier today. That was wrong, and this review withdraws it.
My review panel (code-reviewer, silent-failure-hunter) went idle without delivering; I said so in the review above and posted on my own verification. Both then reported after I had approved, and they found something I missed. I have re-verified each claim myself rather than taking it on trust — evidence is on the inline comments.
Two blockers.
-
POST /api/ordersis unauthenticated and unthrottled, and it spends the organization's Nevermined API key. I confirmed there is no middleware at all undershowcase/at this head — nomiddleware.ts, no auth module — so nothing bounds how many real Orders an anonymous caller can mint against the org's Stripe Connect account. Order creation charges no card, so this is not theft; it is unbounded creation under our own identity, on a route that is about to sit on a public URL. The siblingapp/api/agent/route.tsis explicitly documented as spending no real money — this is the first showcase route that does, and the one with no gate. -
Every package here exceeds the cap
argocd#611is about to deploy. That PR (open, reviewed today) wiresORDER_MAX_AMOUNT_MINOR: '50000'= $500.00 into all four API deployments; this catalog is $3,437.95 / $12,899.00 / $8,750.00.orders.service.ts:292rejects each withBCK.ORDER.0003, which this route surfaces as "Is the Orders backend running?" — pointing any debugger at the wrong layer. Neither PR references the other and both are in flight now.
Also new: the embed base silently falls back to http://localhost:4250 in production (breaking both the iframe and the origin check, with no error anywhere); !CATALOG[packageId] is a prototype lookup that admits constructor/toString/__proto__ (small blast radius — the upstream DTO rejects — but five magic strings reach an authenticated call); and neither fetch has a timeout, so a hung backend spins the UI with no way out.
Everything in my earlier review still stands — the API key really is server-only, prices really are server-owned and match their display strings, clientSecret really is withheld, and the postMessage handler's origin/version/known-order checks are genuinely correct. Those were the right things to check; they were just not all of them.
Verdict: 🔴 Blocked — 2 blockers, 3 should-fix (plus the 1 should-fix and 2 nits from the review above, which are unchanged).
🧭 Inline comments (5)
Each is posted on its line in Files changed.
- 🔴 BLOCKER — Unauthenticated, unthrottled public endpoint that spends the org's identity —
showcase/app/api/orders/route.ts:31 - 🔴 BLOCKER — Every package here exceeds the $500 cap that argocd#611 is about to deploy —
showcase/app/api/orders/route.ts:19 - 🟡 SHOULD FIX — The embed base silently falls back to localhost in production —
showcase/app/t/[slug]/page.tsx:191 - 🟡 SHOULD FIX —
!CATALOG[packageId]is a prototype lookup, not an allowlist —showcase/app/api/orders/route.ts:45 - 🟡 SHOULD FIX — No timeout on either fetch, so a hung backend spins the UI forever —
showcase/components/FiatRunPanel.tsx:63
r-marques
left a comment
There was a problem hiding this comment.
🔴 Blocked — 2 blocker(s) to address before merge. Details are inline on the changed lines.
Inline review: #73 (review)
…-safe prices Two blockers + 4 should-fix + 2 nits on the Fiat checkout demo (PR #73): BLOCKERS - POST /api/orders is public and spends the org's identity (creates real Orders). Add a same-origin (Origin/Referer) guard + an in-memory per-IP rate limit (8/10min). Verified: cross-origin → 403, 9th request → 429. - Every package exceeded argocd#611's incoming $500/order cap (ORDER_MAX_AMOUNT_MINOR=50000) → every trip would 502 in the capped sandbox. Reframe as sub-$500 experiences ($189 / $145 / $329); a low-value impulse buy also fits the "no account, tap a card" story better. SHOULD-FIX - A 200 without an orderId became a silent hang → validate orderId, return 502. - Localhost embed base no longer leaks into production (required there; "" makes the panel show a "not configured" notice instead of pointing at localhost). - packageId lookup uses Object.hasOwn (real allowlist) — "constructor"/"__proto__" no longer pass. CATALOG built with a null prototype. - Both fetches get AbortSignal.timeout(15s) so a hung backend can't spin forever. NITS - Normalize embedBase → origin once (trailing slash / path can't break the origin check or the iframe URL). - Price is single-sourced from content/tutorials.ts (amountMinor); the panel formats the display, the route charges from it — they can't drift. Re-verified e2e (pick → real order → embedded Stripe → 4242 → nvm:success v1 → booked) and the guards (403/429/400). Build green; demo-agent self-check passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014PGfHZCGGTv9S3u2AxMkH8
|
@r-marques all 8 findings addressed in 322f817 (2 blockers + 4 should-fix + 2 nits), each replied on its thread and resolved. Highlights: same-origin + per-IP rate-limit on the public route (403/429 verified), sub-$500 cap-safe prices, |
r-marques
left a comment
There was a problem hiding this comment.
🤖 Automated PR review (round 2) — 🟡 Mergeable with nits
Re-reviewed at 322f8175 (round 1 was 9d788529). Every finding from round 1 and its addendum is fixed, including both blockers, and several are fixed better than I asked for. No blockers remain.
| # | Round-1 finding | Status at 322f8175 |
|---|---|---|
| 1 | 🔴 POST /api/orders unauthenticated and unthrottled |
FIXED — same-origin guard + per-IP limiter (8 / 10 min); see the one nit |
| 2 | 🔴 All three prices exceed argocd#611's $500 cap |
FIXED — now $189 / $145 / $329, and the type doc names ORDER_MAX_AMOUNT_MINOR |
| 3 | 🟡 embedBase falls back to localhost in production |
FIXED — "" in prod, and the panel refuses visibly instead of half-working |
| 4 | 🟡 !CATALOG[packageId] is a prototype lookup |
FIXED — Object.create(null) + Object.hasOwn |
| 5 | 🟡 No timeout on either fetch | FIXED — AbortSignal.timeout(15_000) both sides, with a distinct timeout message |
| 6 | 🟡 Unguarded res.json() → silent 200 {} |
FIXED — parse guarded, orderId type-checked, routed into the 502 the client already handles |
| 7 | 💡 Raw string compare on e.origin |
FIXED — embedOrigin derived once and used for the check and the iframe |
| 8 | 💡 Charged price and displayed price duplicated | FIXED — the second constant is deleted, not documented |
Two are worth calling out as better than the ask. The price fix removes amount: string rather than syncing it, so display and charge cannot drift because there is nothing left to drift from — and the type's doc comment records the ORDER_MAX_AMOUNT_MINOR constraint where the next person will meet it. And the embedOrigin normalization is computed once and feeds both consumers, so the trailing-slash class is closed at the source rather than at each call site.
One nit left, and it is about documentation rather than code: the origin guard is conditional (if (origin && host), so a header-less request skips it) and x-forwarded-for is caller-supplied unless a proxy overwrites it — so the two halves of the bound share a bypass. That is fine for a demo; it just deserves a sentence so whoever deploys it publicly knows what it does and does not stop.
What I reviewed
- Diff range
origin/main...HEAD— 10 files, head322f8175; also diffed9d788529...322f8175directly. - Verified at this head: the new prices against
#611's50000cap and the API's100floor, that no.amountreferences survive,Object.hasOwnon a null-prototype map, bothAbortSignal.timeoutcall sites, theorderIdtype check, and thatembedOriginfeeds both the origin check and the iframesrc.
Verdict: 🟡 Mergeable with nits — 0 blockers, 0 should-fix, 1 nit.
🧭 Inline comments (3)
Each is posted on its line in Files changed.
- 💡 NIT — Both halves of the bound have a documented bypass — worth naming the second one too —
showcase/app/api/orders/route.ts:79 - ✅ NICE — One price, formatted for display — this is the right shape, and it names the cap —
showcase/lib/types.ts:137 - ✅ NICE —
embedOriginderived once, and the unconfigured case now fails visibly —showcase/components/FiatRunPanel.tsx:39
r-marques
left a comment
There was a problem hiding this comment.
🟡 Approved with comments — no blockers; 0 should-fix, 1 nit(s) left inline. Auto-merge was disabled so these can be looked at first.
Inline review: #73 (review)
r-marques round-2 nit: the same-origin check is skipped for header-less requests and x-forwarded-for is caller-supplied, so both halves of the bound share a bypass. Fine for a demo; document it so a public deployer knows to put the route behind a platform WAF / rate-limiter and treat the in-route guards as a backstop. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014PGfHZCGGTv9S3u2AxMkH8
Why this matters
The
fiat-checkout-chattutorial shipped as a folder, but the showcase gallery — where people browse what Nevermined can do — didn't surface it. This adds it there the way every other tutorial appears: a new Fiat checkout category and a one-page tutorial with an embedded demo that runs the real Orders flow. A visitor picks a trip, a real order is created, the hosted Stripe checkout loads right in the page, and paying a test card books it — the headline of the Orders flow, live in the gallery, no cloning required.What's in it
Follows the existing showcase structure (like the MPP demo): a new category + a normalized tutorial page (learn / how / code / an in-page run panel).
orders, label "Fiat checkout", a card glyph, placed right after Catalog in the sidebar and index.content/tutorials.ts— thefiat-checkout-chatentry: what you'll learn, how it works, the code (the merchant backend that holds the org key + the origin/version-verifiedpostMessagelistener), and the file map.app/api/orders/route.ts— the merchant backend: holds the org key (server-only), owns the prices (the client only sends apackageId), and calls the Nevermined Orders API.fiatrun-kind +FiatRunPanel— the real embedded flow: pick a trip →POST /api/orders→ mount the hosted Stripe checkout iframe → show booked only after a verifiednvm:success(event.origin === embedBaseandversion === '1'). It reuses the existing run-panel CSS, so it looks native next to the other tutorials.Configuration (and the deployed gallery)
The panel needs a running Orders backend + an org key, read from server env (
.env.exampleadded):NVM_ORDER_API_KEY(secret, server-only),NVM_API_BASE_URL,NVM_EMBED_BASE_URL. Local dev points at the local stack; production points at the sandbox Orders API once it ships (epic #3238). When the backend isn't configured/reachable the panel shows a clear "start the Orders backend" notice rather than looking broken, and the rest of the showcase is unaffected. No real money moves — Stripe test mode.Verified end-to-end
Ran the whole thing through the gallery panel in a browser against the local Orders stack: pick Barcelona → real
POST /api/orders→ orderord_80f87c3a…→ the hosted Stripe checkout renders in the iframe → paid4242 4242 4242 4242→nvm:success(originhttp://localhost:4250,version: '1', realpaymentIntent) → booked. Also:next buildgreen (typechecks the content array + new types),node lib/demo-agent.mjsself-check passes, and the merchant route rejects an unknown package (400) and a missing key (503).Scope
Showcase-only. No change to the
fiat-checkout-chat/tutorial itself (already onmain) and nothing innvm-monorepo. Triggers theshowcase-imageworkflow (verify:npm ci+ demo-agent self-check +next build).🤖 Generated with Claude Code
https://claude.ai/code/session_014PGfHZCGGTv9S3u2AxMkH8