From e78f33894870c164ec5485afa6f8ff7319f46a1c Mon Sep 17 00:00:00 2001 From: Moses Fawole <55624166+mosesfawole@users.noreply.github.com> Date: Sun, 13 Sep 2026 18:02:35 +0200 Subject: [PATCH] feat: add checkout pilot submission --- .../mosesfawole-checkout-pilot/RIGHTS.md | 14 + .../mosesfawole-checkout-pilot/SUBMISSION.md | 45 ++ .../source/.dockerignore | 7 + .../source/.env.example | 9 + .../source/.gitignore | 5 + .../source/Dockerfile | 13 + .../mosesfawole-checkout-pilot/source/LICENSE | 21 + .../source/README.md | 77 ++++ .../source/package.json | 14 + .../source/public/app.js | 235 ++++++++++ .../source/public/index.html | 150 +++++++ .../source/public/openapi.json | 104 +++++ .../source/public/styles.css | 172 ++++++++ .../source/render.yaml | 15 + .../source/server.mjs | 403 ++++++++++++++++++ .../source/test/server.test.mjs | 170 ++++++++ .../submission.json | 10 + .../verification/README.md | 38 ++ 18 files changed, 1502 insertions(+) create mode 100644 submissions/mcp-hackathon/mosesfawole-checkout-pilot/RIGHTS.md create mode 100644 submissions/mcp-hackathon/mosesfawole-checkout-pilot/SUBMISSION.md create mode 100644 submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/.dockerignore create mode 100644 submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/.env.example create mode 100644 submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/.gitignore create mode 100644 submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/Dockerfile create mode 100644 submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/LICENSE create mode 100644 submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/README.md create mode 100644 submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/package.json create mode 100644 submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/public/app.js create mode 100644 submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/public/index.html create mode 100644 submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/public/openapi.json create mode 100644 submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/public/styles.css create mode 100644 submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/render.yaml create mode 100644 submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/server.mjs create mode 100644 submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/test/server.test.mjs create mode 100644 submissions/mcp-hackathon/mosesfawole-checkout-pilot/submission.json create mode 100644 submissions/mcp-hackathon/mosesfawole-checkout-pilot/verification/README.md diff --git a/submissions/mcp-hackathon/mosesfawole-checkout-pilot/RIGHTS.md b/submissions/mcp-hackathon/mosesfawole-checkout-pilot/RIGHTS.md new file mode 100644 index 0000000..182649a --- /dev/null +++ b/submissions/mcp-hackathon/mosesfawole-checkout-pilot/RIGHTS.md @@ -0,0 +1,14 @@ +# Submission rights declaration + +Project: `Checkout Pilot` +Submission slug: `mosesfawole-checkout-pilot` +Submitter: `Moses Fawole` +Date: `2026-09-12` + +The submitter confirms that they own, or have sufficient authorization for, the source code, dependencies, service, data, branding, and other materials submitted in this pull request. + +Subject to the official program terms, the submitter authorizes X-Agent to retain, reproduce, audit, test, archive, and publish the submitted program artifact for judging, fraud prevention, dispute handling, ecosystem submission, and post-award accountability. + +Third-party components and their licenses: Node.js platform dependencies are listed in `source/package.json` and are used under their respective licenses. + +Exceptions or restrictions: None. diff --git a/submissions/mcp-hackathon/mosesfawole-checkout-pilot/SUBMISSION.md b/submissions/mcp-hackathon/mosesfawole-checkout-pilot/SUBMISSION.md new file mode 100644 index 0000000..6c50da4 --- /dev/null +++ b/submissions/mcp-hackathon/mosesfawole-checkout-pilot/SUBMISSION.md @@ -0,0 +1,45 @@ +# Checkout Pilot + +Checkout Pilot is a hosted payment-link and settlement demo for small merchants. It lets an agent create a payable link, inspect its status, and simulate settlement without handling private keys or moving funds. + +## Capability + +- **One-line description:** Create and manage payment links through a safe demo or server-side Moove Receive API adapter. +- **Who it helps:** Freelancers, tutors, consultants, event organizers, and small digital sellers. +- **Capability boundary:** Creates links and reads/settles demo records; live mode only calls Moove Receive payment-link endpoints. It does not custody keys, swap, bridge, stake, withdraw, or manufacture volume. + +## Live API + +- **API base URL:** https://checkout-pilot.onrender.com/v1 +- **Health-check URL:** https://checkout-pilot.onrender.com/health +- **Authentication:** None in demo mode. +- **Rate limits / known limits:** Render free instances may cold-start; demo state is in-memory and resets on restart. +- **API contract:** `source/openapi.json` + +## Source and reproducibility + +- **Source repository:** https://github.com/mosesfawole/checkout-pilot +- **Review commit:** `1c4740857de59595f8d2bb4501f6e44d00a160f4` +- **Source submitted in this PR:** `source/` +- **Run tests:** `npm test` +- **Run locally:** `node server.mjs` +- **Deploy:** Use the included `Dockerfile` or `render.yaml`. +- **Version binding:** `SOURCE_COMMIT` is set to the review commit and is exposed by `/health` and `/.well-known/xagent-verification.json`. + +## Verification + +See `verification/README.md` for reproducible calls and expected responses. + +## Security and data handling + +- **Data collected:** Demo payment-link metadata and optional payer/merchant labels supplied in requests. +- **Purpose and retention:** In-memory demo operation only; no durable retention is intended. +- **Third parties / outbound network calls:** Live mode optionally calls the configured Moove Receive API over HTTPS. +- **Secrets:** No secrets are committed; Moove credentials remain server-side environment variables. +- **Known risks / restrictions:** Demo state is not production accounting. Reviewers should use test values and leave live credentials unset. + +## Support + +- **Team / builder:** Moses Fawole +- **Contact:** GitHub: https://github.com/mosesfawole +- **License / rights:** MIT; submitter can authorize review and deployment. diff --git a/submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/.dockerignore b/submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/.dockerignore new file mode 100644 index 0000000..bda197d --- /dev/null +++ b/submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/.dockerignore @@ -0,0 +1,7 @@ +.git +.env +.env.* +!.env.example +node_modules +test +*.log diff --git a/submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/.env.example b/submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/.env.example new file mode 100644 index 0000000..494afd0 --- /dev/null +++ b/submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/.env.example @@ -0,0 +1,9 @@ +# Leave both values unset for the local demo. The app then never calls Moove. +# For live mode, copy the exact API host shown beside the key in the Moove dashboard. +MOOVE_API_BASE_URL=https://api.moove.xyz +MOOVE_API_KEY=mk_live_replace_me +PORT=8787 + +# Required by the public X-Agent deployment. Set these after the source commit exists. +SOURCE_COMMIT=0000000000000000000000000000000000000000 +XAGENT_SUBMISSION_SLUG=mosesfawole-checkout-pilot diff --git a/submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/.gitignore b/submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/.gitignore new file mode 100644 index 0000000..1093530 --- /dev/null +++ b/submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/.gitignore @@ -0,0 +1,5 @@ +.env +.env.* +!.env.example +node_modules/ +*.log diff --git a/submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/Dockerfile b/submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/Dockerfile new file mode 100644 index 0000000..47a5f13 --- /dev/null +++ b/submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/Dockerfile @@ -0,0 +1,13 @@ +FROM node:22-alpine + +WORKDIR /app +COPY package.json ./ +COPY server.mjs ./ +COPY public ./public + +ENV NODE_ENV=production +ENV PORT=8787 +EXPOSE 8787 + +USER node +CMD ["node", "server.mjs"] diff --git a/submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/LICENSE b/submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/LICENSE new file mode 100644 index 0000000..27934d3 --- /dev/null +++ b/submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Checkout Pilot contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/README.md b/submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/README.md new file mode 100644 index 0000000..f6d8d52 --- /dev/null +++ b/submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/README.md @@ -0,0 +1,77 @@ +# Checkout Pilot + +Checkout Pilot is a demo-first payment-request dashboard built around Moove's current Receive API. It creates one-off hosted checkout links, keeps a merchant reference on every request, and reads settlement status without exposing an API key to the browser. + +## Run the demo + +Requirements: Node.js 20 or newer. + +```powershell +npm start +``` + +Open `http://127.0.0.1:8787`. Demo mode stores links in memory, moves no money, and clears all records when the process stops. + +## Connect Moove later + +1. Claim a Moove Handle and configure a default settlement wallet. +2. Create a Receive-scoped key at . +3. Set both values in the server environment. Use the exact base URL displayed beside the key. + +```powershell +$env:MOOVE_API_BASE_URL = "https://api.moove.xyz" +$env:MOOVE_API_KEY = "your-key-from-the-dashboard" +npm start +``` + +Do not paste the key into this repository or send it in a chat message. Live mode calls only: + +- `POST /v1/payment-link` +- `GET /v1/payment-link` +- `GET /v1/payment-link/{id}` + +The app does not send, swap, bridge, stake, withdraw, or connect a payer wallet. + +## X-Agent deployment contract + +The public deployment exposes: + +- `GET /health`: returns `status: "ok"` and the deployed source commit. +- `GET /.well-known/xagent-verification.json`: binds the deployment to the submission slug and source commit. +- `GET /api/capabilities`: documents operations, inputs, side effects, and constraints for agent productization. +- `GET /openapi.json`: machine-readable OpenAPI 3.1 contract. + +Set `SOURCE_COMMIT` to the exact 40-character commit pushed to the public source repository and set `XAGENT_SUBMISSION_SLUG` to the final `-checkout-pilot` directory name. The verification endpoint refuses to claim a development or placeholder commit. + +## Container deployment + +```powershell +docker build -t checkout-pilot . +docker run --rm -p 8787:8787 ` + -e SOURCE_COMMIT=<40-character-public-commit> ` + -e XAGENT_SUBMISSION_SLUG=mosesfawole-checkout-pilot ` + checkout-pilot +``` + +For the hackathon's public capability review, demo mode is enough to exercise the API without credentials or financial activity. Moove live mode is a separate configuration and should be enabled only when a real pilot is ready. + +### Render + +The included `render.yaml` is a Blueprint deployment. Create a Render service from the public GitHub repository, set `SOURCE_COMMIT` to the deployed Git commit, and leave the Moove variables blank for demo mode. Render will expose `/health` and the X-Agent verification endpoint over HTTPS. + +## Verify + +```powershell +npm test +``` + +## Product wedge + +The next defensible step is a merchant pilot: embed the link creator in Telegram or Discord, recruit one real seller, and measure genuine orders, settlement completion, and time saved. Those records become evidence for the Moove Developer Program and material for a Decentralize AI technical article. + +## Current constraints + +- Moove Receive is live; the public Send, Swap, Bridge, Stake, and Ramp APIs are not. +- Moove currently has no webhooks, so status checks use polite polling. +- Demo records are in-memory only. +- USD labels in this prototype are display labels; settlement behavior is controlled by the merchant's Moove account. diff --git a/submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/package.json b/submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/package.json new file mode 100644 index 0000000..cd36fef --- /dev/null +++ b/submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/package.json @@ -0,0 +1,14 @@ +{ + "name": "checkout-pilot", + "version": "0.1.0", + "private": true, + "description": "A server-side, demo-first agentic checkout pilot for Moove Receive payments.", + "type": "module", + "scripts": { + "start": "node server.mjs", + "test": "node --test" + }, + "engines": { + "node": ">=20" + } +} diff --git a/submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/public/app.js b/submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/public/app.js new file mode 100644 index 0000000..ef0ce20 --- /dev/null +++ b/submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/public/app.js @@ -0,0 +1,235 @@ +const state = { + config: { mode: 'demo', liveReady: false }, + links: [], + current: null, + toastTimer: null +}; + +const $ = (selector) => document.querySelector(selector); +const dashboard = $('#dashboard-view'); +const payerView = $('#payer-view'); + +function escapeHtml(value) { + return String(value ?? '').replace(/[&<>'"]/g, (char) => ({ + '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' + }[char])); +} + +function formatAmount(value) { + const number = Number.parseFloat(value); + if (!Number.isFinite(number)) return '--'; + return number.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +} + +function formatDate(value) { + if (!value) return '--'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return '--'; + return date.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); +} + +function statusClass(status) { + return `status-${String(status || 'idle').toLowerCase()}`; +} + +function statusLabel(status) { + return String(status || 'waiting').toUpperCase(); +} + +function showToast(message) { + const toast = $('#toast'); + toast.textContent = message; + toast.hidden = false; + clearTimeout(state.toastTimer); + state.toastTimer = setTimeout(() => { toast.hidden = true; }, 2800); +} + +async function requestJson(url, options = {}) { + const response = await fetch(url, { headers: { accept: 'application/json', ...(options.headers || {}) }, ...options }); + const body = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(body.error || `Request failed (${response.status})`); + return body; +} + +function setMode(config) { + state.config = config; + const pill = $('#mode-pill'); + const label = $('#mode-label'); + pill.classList.toggle('mode-live', config.mode === 'live'); + pill.classList.toggle('mode-demo', config.mode !== 'live'); + label.textContent = config.mode === 'live' ? 'LIVE MODE' : (config.mode === 'misconfigured' ? 'SETUP NEEDED' : 'DEMO MODE'); + if (config.configurationIssue) showToast(config.configurationIssue); +} + +function renderPreview(link) { + state.current = link; + const status = $('#preview-status'); + status.className = `status-badge ${statusClass(link?.status || 'idle')}`; + status.textContent = statusLabel(link?.status || 'waiting'); + const content = $('#preview-content'); + if (!link) { + content.className = 'preview-content empty-preview'; + content.innerHTML = '
+

Your link will appear here

Create a request to get a hosted checkout URL.

'; + return; + } + + const canSimulate = state.config.mode === 'demo' && link.status === 'active'; + content.className = 'preview-content link-output'; + content.innerHTML = ` +
+
$${escapeHtml(formatAmount(link.toAmount))}USD
+

${escapeHtml(link.description || 'Payment request')}

+
+
${escapeHtml(link.url || '')}
+
+ Open checkout -> + ${canSimulate ? '' : ''} +
+
Created ${escapeHtml(formatDate(link.createdAt))}${link.demo ? 'Local demo record' : 'Moove API record'}
+ `; + content.querySelector('[data-action="copy"]')?.addEventListener('click', () => copyLink(link.url)); + content.querySelector('[data-action="simulate"]')?.addEventListener('click', () => simulateLink(link.id)); +} + +async function copyLink(url) { + try { + await navigator.clipboard.writeText(url); + showToast('Checkout URL copied.'); + } catch { + showToast('Copy failed. Select the URL manually.'); + } +} + +function renderRecent() { + const list = $('#recent-list'); + if (!state.links.length) { + list.innerHTML = '
No requests in this session.
'; + return; + } + list.innerHTML = state.links.slice(0, 8).map((link) => ` + + `).join(''); + list.querySelectorAll('[data-id]').forEach((button) => { + button.addEventListener('click', () => loadLink(button.dataset.id, true)); + }); +} + +async function loadLinks() { + try { + const body = await requestJson('/api/payment-links'); + state.links = Array.isArray(body.links) ? body.links : []; + renderRecent(); + } catch (error) { + showToast(error.message); + } +} + +async function loadLink(id, select = false) { + try { + const link = await requestJson(`/api/payment-links/${encodeURIComponent(id)}`); + const index = state.links.findIndex((item) => item.id === link.id); + if (index >= 0) state.links[index] = { ...state.links[index], ...link }; + else state.links.unshift(link); + renderRecent(); + if (select) renderPreview(link); + return link; + } catch (error) { + showToast(error.message); + return null; + } +} + +async function simulateLink(id) { + try { + const link = await requestJson(`/api/payment-links/${encodeURIComponent(id)}/simulate`, { method: 'POST' }); + state.links = state.links.map((item) => item.id === link.id ? link : item); + renderRecent(); + renderPreview(link); + showToast('Demo settlement recorded.'); + } catch (error) { + showToast(error.message); + } +} + +async function createLink(event) { + event.preventDefault(); + const button = $('#create-button'); + const errorBox = $('#form-error'); + errorBox.hidden = true; + button.disabled = true; + button.querySelector('span').textContent = 'Creating...'; + const expirationValue = $('#expiration').value; + try { + const link = await requestJson('/api/payment-links', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + amount: $('#amount').value, + item: $('#item').value, + reference: $('#reference').value, + expirationDate: expirationValue ? new Date(expirationValue).toISOString() : null + }) + }); + state.links = [link, ...state.links.filter((item) => item.id !== link.id)]; + renderRecent(); + renderPreview(link); + showToast(state.config.mode === 'live' ? 'Live Moove link created.' : 'Demo link created.'); + } catch (error) { + errorBox.textContent = error.message; + errorBox.hidden = false; + } finally { + button.disabled = false; + button.querySelector('span').textContent = 'Create link'; + } +} + +async function initDashboard() { + try { setMode(await requestJson('/api/config')); } catch (error) { showToast(error.message); } + $('#link-form').addEventListener('submit', createLink); + $('#refresh-button').addEventListener('click', async () => { + await loadLinks(); + if (state.current?.id) await loadLink(state.current.id, true); + showToast('Ledger refreshed.'); + }); + await loadLinks(); +} + +async function initPayer() { + dashboard.hidden = true; + dashboard.setAttribute('aria-hidden', 'true'); + payerView.hidden = false; + payerView.setAttribute('aria-hidden', 'false'); + const id = decodeURIComponent(location.pathname.split('/').filter(Boolean).pop() || ''); + const content = $('#payer-content'); + try { + const link = await requestJson(`/api/payment-links/${encodeURIComponent(id)}`); + const isActive = link.status === 'active'; + const payerAction = link.demo ? 'Simulate payment' : 'Continue to payment'; + content.innerHTML = ` +
$${escapeHtml(formatAmount(link.toAmount))}USD
+

${escapeHtml(link.description || 'Payment request')}

+

Pay this request through the hosted Moove checkout. The recipient receives the settlement in their configured wallet.

+
Reference: ${escapeHtml(link.description || link.id)}
+ ${isActive ? `` : `
${escapeHtml(statusLabel(link.status))}
`} + `; + $('#payer-action')?.addEventListener('click', async () => { + if (link.demo) { + const updated = await requestJson(`/api/payment-links/${encodeURIComponent(link.id)}/simulate`, { method: 'POST' }); + content.querySelector('#payer-action').outerHTML = `
SETTLED IN DEMO
`; + showToast(`Received $${formatAmount(updated.receivedAmount || updated.toAmount)}.`); + } else { + showToast('Open the hosted Moove checkout to complete payment.'); + window.open(link.url, '_blank', 'noopener,noreferrer'); + } + }); + } catch (error) { + content.innerHTML = `

${escapeHtml(error.message)}

`; + } +} + +if (location.pathname.startsWith('/pay/')) initPayer(); +else initDashboard(); diff --git a/submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/public/index.html b/submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/public/index.html new file mode 100644 index 0000000..fc918a2 --- /dev/null +++ b/submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/public/index.html @@ -0,0 +1,150 @@ + + + + + + + + Checkout Pilot + + + +
+
+ + CP + Checkout Pilot + +
+ MOOVE RECEIVE + DEMO MODE +
+
+ +
+
+
+

PAYMENT OPERATIONS / 01

+

Turn a request into a paid link.

+

A focused checkout layer for agents, communities, and small teams.

+
+
+ 01 + One link. One reference. A clean settlement trail. +
+
+ +
+ + +
+
+
+

OUTPUT

+

Link preview

+
+ WAITING +
+
+
+
+

Your link will appear here

+

Create a request to get a hosted checkout URL.

+
+
+
+ +
+
+
+
+

LEDGER

+

Recent requests

+
+ +
+
+
No requests in this session.
+
+
+ + +
+
+ +
+
+
CPCheckout Pilot
+
+

SECURE PAYMENT REQUEST

+

Loading request...

+
+

Hosted payment request powered by Moove Receive.

+
+
+
+ + + + + diff --git a/submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/public/openapi.json b/submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/public/openapi.json new file mode 100644 index 0000000..90c8747 --- /dev/null +++ b/submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/public/openapi.json @@ -0,0 +1,104 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Checkout Pilot API", + "version": "0.1.0", + "description": "Create referenceable, one-off payment requests and read settlement status. Demo mode never moves money." + }, + "servers": [{ "url": "/" }], + "paths": { + "/health": { + "get": { + "operationId": "health", + "summary": "Read service and source-version health", + "responses": { + "200": { + "description": "Healthy service", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Health" } } } + } + } + } + }, + "/api/capabilities": { + "get": { + "operationId": "listCapabilities", + "summary": "Describe the agent capability boundary", + "responses": { "200": { "description": "Capability metadata" } } + } + }, + "/api/payment-links": { + "post": { + "operationId": "createPaymentRequest", + "summary": "Create a one-off payment request", + "description": "Creates a hosted request. This operation never sends, swaps, signs, or withdraws funds.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreatePaymentRequest" }, + "example": { "amount": "49.00", "item": "Pilot onboarding", "reference": "PILOT-001" } + } + } + }, + "responses": { + "201": { "description": "Payment request created", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaymentRequest" } } } }, + "400": { "description": "Invalid decimal amount or request fields" }, + "503": { "description": "Live configuration is incomplete" } + } + }, + "get": { + "operationId": "listPaymentRequests", + "summary": "List recent requests", + "responses": { "200": { "description": "Recent payment requests" } } + } + }, + "/api/payment-links/{id}": { + "get": { + "operationId": "getPaymentRequest", + "summary": "Read current request status", + "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }], + "responses": { + "200": { "description": "Current payment request", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaymentRequest" } } } }, + "404": { "description": "Request not found" } + } + } + } + }, + "components": { + "schemas": { + "Health": { + "type": "object", + "required": ["status", "service", "mode", "commit"], + "properties": { + "status": { "type": "string", "const": "ok" }, + "service": { "type": "string" }, + "mode": { "type": "string", "enum": ["demo", "live", "misconfigured"] }, + "commit": { "type": "string" } + } + }, + "CreatePaymentRequest": { + "type": "object", + "required": ["amount"], + "properties": { + "amount": { "type": "string", "pattern": "^\\d+(?:\\.\\d{1,2})?$", "description": "Positive decimal string; never a JSON number." }, + "item": { "type": "string", "maxLength": 80 }, + "reference": { "type": "string", "maxLength": 48 }, + "expirationDate": { "type": ["string", "null"], "format": "date-time" } + } + }, + "PaymentRequest": { + "type": "object", + "required": ["id", "url", "status", "toAmount"], + "properties": { + "id": { "type": "string" }, + "url": { "type": "string", "format": "uri" }, + "status": { "type": "string", "enum": ["active", "completed", "inactive"] }, + "toAmount": { "type": "string" }, + "description": { "type": ["string", "null"] }, + "receivedAmount": { "type": "string" }, + "demo": { "type": "boolean" } + } + } + } + } +} diff --git a/submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/public/styles.css b/submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/public/styles.css new file mode 100644 index 0000000..2067d23 --- /dev/null +++ b/submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/public/styles.css @@ -0,0 +1,172 @@ +:root { + color-scheme: light; + --ink: #171a18; + --muted: #6f7772; + --line: #d9ded9; + --line-strong: #b8c1ba; + --paper: #f4f6f3; + --surface: #ffffff; + --yellow: #ffce31; + --yellow-deep: #d39d00; + --mint: #ccefe0; + --mint-strong: #2f8b68; + --coral: #d55f46; + --shadow: 0 14px 30px rgba(28, 39, 32, .07); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-synthesis: none; +} + +* { box-sizing: border-box; } +*[hidden] { display: none !important; } +html { min-width: 320px; background: var(--paper); } +body { margin: 0; color: var(--ink); background: var(--paper); } +button, input { font: inherit; } +button { cursor: pointer; } +a { color: inherit; } + +.app-shell { min-height: 100vh; } +.topbar { + height: 72px; + padding: 0 5vw; + border-bottom: 1px solid var(--line); + display: flex; + align-items: center; + justify-content: space-between; + background: rgba(244, 246, 243, .94); +} +.brand { display: inline-flex; align-items: center; gap: 10px; text-decoration: none; font-weight: 760; letter-spacing: 0; } +.brand-mark { width: 31px; height: 31px; display: inline-grid; place-items: center; background: var(--yellow); border: 1px solid var(--ink); font-size: 10px; font-weight: 850; letter-spacing: .05em; } +.brand-name { font-size: 15px; } +.topbar-right { display: flex; align-items: center; gap: 14px; } +.quiet-label, .eyebrow, .section-kicker { color: var(--muted); font-size: 10px; letter-spacing: .13em; font-weight: 800; } +.quiet-label { display: none; } +.mode-pill { display: inline-flex; align-items: center; gap: 7px; padding: 7px 10px; border: 1px solid var(--line-strong); font-size: 10px; letter-spacing: .08em; font-weight: 800; } +.mode-live { background: var(--mint); border-color: #8cc8ad; } +.mode-demo { background: #fff6cf; border-color: #e5cb67; } +.status-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--yellow-deep); } +.mode-live .status-dot { background: var(--mint-strong); } + +.page-content { width: min(1180px, 90vw); margin: 0 auto; padding: 58px 0 72px; } +.intro-row { display: flex; justify-content: space-between; align-items: end; gap: 32px; margin-bottom: 38px; } +.eyebrow { margin: 0 0 12px; } +h1, h2, h3, p { margin-top: 0; } +h1 { max-width: 670px; margin-bottom: 10px; font-size: 54px; line-height: 1.02; letter-spacing: 0; font-weight: 780; } +.intro-copy { color: var(--muted); margin-bottom: 0; font-size: 16px; } +.intro-note { max-width: 245px; display: flex; gap: 11px; color: var(--muted); font-size: 12px; line-height: 1.45; padding-bottom: 5px; } +.note-number { color: var(--ink); font-weight: 800; } + +.workspace-grid, .lower-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 18px; } +.lower-grid { margin-top: 18px; grid-template-columns: minmax(0, 1.35fr) minmax(280px, .65fr); } +.panel { background: var(--surface); border: 1px solid var(--line); box-shadow: var(--shadow); } +.composer-panel, .preview-panel { min-height: 528px; padding: 26px; } +.recent-panel, .lane-panel { padding: 24px 26px; min-height: 275px; } +.panel-heading { display: flex; align-items: start; justify-content: space-between; gap: 16px; padding-bottom: 24px; border-bottom: 1px solid var(--line); } +.compact-heading { padding-bottom: 18px; align-items: center; } +.section-kicker { margin: 0 0 8px; } +h2 { margin-bottom: 0; font-size: 21px; line-height: 1.1; letter-spacing: 0; } +.step-chip { color: var(--muted); border: 1px solid var(--line); padding: 6px 8px; font-size: 10px; font-weight: 800; letter-spacing: .08em; } +.field-stack { display: grid; gap: 22px; padding: 27px 0; } +.field { display: grid; gap: 8px; } +.field-label { font-size: 12px; font-weight: 760; } +.required { color: var(--coral); } +.optional { color: var(--muted); font-size: 10px; font-weight: 500; margin-left: 5px; } +input { width: 100%; height: 44px; padding: 0 12px; border: 1px solid var(--line-strong); background: #fbfcfb; color: var(--ink); outline: none; transition: border-color .15s, box-shadow .15s; } +input:focus { border-color: var(--ink); box-shadow: 0 0 0 3px rgba(255, 206, 49, .32); } +.input-with-suffix { display: flex; align-items: stretch; } +.input-with-suffix input { border-right: 0; } +.input-suffix { display: grid; place-items: center; min-width: 55px; border: 1px solid var(--line-strong); background: #eef1ee; color: var(--muted); font-size: 11px; font-weight: 800; } +.field-hint { color: var(--muted); font-size: 11px; line-height: 1.35; } +.form-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; border-top: 1px solid var(--line); padding-top: 20px; } +.security-note { display: inline-flex; gap: 8px; align-items: center; color: var(--muted); font-size: 11px; } +.shield-mark { width: 18px; height: 18px; display: grid; place-items: center; border: 1px solid var(--line-strong); color: var(--mint-strong); font-weight: 900; } +.primary-button { display: inline-flex; align-items: center; gap: 16px; height: 44px; padding: 0 16px; border: 1px solid var(--ink); background: var(--ink); color: #fff; font-weight: 760; transition: transform .15s, background .15s; } +.primary-button:hover { background: #2c312e; transform: translateY(-1px); } +.primary-button:disabled { opacity: .55; cursor: wait; transform: none; } +.button-arrow, .lane-arrow { color: var(--yellow); font-weight: 900; } +.form-error { margin: 16px 0 0; padding: 10px 12px; border-left: 3px solid var(--coral); background: #fff0ed; color: #8d3527; font-size: 12px; } + +.preview-content { min-height: 407px; display: grid; align-content: center; } +.empty-preview { justify-items: center; text-align: center; color: var(--muted); } +.empty-icon { width: 52px; height: 52px; display: grid; place-items: center; margin-bottom: 18px; border: 1px dashed var(--line-strong); color: var(--yellow-deep); font-size: 24px; } +.empty-preview h3 { margin-bottom: 7px; color: var(--ink); font-size: 17px; } +.empty-preview p { max-width: 230px; margin-bottom: 0; font-size: 12px; line-height: 1.45; } +.status-badge { display: inline-flex; align-items: center; padding: 6px 8px; font-size: 10px; letter-spacing: .08em; font-weight: 850; } +.status-idle { background: #eef1ee; color: var(--muted); } +.status-active { background: #fff6cf; color: #735800; } +.status-completed { background: var(--mint); color: #176241; } +.status-inactive { background: #f1e5e1; color: #843a2d; } +.link-output { display: grid; align-content: center; gap: 20px; min-height: 407px; } +.amount-block { display: flex; align-items: baseline; gap: 8px; } +.amount-value { font-size: 49px; line-height: 1; font-weight: 800; letter-spacing: 0; } +.amount-currency { color: var(--muted); font-size: 12px; font-weight: 800; } +.output-description { color: var(--muted); font-size: 13px; } +.url-box { display: flex; align-items: center; gap: 10px; padding: 12px; border: 1px solid var(--line); background: #f8faf8; } +.url-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; } +.copy-button { flex: none; border: 0; background: transparent; color: var(--ink); font-size: 11px; font-weight: 800; text-decoration: underline; text-underline-offset: 3px; } +.output-actions { display: flex; flex-wrap: wrap; gap: 9px; } +.output-meta { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; color: var(--muted); font-size: 10px; } +.outline-button, .subtle-button { display: inline-flex; align-items: center; gap: 8px; height: 38px; padding: 0 12px; border: 1px solid var(--line-strong); background: #fff; color: var(--ink); font-size: 11px; font-weight: 760; } +.outline-button:hover, .subtle-button:hover { border-color: var(--ink); background: #f8faf8; } +.outline-button.accent { background: var(--yellow); border-color: var(--ink); } +.outline-button.accent:hover { background: #ffd94f; } +.subtle-button { height: 32px; color: var(--muted); border-color: var(--line); font-weight: 700; } +.refresh-glyph { font-size: 12px; font-weight: 900; } + +.recent-list { display: grid; gap: 0; } +.list-empty { padding: 28px 0 8px; color: var(--muted); font-size: 12px; } +.recent-row { width: 100%; display: grid; grid-template-columns: 1fr auto auto; align-items: center; gap: 14px; padding: 14px 0; border: 0; border-bottom: 1px solid var(--line); background: transparent; color: var(--ink); text-align: left; } +.recent-row:hover { background: #f8faf8; } +.recent-row:last-child { border-bottom: 0; } +.recent-main { min-width: 0; } +.recent-title { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-bottom: 4px; font-size: 12px; font-weight: 760; } +.recent-meta { display: block; color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 10px; } +.recent-amount { font-size: 12px; font-weight: 800; white-space: nowrap; } +.recent-status { white-space: nowrap; } +.lane-list { display: grid; } +.lane-row { display: grid; grid-template-columns: 24px 1fr auto; align-items: center; gap: 10px; padding: 14px 0; border-bottom: 1px solid var(--line); text-decoration: none; } +.lane-row:last-child { border-bottom: 0; } +.lane-row:hover .lane-copy strong { text-decoration: underline; text-underline-offset: 3px; } +.lane-index { color: var(--yellow-deep); font-size: 11px; font-weight: 900; } +.lane-copy { min-width: 0; display: grid; gap: 3px; } +.lane-copy strong { font-size: 12px; } +.lane-copy small { color: var(--muted); font-size: 10px; } +.lane-arrow { font-size: 12px; } +.lane-footnote { margin: 15px 0 0; color: var(--muted); font-size: 10px; line-height: 1.4; } + +.payer-view { min-height: 100vh; display: grid; place-items: center; padding: 30px 20px; } +.payer-shell { width: min(460px, 100%); text-align: center; } +.payer-brand { display: inline-flex; align-items: center; gap: 9px; margin-bottom: 22px; font-size: 14px; font-weight: 780; } +.payer-card { padding: 28px; border: 1px solid var(--line); background: var(--surface); box-shadow: var(--shadow); text-align: left; } +.payer-content { padding-top: 10px; } +.payer-content h2 { margin: 8px 0 10px; font-size: 24px; line-height: 1.15; } +.payer-description { color: var(--muted); font-size: 13px; line-height: 1.5; } +.payer-reference { margin: 22px 0; padding: 11px 12px; border: 1px solid var(--line); background: #f8faf8; color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 10px; } +.payer-content .primary-button { width: 100%; justify-content: space-between; } +.payer-footnote { color: var(--muted); font-size: 10px; margin: 14px 0 0; } +.toast { position: fixed; right: 24px; bottom: 24px; max-width: min(340px, calc(100vw - 48px)); padding: 12px 14px; border: 1px solid var(--ink); background: var(--ink); color: #fff; box-shadow: var(--shadow); font-size: 12px; } + +@media (max-width: 820px) { + .page-content { width: min(680px, 90vw); padding-top: 40px; } + h1 { font-size: 44px; } + .intro-row { display: block; } + .intro-note { margin-top: 18px; } + .workspace-grid, .lower-grid { grid-template-columns: 1fr; } + .composer-panel, .preview-panel { min-height: auto; } + .preview-content, .link-output { min-height: 320px; } +} + +@media (max-width: 520px) { + .topbar { height: 64px; padding: 0 5vw; } + .brand-name { font-size: 14px; } + .page-content { width: 90vw; padding-bottom: 48px; } + h1 { font-size: 37px; } + .composer-panel, .preview-panel, .recent-panel, .lane-panel { padding: 20px; } + .form-footer { display: grid; align-items: stretch; } + .primary-button { justify-content: space-between; } + .recent-row { grid-template-columns: 1fr auto; } + .recent-status { grid-column: 2; grid-row: 1; } + .recent-amount { grid-column: 1; grid-row: 2; } + .url-box { align-items: start; } + .url-text { white-space: normal; overflow-wrap: anywhere; } + .copy-button { padding-top: 1px; } +} diff --git a/submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/render.yaml b/submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/render.yaml new file mode 100644 index 0000000..ef6ea66 --- /dev/null +++ b/submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/render.yaml @@ -0,0 +1,15 @@ +services: + - type: web + name: checkout-pilot + runtime: docker + plan: free + healthCheckPath: /health + envVars: + - key: SOURCE_COMMIT + sync: false + - key: XAGENT_SUBMISSION_SLUG + value: mosesfawole-checkout-pilot + - key: MOOVE_API_BASE_URL + sync: false + - key: MOOVE_API_KEY + sync: false diff --git a/submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/server.mjs b/submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/server.mjs new file mode 100644 index 0000000..06f807d --- /dev/null +++ b/submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/server.mjs @@ -0,0 +1,403 @@ +import http from 'node:http'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { randomUUID } from 'node:crypto'; + +const ROOT = path.dirname(fileURLToPath(import.meta.url)); +const PUBLIC_DIR = path.join(ROOT, 'public'); +const DEFAULT_PORT = 8787; +const BODY_LIMIT = 32 * 1024; +const DEFAULT_SLUG = 'checkout-pilot'; + +const MIME_TYPES = { + '.css': 'text/css; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.ico': 'image/x-icon' +}; + +function json(res, status, payload) { + const body = JSON.stringify(payload); + res.writeHead(status, { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + 'content-length': Buffer.byteLength(body) + }); + res.end(body); +} + +function text(res, status, body, contentType = 'text/plain; charset=utf-8') { + res.writeHead(status, { + 'content-type': contentType, + 'cache-control': 'no-store', + 'content-length': Buffer.byteLength(body) + }); + res.end(body); +} + +function getBaseUrl(env) { + const value = String(env.MOOVE_API_BASE_URL || '').trim(); + if (!value) return null; + try { + const url = new URL(value); + const localHost = ['localhost', '127.0.0.1', '::1'].includes(url.hostname); + if (url.protocol !== 'https:' && !(url.protocol === 'http:' && localHost)) return null; + return url.toString().replace(/\/$/, ''); + } catch { + return null; + } +} + +function getMode(env) { + const hasKey = Boolean(String(env.MOOVE_API_KEY || '').trim()); + const baseUrl = getBaseUrl(env); + const misconfigured = hasKey !== Boolean(baseUrl); + return { + mode: misconfigured ? 'misconfigured' : (hasKey ? 'live' : 'demo'), + liveReady: hasKey && Boolean(baseUrl), + baseUrl: baseUrl || null, + configurationIssue: misconfigured + ? 'Set both MOOVE_API_KEY and MOOVE_API_BASE_URL together to enable live mode.' + : null + }; +} + +function getSourceIdentity(env) { + const commit = String(env.SOURCE_COMMIT || '').trim().toLowerCase(); + const slug = String(env.XAGENT_SUBMISSION_SLUG || DEFAULT_SLUG).trim().toLowerCase(); + return { + commit: /^[0-9a-f]{40}$/.test(commit) ? commit : null, + slug: /^[a-z0-9]+(?:-[a-z0-9]+)+$/.test(slug) ? slug : DEFAULT_SLUG + }; +} + +function isPositiveDecimalString(value) { + if (typeof value !== 'string' || !/^\d+(?:\.\d{1,2})?$/.test(value.trim())) return false; + const normalized = value.trim(); + return !/^0+(?:\.0{1,2})?$/.test(normalized); +} + +function canonicalAmount(value) { + const normalized = String(value).trim(); + const [whole, fraction = ''] = normalized.split('.'); + const cleanWhole = whole.replace(/^0+(?=\d)/, '') || '0'; + return fraction ? `${cleanWhole}.${fraction.padEnd(2, '0')}` : `${cleanWhole}.00`; +} + +export function validatePaymentInput(payload) { + if (!payload || typeof payload !== 'object') { + return { ok: false, error: 'Send a JSON object.' }; + } + + const rawAmount = payload.amount ?? payload.toAmount ?? ''; + if (typeof rawAmount !== 'string') { + return { ok: false, error: 'Amount must be a decimal string, not a number.' }; + } + const amount = rawAmount.trim(); + if (!isPositiveDecimalString(amount)) { + return { ok: false, error: 'Amount must be a positive decimal with up to two decimals.' }; + } + + const item = String(payload.item ?? '').trim(); + const reference = String(payload.reference ?? '').trim(); + if (item.length > 80) return { ok: false, error: 'Item name is limited to 80 characters.' }; + if (reference.length > 48) return { ok: false, error: 'Reference is limited to 48 characters.' }; + + const safeReference = reference || `CP-${new Date().toISOString().slice(0, 10).replaceAll('-', '')}`; + const description = item ? `${safeReference} | ${item}` : safeReference; + if (description.length > 120) return { ok: false, error: 'Item and reference create a description over 120 characters.' }; + + let expirationDate = null; + if (payload.expirationDate) { + const parsed = new Date(String(payload.expirationDate)); + if (Number.isNaN(parsed.getTime())) return { ok: false, error: 'Expiration must be a valid date.' }; + if (parsed.getTime() <= Date.now()) return { ok: false, error: 'Expiration must be in the future.' }; + expirationDate = parsed.toISOString(); + } + + return { + ok: true, + value: { + toAmount: canonicalAmount(amount), + description, + maxUsage: 1, + expirationDate, + item, + reference: safeReference + } + }; +} + +function originFor(req) { + const forwarded = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim(); + const protocol = forwarded || 'http'; + const host = req.headers.host || `127.0.0.1:${DEFAULT_PORT}`; + return `${protocol}://${host}`; +} + +function demoLink(input, origin) { + const id = `demo-${randomUUID()}`; + return { + id, + url: `${origin}/pay/${id}`, + status: 'active', + toAmount: input.toAmount, + description: input.description, + maxUsage: input.maxUsage, + expirationDate: input.expirationDate, + createdAt: new Date().toISOString(), + demo: true + }; +} + +function unwrap(value) { + if (value && typeof value === 'object' && value.data && typeof value.data === 'object' && !Array.isArray(value.data)) { + return value.data; + } + return value; +} + +function normalizeLink(value, fallback = {}) { + const item = unwrap(value) || {}; + return { + ...fallback, + ...item, + id: item.id || fallback.id || null, + url: item.url || fallback.url || null, + status: item.status || fallback.status || 'active', + toAmount: item.toAmount || item.amount || fallback.toAmount || null, + description: item.description || fallback.description || null + }; +} + +function publicApiError(status, code) { + if (status === 401) return { status: 502, error: 'Moove rejected the API key. Check it in the dashboard.' }; + if (status === 403) return { status: 502, error: 'The API key does not have the required payment-link scope.' }; + if (status === 404) return { status: 404, error: 'Payment link was not found for this API key.' }; + if (status === 409) return { status: 409, error: 'The Moove account needs a claimed Handle and default settlement wallet.' }; + return { status: 502, error: `Moove API request failed${code ? ` (${code})` : ''}.` }; +} + +async function mooveRequest(env, route, options = {}) { + const baseUrl = getBaseUrl(env); + const apiKey = String(env.MOOVE_API_KEY || '').trim(); + if (!baseUrl || !apiKey) { + const error = new Error('Live mode is not configured.'); + error.publicStatus = 503; + throw error; + } + + const response = await fetch(`${baseUrl}${route}`, { + ...options, + headers: { + ...(options.body ? { 'content-type': 'application/json' } : {}), + 'X-API-Key': apiKey, + ...(options.headers || {}) + }, + signal: AbortSignal.timeout(15_000) + }); + const raw = await response.text(); + let body = null; + try { body = raw ? JSON.parse(raw) : null; } catch { body = null; } + if (!response.ok) { + const code = body?.code || body?.error?.code; + const mapped = publicApiError(response.status, code); + const error = new Error(mapped.error); + error.publicStatus = mapped.status; + throw error; + } + return body; +} + +async function readBody(req) { + const chunks = []; + let size = 0; + for await (const chunk of req) { + size += chunk.length; + if (size > BODY_LIMIT) { + const error = new Error('Request body is too large.'); + error.publicStatus = 413; + throw error; + } + chunks.push(chunk); + } + const raw = Buffer.concat(chunks).toString('utf8'); + if (!raw) return {}; + try { return JSON.parse(raw); } catch { + const error = new Error('Request body must be valid JSON.'); + error.publicStatus = 400; + throw error; + } +} + +async function serveStatic(req, res, pathname) { + let requested = pathname === '/' ? '/index.html' : pathname; + try { requested = decodeURIComponent(requested); } catch { return text(res, 400, 'Bad path'); } + const candidate = path.resolve(PUBLIC_DIR, `.${requested}`); + if (!candidate.startsWith(`${PUBLIC_DIR}${path.sep}`)) return text(res, 403, 'Forbidden'); + try { + const info = await fs.stat(candidate); + if (!info.isFile()) return text(res, 404, 'Not found'); + const body = await fs.readFile(candidate); + const type = MIME_TYPES[path.extname(candidate).toLowerCase()] || 'application/octet-stream'; + res.writeHead(200, { 'content-type': type, 'cache-control': 'no-store', 'content-length': body.length }); + res.end(body); + } catch { + if (pathname.startsWith('/pay/')) { + const body = await fs.readFile(path.join(PUBLIC_DIR, 'index.html')); + res.writeHead(200, { 'content-type': MIME_TYPES['.html'], 'cache-control': 'no-store', 'content-length': body.length }); + res.end(body); + return; + } + text(res, 404, 'Not found'); + } +} + +export function createAppServer({ env = process.env, store = new Map(), now = () => Date.now() } = {}) { + const server = http.createServer(async (req, res) => { + const requestUrl = new URL(req.url || '/', originFor(req)); + const pathname = requestUrl.pathname; + const method = req.method || 'GET'; + const config = getMode(env); + const source = getSourceIdentity(env); + + try { + if (pathname === '/health' && method === 'GET') { + return json(res, 200, { + status: 'ok', + service: 'checkout-pilot', + mode: config.mode, + commit: source.commit || 'development' + }); + } + if (pathname === '/.well-known/xagent-verification.json' && method === 'GET') { + if (!source.commit) { + return json(res, 503, { + error: 'SOURCE_COMMIT must be a 40-character Git commit in a public deployment.' + }); + } + return json(res, 200, { + schemaVersion: 1, + slug: source.slug, + commit: source.commit + }); + } + if (pathname === '/api/capabilities' && method === 'GET') { + return json(res, 200, { + name: 'Checkout Pilot', + description: 'Create one-off, referenceable payment requests and read settlement status.', + sideEffects: { + createPaymentRequest: 'Creates a payment request but never sends, swaps, or withdraws funds.', + readPaymentStatus: 'Read-only.' + }, + operations: [ + { + id: 'create_payment_request', + method: 'POST', + path: '/api/payment-links', + required: ['amount'], + optional: ['item', 'reference', 'expirationDate'] + }, + { + id: 'get_payment_request', + method: 'GET', + path: '/api/payment-links/{id}' + } + ], + constraints: [ + 'Amounts are positive decimal strings with at most two fractional digits.', + 'Live creation requires a server-side Moove Receive API key.', + 'The service never accepts private keys or destination wallet parameters.' + ] + }); + } + if (pathname === '/api/config' && method === 'GET') { + return json(res, 200, { + mode: config.mode, + liveReady: config.liveReady, + configurationIssue: config.configurationIssue + }); + } + + if (pathname === '/api/payment-links' && method === 'POST') { + if (config.mode === 'misconfigured') return json(res, 503, { error: config.configurationIssue }); + const payload = await readBody(req); + const validation = validatePaymentInput(payload); + if (!validation.ok) return json(res, 400, { error: validation.error }); + + if (config.mode === 'demo') { + const link = demoLink(validation.value, originFor(req)); + store.set(link.id, link); + return json(res, 201, link); + } + + const remote = await mooveRequest(env, '/v1/payment-link', { + method: 'POST', + body: JSON.stringify({ + toAmount: validation.value.toAmount, + description: validation.value.description, + maxUsage: validation.value.maxUsage, + expirationDate: validation.value.expirationDate + }) + }); + const link = normalizeLink(remote, { demo: false, createdAt: new Date(now()).toISOString() }); + if (link.id) store.set(link.id, link); + return json(res, 201, link); + } + + if (pathname === '/api/payment-links' && method === 'GET') { + if (config.mode === 'misconfigured') return json(res, 503, { error: config.configurationIssue }); + if (config.mode === 'demo') { + const links = [...store.values()].sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt))); + return json(res, 200, { links }); + } + const remote = await mooveRequest(env, '/v1/payment-link?offset=0', { method: 'GET' }); + const list = Array.isArray(remote) ? remote : (Array.isArray(remote?.data) ? remote.data : (Array.isArray(remote?.items) ? remote.items : [])); + return json(res, 200, { links: list.map((item) => normalizeLink(item, { demo: false })) }); + } + + const match = pathname.match(/^\/api\/payment-links\/([^/]+)(\/simulate)?$/); + if (match && method === 'POST' && match[2] === '/simulate') { + if (config.mode !== 'demo') return json(res, 409, { error: 'Simulation is available only in demo mode.' }); + const link = store.get(decodeURIComponent(match[1])); + if (!link) return json(res, 404, { error: 'Demo payment link not found.' }); + link.status = 'completed'; + link.receivedAmount = link.toAmount; + link.completedAt = new Date(now()).toISOString(); + return json(res, 200, link); + } + + if (match && method === 'GET') { + if (config.mode === 'misconfigured') return json(res, 503, { error: config.configurationIssue }); + const id = decodeURIComponent(match[1]); + if (config.mode === 'demo') { + const link = store.get(id); + if (!link) return json(res, 404, { error: 'Demo payment link not found.' }); + return json(res, 200, link); + } + const remote = await mooveRequest(env, `/v1/payment-link/${encodeURIComponent(id)}`, { method: 'GET' }); + return json(res, 200, normalizeLink(remote, { id, demo: false })); + } + + return serveStatic(req, res, pathname); + } catch (error) { + const status = Number(error.publicStatus) || 500; + if (status >= 500) console.error(`[checkout-pilot] ${error.message}`); + return json(res, status, { error: error.message || 'Unexpected server error.' }); + } + }); + return server; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const port = Number(process.env.PORT || DEFAULT_PORT); + const server = createAppServer(); + server.listen(port, '0.0.0.0', () => { + console.log(`Checkout Pilot running on port ${port}`); + console.log(`Mode: ${getMode(process.env).mode}`); + }); +} diff --git a/submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/test/server.test.mjs b/submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/test/server.test.mjs new file mode 100644 index 0000000..d9f0387 --- /dev/null +++ b/submissions/mcp-hackathon/mosesfawole-checkout-pilot/source/test/server.test.mjs @@ -0,0 +1,170 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import { createAppServer, validatePaymentInput } from '../server.mjs'; + +test('validates and canonicalizes decimal-string amounts', () => { + const result = validatePaymentInput({ amount: '049.5', item: 'Pilot', reference: 'INV-1' }); + assert.equal(result.ok, true); + assert.equal(result.value.toAmount, '49.50'); + assert.equal(result.value.description, 'INV-1 | Pilot'); + assert.equal(result.value.maxUsage, 1); +}); + +test('rejects non-positive, numeric, and over-precise amounts', () => { + for (const amount of ['0', '0.00', '-1', '1.001', 'abc']) { + assert.equal(validatePaymentInput({ amount }).ok, false, amount); + } + assert.equal(validatePaymentInput({ toAmount: 49.99 }).ok, false); +}); + +test('demo API creates, reads, and settles a payment link', async (t) => { + const server = createAppServer({ env: {} }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => new Promise((resolve) => server.close(resolve))); + const { port } = server.address(); + const base = `http://127.0.0.1:${port}`; + + const config = await fetch(`${base}/api/config`).then((response) => response.json()); + assert.equal(config.mode, 'demo'); + + const health = await fetch(`${base}/health`).then((response) => response.json()); + assert.equal(health.status, 'ok'); + assert.equal(health.commit, 'development'); + + const createResponse = await fetch(`${base}/api/payment-links`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ amount: '25', item: 'Workshop seat', reference: 'TEST-25' }) + }); + assert.equal(createResponse.status, 201); + const created = await createResponse.json(); + assert.match(created.id, /^demo-/); + assert.equal(created.status, 'active'); + assert.equal(created.toAmount, '25.00'); + assert.match(created.url, new RegExp(`^${base.replaceAll('.', '\\.')}\/pay\/demo-`)); + + const fetched = await fetch(`${base}/api/payment-links/${created.id}`).then((response) => response.json()); + assert.equal(fetched.description, 'TEST-25 | Workshop seat'); + + const settledResponse = await fetch(`${base}/api/payment-links/${created.id}/simulate`, { method: 'POST' }); + assert.equal(settledResponse.status, 200); + const settled = await settledResponse.json(); + assert.equal(settled.status, 'completed'); + assert.equal(settled.receivedAmount, '25.00'); + + const pageResponse = await fetch(created.url); + assert.equal(pageResponse.status, 200); + assert.match(await pageResponse.text(), /Checkout Pilot/); +}); + +test('serves X-Agent deployment proof only for a real source commit', async (t) => { + const commit = 'a'.repeat(40); + const server = createAppServer({ + env: { SOURCE_COMMIT: commit, XAGENT_SUBMISSION_SLUG: 'builder-checkout-pilot' } + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => new Promise((resolve) => server.close(resolve))); + const { port } = server.address(); + const base = `http://127.0.0.1:${port}`; + + const health = await fetch(`${base}/health`).then((response) => response.json()); + assert.equal(health.commit, commit); + + const proofResponse = await fetch(`${base}/.well-known/xagent-verification.json`); + assert.equal(proofResponse.status, 200); + assert.deepEqual(await proofResponse.json(), { + schemaVersion: 1, + slug: 'builder-checkout-pilot', + commit + }); +}); + +test('documents agent capability boundaries', async (t) => { + const server = createAppServer({ env: {} }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => new Promise((resolve) => server.close(resolve))); + const { port } = server.address(); + const body = await fetch(`http://127.0.0.1:${port}/api/capabilities`).then((response) => response.json()); + assert.deepEqual(body.operations.map((operation) => operation.id), [ + 'create_payment_request', + 'get_payment_request' + ]); + assert.match(body.sideEffects.createPaymentRequest, /never sends/); +}); + +test('rejects invalid payment input without creating a record', async (t) => { + const server = createAppServer({ env: {} }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => new Promise((resolve) => server.close(resolve))); + const { port } = server.address(); + const response = await fetch(`http://127.0.0.1:${port}/api/payment-links`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ amount: '12.345' }) + }); + assert.equal(response.status, 400); + assert.match((await response.json()).error, /Amount/); +}); + +test('does not silently fall back to demo when live configuration is partial', async (t) => { + const server = createAppServer({ env: { MOOVE_API_KEY: 'present-but-not-used' } }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => new Promise((resolve) => server.close(resolve))); + const { port } = server.address(); + const config = await fetch(`http://127.0.0.1:${port}/api/config`).then((response) => response.json()); + assert.equal(config.mode, 'misconfigured'); + const response = await fetch(`http://127.0.0.1:${port}/api/payment-links`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ amount: '1.00' }) + }); + assert.equal(response.status, 503); +}); + +test('does not allow a non-local HTTP API base for live credentials', async (t) => { + const server = createAppServer({ env: { MOOVE_API_BASE_URL: 'http://api.example.test', MOOVE_API_KEY: 'present-but-not-used' } }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => new Promise((resolve) => server.close(resolve))); + const { port } = server.address(); + const config = await fetch(`http://127.0.0.1:${port}/api/config`).then((response) => response.json()); + assert.equal(config.mode, 'misconfigured'); +}); + +test('live mode forwards the exact Receive contract server-side', async (t) => { + let receivedHeaders; + let receivedBody; + const moove = http.createServer(async (req, res) => { + receivedHeaders = req.headers; + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + receivedBody = JSON.parse(Buffer.concat(chunks).toString('utf8')); + res.writeHead(201, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ id: 'live-1', url: 'https://www.moove.xyz/@pilot/pay/live-1', status: 'active' })); + }); + await new Promise((resolve) => moove.listen(0, '127.0.0.1', resolve)); + t.after(() => new Promise((resolve) => moove.close(resolve))); + const moovePort = moove.address().port; + + const app = createAppServer({ + env: { MOOVE_API_BASE_URL: `http://127.0.0.1:${moovePort}`, MOOVE_API_KEY: 'mk_test_server_only' } + }); + await new Promise((resolve) => app.listen(0, '127.0.0.1', resolve)); + t.after(() => new Promise((resolve) => app.close(resolve))); + const appPort = app.address().port; + + const response = await fetch(`http://127.0.0.1:${appPort}/api/payment-links`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ amount: '12.5', item: 'Test order', reference: 'LIVE-1' }) + }); + assert.equal(response.status, 201); + assert.equal((await response.json()).id, 'live-1'); + assert.equal(receivedHeaders['x-api-key'], 'mk_test_server_only'); + assert.deepEqual(receivedBody, { + toAmount: '12.50', + description: 'LIVE-1 | Test order', + maxUsage: 1, + expirationDate: null + }); +}); diff --git a/submissions/mcp-hackathon/mosesfawole-checkout-pilot/submission.json b/submissions/mcp-hackathon/mosesfawole-checkout-pilot/submission.json new file mode 100644 index 0000000..18145e3 --- /dev/null +++ b/submissions/mcp-hackathon/mosesfawole-checkout-pilot/submission.json @@ -0,0 +1,10 @@ +{ + "schemaVersion": 1, + "name": "Checkout Pilot", + "slug": "mosesfawole-checkout-pilot", + "sourceRepository": "https://github.com/mosesfawole/checkout-pilot", + "reviewCommit": "1c4740857de59595f8d2bb4501f6e44d00a160f4", + "apiBaseUrl": "https://checkout-pilot.onrender.com/v1", + "healthCheckUrl": "https://checkout-pilot.onrender.com/health", + "deploymentProofUrl": "https://checkout-pilot.onrender.com/.well-known/xagent-verification.json" +} diff --git a/submissions/mcp-hackathon/mosesfawole-checkout-pilot/verification/README.md b/submissions/mcp-hackathon/mosesfawole-checkout-pilot/verification/README.md new file mode 100644 index 0000000..7ac4561 --- /dev/null +++ b/submissions/mcp-hackathon/mosesfawole-checkout-pilot/verification/README.md @@ -0,0 +1,38 @@ +# Verification evidence + +Review commit: `1c4740857de59595f8d2bb4501f6e44d00a160f4` + +## 1. Health check + +```bash +curl --fail --silent --show-error https://checkout-pilot.onrender.com/health +``` + +Expected response includes: + +```json +{"status":"ok","commit":"1c4740857de59595f8d2bb4501f6e44d00a160f4"} +``` + +## 2. Deployment proof + +```bash +curl --fail --silent --show-error https://checkout-pilot.onrender.com/.well-known/xagent-verification.json +``` + +Expected response: + +```json +{"schemaVersion":1,"slug":"mosesfawole-checkout-pilot","commit":"1c4740857de59595f8d2bb4501f6e44d00a160f4"} +``` + +## 3. Safe capability call + +```bash +curl --fail --silent --show-error \ + --request POST https://checkout-pilot.onrender.com/v1/payment-link \ + --header "content-type: application/json" \ + --data '{"amount":"25.00","currency":"USDC","description":"Pilot invoice"}' +``` + +In demo mode this returns a generated link identifier and payable URL. Invalid amounts are rejected with a 4xx response. No credentials or private keys are required.