Skip to content

feat(showcase): "Fiat checkout" category + embedded Acme Travel Orders demo - #73

Merged
aaitor merged 5 commits into
mainfrom
aaitor/showcase-fiat-checkout
Sep 7, 2026
Merged

feat(showcase): "Fiat checkout" category + embedded Acme Travel Orders demo#73
aaitor merged 5 commits into
mainfrom
aaitor/showcase-fiat-checkout

Conversation

@aaitor

@aaitor aaitor commented Sep 6, 2026

Copy link
Copy Markdown
Member

Why this matters

The fiat-checkout-chat tutorial 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).

  • New category — protocol orders, label "Fiat checkout", a card glyph, placed right after Catalog in the sidebar and index.
  • content/tutorials.ts — the fiat-checkout-chat entry: what you'll learn, how it works, the code (the merchant backend that holds the org key + the origin/version-verified postMessage listener), and the file map.
  • New showcase server route app/api/orders/route.ts — the merchant backend: holds the org key (server-only), owns the prices (the client only sends a packageId), and calls the Nevermined Orders API.
  • New fiat run-kind + FiatRunPanel — the real embedded flow: pick a trip → POST /api/orders → mount the hosted Stripe checkout iframe → show booked only after a verified nvm:success (event.origin === embedBase and version === '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.example added):
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 → order ord_80f87c3a… → the hosted Stripe checkout renders in the iframe → paid 4242 4242 4242 4242nvm:success (origin http://localhost:4250, version: '1', real paymentIntent) → booked. Also: next build green (typechecks the content array + new types), node lib/demo-agent.mjs self-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 on main) and nothing in nvm-monorepo. Triggers the showcase-image workflow (verify: npm ci + demo-agent self-check + next build).

🤖 Generated with Claude Code

https://claude.ai/code/session_014PGfHZCGGTv9S3u2AxMkH8

…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
@aaitor
aaitor requested a review from r-marques September 6, 2026 18:07
aaitor and others added 2 commits September 6, 2026 20:19
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
@aaitor

aaitor commented Sep 6, 2026

Copy link
Copy Markdown
Member Author

@r-marques ready for review when you get a chance — showcase-only, CI green. It surfaces the fiat-checkout-chat tutorial (merged in #71) in the gallery as a new Fiat checkout category with the real embedded Orders flow (pick a trip → POST /api/orders → hosted Stripe iframe → booked). Thanks!

@aaitor
aaitor enabled auto-merge (squash) September 6, 2026 21:28
@r-marques

Copy link
Copy Markdown
Member

👀 Reviewing

@r-marques r-marques left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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_KEY is referenced in exactly one place that executes — route.ts:53. Every other hit under showcase/ is a comment, a doc string, or the user-facing "set it to run the demo" message. No NEXT_PUBLIC_ carries a secret, and page.tsx has no "use client", so reading NVM_EMBED_BASE_URL there 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 in content/tutorials.ts — 343795/1289900/875000 match $3,437.95/$12,899.00/$8,750.00 exactly.
  • clientSecret is genuinely withheld — only orderId is forwarded; the hosted checkout fetches the rest itself.
  • The postMessage handler is properly guarded — origin, then type, then version === "1", then dedupe, and a check that the orderId is one this panel created. A forged nvm:success from 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, head 9d788529. .env* skipped per the review contract, showcase/.env.example included.
  • Panel: spawned code-reviewer and silent-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

Comment thread showcase/app/api/orders/route.ts Outdated
Comment thread showcase/components/FiatRunPanel.tsx Outdated
Comment thread showcase/app/api/orders/route.ts Outdated
Comment thread showcase/app/api/orders/route.ts Outdated
@r-marques
r-marques disabled auto-merge September 7, 2026 08:36

@r-marques r-marques left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 r-marques left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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.

  1. POST /api/orders is unauthenticated and unthrottled, and it spends the organization's Nevermined API key. I confirmed there is no middleware at all under showcase/ at this head — no middleware.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 sibling app/api/agent/route.ts is explicitly documented as spending no real money — this is the first showcase route that does, and the one with no gate.

  2. Every package here exceeds the cap argocd#611 is about to deploy. That PR (open, reviewed today) wires ORDER_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:292 rejects each with BCK.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

Comment thread showcase/app/api/orders/route.ts
Comment thread showcase/app/api/orders/route.ts Outdated
Comment thread showcase/app/t/[slug]/page.tsx Outdated
Comment thread showcase/app/api/orders/route.ts Outdated
Comment thread showcase/components/FiatRunPanel.tsx

@r-marques r-marques left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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
@aaitor
aaitor requested a review from r-marques September 7, 2026 09:59
@aaitor

aaitor commented Sep 7, 2026

Copy link
Copy Markdown
Member Author

@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, Object.hasOwn allowlist, orderId-shape guard, 15s fetch timeouts, single-sourced price. Re-verified the full e2e + guards; CI green. Ready for re-review 🙏

@aaitor
aaitor enabled auto-merge (squash) September 7, 2026 09:59

@r-marques r-marques left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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 FIXEDObject.create(null) + Object.hasOwn
5 🟡 No timeout on either fetch FIXEDAbortSignal.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 FIXEDembedOrigin 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, head 322f8175; also diffed 9d788529...322f8175 directly.
  • Verified at this head: the new prices against #611's 50000 cap and the API's 100 floor, that no .amount references survive, Object.hasOwn on a null-prototype map, both AbortSignal.timeout call sites, the orderId type check, and that embedOrigin feeds both the origin check and the iframe src.

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
  • NICEembedOrigin derived once, and the unconfigured case now fails visibly — showcase/components/FiatRunPanel.tsx:39

Comment thread showcase/app/api/orders/route.ts
Comment thread showcase/lib/types.ts
Comment thread showcase/components/FiatRunPanel.tsx
@r-marques
r-marques disabled auto-merge September 7, 2026 10:03

@r-marques r-marques left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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
@aaitor
aaitor merged commit 2824075 into main Sep 7, 2026
2 checks passed
@aaitor
aaitor deleted the aaitor/showcase-fiat-checkout branch September 7, 2026 10:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants