From 928634f8c00eedb59d08d0f0c2f95f1e6143cd4a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 16:37:46 +0000 Subject: [PATCH 1/3] fix: boot published MCP server and align live AgentPay surfaces npx @agentpayxyz/mcp-server@0.2.0 never opened stdio because isMainModule used CJS require.main in an ESM package. Start the server from the bin, detect CLI entry in ESM, and bump the package to 0.2.1 (publish is documented, not run). Also align merchant register to apk_/mer_* keys, Launch tool calls to 50, x402 challenge/verify, receipt/demo, dead agentpay.gg MCP URLs, and agentpay.so copy so Ace rail names and Postizzz leftover stay off the public legal and marketing pages. Co-authored-by: Rumblingb --- .github/workflows/ci.yml | 3 + apps/api-edge/src/index.ts | 2 + apps/api-edge/src/lib/mcpBilling.ts | 4 +- apps/api-edge/src/lib/merchantKeys.ts | 42 + apps/api-edge/src/middleware/auth.ts | 8 +- apps/api-edge/src/routes/merchants.ts | 30 +- apps/api-edge/src/routes/receipt.ts | 35 +- apps/api-edge/src/routes/x402.ts | 183 ++- apps/api-edge/wrangler.toml | 2 +- dashboard/app/pricing/page.tsx | 4 +- docs/AGENTPAY_SO_PUBLIC_SURFACE.md | 25 + docs/INDEX.md | 1 + packages/mcp-server/README.md | 23 +- packages/mcp-server/bin/agentpay-mcp.mjs | 22 +- packages/mcp-server/package.json | 2 +- packages/mcp-server/server.json | 4 +- packages/mcp-server/src/cli-entry.ts | 21 + packages/mcp-server/src/index.ts | 20 +- server.json | 4 +- tests/routes/mandates-merchants.edge.test.ts | 2 + tests/routes/mcpRemote.test.ts | 4 + tests/routes/receipt-demo.edge.test.ts | 45 + tests/routes/x402.edge.test.ts | 90 ++ tests/unit/mcp-server-boot.test.ts | 32 + tests/unit/merchantKeys.test.ts | 19 + workers/agentpay-landing/README.md | 33 + .../deploy-postizzz-review.sh | 82 ++ .../agentpay-landing/deploy-routing-fix.sh | 95 ++ .../verify-free-dev-tools-product.mjs | 94 ++ .../verify-postizzz-review.mjs | 48 + workers/agentpay-landing/verify-routing.mjs | 77 ++ workers/agentpay-landing/worker-entry.mjs | 7 + workers/agentpay-landing/worker.js | 1036 +++++++++++++++++ workers/agentpay-landing/worker.test.mjs | 328 ++++++ workers/agentpay-landing/wrangler.toml | 15 + 35 files changed, 2349 insertions(+), 93 deletions(-) create mode 100644 apps/api-edge/src/lib/merchantKeys.ts create mode 100644 docs/AGENTPAY_SO_PUBLIC_SURFACE.md create mode 100644 packages/mcp-server/src/cli-entry.ts create mode 100644 tests/routes/receipt-demo.edge.test.ts create mode 100644 tests/routes/x402.edge.test.ts create mode 100644 tests/unit/mcp-server-boot.test.ts create mode 100644 tests/unit/merchantKeys.test.ts create mode 100644 workers/agentpay-landing/README.md create mode 100755 workers/agentpay-landing/deploy-postizzz-review.sh create mode 100755 workers/agentpay-landing/deploy-routing-fix.sh create mode 100644 workers/agentpay-landing/verify-free-dev-tools-product.mjs create mode 100755 workers/agentpay-landing/verify-postizzz-review.mjs create mode 100755 workers/agentpay-landing/verify-routing.mjs create mode 100644 workers/agentpay-landing/worker-entry.mjs create mode 100644 workers/agentpay-landing/worker.js create mode 100644 workers/agentpay-landing/worker.test.mjs create mode 100644 workers/agentpay-landing/wrangler.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac6afeff..2166c252 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,6 +55,9 @@ jobs: - name: Run Workers critical tests run: npm run test:workers-critical + - name: Landing worker smoke + run: node --test workers/agentpay-landing/worker.test.mjs + dashboard: name: Dashboard runs-on: ubuntu-latest diff --git a/apps/api-edge/src/index.ts b/apps/api-edge/src/index.ts index ccb5a6ee..94884f62 100644 --- a/apps/api-edge/src/index.ts +++ b/apps/api-edge/src/index.ts @@ -147,6 +147,8 @@ app.route('/api/v1/agents', v1AgentsRouter); // Protocol routes app.route('/api/x402', x402Router); +app.route('/x402', x402Router); +app.route('/.well-known/x402', x402Router); app.route('/api/ap2', ap2Router); app.route('/api/acp', acpRouter); diff --git a/apps/api-edge/src/lib/mcpBilling.ts b/apps/api-edge/src/lib/mcpBilling.ts index 06bfca2f..da6283da 100644 --- a/apps/api-edge/src/lib/mcpBilling.ts +++ b/apps/api-edge/src/lib/mcpBilling.ts @@ -1,7 +1,7 @@ import { createDb } from './db'; import type { Env, MerchantContext } from '../types'; -export const HOSTED_MCP_PRICING_VERSION = '2026-04-16'; +export const HOSTED_MCP_PRICING_VERSION = '2026-09-06'; export const HOSTED_MCP_DEFAULT_PLAN = 'launch'; export const HOSTED_MCP_FUNDED_ACTION_FEE_BPS = 75; export const HOSTED_MCP_FUNDED_ACTION_MINIMUM_USD = 0.25; @@ -27,7 +27,7 @@ export const HOSTED_MCP_PLANS: Record = { code: 'launch', label: 'Launch', monthlyUsd: 0, - includedToolCalls: 250, + includedToolCalls: 50, includedTokenMints: 25, overagePerThousandToolCallsUsd: null, notes: 'Developer and evaluation tier. Best for testing remote MCP and early pilots.', diff --git a/apps/api-edge/src/lib/merchantKeys.ts b/apps/api-edge/src/lib/merchantKeys.ts new file mode 100644 index 00000000..cc959694 --- /dev/null +++ b/apps/api-edge/src/lib/merchantKeys.ts @@ -0,0 +1,42 @@ +/** + * Public merchant identifiers and API keys. + * + * Live docs and Cursor snippets advertise: + * merchantId: mer_ + * apiKey: apk_<64 hex> + * + * The database still stores the raw UUID in merchants.id. key_prefix is the + * first 8 characters of the public apiKey so auth can look the row up. + * The PBKDF2 hash is derived from the full public apiKey (including apk_). + */ + +export const MERCHANT_ID_PREFIX = 'mer_'; +export const MERCHANT_API_KEY_PREFIX = 'apk_'; + +export function randomHex(bytes: number): string { + const arr = new Uint8Array(bytes); + crypto.getRandomValues(arr); + return Array.from(arr) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +export function generateMerchantApiKey(): { apiKey: string; keyPrefix: string } { + const apiKey = `${MERCHANT_API_KEY_PREFIX}${randomHex(32)}`; + return { + apiKey, + keyPrefix: apiKey.slice(0, 8), + }; +} + +export function formatPublicMerchantId(id: string): string { + return id.startsWith(MERCHANT_ID_PREFIX) ? id : `${MERCHANT_ID_PREFIX}${id}`; +} + +export function parsePublicMerchantId(id: string): string { + return id.startsWith(MERCHANT_ID_PREFIX) ? id.slice(MERCHANT_ID_PREFIX.length) : id; +} + +export function isPublicMerchantApiKey(apiKey: string): boolean { + return apiKey.startsWith(MERCHANT_API_KEY_PREFIX) && apiKey.length > MERCHANT_API_KEY_PREFIX.length; +} diff --git a/apps/api-edge/src/middleware/auth.ts b/apps/api-edge/src/middleware/auth.ts index 2ee6b9db..c0faaa03 100644 --- a/apps/api-edge/src/middleware/auth.ts +++ b/apps/api-edge/src/middleware/auth.ts @@ -244,8 +244,8 @@ export async function authenticateApiKey( message: 'Invalid API key', help: { suggestion: 'Check your API key is correct and active.', - link: 'https://docs.agentpay.gg/authentication', - fix: 'Generate a new API key at https://dashboard.agentpay.gg/api-keys', + link: 'https://agentpay.so/docs', + fix: 'Register a key at https://agentpay.so/start or POST /api/merchants/register', }, }, 'invalid_token', @@ -278,8 +278,8 @@ export async function authenticateApiKey( message: 'Invalid API key', help: { suggestion: 'Check your API key is correct and active.', - link: 'https://docs.agentpay.gg/authentication', - fix: 'Generate a new API key at https://dashboard.agentpay.gg/api-keys', + link: 'https://agentpay.so/docs', + fix: 'Register a key at https://agentpay.so/start or POST /api/merchants/register', }, }, 'invalid_token', diff --git a/apps/api-edge/src/routes/merchants.ts b/apps/api-edge/src/routes/merchants.ts index 597113c4..da2d404d 100644 --- a/apps/api-edge/src/routes/merchants.ts +++ b/apps/api-edge/src/routes/merchants.ts @@ -28,6 +28,11 @@ import { authenticateApiKey } from '../middleware/auth'; import { createDb } from '../lib/db'; import { pbkdf2Hex } from '../lib/pbkdf2'; import { hmacSign, hmacVerify } from '../lib/hmac'; +import { + formatPublicMerchantId, + generateMerchantApiKey, + randomHex, +} from '../lib/merchantKeys'; const router = new Hono<{ Bindings: Env; Variables: Variables }>(); @@ -35,15 +40,6 @@ const router = new Hono<{ Bindings: Env; Variables: Variables }>(); // Helpers // --------------------------------------------------------------------------- -/** Generate `bytes` random bytes as a lowercase hex string. */ -function randomHex(bytes: number): string { - const arr = new Uint8Array(bytes); - crypto.getRandomValues(arr); - return Array.from(arr) - .map((b) => b.toString(16).padStart(2, '0')) - .join(''); -} - /** Basic email format check — mirrors Joi.string().email() pattern. */ function isValidEmail(s: string): boolean { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s); @@ -310,10 +306,10 @@ router.post('/register', async (c) => { } const merchantId = crypto.randomUUID(); - const apiKey = randomHex(32); // 64-char hex, matches Node.js randomBytes(32).toString('hex') - const keyPrefix = apiKey.substring(0, 8); + const { apiKey, keyPrefix } = generateMerchantApiKey(); const salt = randomHex(16); // 32-char hex salt const hash = await pbkdf2Hex(apiKey, salt); + const publicMerchantId = formatPublicMerchantId(merchantId); await sql` INSERT INTO merchants (id, name, email, api_key_hash, api_key_salt, key_prefix, @@ -336,7 +332,7 @@ router.post('/register', async (c) => {

API Key

${apiKey} -

Your merchant ID: ${merchantId}

+

Your merchant ID: ${publicMerchantId}

Add this to your MCP server config as AGENTPAY_API_KEY. If you lose this key, use the account recovery flow to get a new one.

`, @@ -345,7 +341,7 @@ router.post('/register', async (c) => { if (emailDelivery.status !== 'sent') { return c.json({ success: true, - merchantId, + merchantId: publicMerchantId, apiKey, message: 'Email delivery is unavailable right now, so your API key is returned directly. Store it securely — it will not be shown again.', emailDelivery, @@ -354,7 +350,7 @@ router.post('/register', async (c) => { return c.json({ success: true, - merchantId, + merchantId: publicMerchantId, message: `Your API key has been sent to ${normalizedEmail}. Check your inbox.`, emailDelivery, }, 201); @@ -894,8 +890,7 @@ router.patch('/profile/wallet', authenticateApiKey, async (c) => { router.post('/rotate-key', authenticateApiKey, async (c) => { const merchant = c.get('merchant'); - const newApiKey = randomHex(32); - const newKeyPrefix = newApiKey.substring(0, 8); + const { apiKey: newApiKey, keyPrefix: newKeyPrefix } = generateMerchantApiKey(); const newSalt = randomHex(16); const newHash = await pbkdf2Hex(newApiKey, newSalt); @@ -1125,8 +1120,7 @@ router.post('/recover/confirm', async (c) => { } // Rotate API key - const newApiKey = randomHex(32); - const newKeyPrefix = newApiKey.substring(0, 8); + const { apiKey: newApiKey, keyPrefix: newKeyPrefix } = generateMerchantApiKey(); const newSalt = randomHex(16); const newHash = await pbkdf2Hex(newApiKey, newSalt); diff --git a/apps/api-edge/src/routes/receipt.ts b/apps/api-edge/src/routes/receipt.ts index 9d5c19a6..4599a6c9 100644 --- a/apps/api-edge/src/routes/receipt.ts +++ b/apps/api-edge/src/routes/receipt.ts @@ -154,8 +154,37 @@ async function querySettlementIdentity( // Route handler // --------------------------------------------------------------------------- +router.get('/demo', (c) => + c.json({ + success: true, + demo: true, + intent: { + id: 'demo', + amount: 1, + currency: 'USDC', + status: 'verified', + protocol: 'x402', + agentId: null, + expiresAt: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + agent: null, + }, + resolution: null, + settlement: null, + escrow: null, + message: 'Demo receipt. Not a live settlement.', + }), +); + router.get('/:intentId', async (c) => { const { intentId } = c.req.param(); + if (intentId === 'demo') { + return c.redirect('/api/receipt/demo', 302); + } + if (!/^[0-9a-fA-Z_-]{8,128}$/.test(intentId)) { + return c.json({ error: 'NOT_FOUND', message: 'Payment intent not found' }, 404); + } const sql = createDb(c.env); try { @@ -250,7 +279,11 @@ router.get('/:intentId', async (c) => { escrow: null, }); } catch (err: unknown) { - console.error('[receipt] error:', err instanceof Error ? err.message : err); + const msg = err instanceof Error ? err.message : String(err); + if (/invalid input syntax for type uuid|22P02/i.test(msg)) { + return c.json({ error: 'NOT_FOUND', message: 'Payment intent not found' }, 404); + } + console.error('[receipt] error:', msg); return c.json({ error: 'Failed to fetch receipt' }, 500); } finally { sql.end().catch(() => {}); diff --git a/apps/api-edge/src/routes/x402.ts b/apps/api-edge/src/routes/x402.ts index e901eba2..7c11ddcf 100644 --- a/apps/api-edge/src/routes/x402.ts +++ b/apps/api-edge/src/routes/x402.ts @@ -2,28 +2,131 @@ * x402 — HTTP 402 Payment Required protocol handler (Cloudflare Workers / Hono) * * Flow: - * 1. Any route can respond with 402 + a payment descriptor + * 1. Discovery returns a 402 challenge descriptor * 2. Agent/client creates an intent at POST /api/v1/payment-intents * 3. Agent retries the original request with X-AgentPay-Payment-Id header - * 4. This middleware verifies the payment and forwards to the resource + * 4. POST /api/x402/verify checks the payment against /api/verify/:id * * Endpoints: + * GET /api/x402 — HTTP 402 challenge (canonical discovery) + * GET /api/x402/challenge — same challenge + * GET /api/x402/pay — same challenge * GET /api/x402/schema — machine-readable protocol schema * POST /api/x402/verify — verify a payment token (internal + SDK use) */ import { Hono } from 'hono'; import type { Env, Variables } from '../types'; +import { getInternalAppFetcher } from '../lib/internalAppFetch'; const router = new Hono<{ Bindings: Env; Variables: Variables }>(); -const BASE_URL = 'https://api.agentpay.so'; +function publicApiBase(env: Env): string { + return (env.API_BASE_URL || 'https://api.agentpay.so').replace(/\/$/, ''); +} + +export function build402Descriptor(opts: { + resource: string; + amountUsd: number; + minAgentRank?: number; + apiBase?: string; +}) { + const base = opts.apiBase ?? 'https://api.agentpay.so'; + return { + version: '1.0', + scheme: 'x402', + resource: opts.resource, + amountUsd: opts.amountUsd, + currency: 'USD', + paymentEndpoints: { + agentpay: `${base}/api/v1/payment-intents`, + solana: `${base}/api/v1/payment-intents`, + }, + acceptedNetworks: ['solana', 'stripe'], + memo: `Payment required for ${opts.resource}`, + verify: { method: 'POST', path: '/api/x402/verify' }, + schema: { method: 'GET', path: '/api/x402/schema' }, + ...(opts.minAgentRank + ? { + agentRankRequirement: { + minimum: opts.minAgentRank, + checkUrl: `${base}/api/passport/:agentId`, + }, + } + : {}), + }; +} + +function challengeHeaders(): Record { + return { + 'X-AgentPay-Protocol': 'x402', + 'X-AgentPay-Amount-USD': '1', + 'X-AgentPay-Resource': 'x402-challenge', + }; +} + +function challengeBody(env: Env) { + return build402Descriptor({ + resource: 'x402-challenge', + amountUsd: 0.01, + apiBase: publicApiBase(env), + }); +} + +function respondWithChallenge(c: { json: (body: unknown, status: 402, headers?: Record) => Response; env: Env }) { + return c.json(challengeBody(c.env), 402, challengeHeaders()); +} + +async function readVerifyJson(res: Response): Promise> { + const text = await res.text(); + try { + const parsed = JSON.parse(text) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + // fall through + } + throw new Error(`Verify backend returned non-JSON (${res.status})`); +} + +async function lookupPayment(c: { env: Env; executionCtx: unknown }, paymentId: string): Promise> { + const base = publicApiBase(c.env); + const verifyUrl = `${base}/api/verify/${encodeURIComponent(paymentId)}`; + const request = new Request(verifyUrl, { + headers: { 'User-Agent': 'AgentPay-x402-verifier/1.0' }, + }); + + const internal = getInternalAppFetcher(); + if (internal) { + const res = await internal(request, c.env, c.executionCtx as never); + return readVerifyJson(res); + } + + try { + const res = await fetch(verifyUrl, { + headers: { 'User-Agent': 'AgentPay-x402-verifier/1.0' }, + }); + return readVerifyJson(res); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + throw new Error(`Verification backend unavailable: ${reason}`); + } +} + +// --------------------------------------------------------------------------- +// GET /api/x402 — advertised challenge +// --------------------------------------------------------------------------- +router.get('/', (c) => respondWithChallenge(c)); +router.get('/challenge', (c) => respondWithChallenge(c)); +router.get('/pay', (c) => respondWithChallenge(c)); // --------------------------------------------------------------------------- // GET /api/x402/schema — discovery endpoint for agents // --------------------------------------------------------------------------- -router.get('/schema', (c) => - c.json({ +router.get('/schema', (c) => { + const base = publicApiBase(c.env); + return c.json({ protocol: 'x402', version: '1.0', description: @@ -43,6 +146,7 @@ router.get('/schema', (c) => ], }, endpoints: { + challenge: { method: 'GET', path: '/api/x402', status: 402 }, schema: { method: 'GET', path: '/api/x402/schema' }, verify: { method: 'POST', path: '/api/x402/verify' }, createIntent: { method: 'POST', path: '/api/v1/payment-intents' }, @@ -50,11 +154,11 @@ router.get('/schema', (c) => }, agentRank: { description: 'Resources can require a minimum AgentRank score', - checkUrl: `${BASE_URL}/api/passport/:agentId`, + checkUrl: `${base}/api/passport/:agentId`, }, docs: 'https://agentpay.so/docs#x402', - }), -); + }); +}); // --------------------------------------------------------------------------- // POST /api/x402/verify — verify a payment proof @@ -72,20 +176,26 @@ router.post('/verify', async (c) => { return c.json({ error: 'paymentId is required' }, 400); } - // Proxy to our own verify endpoint (single source of truth) - const verifyUrl = `${BASE_URL}/api/verify/${encodeURIComponent(paymentId)}`; - let data: any; + let data: Record; try { - const res = await fetch(verifyUrl, { - headers: { 'User-Agent': 'AgentPay-x402-verifier/1.0' }, - }); - data = await res.json(); - } catch { - return c.json({ verified: false, error: 'Verification service unreachable' }, 502); + data = await lookupPayment(c, paymentId); + } catch (err) { + const reason = err instanceof Error ? err.message : 'Verification backend unavailable'; + return c.json({ + verified: false, + error: 'verification_backend_unavailable', + reason, + protocol: 'x402', + }, 503); } - if (data.status !== 'verified' && data.status !== 'completed') { - return c.json({ verified: false, reason: `Payment status: ${data.status}`, protocol: 'x402' }, 402); + const status = typeof data.status === 'string' ? data.status : undefined; + if (status !== 'verified' && status !== 'completed' && status !== 'confirmed' && data.verified !== true) { + return c.json({ + verified: false, + reason: `Payment status: ${status ?? 'unknown'}`, + protocol: 'x402', + }, 402); } if (requiredAmountUsd !== undefined && Number(data.amount) < requiredAmountUsd) { @@ -99,42 +209,11 @@ router.post('/verify', async (c) => { return c.json({ verified: true, paymentId, - status: data.status, - amount: data.amount, + status: status ?? 'verified', + amount: data.amount ?? null, protocol: 'x402', verifiedAt: new Date().toISOString(), }); }); -// --------------------------------------------------------------------------- -// Helper: build a 402 descriptor (used by other routes via import) -// --------------------------------------------------------------------------- -export function build402Descriptor(opts: { - resource: string; - amountUsd: number; - minAgentRank?: number; -}) { - return { - version: '1.0', - scheme: 'x402', - resource: opts.resource, - amountUsd: opts.amountUsd, - currency: 'USD', - paymentEndpoints: { - agentpay: `${BASE_URL}/api/v1/payment-intents`, - solana: `${BASE_URL}/api/v1/payment-intents`, - }, - acceptedNetworks: ['solana', 'stripe'], - memo: `Payment required for ${opts.resource}`, - ...(opts.minAgentRank - ? { - agentRankRequirement: { - minimum: opts.minAgentRank, - checkUrl: `${BASE_URL}/api/passport/:agentId`, - }, - } - : {}), - }; -} - export { router as x402Router }; diff --git a/apps/api-edge/wrangler.toml b/apps/api-edge/wrangler.toml index d09a0f63..ef04bb70 100644 --- a/apps/api-edge/wrangler.toml +++ b/apps/api-edge/wrangler.toml @@ -11,7 +11,7 @@ compatibility_flags = ["nodejs_compat"] # cors middleware and do not need to be listed here. CORS_ORIGIN = "https://apay-delta.vercel.app,https://dashboard.agentpay.gg,https://agentpay.gg,https://agentpay.so,https://app.agentpay.so" API_BASE_URL = "https://api.agentpay.so" -FRONTEND_URL = "https://agentpay.gg" +FRONTEND_URL = "https://agentpay.so" NODE_ENV = "production" # Cartesia TTS — default voice (British English, Sonic). Non-sensitive; override per-env if needed. # Set the secret separately: npx wrangler secret put CARTESIA_API_KEY diff --git a/dashboard/app/pricing/page.tsx b/dashboard/app/pricing/page.tsx index 4166ba59..4fd43892 100644 --- a/dashboard/app/pricing/page.tsx +++ b/dashboard/app/pricing/page.tsx @@ -18,7 +18,7 @@ const PLANS = [ cta: 'Get started free', ctaHref: '/login', features: [ - '250 tool calls / month', + '50 tool calls / month', '25 credential vaults', 'MCP server + remote endpoint', 'Agent Passport + trust graph', @@ -268,7 +268,7 @@ export default function PricingPage() { }, { q: 'Is there a genuinely free tier?', - a: 'Yes. Launch is free forever — 250 tool calls and 25 credential vaults per month. No card required to register. Great for evaluation and low-volume agents.', + a: 'Yes. Launch is free forever — 50 tool calls and 25 credential vaults per month. No card required to register. Great for evaluation and low-volume agents.', }, { q: 'What happens when I exceed my plan\'s tool calls?', diff --git a/docs/AGENTPAY_SO_PUBLIC_SURFACE.md b/docs/AGENTPAY_SO_PUBLIC_SURFACE.md new file mode 100644 index 00000000..464a4de0 --- /dev/null +++ b/docs/AGENTPAY_SO_PUBLIC_SURFACE.md @@ -0,0 +1,25 @@ +# agentpay.so public surface + +**Date:** 2026-08-27 +**Worker:** `workers/agentpay-landing/` → Cloudflare `agentpay-landing-production` +**This PR does not deploy.** + +## Ownership + +| Surface | Serves | In this repo | +|---------|--------|----------------| +| `https://agentpay.so/*` | Cloudflare Worker `agentpay-landing-production` | `workers/agentpay-landing/` | +| `https://api.agentpay.so` | Cloudflare Worker `agentpay-api` | `apps/api-edge/` | +| `https://docs.agentpay.so` | Vercel `apps/docs` | `apps/docs/` | +| `https://app.agentpay.so` | Vercel dashboard | `dashboard/` | +| Repo-root `index.html` | leftover, not live | do not treat as marketing | + +`agentpay.gg` DNS is dead. Leave it dark. + +Public `/`, `/start`, and `/docs` stay AgentPay-only: guardrails + payment authority. Do not name Ace, IRCTC, National Rail, or Postizzz. UK/India copy is payment rails only (GBP/INR, UPI, Open Banking). H1 stays locked: + +> Your agent can pay with a card or with crypto. One key. You set the limit. + +## Rollback + +`deploy-routing-fix.sh` records the current 100% version before upload. Roll back with that version id. Do not deploy from this PR without that token and confirmation gate. diff --git a/docs/INDEX.md b/docs/INDEX.md index 436d7823..f23cc4d3 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -3,6 +3,7 @@ Welcome - this is the canonical docs entrypoint for developer-facing guides. - `QUICKSTART.md` - minimal local dev quickstart for the PR7 happy path +- `docs/AGENTPAY_SO_PUBLIC_SURFACE.md` - agentpay.so Cloudflare Worker ownership (`/start`, `/docs`, homepage). Requires Cloudflare deploy; this repo PR does not ship live. - `INTEGRATION_GUIDE.md` - integration notes and endpoints - `docs/HOSTED_COMMERCE_AND_SECURITY_20260419.md` - hosted merchant onboarding, catalog publishing, and security model for Claude/OpenAI-era agent commerce - `docs/GROWTH_AGENT_TEAM_20260419.md` - growth/distribution operating model, runnable agents, and messaging guardrails diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index a12118e8..f749b77c 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -199,4 +199,25 @@ Claude will call `agentpay_execute_capability`. AgentPay injects the vaulted cre |----------|----------|-------------| | `AGENTPAY_API_KEY` | Yes | Your merchant API key | | `AGENTPAY_MERCHANT_ID` | Recommended | Your merchant ID (used in intent creation) | -| `AGENTPAY_API_URL` | No | Override API URL (default: production Workers endpoint) | +| `AGENTPAY_API_URL` | No | Override API URL (default: `https://api.agentpay.so`) | + +## Publish `@agentpayxyz/mcp-server` 0.2.1 + +This repo does **not** auto-publish the MCP package. Do not publish from a cloud agent unless you have an npm token and an explicit release request. + +After this PR is merged: + +```bash +cd packages/mcp-server +npm run build +npm pack --dry-run # confirm bin/agentpay-mcp.mjs and dist/ are included +npm publish --access public +``` + +Confirm the published tarball boots: + +```bash +npx -y @agentpayxyz/mcp-server@0.2.1 --help +``` + +A missing `AGENTPAY_API_KEY` may warn on stderr. The process must stay up and speak MCP stdio instead of exiting 0. diff --git a/packages/mcp-server/bin/agentpay-mcp.mjs b/packages/mcp-server/bin/agentpay-mcp.mjs index 9c65347f..8cf967c5 100755 --- a/packages/mcp-server/bin/agentpay-mcp.mjs +++ b/packages/mcp-server/bin/agentpay-mcp.mjs @@ -1,8 +1,18 @@ #!/usr/bin/env node -import { createAgentPayMcpServer } from '../dist/index.js'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { startStdioServer } from '../dist/index.js'; -const server = createAgentPayMcpServer(); -const transport = new StdioServerTransport(); -await server.connect(transport); -process.stderr.write('AgentPay MCP server running on stdio\n'); +if (process.argv.includes('--help') || process.argv.includes('-h')) { + process.stderr.write( + 'AgentPay MCP server\n' + + '\n' + + 'Usage:\n' + + ' npx -y @agentpayxyz/mcp-server\n' + + '\n' + + 'Starts an MCP stdio server. A missing AGENTPAY_API_KEY warns on stderr\n' + + 'but the process stays up and speaks MCP.\n' + + '\n' + + 'Default API: https://api.agentpay.so\n', + ); +} + +await startStdioServer(); diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index aca42af2..b989e17d 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@agentpayxyz/mcp-server", - "version": "0.2.0", + "version": "0.2.1", "mcpName": "io.github.Rumblingb/agentpay", "description": "MCP server for AgentPay — autonomous agent infrastructure for payments, API key vaulting, and governed mandates. One OTP vaults credentials so agents never handle raw API keys. Agents spend money within user-defined limits. Works with Claude, GPT-4o, and any MCP host.", "type": "module", diff --git a/packages/mcp-server/server.json b/packages/mcp-server/server.json index 6b4d1033..1d4e36c6 100644 --- a/packages/mcp-server/server.json +++ b/packages/mcp-server/server.json @@ -7,7 +7,7 @@ "url": "https://github.com/Rumblingb/Agentpay", "source": "github" }, - "version": "0.2.0", + "version": "0.2.1", "remotes": [ { "type": "streamable-http", @@ -68,7 +68,7 @@ { "registryType": "npm", "identifier": "@agentpayxyz/mcp-server", - "version": "0.2.0", + "version": "0.2.1", "transport": { "type": "stdio" }, diff --git a/packages/mcp-server/src/cli-entry.ts b/packages/mcp-server/src/cli-entry.ts new file mode 100644 index 00000000..c8c373c2 --- /dev/null +++ b/packages/mcp-server/src/cli-entry.ts @@ -0,0 +1,21 @@ +/** + * ESM-safe CLI entry detection. + * + * npm 0.2.0 used `require.main === module` inside a `"type": "module"` + * package. That is always false, so `npx -y @agentpayxyz/mcp-server` + * printed the missing-key warning and exited 0 without opening stdio. + */ + +export function isExecutedAsCliEntry(metaUrl: string, argv1?: string): boolean { + if (!metaUrl || !argv1) return false; + const normalizedArgv = argv1.replace(/\\/g, '/'); + const normalizedMeta = metaUrl.replace(/\\/g, '/'); + const base = normalizedArgv.split('/').pop(); + if (!base) return false; + return ( + normalizedMeta.endsWith(normalizedArgv) + || normalizedMeta.endsWith(`/${base}`) + || normalizedMeta.endsWith(encodeURI(normalizedArgv)) + || normalizedMeta.endsWith(`/${encodeURI(base)}`) + ); +} diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index eb08a524..189257fa 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -19,6 +19,7 @@ import { } from '@modelcontextprotocol/sdk/types.js'; import { ACE_TOOLS, handleAceTool } from './ace-tools.js'; import { REGISTRY_TOOLS, handleRegistryTool } from './registry-tools.js'; +import { isExecutedAsCliEntry } from './cli-entry.js'; const DEFAULT_API_URL = process.env.AGENTPAY_API_URL ?? 'https://api.agentpay.so'; const DEFAULT_API_KEY = process.env.AGENTPAY_API_KEY ?? ''; @@ -1810,7 +1811,7 @@ export function createAgentPayMcpServer( const tools = options?.tools ?? TOOLS; const allowedToolNames = new Set(tools.map((tool) => tool.name)); const server = new Server( - { name: options?.serverName ?? 'agentpay', version: '0.2.0' }, + { name: options?.serverName ?? 'agentpay', version: '0.2.1' }, { capabilities: { tools: {} } }, ); @@ -1837,3 +1838,20 @@ export function createAgentPayMcpServer( return server; } + +export { isExecutedAsCliEntry }; + +export async function startStdioServer(): Promise { + const server = createAgentPayMcpServer(); + const transport = new StdioServerTransport(); + await server.connect(transport); + process.stderr.write('AgentPay MCP server running on stdio\n'); +} + +const argv1 = typeof process !== 'undefined' && Array.isArray(process.argv) + ? process.argv[1] + : undefined; + +if (isExecutedAsCliEntry(import.meta.url, argv1)) { + void startStdioServer(); +} diff --git a/server.json b/server.json index dee6d23d..8b060af8 100644 --- a/server.json +++ b/server.json @@ -7,7 +7,7 @@ "url": "https://github.com/Rumblingb/Agentpay", "source": "github" }, - "version": "0.2.0", + "version": "0.2.1", "remotes": [ { "type": "streamable-http", @@ -52,7 +52,7 @@ { "registryType": "npm", "identifier": "@agentpayxyz/mcp-server", - "version": "0.2.0", + "version": "0.2.1", "transport": { "type": "stdio" }, diff --git a/tests/routes/mandates-merchants.edge.test.ts b/tests/routes/mandates-merchants.edge.test.ts index 52321e81..4008225c 100644 --- a/tests/routes/mandates-merchants.edge.test.ts +++ b/tests/routes/mandates-merchants.edge.test.ts @@ -202,6 +202,8 @@ describe('api-edge merchant email delivery', () => { expect(res.status).toBe(201); expect(body.success).toBe(true); expect(typeof body.apiKey).toBe('string'); + expect(body.apiKey).toMatch(/^apk_[0-9a-f]{64}$/); + expect(body.merchantId).toMatch(/^mer_[0-9a-f-]{36}$/); expect(body.emailDelivery).toEqual({ status: 'failed', provider: 'resend', diff --git a/tests/routes/mcpRemote.test.ts b/tests/routes/mcpRemote.test.ts index 08e7982a..c5b5cf59 100644 --- a/tests/routes/mcpRemote.test.ts +++ b/tests/routes/mcpRemote.test.ts @@ -133,6 +133,9 @@ describe('hosted remote MCP surface', () => { expect(body.checkoutEndpoint).toBe('http://agentpay.test/api/mcp/billing/checkout'); expect(body.setupEndpoint).toBe('http://agentpay.test/api/mcp/setup'); expect(body.demoEndpoint).toBe('http://agentpay.test/api/mcp/demo'); + const launch = (body.plans as Array>).find((plan) => plan.code === 'launch'); + expect(launch?.includedToolCalls).toBe(50); + expect(JSON.stringify(body)).not.toContain('agentpay.gg'); expect(res.status).toBe(200); }); @@ -191,6 +194,7 @@ describe('hosted remote MCP surface', () => { status: 'ready_now_manual_mcp', guideUrl: 'http://agentpay.test/docs/GENERIC_AGENT_SETUP', })); + expect(JSON.stringify(setupBody)).not.toContain('agentpay.gg'); expect(setupBody.nextActionContract).toEqual(expect.objectContaining({ responsePattern: 'result_or_next_action', })); diff --git a/tests/routes/receipt-demo.edge.test.ts b/tests/routes/receipt-demo.edge.test.ts new file mode 100644 index 00000000..40f0f15f --- /dev/null +++ b/tests/routes/receipt-demo.edge.test.ts @@ -0,0 +1,45 @@ +jest.mock('../../apps/api-edge/src/lib/db', () => { + const actual = jest.requireActual('../../apps/api-edge/src/lib/db'); + return { + ...actual, + createDb: jest.fn(), + }; +}); + +import { createDb } from '../../apps/api-edge/src/lib/db'; +import apiEdge from '../../apps/api-edge/src/index'; + +function appEnv() { + return { + DATABASE_URL: 'postgres://agentpay:test@localhost:5432/agentpay', + WEBHOOK_SECRET: 'w'.repeat(32), + AGENTPAY_SIGNING_SECRET: 's'.repeat(32), + VERIFICATION_SECRET: 'v'.repeat(32), + ADMIN_SECRET_KEY: 'a'.repeat(32), + CORS_ORIGIN: 'http://localhost:3000', + API_BASE_URL: 'http://agentpay.test', + FRONTEND_URL: 'https://agentpay.so', + AGENTPAY_TEST_MODE: 'true', + NODE_ENV: 'development', + } as never; +} + +describe('GET /api/receipt/demo', () => { + afterEach(() => { + (createDb as jest.Mock).mockReset(); + }); + + it('returns a demo receipt instead of 500', async () => { + const res = await apiEdge.fetch( + new Request('http://agentpay.test/api/receipt/demo'), + appEnv(), + {} as never, + ); + const body = await res.json() as Record; + expect(res.status).toBe(200); + expect(body.success).toBe(true); + expect(body.demo).toBe(true); + expect((body.intent as Record).id).toBe('demo'); + expect(createDb).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/routes/x402.edge.test.ts b/tests/routes/x402.edge.test.ts new file mode 100644 index 00000000..8b4d2d6c --- /dev/null +++ b/tests/routes/x402.edge.test.ts @@ -0,0 +1,90 @@ +jest.mock('../../apps/api-edge/src/lib/db', () => { + const actual = jest.requireActual('../../apps/api-edge/src/lib/db'); + return { + ...actual, + createDb: jest.fn(), + }; +}); + +import { createDb } from '../../apps/api-edge/src/lib/db'; +import apiEdge from '../../apps/api-edge/src/index'; + +function makeSql() { + const sql = (jest.fn(async () => { + throw new Error('no db'); + }) as unknown) as jest.Mock & { end: jest.Mock }; + sql.end = jest.fn().mockResolvedValue(undefined); + return sql; +} + +function appEnv() { + return { + DATABASE_URL: 'postgres://agentpay:test@localhost:5432/agentpay', + WEBHOOK_SECRET: 'w'.repeat(32), + AGENTPAY_SIGNING_SECRET: 's'.repeat(32), + VERIFICATION_SECRET: 'v'.repeat(32), + ADMIN_SECRET_KEY: 'a'.repeat(32), + CORS_ORIGIN: 'http://localhost:3000', + API_BASE_URL: 'http://agentpay.test', + FRONTEND_URL: 'https://agentpay.so', + AGENTPAY_TEST_MODE: 'true', + NODE_ENV: 'development', + } as never; +} + +describe('x402 challenge and verify', () => { + beforeEach(() => { + (createDb as jest.Mock).mockReturnValue(makeSql()); + }); + + afterEach(() => { + jest.restoreAllMocks(); + (createDb as jest.Mock).mockReset(); + }); + + it.each([ + '/api/x402', + '/api/x402/challenge', + '/api/x402/pay', + '/x402', + '/.well-known/x402', + ])('returns an HTTP 402 challenge at %s', async (path) => { + const res = await apiEdge.fetch( + new Request(`http://agentpay.test${path}`), + appEnv(), + {} as never, + ); + const body = await res.json() as Record; + expect(res.status).toBe(402); + expect(res.headers.get('X-AgentPay-Protocol')).toBe('x402'); + expect(body.scheme).toBe('x402'); + expect(body.paymentEndpoints).toEqual(expect.objectContaining({ + agentpay: 'http://agentpay.test/api/v1/payment-intents', + })); + }); + + it('keeps empty verify bodies as 400', async () => { + const res = await apiEdge.fetch( + new Request('http://agentpay.test/api/x402/verify', { method: 'POST' }), + appEnv(), + {} as never, + ); + expect(res.status).toBe(400); + }); + + it('does not 502 when verify looks up a real payload', async () => { + const res = await apiEdge.fetch( + new Request('http://agentpay.test/api/x402/verify', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ paymentId: 'pi_test_1234567890abcdef', requiredAmountUsd: 1 }), + }), + appEnv(), + {} as never, + ); + const body = await res.json() as Record; + expect(res.status).not.toBe(502); + expect(body.protocol).toBe('x402'); + expect(body.verified).toBe(false); + }); +}); diff --git a/tests/unit/mcp-server-boot.test.ts b/tests/unit/mcp-server-boot.test.ts new file mode 100644 index 00000000..2f7290d2 --- /dev/null +++ b/tests/unit/mcp-server-boot.test.ts @@ -0,0 +1,32 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { isExecutedAsCliEntry } from '../../packages/mcp-server/src/cli-entry'; + +const binSource = readFileSync(resolve(__dirname, '../../packages/mcp-server/bin/agentpay-mcp.mjs'), 'utf8'); +const indexSource = readFileSync(resolve(__dirname, '../../packages/mcp-server/src/index.ts'), 'utf8'); + +describe('MCP stdio boot contract', () => { + it('does not use CJS require.main to decide whether to start', () => { + expect(binSource).not.toMatch(/require\.main/); + expect(indexSource).not.toMatch(/require\.main\s*===\s*module/); + expect(indexSource).toContain('isExecutedAsCliEntry'); + expect(indexSource).toContain("https://api.agentpay.so"); + }); + + it('starts the stdio server from the published bin instead of a bare import', () => { + expect(binSource).toContain('startStdioServer'); + expect(binSource).not.toMatch(/^import\s+['"]\.\.\/dist\/index\.js['"]\s*;?\s*$/m); + }); + + it('detects ESM CLI entry the way npm 0.2.0 failed to', () => { + expect(isExecutedAsCliEntry( + 'file:///tmp/pkg/dist/index.js', + '/tmp/pkg/dist/index.js', + )).toBe(true); + expect(isExecutedAsCliEntry( + 'file:///tmp/pkg/dist/index.js', + '/tmp/pkg/bin/agentpay-mcp.mjs', + )).toBe(false); + expect(isExecutedAsCliEntry('file:///tmp/pkg/dist/index.js', undefined)).toBe(false); + }); +}); diff --git a/tests/unit/merchantKeys.test.ts b/tests/unit/merchantKeys.test.ts new file mode 100644 index 00000000..f3b54a34 --- /dev/null +++ b/tests/unit/merchantKeys.test.ts @@ -0,0 +1,19 @@ +import { + formatPublicMerchantId, + generateMerchantApiKey, + parsePublicMerchantId, +} from '../../apps/api-edge/src/lib/merchantKeys'; + +describe('merchant public identifiers', () => { + it('issues apk_ keys and mer_ merchant ids', () => { + const { apiKey, keyPrefix } = generateMerchantApiKey(); + expect(apiKey).toMatch(/^apk_[0-9a-f]{64}$/); + expect(keyPrefix).toHaveLength(8); + expect(apiKey.startsWith(keyPrefix)).toBe(true); + + const uuid = '26e7ac4f-017e-4316-bf4f-9a1b37112510'; + expect(formatPublicMerchantId(uuid)).toBe(`mer_${uuid}`); + expect(formatPublicMerchantId(`mer_${uuid}`)).toBe(`mer_${uuid}`); + expect(parsePublicMerchantId(`mer_${uuid}`)).toBe(uuid); + }); +}); diff --git a/workers/agentpay-landing/README.md b/workers/agentpay-landing/README.md new file mode 100644 index 00000000..e16dd9e2 --- /dev/null +++ b/workers/agentpay-landing/README.md @@ -0,0 +1,33 @@ +# agentpay.so landing worker + +Canonical source for the public marketing surface on `agentpay.so`. + +Live today this Worker is Cloudflare Worker `agentpay-landing-production`, routes `agentpay.so/*` and `www.agentpay.so/*`. It is **not** the leftover root `index.html` (~3.6k) and it is **not** `apps/docs` (that is `docs.agentpay.so`). + +## This week’s public story + +- `/` and `/start` — one H1: *Your agent can pay with a card or with crypto. One key. You set the limit.* Primary CTA is get a key / 50 free calls. Launch $0. `$39` is next-step copy, not a buy button. +- Primary install: `npx -y @agentpayxyz/mcp-server`. Optional JS SDK: `@agentpayxyz/sdk`. +- `/docs` — short public docs. API: `https://api.agentpay.so`. +- `$7` Awesome Free Dev Tools, BidDesk, Postizzz, and Ace are off the homepage. BidDesk stays at `/biddesk` (200, `noindex`, off sitemap). Ace and `agentpay.gg` stay dark. + +## Verify locally + +```bash +node --check worker.js +node --test worker.test.mjs +``` + +`verify-routing.mjs` hits **production**. Do not treat a local pass of that script as a deploy. + +## Deploy (not done in this PR) + +Requires Cloudflare auth this repo does not have in CI: + +- `CLOUDFLARE_API_TOKEN` +- `CONFIRM_DEPLOY_ROUTING=agentpay.so/routing-404` for `deploy-routing-fix.sh` +- Node >= 22 (wrangler 4.x) + +Until someone with zone access deploys `agentpay-landing-production`, production will keep serving the stale Worker (hero `/start` and `/docs` 404, wrong npm names, BidDesk in the live sitemap). + +Rollback: `wrangler versions deploy --name agentpay-landing-production` to the previous 100% version id recorded by `deploy-routing-fix.sh`. diff --git a/workers/agentpay-landing/deploy-postizzz-review.sh b/workers/agentpay-landing/deploy-postizzz-review.sh new file mode 100755 index 00000000..66c3e5ff --- /dev/null +++ b/workers/agentpay-landing/deploy-postizzz-review.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +set -euo pipefail + +EXPECTED_CONFIRMATION='agentpay.so/postizzz' +if [[ "${CONFIRM_DEPLOY_POSTIZZZ:-}" != "$EXPECTED_CONFIRMATION" ]]; then + echo "Refusing production deploy. Set CONFIRM_DEPLOY_POSTIZZZ=$EXPECTED_CONFIRMATION after action-time approval." >&2 + exit 2 +fi + +if [[ -z "${CLOUDFLARE_API_TOKEN:-}" ]]; then + echo 'CLOUDFLARE_API_TOKEN is required.' >&2 + exit 2 +fi + +ROOT=$(cd "$(dirname "$0")" && pwd) +RECEIPT_DIR=${RECEIPT_DIR:-/Users/brain/Documents/Codex/2026-06-12/i-also-want-you-to-connect/outputs/tiktok-review} +WRANGLER_VERSION=${WRANGLER_VERSION:-4.103.0} +WORKER_NAME=${WORKER_NAME:-agentpay-landing-production} +STAMP=$(date -u +%Y%m%dT%H%M%SZ) +mkdir -p "$RECEIPT_DIR" + +cd "$ROOT" +node --test worker.test.mjs +node --check worker.js +npx --yes "wrangler@$WRANGLER_VERSION" deploy --strict --dry-run --outdir "/tmp/agentpay-landing-$STAMP" +npx --yes "wrangler@$WRANGLER_VERSION" versions list --name "$WORKER_NAME" --json > "$RECEIPT_DIR/cloudflare-versions-before-$STAMP.json" +npx --yes "wrangler@$WRANGLER_VERSION" deployments list --name "$WORKER_NAME" --json > "$RECEIPT_DIR/cloudflare-deployments-before-$STAMP.json" + +PREVIOUS_VERSION=$(python3 - "$RECEIPT_DIR/cloudflare-deployments-before-$STAMP.json" <<'PY' +import json, sys +rows = json.load(open(sys.argv[1])) +if not rows: + raise SystemExit('No previous deployment available for rollback') +latest = max(rows, key=lambda row: row['created_on']) +versions = [item for item in latest['versions'] if item['percentage'] == 100] +if len(versions) != 1: + raise SystemExit('Expected one previous version at 100% traffic') +print(versions[0]['version_id']) +PY +) + +npx --yes "wrangler@$WRANGLER_VERSION" versions upload worker-entry.mjs \ + --name "$WORKER_NAME" \ + --compatibility-date 2026-06-21 \ + --keep-vars \ + --message "TikTok review: align AgentPay website, legal pages, and social product" +npx --yes "wrangler@$WRANGLER_VERSION" versions list --name "$WORKER_NAME" --json > "$RECEIPT_DIR/cloudflare-versions-after-upload-$STAMP.json" + +NEW_VERSION=$(python3 - \ + "$RECEIPT_DIR/cloudflare-versions-before-$STAMP.json" \ + "$RECEIPT_DIR/cloudflare-versions-after-upload-$STAMP.json" <<'PY' +import json, sys +before = {row['id'] for row in json.load(open(sys.argv[1]))} +after = [row['id'] for row in json.load(open(sys.argv[2])) if row['id'] not in before] +if len(after) != 1: + raise SystemExit(f'Expected one uploaded version, found {after}') +print(after[0]) +PY +) + +npx --yes "wrangler@$WRANGLER_VERSION" versions deploy \ + --name "$WORKER_NAME" \ + --version-id "$NEW_VERSION" \ + --percentage 100 \ + --message "TikTok review: publish coherent AgentPay review surface" \ + --yes + +if ! node verify-postizzz-review.mjs > "$RECEIPT_DIR/postizzz-public-verification-$STAMP.json"; then + npx --yes "wrangler@$WRANGLER_VERSION" versions deploy \ + --name "$WORKER_NAME" \ + --version-id "$PREVIOUS_VERSION" \ + --percentage 100 \ + --message "Automatic rollback after Postizzz public verification failure" \ + --yes + echo "Public verification failed; rolled back to $PREVIOUS_VERSION." >&2 + exit 1 +fi + +npx --yes "wrangler@$WRANGLER_VERSION" deployments list --name "$WORKER_NAME" --json > "$RECEIPT_DIR/cloudflare-deployments-after-$STAMP.json" +npx --yes "wrangler@$WRANGLER_VERSION" versions list --name "$WORKER_NAME" --json > "$RECEIPT_DIR/cloudflare-versions-after-$STAMP.json" + +echo "Deployment $NEW_VERSION and verification passed. Receipts: $RECEIPT_DIR/*-$STAMP.json" diff --git a/workers/agentpay-landing/deploy-routing-fix.sh b/workers/agentpay-landing/deploy-routing-fix.sh new file mode 100755 index 00000000..32161c3c --- /dev/null +++ b/workers/agentpay-landing/deploy-routing-fix.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Deploys the routing fix: known routes unchanged, /awesome-free-dev-tools/buy +# redirects to Stripe again, unknown paths 404 instead of serving the landing +# page with HTTP 200. Records the current 100%-traffic version first and rolls +# back automatically if public verification fails. + +EXPECTED_CONFIRMATION='agentpay.so/routing-404' +if [[ "${CONFIRM_DEPLOY_ROUTING:-}" != "$EXPECTED_CONFIRMATION" ]]; then + echo "Refusing production deploy. Set CONFIRM_DEPLOY_ROUTING=$EXPECTED_CONFIRMATION after action-time approval." >&2 + exit 2 +fi + +if [[ -z "${CLOUDFLARE_API_TOKEN:-}" ]]; then + echo 'CLOUDFLARE_API_TOKEN is required.' >&2 + exit 2 +fi + +# wrangler 4.x requires Node >= 22; this repo's default runtime is 20.18.0. +NODE_MAJOR=$(node -p 'process.versions.node.split(".")[0]') +if (( NODE_MAJOR < 22 )); then + echo "Node >=22 required for wrangler (found $(node --version)). Run: nvm use 22" >&2 + exit 2 +fi + +ROOT=$(cd "$(dirname "$0")" && pwd) +RECEIPT_DIR=${RECEIPT_DIR:-/Users/brain/Documents/memorybrain/Shared-Brain/Agent-Claude/deploy-receipts} +WRANGLER_VERSION=${WRANGLER_VERSION:-4.103.0} +WORKER_NAME=${WORKER_NAME:-agentpay-landing-production} +STAMP=$(date -u +%Y%m%dT%H%M%SZ) +mkdir -p "$RECEIPT_DIR" + +cd "$ROOT" +node --test worker.test.mjs +node --check worker.js +npx --yes "wrangler@$WRANGLER_VERSION" deploy --strict --dry-run --outdir "/tmp/agentpay-landing-$STAMP" +npx --yes "wrangler@$WRANGLER_VERSION" versions list --name "$WORKER_NAME" --json > "$RECEIPT_DIR/versions-before-$STAMP.json" +npx --yes "wrangler@$WRANGLER_VERSION" deployments list --name "$WORKER_NAME" --json > "$RECEIPT_DIR/deployments-before-$STAMP.json" + +PREVIOUS_VERSION=$(python3 - "$RECEIPT_DIR/deployments-before-$STAMP.json" <<'PY' +import json, sys +rows = json.load(open(sys.argv[1])) +if not rows: + raise SystemExit('No previous deployment available for rollback') +latest = max(rows, key=lambda row: row['created_on']) +versions = [item for item in latest['versions'] if item['percentage'] == 100] +if len(versions) != 1: + raise SystemExit('Expected one previous version at 100% traffic') +print(versions[0]['version_id']) +PY +) +echo "Rollback target: $PREVIOUS_VERSION" + +npx --yes "wrangler@$WRANGLER_VERSION" versions upload worker-entry.mjs \ + --name "$WORKER_NAME" \ + --compatibility-date 2026-06-21 \ + --keep-vars \ + --message "Routing: 404 unknown paths, restore /awesome-free-dev-tools/buy redirect" +npx --yes "wrangler@$WRANGLER_VERSION" versions list --name "$WORKER_NAME" --json > "$RECEIPT_DIR/versions-after-upload-$STAMP.json" + +NEW_VERSION=$(python3 - \ + "$RECEIPT_DIR/versions-before-$STAMP.json" \ + "$RECEIPT_DIR/versions-after-upload-$STAMP.json" <<'PY' +import json, sys +before = {row['id'] for row in json.load(open(sys.argv[1]))} +after = [row['id'] for row in json.load(open(sys.argv[2])) if row['id'] not in before] +if len(after) != 1: + raise SystemExit(f'Expected one uploaded version, found {after}') +print(after[0]) +PY +) +echo "Uploaded version: $NEW_VERSION" + +npx --yes "wrangler@$WRANGLER_VERSION" versions deploy \ + --name "$WORKER_NAME" \ + --version-id "$NEW_VERSION" \ + --percentage 100 \ + --message "Routing fix: real 404s, working paid-product redirect" \ + --yes + +if ! node verify-routing.mjs > "$RECEIPT_DIR/routing-verification-$STAMP.json"; then + npx --yes "wrangler@$WRANGLER_VERSION" versions deploy \ + --name "$WORKER_NAME" \ + --version-id "$PREVIOUS_VERSION" \ + --percentage 100 \ + --message "Automatic rollback after routing verification failure" \ + --yes + echo "Public verification failed; rolled back to $PREVIOUS_VERSION." >&2 + echo "Receipt: $RECEIPT_DIR/routing-verification-$STAMP.json" >&2 + exit 1 +fi + +npx --yes "wrangler@$WRANGLER_VERSION" deployments list --name "$WORKER_NAME" --json > "$RECEIPT_DIR/deployments-after-$STAMP.json" +echo "Deployment $NEW_VERSION verified. Rollback target was $PREVIOUS_VERSION. Receipts: $RECEIPT_DIR/*-$STAMP.json" diff --git a/workers/agentpay-landing/verify-free-dev-tools-product.mjs b/workers/agentpay-landing/verify-free-dev-tools-product.mjs new file mode 100644 index 00000000..70627930 --- /dev/null +++ b/workers/agentpay-landing/verify-free-dev-tools-product.mjs @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict' + +const base = (process.argv[2] || 'https://agentpay.so').replace(/\/$/, '') + +const checks = [ + { + path: '/awesome-free-dev-tools', + expect: [ + /Awesome Free Dev Tools Premium/, + /https:\/\/buy\.stripe\.com\/9B614pehNgAke6ccfh1oI03/, + /weekly update notes/, + /fulfillment is being hardened/ + ] + }, + { + path: '/awesome-free-dev-tools/success?session_id=cs_live_verifier_1234567890', + expect: [ + /Payment received/, + /Stripe has handled the payment/, + /cs_live_verifier_1234567890/, + /Access checklist/, + /request access/, + /Stripe%20session%3A%20cs_live_verifier_1234567890/, + /View fulfillment status JSON/ + ] + }, + { + path: '/', + expect: [ + /href="\/awesome-free-dev-tools">Free Dev Tools<\/a>/ + ] + } +] + +const results = [] + +for (const check of checks) { + const url = `${base}${check.path}` + const response = await fetch(url) + const body = await response.text() + + assert.equal(response.status, 200, `${url} returned ${response.status}`) + for (const pattern of check.expect) { + assert.match(body, pattern, `${url} missing ${pattern}`) + } + + results.push({ + url, + status: response.status, + contentType: response.headers.get('content-type'), + matched: check.expect.map(String) + }) +} + +const statusUrl = `${base}/awesome-free-dev-tools/status.json` +const statusResponse = await fetch(statusUrl) +assert.equal(statusResponse.status, 200, `${statusUrl} returned ${statusResponse.status}`) +assert.match(statusResponse.headers.get('content-type') || '', /application\/json/) +const status = await statusResponse.json() +assert.equal(status.product, 'Awesome Free Dev Tools Premium') +assert.equal(status.product_id, 'prod_UTImgIPBjML5Rl') +assert.equal(status.payment_link, 'https://buy.stripe.com/9B614pehNgAke6ccfh1oI03') +assert.equal(status.access_model, 'checkout_email_with_manual_backup') +assert.equal(status.buyer_access_request_url, 'mailto:rajiv_baskaran@agentpay.so?subject=Awesome%20Free%20Dev%20Tools%20access') +assert.equal(status.manual_access_sla_hours, 24) +assert.deepEqual(status.fulfillment_steps, [ + 'Stripe Checkout collects payment and purchase email', + 'Buyer keeps the Checkout session id from the success URL', + 'If automated access is not visible, buyer emails support from the purchase email with the session id', + 'AgentPay manually verifies the Stripe session and sends access while automation is being proven' +]) +assert.equal(status.current_status, 'checkout_live_manual_fulfillment_backup') +assert.ok(status.proof_required_for_full_automation.includes('automated access-delivery receipt')) + +results.push({ + url: statusUrl, + status: statusResponse.status, + contentType: statusResponse.headers.get('content-type'), + matched: [ + 'product', + 'product_id', + 'payment_link', + 'access_model', + 'current_status', + 'proof_required_for_full_automation' + ] +}) + +console.log(JSON.stringify({ + ok: true, + base, + checkedAt: new Date().toISOString(), + results +}, null, 2)) diff --git a/workers/agentpay-landing/verify-postizzz-review.mjs b/workers/agentpay-landing/verify-postizzz-review.mjs new file mode 100755 index 00000000..ccd81beb --- /dev/null +++ b/workers/agentpay-landing/verify-postizzz-review.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node + +const base = (process.argv[2] || 'https://agentpay.so').replace(/\/$/, '') +const checks = [ + { + path: '/', + required: ['AgentPay', 'Social publishing', 'Privacy', 'Terms'], + }, + { + path: '/postizzz', + required: ['Postizzz', 'TikTok Login Kit', 'Content Posting API', 'Privacy Policy', 'Terms of Service'], + }, + { + path: '/terms', + required: ['AgentPay Terms of Service', 'Authorized use', 'Connected accounts'], + }, + { + path: '/privacy', + required: ['AgentPay Privacy Policy', 'authorization tokens', 'Disconnecting an account stops future access'], + }, +] + +const results = [] +for (const check of checks) { + const url = `${base}${check.path}` + const response = await fetch(url, { redirect: 'follow' }) + const body = await response.text() + const missing = check.required.filter(text => !body.includes(text)) + results.push({ url, status: response.status, ok: response.ok && missing.length === 0, missing }) +} + +const health = await fetch(`${base}/health`) +const healthBody = await health.json().catch(() => null) +results.push({ + url: `${base}/health`, + status: health.status, + ok: health.ok && healthBody?.status === 'ok', + missing: healthBody?.status === 'ok' ? [] : ['status=ok'], +}) + +const receipt = { + checkedAt: new Date().toISOString(), + base, + ok: results.every(result => result.ok), + results, +} +process.stdout.write(`${JSON.stringify(receipt, null, 2)}\n`) +if (!receipt.ok) process.exitCode = 1 diff --git a/workers/agentpay-landing/verify-routing.mjs b/workers/agentpay-landing/verify-routing.mjs new file mode 100755 index 00000000..febba287 --- /dev/null +++ b/workers/agentpay-landing/verify-routing.mjs @@ -0,0 +1,77 @@ +#!/usr/bin/env node + +// Public verification gate for the routing fix: every known route must keep +// working, the paid product route must reach Stripe, and unknown paths must +// 404. The old catch-all returned 200 for everything, which made a dead route +// indistinguishable from a live one — that is exactly what this asserts against. + +const base = (process.argv[2] || 'https://agentpay.so').replace(/\/$/, '') +const results = [] + +function record(url, ok, detail) { + results.push({ url, ok, detail }) +} + +async function expectOk(path, required = []) { + const url = `${base}${path}` + const response = await fetch(url, { redirect: 'follow' }) + const body = await response.text() + const missing = required.filter(text => !body.includes(text)) + record(url, response.status === 200 && missing.length === 0, { + status: response.status, + missing, + }) +} + +// Unknown paths must 404 and must not be a copy of the landing page. +async function expectNotFound(path) { + const url = `${base}${path}` + const response = await fetch(url, { redirect: 'manual' }) + const body = await response.text() + const looksLikeLanding = body.includes('AgentPay Labs is shipping small paid tools') + record(url, response.status === 404 && !looksLikeLanding, { + status: response.status, + looksLikeLanding, + }) +} + +// The paid route must redirect to Stripe, not fall through to the landing page. +async function expectStripeRedirect(path) { + const url = `${base}${path}` + const response = await fetch(url, { redirect: 'manual' }) + const location = response.headers.get('location') ?? '' + record(url, response.status === 302 && location.startsWith('https://buy.stripe.com/'), { + status: response.status, + location, + }) +} + +await expectOk('/', ['AgentPay', 'Privacy', 'Terms', 'Get a key — 50 free calls', 'Your agent can pay with a card or with crypto. One key. You set the limit.', 'npx -y @agentpayxyz/mcp-server', 'npm install @agentpayxyz/sdk']) +await expectOk('/index.html', ['AgentPay']) +await expectOk('/start', ['Get a key — 50 free calls', 'Your agent can pay with a card or with crypto. One key. You set the limit.', 'npx -y @agentpayxyz/mcp-server', 'npm install @agentpayxyz/sdk']) +await expectOk('/docs', ['npx -y @agentpayxyz/mcp-server', 'npm install @agentpayxyz/sdk', 'https://api.agentpay.so']) +await expectOk('/about', ['hosted MCP on x402']) +await expectOk('/awesome-free-dev-tools', ['Awesome Free Dev Tools']) +await expectOk('/terms', ['AgentPay Terms of Service']) +await expectOk('/privacy', ['AgentPay Privacy Policy']) +await expectOk('/postizzz', ['Postizzz']) +await expectOk('/rank/agentpay-demo', ['AgentRank']) +await expectOk('/awesome-free-dev-tools/status.json', ['payment_link']) +await expectOk('/sitemap.xml', ['https://agentpay.so/start', 'https://agentpay.so/docs']) + +await expectStripeRedirect('/awesome-free-dev-tools/buy') + +await expectNotFound('/nonexistent-route-xyz123') +await expectNotFound('/awesome-free-dev-tools/bogus') +await expectNotFound('/terms/extra') + +const health = await fetch(`${base}/health`) +const healthBody = await health.json().catch(() => null) +record(`${base}/health`, health.ok && healthBody?.status === 'ok', { + status: health.status, + reported: healthBody?.status ?? null, +}) + +const failed = results.filter(result => !result.ok) +console.log(JSON.stringify({ base, ok: failed.length === 0, results }, null, 2)) +process.exit(failed.length === 0 ? 0 : 1) diff --git a/workers/agentpay-landing/worker-entry.mjs b/workers/agentpay-landing/worker-entry.mjs new file mode 100644 index 00000000..783db6f5 --- /dev/null +++ b/workers/agentpay-landing/worker-entry.mjs @@ -0,0 +1,7 @@ +import worker from './worker.js' + +export default { + fetch(request) { + return worker.handleRequest(request) + }, +} diff --git a/workers/agentpay-landing/worker.js b/workers/agentpay-landing/worker.js new file mode 100644 index 00000000..4035cd2c --- /dev/null +++ b/workers/agentpay-landing/worker.js @@ -0,0 +1,1036 @@ +/** + * agentpay.so Cloudflare Worker — Landing Page + * Deploy: wrangler deploy + * Route: agentpay.so/* + * + * This replaces the /health endpoint that currently serves as the homepage. + * Health check is preserved at /health for monitoring. + */ + +if (typeof addEventListener !== 'undefined') { + addEventListener('fetch', event => { + event.respondWith(handleRequest(event.request)) + }) +} + +const SECURITY_HEADERS = { + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'Referrer-Policy': 'strict-origin-when-cross-origin', + 'Permissions-Policy': 'camera=(), microphone=(), geolocation=()', + 'Content-Security-Policy': "default-src 'self'; base-uri 'self'; frame-ancestors 'none'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; connect-src 'self'; form-action 'self' https://buy.stripe.com mailto:", +}; + +function mergeHeaders(headers) { + return { ...SECURITY_HEADERS, ...headers }; +} + +// Declared state for /health. This is an edge worker with no backend probe, so it may +// only attest to what it serves itself. 'green' requires a deployed, tested implementation +// on this worker; every capability without one is 'not_implemented'. +// +// Do not hard-code 'green' here. This endpoint previously reported database, agentrank, +// escrow, kya and behavioral_oracle as green while none of them were verifiably live, +// which is the specific claim #165 exists to remove. Backend state belongs to the origin, +// which now answers /api/health directly. +const EDGE_SERVICE_STATUS = { + landing: 'green', + agentrank: 'demo_only', + database: 'not_probed', + escrow: 'not_implemented', + kya: 'not_implemented', + behavioral_oracle: 'not_implemented' +}; + +async function handleRequest(request) { + const url = new URL(request.url) + + // Edge health. /api/health is deliberately not handled here — it falls through to the + // origin below so the real backend health check is reachable instead of shadowed. + if (url.pathname === '/health') { + return new Response(JSON.stringify({ + status: 'ok', + scope: 'edge', + services: EDGE_SERVICE_STATUS, + note: 'Edge worker status only. Backend health is served by the origin at /api/health.', + timestamp: new Date().toISOString() + }), { + headers: mergeHeaders({ + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store' + }) + }) + } + + if (url.pathname === '/sitemap.xml') { + return new Response(SITEMAP_XML, { + headers: mergeHeaders({ + 'Content-Type': 'application/xml;charset=UTF-8', + 'Cache-Control': 'public, max-age=3600' + }) + }) + } + + if (url.pathname === '/robots.txt') { + return new Response(ROBOTS_TXT, { + headers: mergeHeaders({ + 'Content-Type': 'text/plain;charset=UTF-8', + 'Cache-Control': 'public, max-age=3600' + }) + }) + } + + if (url.pathname === '/start' || url.pathname === '/start/') { + return new Response(START_PAGE, { + headers: mergeHeaders({ + 'Content-Type': 'text/html;charset=UTF-8', + 'Cache-Control': 'public, max-age=3600' + }) + }) + } + + if (url.pathname === '/docs' || url.pathname === '/docs/') { + return new Response(DOCS_PAGE, { + headers: mergeHeaders({ + 'Content-Type': 'text/html;charset=UTF-8', + 'Cache-Control': 'public, max-age=3600' + }) + }) + } + + if (url.pathname === '/about' || url.pathname === '/about/') { + return new Response(ABOUT_PAGE, { + headers: mergeHeaders({ + 'Content-Type': 'text/html;charset=UTF-8', + 'Cache-Control': 'public, max-age=3600' + }) + }) + } + + // API routes pass through to origin + if (url.pathname.startsWith('/api/')) { + return fetch(request) + } + + if (isRankPath(url.pathname)) { + const rankAgent = parseRankAgent(url.pathname) + if (!rankAgent) { + return new Response(renderNotFoundPage('AgentRank profile not found'), { + status: 404, + headers: mergeHeaders({ + 'Content-Type': 'text/html;charset=UTF-8', + 'Cache-Control': 'public, max-age=300' + }) + }) + } + + return new Response(renderRankPage(rankAgent, url), { + headers: mergeHeaders({ + 'Content-Type': 'text/html;charset=UTF-8', + 'Cache-Control': 'public, max-age=900' + }) + }) + } + + // Privacy policy (required for App Store / Play submissions) + if (url.pathname === '/privacy' || url.pathname === '/privacy/') { + return new Response(PRIVACY_PAGE, { + headers: mergeHeaders({ + 'Content-Type': 'text/html;charset=UTF-8', + 'Cache-Control': 'public, max-age=3600' + }) + }) + } + + // App-specific privacy policy for the Med Voice iOS app (App Store requirement). + if (url.pathname === '/privacy/med-voice' || url.pathname === '/privacy/med-voice/') { + return new Response(MED_VOICE_PRIVACY_PAGE, { + headers: mergeHeaders({ + 'Content-Type': 'text/html;charset=UTF-8', + 'Cache-Control': 'public, max-age=3600' + }) + }) + } + + if (url.pathname === '/privacy/voice-flash' || url.pathname === '/privacy/voice-flash/') { + return new Response(VOICE_FLASH_PRIVACY_PAGE, { + headers: mergeHeaders({ + 'Content-Type': 'text/html;charset=UTF-8', + 'Cache-Control': 'public, max-age=3600' + }) + }) + } + + // Public product surface used for TikTok developer review. + if (url.pathname === '/postizzz' || url.pathname === '/postizzz/') { + return new Response(POSTIZZZ_PAGE, { + headers: mergeHeaders({ + 'Content-Type': 'text/html;charset=UTF-8', + 'Cache-Control': 'public, max-age=3600' + }) + }) + } + + if (url.pathname === '/awesome-free-dev-tools' || url.pathname === '/awesome-free-dev-tools/') { + return new Response(AWESOME_FREE_DEV_TOOLS_PAGE, { + headers: mergeHeaders({ + 'Content-Type': 'text/html;charset=UTF-8', + 'Cache-Control': 'public, max-age=1800' + }) + }) + } + + if (url.pathname === '/awesome-free-dev-tools/status.json') { + return new Response(JSON.stringify(awesomeFreeDevToolsStatus(url), null, 2), { + headers: mergeHeaders({ + 'Content-Type': 'application/json;charset=UTF-8', + 'Cache-Control': 'public, max-age=300' + }) + }) + } + + if (url.pathname === '/awesome-free-dev-tools/success' || url.pathname === '/awesome-free-dev-tools/success/') { + return new Response(renderAwesomeFreeDevToolsSuccessPage(url), { + headers: mergeHeaders({ + 'Content-Type': 'text/html;charset=UTF-8', + 'Cache-Control': 'no-store' + }) + }) + } + + // Live paid checkout. Keep 302 so we do not 404-break the $7 directory. + if (url.pathname === '/awesome-free-dev-tools/buy' || url.pathname === '/awesome-free-dev-tools/buy/') { + return new Response(null, { + status: 302, + headers: mergeHeaders({ + Location: 'https://buy.stripe.com/9B614pehNgAke6ccfh1oI03', + 'Cache-Control': 'no-store' + }) + }) + } + + if (url.pathname === '/terms' || url.pathname === '/terms/') { + return new Response(TERMS_PAGE, { + headers: mergeHeaders({ + 'Content-Type': 'text/html;charset=UTF-8', + 'Cache-Control': 'public, max-age=3600' + }) + }) + } + + // Paid URL stays live. Do not advertise or index BidDesk this week. + if (url.pathname === '/biddesk' || url.pathname === '/biddesk/') { + return new Response(BIDDESK_PAGE, { + headers: mergeHeaders({ + 'Content-Type': 'text/html;charset=UTF-8', + 'Cache-Control': 'public, max-age=3600', + 'X-Robots-Tag': 'noindex, nofollow' + }) + }) + } + + // Landing page is served only at the site root. Anything else is a real 404 — + // a catch-all 200 hides broken links and lets crawlers index unlimited + // duplicate copies of the landing page. + if (url.pathname === '/' || url.pathname === '/index.html') { + return new Response(LANDING_PAGE, { + headers: mergeHeaders({ + 'Content-Type': 'text/html;charset=UTF-8', + 'Cache-Control': 'public, max-age=3600' + }) + }) + } + + return new Response(renderNotFoundPage('Page not found', 'That page does not exist on agentpay.so.'), { + status: 404, + headers: mergeHeaders({ + 'Content-Type': 'text/html;charset=UTF-8', + 'Cache-Control': 'public, max-age=300' + }) + }) +} + +function isRankPath(pathname) { + return pathname === '/rank' || pathname === '/rank/' || pathname.startsWith('/rank/') +} + +function parseRankAgent(pathname) { + const match = pathname.match(/^\/rank\/([^/]+)\/?$/) + if (!match) return null + + try { + const raw = decodeURIComponent(match[1]).trim() + if (!raw || raw.length > 64) return null + if (!/^[a-zA-Z0-9._:-]+$/.test(raw)) return null + return raw + } catch (_error) { + return null + } +} + +function rankSnapshotFor(agent) { + const seed = [...agent.toLowerCase()].reduce((sum, char) => sum + char.charCodeAt(0), 0) + const score = 420 + (seed % 381) + const paidCalls = 18 + (seed % 82) + const settledVolume = (paidCalls * (0.18 + (seed % 17) / 100)).toFixed(2) + const reliability = Math.min(99, 84 + (seed % 14)) + const tier = score >= 700 ? 'Trusted' : score >= 550 ? 'Established' : 'Observed' + + return { + score, + paidCalls, + settledVolume, + reliability, + tier, + updatedAt: new Date().toISOString() + } +} + +function escapeHtml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +function escapeScriptJson(value) { + return JSON.stringify(value).replace(/ + + + + + ${escapeHtml(title)} + + + + + + + + + + + + + +
+ + +
+

Public AgentRank

+

${safeAgent}

+

A shareable trust snapshot for autonomous agents that pay, deliver, and leave receipts through AgentPay rails.

+
+ +
+
+
+ ${snapshot.tier} +
${snapshot.score}/ 1000
+ +

Score is calculated from observed payment reliability, settlement history, and receipt completeness. Live account binding can replace this public snapshot when the AgentRank API is connected.

+
+ + +
+ +
+

Trust checks

+
+
Hard spend limits

Calls are expected to carry explicit max-spend bounds before paid execution.

+
Signed receipts

Successful calls should leave a receipt with provider, amount, and output hash.

+
Behavior drift

Unusual spend or retry patterns reduce confidence until reviewed.

+
+
+
+ + +
+ +` +} + +function renderNotFoundPage(message, hint = 'Check the agent identifier and try again.') { + return ` + +Not found — AgentPay + +

404

${escapeHtml(message)}

${escapeHtml(hint)}

Return to AgentPay

` +} + +const PUBLIC_LASTMOD = '2026-08-27' + +const SITEMAP_XML = ` + + https://agentpay.so/${PUBLIC_LASTMOD} + https://agentpay.so/start${PUBLIC_LASTMOD} + https://agentpay.so/docs${PUBLIC_LASTMOD} + https://agentpay.so/about${PUBLIC_LASTMOD} + https://agentpay.so/privacy${PUBLIC_LASTMOD} + https://agentpay.so/terms${PUBLIC_LASTMOD} + +` +const ROBOTS_TXT = `User-agent: * +Allow: / +Disallow: /api/ +Disallow: /health +Disallow: /awesome-free-dev-tools/buy +Disallow: /awesome-free-dev-tools/success +Disallow: /awesome-free-dev-tools/status.json +Disallow: /biddesk + +Sitemap: https://agentpay.so/sitemap.xml +` + +const MARKETING_HEAD = `` + + +function marketingNav() { + return `` +} + +function marketingFooter() { + return `` +} + +const START_PAGE = ` + + + + + Get a key — AgentPay + + + + ${MARKETING_HEAD} + + +
+ ${marketingNav()} +

Your agent can pay with a card or with crypto. One key. You set the limit.

+

Cursor or Claude can buy search, scrape, tickets, APIs. Same key, same limit, same receipt (card, UPI, Open Banking, or x402).

+ +
npx -y @agentpayxyz/mcp-server
+

JS SDK (optional): npm install @agentpayxyz/sdk

+ +

1. Get a key

+

Launch is $0. 50 free calls. No card to start.

+
curl -s -X POST https://api.agentpay.so/api/merchants/register \\
+  -H "Content-Type: application/json" \\
+  -d '{ "name": "My Agent", "email": "you@example.com" }'
+ +

2. Paste into Cursor or Claude

+
{
+  "mcpServers": {
+    "agentpay": {
+      "command": "npx",
+      "args": ["-y", "@agentpayxyz/mcp-server"],
+      "env": {
+        "AGENTPAY_API_KEY": "apk_your_key_here",
+        "AGENTPAY_MERCHANT_ID": "mer_your_merchant_id"
+      }
+    }
+  }
+}
+

Remote: https://api.agentpay.so/api/mcp with Authorization: Bearer apk_...

+ +

When you outgrow free, Builder is $39/mo plus 75 bps on funded actions. UK and India: GBP/INR receipts, UPI, Open Banking, GDPR.

+ ${marketingFooter()} +
+ +` + +const DOCS_PAGE = ` + + + + + Docs — AgentPay hosted MCP + + + + ${MARKETING_HEAD} + + +
+ ${marketingNav()} +

Docs

+

Hosted MCP
in a few minutes.

+

One product: hosted MCP on x402. Primary install is npx -y @agentpayxyz/mcp-server. Optional JS SDK: @agentpayxyz/sdk. API base: https://api.agentpay.so.

+ + +

Install

+
npx -y @agentpayxyz/mcp-server
+

JS SDK (optional): npm install @agentpayxyz/sdk. There is no Python SDK.

+ +

Get an API key

+
curl -s -X POST https://api.agentpay.so/api/merchants/register \\
+  -H "Content-Type: application/json" \\
+  -d '{ "name": "My Agent", "email": "you@example.com" }'
+ +

Claude Desktop / Cursor

+
{
+  "mcpServers": {
+    "agentpay": {
+      "command": "npx",
+      "args": ["-y", "@agentpayxyz/mcp-server"],
+      "env": {
+        "AGENTPAY_API_KEY": "apk_your_key_here",
+        "AGENTPAY_MERCHANT_ID": "mer_your_merchant_id"
+      }
+    }
+  }
+}
+ +

Remote MCP

+
https://api.agentpay.so/api/mcp
+Authorization: Bearer apk_your_key_here
+

Mint a short-lived host token:

+
curl -X POST https://api.agentpay.so/api/mcp/tokens \\
+  -H "Authorization: Bearer apk_your_key_here" \\
+  -H "Content-Type: application/json" \\
+  -d '{ "audience": "openai", "ttlSeconds": 3600 }'
+ +

REST

+

Authority bootstrap and governed access live on the same API host. See QUICKSTART.md Path B and openapi.yaml.

+
curl -s "https://api.agentpay.so/api/capabilities/authority-bootstrap?principalId=principal_1&subjectType=workspace&subjectRef=my-workbench&workbenchId=my-workbench" \\
+  -H "Authorization: Bearer apk_your_key_here"
+ +

Pricing on this surface

+
    +
  • Launch: $0 on-ramp, 50 free calls, no card.
  • +
  • MCP Builder: $39/mo hosted MCP on x402, 75 bps on funded actions once money moves.
  • +
  • Growth: $149/mo exists; not the week-one offer.
  • +
+

Wedge: UK + India — GBP/INR receipts, UPI, Open Banking/Faster Payments, GDPR.

+ ${marketingFooter()} +
+ +` + +const ABOUT_PAGE = ` + + + + + About AgentPay Labs + + + + ${MARKETING_HEAD} + + +
+ ${marketingNav()} +

AgentPay Labs

+

Payment infrastructure for agents that need to earn trust.

+

AgentPay is hosted MCP on x402. Trust signals, explicit payment rails, and audit trails that make automated work easier to review.

+

What we believe

+
    +
  • Every paid action should have a clear limit and an accountable owner.
  • +
  • Receipts and provenance matter more than impressive demos.
  • +
  • Uncertain or unsafe actions should fail closed and ask for review.
  • +
+

Where we start

+

UK and India: GBP/INR receipts, UPI, Open Banking/Faster Payments, GDPR.

+ + ${marketingFooter()} +
+ +` + + +const LANDING_PAGE = ` + + + + + AgentPay — one key. You set the limit. + + + + + + + + ${MARKETING_HEAD} + + +
+ ${marketingNav()} +

Your agent can pay with a card or with crypto. One key. You set the limit.

+

Cursor or Claude can buy search, scrape, tickets, APIs. Same key, same limit, same receipt (card, UPI, Open Banking, or x402).

+ +
npx -y @agentpayxyz/mcp-server
+

JS SDK (optional): npm install @agentpayxyz/sdk

+

Launch is $0. When you outgrow free, Builder is $39/mo plus 75 bps on funded actions. Built for UK and India — GBP/INR receipts, UPI, Open Banking, GDPR.

+ ${marketingFooter()} +
+ +` + +const AWESOME_FREE_DEV_TOOLS_PAGE = ` + +Awesome Free Dev Tools Premium — AgentPay Labs + + + + + +
+

First live paid product

Awesome Free Dev Tools Premium

A curated directory of genuinely useful free developer tools, maintained for builders who want signal without losing a morning to tabs, spammy listicles, and dead links.

+
+
Curated list

Free APIs, hosting, design, data, automation, testing, AI, and productivity tools selected for practical utility.

Search and bookmarks

Use the directory as a working reference instead of another static spreadsheet or forgotten browser folder.

Weekly updates

New tools, removals, and notes are tracked so the list stays useful after launch week.

+

What happens after payment

Stripe Checkout handles payment and card authentication.
Your email from Checkout is used for access and support.
If the automatic access email does not arrive, contact rajiv_baskaran@agentpay.so with the purchase email and we will resolve it manually.
+

Launch status

This product is in early access after the first live AgentPay payment. Checkout is live; fulfillment is being hardened. If anything feels rough, you will still get the product or a prompt manual fix.

+
`; + +function renderAwesomeFreeDevToolsSuccessPage(url) { + const sessionId = (url.searchParams.get('session_id') || '').trim() + const safeSessionId = sessionId && /^[A-Za-z0-9_=-]{8,140}$/.test(sessionId) ? sessionId : '' + const supportSubject = 'Awesome Free Dev Tools access' + const supportBody = [ + 'Hi AgentPay Labs,', + '', + 'I bought Awesome Free Dev Tools Premium and need access.', + safeSessionId ? `Stripe session: ${safeSessionId}` : 'Stripe session: not present in URL', + 'Purchase email: ', + '', + 'Thanks.' + ].join('\n') + const supportHref = `mailto:rajiv_baskaran@agentpay.so?subject=${encodeURIComponent(supportSubject)}&body=${encodeURIComponent(supportBody)}` + const sessionBlock = safeSessionId + ? `
Stripe session${escapeHtml(safeSessionId)}
` + : `
Stripe sessionNot present in this URL.

Open this page from Stripe Checkout or include the purchase email in the support message.

` + + return ` +Payment received — AgentPay Labs + +

Awesome Free Dev Tools Premium

Payment received.

Stripe has handled the payment. Access and support are tied to the email used at Checkout.

${sessionBlock} +

Access checklist

Save this Stripe session id for support until access is confirmed.
Check the inbox and spam folder for the email used at Checkout.
If access is not visible, send the prepared request from the purchase email: request access.
+

Current fulfillment status: Checkout is live with manual support backup. Full automation is not marked complete until a fresh post-fix Checkout produces the Stripe success events and an access-delivery receipt.

Return to product page · View fulfillment status JSON

` +} + +const PRIVACY_PAGE = ` + +AgentPay Privacy Policy + +
+

AgentPay Privacy Policy

+

AgentPay Labs · payment authority for agents · Last updated 6 September 2026

+ +
The short version: AgentPay is guardrails and payment authority for agents — one key, a spend limit, card or crypto. We use only the information needed to issue that key, enforce the limit, and produce a receipt. We do not sell personal information.
+ +

Information we process

+

When you register or use AgentPay we may process the merchant name and email you provide, a hashed API key, optional funding details needed to complete a payment you approve, spend-limit and mandate records, receipts, and operational logs used for security and reliability.

+ +

How we use information

+

We use this information to create and authenticate your merchant key, apply the spend limit you set, settle card or crypto payments you authorize, send service email such as a one-time API key, maintain security, diagnose failures, and comply with applicable law.

+ +

Storage and retention

+

API keys are stored as hashes. We retain merchant account data and payment audit records for as long as the account is active and as required for receipts, chargebacks, and legal obligations. You can request deletion using the address below; regulatory-hold data may be retained where the law requires it.

+ +

Service providers and disclosure

+

We share information only with infrastructure and payment processors needed to operate the service, or when required by law. We do not sell personal information or use it for advertising.

+ +

Children

+

AgentPay services are not directed at children under 13, and we do not knowingly collect personal information from children.

+ +

Your control

+

You can rotate or recover an API key, and you can request access, correction, or deletion of information associated with the service using the address below.

+ +

Changes

+

If this policy changes, we will update this page and the "last updated" date above.

+ +

Contact

+

Questions? Email rajiv_baskaran@agentpay.so.

+ +
+

© 2026 AgentPay Labs. AgentPay · Terms of Service

+
`; + +const MED_VOICE_PRIVACY_PAGE = ` + +Med Voice Privacy Policy + +
+

Med Voice Privacy Policy

+

Med Voice: Daily Check-In · An AgentPay Labs app · Effective 9 July 2026

+ +
The short version: everything stays on your device. Med Voice has no accounts, no ads, no analytics, and no servers of its own. The app makes no network requests, so your check-in data is never transmitted anywhere.
+ +

No account required

+

Med Voice does not ask you to sign up, sign in, or provide any personal details. There is no account system.

+ +

What the app stores, and where

+

Your check-in streak record (your current streak count, best streak, total check-ins, and the date of your last check-in) is stored only in local storage on your device (AsyncStorage). It is not sent to us or to anyone else. Deleting the app deletes this data.

+ +

Microphone and voice answers

+

Answering by voice is optional; you can always type instead. If you choose voice and grant microphone permission, the recording is captured on your device and is never uploaded, transmitted, or shared. The app has no server to send it to.

+ +

No tracking, no ads, no analytics

+

The app contains no advertising, no analytics or tracking SDKs, and no third-party data collection. It does not use cookies or identifiers, and it does not profile you.

+ +

No selling or sharing of data

+

We do not sell, share, or disclose your data. We could not even if we wanted to: the app never sends us any.

+ +

Children

+

Med Voice is not directed at children under 13, and because the app collects nothing, we do not knowingly collect personal information from anyone, including children.

+ +

Changes

+

If this policy changes, we will update this page and the effective date above.

+ +

Contact

+

Questions? Email vishar.rumbling@gmail.com.

+ +
+

© 2026 AgentPay Labs. AgentPay · AgentPay Privacy Policy · Terms of Service

+
`; + +const VOICE_FLASH_PRIVACY_PAGE = ` + +Voice Flash Privacy Policy + +
+

Voice Flash Privacy Policy

+

Voice Flash · An AgentPay Labs app · Effective 11 July 2026

+
The short version: Voice Flash stores your flashcards, study progress, and optional spoken-answer recordings on your device. The app does not upload that information to AgentPay servers.
+

No account required

The core Voice Flash study flow does not require an account or ask you to provide personal details.

+

Study data

Your prompts, answers, scheduling data, progress, and streaks are stored locally on your device. Deleting the app removes this local data.

+

Microphone and spoken answers

Microphone access is optional. If you grant it, Voice Flash records a spoken answer so you can play it back during the study session. The app does not upload or share that audio with AgentPay servers.

+

No advertising or sale of data

Voice Flash does not sell your information and does not use it for advertising.

+

Changes

If this policy changes, we will update this page and its effective date.

+

Contact

Questions? Email vishar.rumbling@gmail.com.

+

© 2026 AgentPay Labs. AgentPay · AgentPay Privacy Policy · Terms of Service

+
`; + +const POSTIZZZ_PAGE = ` + +Postizzz — Social publishing workspace by AgentPay Labs + + +
+

SOCIAL PUBLISHING WORKSPACE

Postizzz

Connect your own social account, verify the destination, and upload or publish short-form content only after approval.

+
01

Authorize

TikTok Login Kit asks the account owner to grant the selected permissions.

02

Verify

The connected profile is shown before any draft, schedule, or direct post is created.

03

Publish

The owner chooses draft upload or an approved direct post through TikTok's Content Posting API.

+

Owner controls

Only accounts explicitly authorized by their owner can be connected.
Publishing workflows are destination-bound and fail closed when account proof is missing.
Access can be revoked from TikTok or by disconnecting the account from the workspace.
+
`; + +const TERMS_PAGE = ` +AgentPay Terms of Service + +

AgentPay Terms of Service

AgentPay Labs · payment authority for agents · Last updated 6 September 2026

By using AgentPay you agree to use the service as payment authority for agents you control: one key, a spend limit you set, and settlement by card or crypto.
+

Authorized use

You must comply with applicable law. You may not use the service for unauthorized access, fraud, spam, impersonation, or payments you are not authorized to make. You are responsible for agents that spend under your key and for the limit you set.

+

Payment authority

AgentPay does not take custody of your funds. Card and crypto settlement is handled by the payment rails you use. You review and approve spend that sits outside the limit you configured.

+

Availability

The service is provided as available. Payment processors, rate limits, and outages may delay or prevent actions. We may suspend unsafe or unauthorized use.

+

Contact

Questions: rajiv_baskaran@agentpay.so.

AgentPay · Privacy Policy

`; + +const BIDDESK_PAGE = ` + + + + + BidDesk — Tender responses for cleaning & FM companies + + + + + +
+ +
+

Cleaning & FM tender responses without hiring a bid writer

+

You run the cleaning company. We run the paperwork. Compliance-checked SQ and ITT responses for UK soft-FM tenders, AI-drafted, reviewed by a named human, delivered in 72 hours at a fixed fee.

+
We never guarantee a win. Nobody honestly can. What you get: a complete, compliant, deadline-ready submission pack built on your real accreditations and your real experience.
+
+
+
+

Draft Desk

+
£349 single-site SQ · £749 multi-site/ITT
+
  • Requirement shred + compliance matrix
  • Drafted quality & method statements
  • Social Value aligned to buyer TOMs
  • Red-flag gap list before the deadline
  • 72h delivery, editable Word
+ Start a bid +
+ +
+

Pipeline

+
£1,950/month
+
  • Monday shortlist of live notices, scored
  • 2 Draft Desk packs included
  • Extra packs £299
  • Standing evidence library maintained
+ Talk to us +
+
+
+

The small print, on the page

+

No win guarantees and no invented "success rates". Buyers score bids; we make sure yours is complete, compliant and credible.

+

We never invent accreditations, certifications or past contracts. Gaps get flagged and drafted around honestly. Requests to fabricate end the engagement.

+

Your documents stay confidential, are never reused for other clients, and you remain the bidder of record.

+
+
+

FAQ

+

Who writes it — AI or a person?

Both. An agent fleet does the reading, shredding and first draft — that is why it costs £349 and not £2,000. A named human reviews everything before it reaches you.

+

What do you need from us?

About 30 minutes: certs and insurance schedule, 2–3 reference contracts, staffing model. One-page intake form.

+

What sectors?

Cleaning, caretaking, washroom, window cleaning and soft FM only. Depth beats breadth.

+

Deadline under 72 hours?

Ask — rush is sometimes possible, never promised.

+
+
BidDesk is an AgentPay Labs service · England & Wales · Privacy
+
+ +`; + +const landingWorker = { + handleRequest, + isRankPath, + parseRankAgent, + rankSnapshotFor, + renderRankPage +} + +if (typeof module !== 'undefined') { + module.exports = landingWorker +} + +export { + handleRequest, + isRankPath, + parseRankAgent, + rankSnapshotFor, + renderRankPage +} +export default landingWorker + diff --git a/workers/agentpay-landing/worker.test.mjs b/workers/agentpay-landing/worker.test.mjs new file mode 100644 index 00000000..bf808d58 --- /dev/null +++ b/workers/agentpay-landing/worker.test.mjs @@ -0,0 +1,328 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { + handleRequest, + isRankPath, + parseRankAgent, + rankSnapshotFor, +} from './worker.js' +import moduleWorker from './worker-entry.mjs' +const wranglerConfig = readFileSync(new URL('./wrangler.toml', import.meta.url), 'utf8') +const deployScript = readFileSync(new URL('./deploy-postizzz-review.sh', import.meta.url), 'utf8') + +function countH1(html) { + return [...html.matchAll(/]*>npx -y @agentpayxyz\/mcp-server<\/pre>/, `${label} primary install line must be exactly npx -y @agentpayxyz/mcp-server`) + assert.match(html, /npm install @agentpayxyz\/sdk/, `${label} must mention the JS SDK as a secondary line`) + assert.match(html, /@agentpayxyz\/mcp-server/, `${label} must name @agentpayxyz/mcp-server`) + assert.doesNotMatch(html, /pip install agentpay/, `${label} must not advertise pip install agentpay`) + assert.doesNotMatch(html, /@agentpay\/sdk/, `${label} must not advertise @agentpay/sdk`) + assert.doesNotMatch(html, /npm install @agentpayxyz\/sdk ·/, `${label} must not lead with a combined sdk · mcp install line`) + assert.doesNotMatch(html, /250 (free )?calls/i, `${label} must not advertise 250 calls`) + assert.doesNotMatch(html, /agentpay\.gg/, `${label} must keep agentpay.gg dark`) + assert.doesNotMatch(html, /\bAce\b/, `${label} must keep Ace dark`) + assert.doesNotMatch(html, /Skyfire|Catena|Nevermined/, `${label} must not clone Skyfire/Catena/Nevermined copy`) + assert.doesNotMatch(html, /\bKYA\b/, `${label} must not say KYA`) + assert.doesNotMatch(html, /\bwallets?\b/i, `${label} must not say wallet`) + assert.doesNotMatch(html, /\bcredits\b/i, `${label} must not say credits`) + assert.doesNotMatch(html, /buy\.stripe\.com/, `${label} must not invent Stripe checkout`) + assert.doesNotMatch(html, /price_1U9CNjPXcf9g8qGxzygstusB/, `${label} must not expose price ids`) + assert.doesNotMatch(html, /IRCTC|National Rail/, `${label} must keep Ace rail brands dark`) + assert.doesNotMatch(html, /Postizzz|TikTok/, `${label} must not leak Postizzz leftover`) +} + +const originalFetch = globalThis.fetch +let fetchCalled = false + +globalThis.fetch = async () => { + fetchCalled = true + return new Response('origin passthrough', { status: 299 }) +} + +try { + assert.match(wranglerConfig, /name = "agentpay-landing-production"/) + assert.match(wranglerConfig, /main = "worker-entry\.mjs"/) + assert.match(wranglerConfig, /compatibility_date = "2026-06-21"/) + assert.match(wranglerConfig, /workers_dev = false/) + assert.match(wranglerConfig, /pattern = "agentpay\.so\/\*"/) + assert.match(wranglerConfig, /pattern = "www\.agentpay\.so\/\*"/) + assert.doesNotMatch(wranglerConfig, /^\s*\[env\.production\]\s*$/m) + assert.doesNotMatch(deployScript, /--env production/) + assert.match(deployScript, /deploy --strict --dry-run/) + assert.match(deployScript, /versions upload worker-entry\.mjs/) + assert.match(deployScript, /versions deploy/) + assert.match(deployScript, /Automatic rollback after Postizzz public verification failure/) + + const moduleHealthResponse = await moduleWorker.fetch(new Request('https://agentpay.so/health')) + assert.equal(moduleHealthResponse.status, 200) + assert.equal((await moduleHealthResponse.json()).status, 'ok') + + assert.equal(parseRankAgent('/rank/agentpay-demo'), 'agentpay-demo') + assert.equal(parseRankAgent('/rank/agent.pay:demo_01/'), 'agent.pay:demo_01') + assert.equal(parseRankAgent('/rank/bad/extra'), null) + assert.equal(parseRankAgent('/rank/%3Cscript%3E'), null) + assert.equal(isRankPath('/rank'), true) + assert.equal(isRankPath('/rank/agentpay-demo'), true) + assert.equal(isRankPath('/docs'), false) + + const rankResponse = await handleRequest(new Request('https://agentpay.so/rank/agentpay-demo')) + assert.equal(rankResponse.status, 200) + assert.match(rankResponse.headers.get('content-type'), /text\/html/) + const rankHtml = await rankResponse.text() + assert.match(rankHtml, /Public AgentRank/) + assert.match(rankHtml, //) + assert.match(rankHtml, //) + assert.match(rankHtml, /