-
Notifications
You must be signed in to change notification settings - Fork 0
orders.serve(app): the human-present checkout, wired in one call (#97) #98
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 3 commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
37d1724
feat(gate,#97): credentagent.orders.* + usd Money — the real HP check…
dzuluaga 3fd3c92
feat(gate,#97): orders.serve(app) — the checkout, wired in one call
dzuluaga e2b7e7b
docs(#97): runnable orders-checkout example + README (real orders.ser…
dzuluaga c85a86d
fix(gate,#97): rails return to the orders checkout page, not /checkout
dzuluaga 136bae7
docs(#97): make the orders snippet's lifecycle explicit (startup vs p…
dzuluaga 2af7c85
docs(#97): call order.settled an in-process event, not a webhook (acc…
dzuluaga e733ce9
fix(gate,#97): address PR #98 review — awaited persistence, idempoten…
dzuluaga 99ebfe4
docs(#97): serverless caveat next to the order.settled snippet
dzuluaga 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
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,67 @@ | ||
| # `orders-checkout/` — a checkout an agent can drive, in a few lines | ||
|
|
||
| An AI agent wants to buy a bottle of wine for you. Wine is age-restricted, so the purchase | ||
| can't just go through — you have to prove you're 21+ and pay. This example is the smallest | ||
| real thing that makes that safe: the agent starts the order and gets a **link**; you open the | ||
| link, prove your age, and pay; the order settles. | ||
|
|
||
| The whole checkout is wired in **one call** — `credentagent.orders.serve(app)`. There's no | ||
| store to assemble and no completion logic to hand-write; the library owns the ceremony. | ||
|
|
||
| ```js | ||
| import express from "express"; | ||
| import { CredentAgent, age, payment, required } from "@openmobilehub/credentagent-gate"; | ||
|
|
||
| const app = express(); | ||
| app.use(express.json()); | ||
|
|
||
| const credentagent = new CredentAgent({ walletOrigin: "http://localhost:4000" }); | ||
| credentagent.orders.serve(app); // ← rails + checkout page + completion | ||
| credentagent.on("order.settled", ({ id }) => fulfill(id)); // ← fired once, when it's paid | ||
|
|
||
| app.post("/buy-wine", (_req, res) => { | ||
| const { approveUrl } = credentagent.orders.create({ | ||
| order: { id: "", total: 2100, currency: "USD", lines: [{ id: "wine", name: "Bottle of wine", quantity: 1, unitPrice: 2100, minimumAge: 21 }] }, | ||
| policy: [required(age.over(21)), required(payment.in("usd"))], | ||
| }); | ||
| res.json({ approveUrl }); // ← hand this link to the human | ||
| }); | ||
| ``` | ||
|
|
||
| ## Run it | ||
|
|
||
| ```bash | ||
| npm run build # build the two @openmobilehub/credentagent-* packages | ||
| node examples/orders-checkout/server.mjs # → http://localhost:4000 | ||
| ``` | ||
|
|
||
| Then: | ||
|
|
||
| 1. `curl -X POST http://localhost:4000/buy-wine` → `{ id, approveUrl }` | ||
| 2. Open the `approveUrl` in a browser → the checkout page (prove age + pay; on your phone for the real wallet ceremony). | ||
| 3. `curl http://localhost:4000/orders/<id>` → `{ ok: true }` once it settles — or just listen for `order.settled`. | ||
|
|
||
| ## Prove it (no browser needed) | ||
|
|
||
| ```bash | ||
| node examples/orders-checkout/smoke.mjs | ||
| ``` | ||
|
|
||
| The smoke test drives the built package over HTTP and asserts the two things that matter: | ||
|
|
||
| - A **gated** order (age + payment) renders a checkout page but **cannot** be completed by a | ||
| direct POST to the instant-demo path — it's refused (403) and stays pending. Skipping the | ||
| gate is refused on the server, not just hidden in the page. | ||
| - An **ungated** order completes via the demo path → `order.settled` fires → `retrieve` is ok, | ||
| with the amount re-derived server-side. | ||
|
|
||
| ## What's real, and what isn't yet | ||
|
|
||
| - **Real:** the order lifecycle (`create` → link → checkout → `order.settled`), the server-side | ||
| amount + age re-derivation (the total is never trusted from the link), and the fail-closed | ||
| rule that a gated order only completes through the wallet ceremony. | ||
| - **Demo-only:** `trust_level` is `"presence-only-demo"`. The wire crypto is real, but there's | ||
| no issuer / device-signature trust anchor yet — a self-crafted credential would pass. Don't | ||
| gate anything needing a real safety guarantee on it until issuer-verified trust lands. | ||
| - The **instant-demo "Complete purchase"** button exists only for ungated orders (so the flow | ||
| is clickable without a wallet); a real age/payment order always goes through the phone. |
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,41 @@ | ||
| // Runnable example — a checkout an AI agent can drive, built on the real credentagent.orders API. | ||
| // | ||
| // node examples/orders-checkout/server.mjs # boots on http://localhost:4000 | ||
| // node examples/orders-checkout/smoke.mjs # drives the whole flow + asserts (no browser) | ||
| // | ||
| // The whole checkout is wired in ONE call — `credentagent.orders.serve(app)`. There is no | ||
| // store to assemble, no completion context to hand-build: the library owns the ceremony. | ||
| // An agent calls POST /buy-wine, gets back an `approveUrl`, and hands that link to the human; | ||
| // the human proves their age + pays on the checkout page; the order settles. | ||
| import express from "express"; | ||
| import { CredentAgent, age, payment, required } from "@openmobilehub/credentagent-gate"; | ||
|
|
||
| const PORT = 4000; | ||
| const app = express(); | ||
| app.use(express.json()); | ||
|
|
||
| // Configure once. `orders.serve(app)` wires the ceremony rails, the checkout page at each | ||
| // order's approveUrl, and completion — a finished ceremony fires `order.settled`. | ||
| const credentagent = new CredentAgent({ walletOrigin: `http://localhost:${PORT}` }); | ||
| credentagent.orders.serve(app); | ||
| credentagent.on("order.settled", ({ id }) => console.log(`✓ order.settled: ${id} — fulfill it now`)); | ||
|
|
||
| // What an agent calls to start a purchase that needs consent. It gets back a link to hand | ||
| // to the human; the amount + age gate are re-derived server-side, never trusted from a token. | ||
| app.post("/buy-wine", (_req, res) => { | ||
| const { id, approveUrl } = credentagent.orders.create({ | ||
| order: { id: "", total: 2100, currency: "USD", lines: [{ id: "wine", name: "Bottle of wine", quantity: 1, unitPrice: 2100, minimumAge: 21 }] }, | ||
| policy: [required(age.over(21)), required(payment.in("usd"))], | ||
| }); | ||
| res.json({ id, approveUrl }); | ||
| }); | ||
|
|
||
| // What the agent polls (or better: subscribe to `order.settled` above and skip polling). | ||
| app.get("/orders/:id", async (req, res) => res.json(await credentagent.orders.retrieve(req.params.id))); | ||
|
|
||
| app.listen(PORT, () => { | ||
| console.log(`orders-checkout example on http://localhost:${PORT}`); | ||
| console.log(` 1) POST /buy-wine → { id, approveUrl }`); | ||
| console.log(` 2) open the approveUrl in a browser → prove age + pay (on your phone for a real ceremony)`); | ||
| console.log(` 3) GET /orders/:id → { ok: true } once it settles`); | ||
| }); |
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,66 @@ | ||
| // Smoke test for the orders-checkout example — drives the REAL built package over HTTP and | ||
| // asserts, so CI (and you) can prove the checkout works end-to-end without a browser or wallet. | ||
| // | ||
| // node examples/orders-checkout/smoke.mjs | ||
| // | ||
| // It covers the two security-critical shapes: | ||
| // • a GATED order (age + payment) renders a checkout page but CANNOT be completed by a | ||
| // direct POST to the instant-demo path — it is refused (403) and stays pending; | ||
| // • an UNGATED order completes via the demo path → order.settled fires → retrieve is ok. | ||
| import express from "express"; | ||
| import { CredentAgent, age, payment, required } from "@openmobilehub/credentagent-gate"; | ||
|
|
||
| const app = express(); | ||
| app.use(express.json()); | ||
|
|
||
| const settled = []; | ||
| const ca = new CredentAgent({ walletOrigin: "http://localhost:0" }); | ||
| ca.orders.serve(app); | ||
| ca.on("order.settled", ({ id }) => settled.push(id)); | ||
|
|
||
| // Two create endpoints — a gated one (wine) and an ungated one (sticker) — plus retrieve. | ||
| app.post("/gated", (_req, res) => res.json(ca.orders.create({ | ||
| order: { id: "", total: 2100, currency: "USD", lines: [{ id: "wine", name: "Wine", quantity: 1, unitPrice: 2100, minimumAge: 21 }] }, | ||
| policy: [required(age.over(21)), required(payment.in("usd"))], | ||
| }))); | ||
| app.post("/ungated", (_req, res) => res.json(ca.orders.create({ | ||
| order: { id: "", total: 500, currency: "USD", lines: [{ id: "sticker", name: "Sticker", quantity: 1, unitPrice: 500 }] }, | ||
| policy: [], | ||
| }))); | ||
| app.get("/orders/:id", async (req, res) => res.json(await ca.orders.retrieve(req.params.id))); | ||
|
|
||
| let failures = 0; | ||
| const check = (label, cond) => { console.log(`${cond ? "✓" : "✗"} ${label}`); if (!cond) failures++; }; | ||
|
|
||
| const server = await new Promise((resolve) => { const s = app.listen(0, () => resolve(s)); }); | ||
| const base = `http://localhost:${server.address().port}`; | ||
| const j = async (r) => ({ status: r.status, body: r.headers.get("content-type")?.includes("json") ? await r.json() : await r.text() }); | ||
|
|
||
| try { | ||
| // ── Gated order: rendered, but never completable from the instant-demo path ── | ||
| const gated = (await j(await fetch(`${base}/gated`, { method: "POST" }))).body; | ||
| check("gated create returns an id + approveUrl on this origin", gated.id?.startsWith("ord_") && gated.approveUrl.includes(gated.id)); | ||
|
|
||
| const page = await j(await fetch(`${base}/credentagent/orders/${gated.id}`)); | ||
| check("gated checkout page renders (200) and shows the item", page.status === 200 && page.body.includes("Wine")); | ||
|
|
||
| const placeGated = await j(await fetch(`${base}/credentagent/orders/${gated.id}/place`, { method: "POST" })); | ||
| check("gated order is REFUSED on the instant-demo place path (403)", placeGated.status === 403); | ||
|
|
||
| const gatedAfter = (await j(await fetch(`${base}/orders/${gated.id}`))).body; | ||
| check("gated order stays PENDING after the refused place (never ok unverified)", gatedAfter.ok === false && gatedAfter.pending === true); | ||
|
|
||
| // ── Ungated order: completes end-to-end via the demo path ── | ||
| const ungated = (await j(await fetch(`${base}/ungated`, { method: "POST" }))).body; | ||
| const placeUngated = await j(await fetch(`${base}/credentagent/orders/${ungated.id}/place`, { method: "POST" })); | ||
| check("ungated order completes on the demo place path (200)", placeUngated.status === 200); | ||
| check("order.settled fired exactly once for the ungated order", settled.length === 1 && settled[0] === ungated.id); | ||
|
|
||
| const ungatedAfter = (await j(await fetch(`${base}/orders/${ungated.id}`))).body; | ||
| check("ungated order retrieves as ok with the server-derived amount ($5.00)", ungatedAfter.ok === true && ungatedAfter.completion?.amount === 500); | ||
| } finally { | ||
| server.close(); | ||
| } | ||
|
|
||
| console.log(failures === 0 ? "\nALL SMOKE CHECKS PASSED" : `\n${failures} CHECK(S) FAILED`); | ||
| process.exit(failures === 0 ? 0 : 1); | ||
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| // Money — an opaque, currency-checked value. Amounts are integer minor units (cents), | ||
| // so no float drift; the raw scalar is not public, so a caller can't accidentally compare | ||
| // or add a bare number across currencies (spec 009 FR-005). Build with `usd.dollars(20)` / | ||
| // `usd.cents(2000)`; compare with `.lt/.gte/.eq`; combine with `.plus/.minus`; emit the wire | ||
| // shape with `.serialize()`. | ||
|
|
||
| export interface Money { | ||
| readonly currency: string; | ||
| lt(other: Money): boolean; | ||
| gte(other: Money): boolean; | ||
| eq(other: Money): boolean; | ||
| plus(other: Money): Money; | ||
| minus(other: Money): Money; | ||
| /** The wire form: `{ amount: <integer minor units>, currency }`. */ | ||
| serialize(): { amount: number; currency: string }; | ||
| toString(): string; | ||
| } | ||
|
|
||
| function money(minor: number, currency: string): Money { | ||
| if (!Number.isInteger(minor)) throw new Error(`Money must be an integer minor-unit amount, got ${minor}`); | ||
| const same = (o: Money) => { | ||
| if (o.currency !== currency) throw new Error(`currency mismatch: ${currency} vs ${o.currency}`); | ||
| return o.serialize().amount; | ||
| }; | ||
| return Object.freeze<Money>({ | ||
| currency, | ||
| lt: (o: Money) => minor < same(o), | ||
| gte: (o: Money) => minor >= same(o), | ||
| eq: (o: Money) => minor === same(o), | ||
| plus: (o: Money) => money(minor + same(o), currency), | ||
| minus: (o: Money) => money(minor - same(o), currency), | ||
| serialize: () => ({ amount: minor, currency }), | ||
| toString: () => `${currency.toUpperCase()} ${(minor / 100).toFixed(2)}`, | ||
| }); | ||
| } | ||
|
|
||
| /** US dollars. `usd.dollars(20)` → $20.00 (2000 cents); `usd.cents(2000)` → the same. */ | ||
| export const usd = Object.assign((minorCents: number) => money(minorCents, "usd"), { | ||
| dollars: (d: number) => money(Math.round(d * 100), "usd"), | ||
| cents: (c: number) => money(c, "usd"), | ||
| }); |
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.