Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions apps/api-edge/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
4 changes: 2 additions & 2 deletions apps/api-edge/src/lib/mcpBilling.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -27,7 +27,7 @@ export const HOSTED_MCP_PLANS: Record<HostedMcpPlanCode, HostedMcpPlan> = {
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.',
Expand Down
42 changes: 42 additions & 0 deletions apps/api-edge/src/lib/merchantKeys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* Public merchant identifiers and API keys.
*
* Live docs and Cursor snippets advertise:
* merchantId: mer_<uuid>
* 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;
}
8 changes: 4 additions & 4 deletions apps/api-edge/src/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions apps/api-edge/src/routes/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1252,6 +1252,7 @@ router.post('/onboarding-sessions/:sessionId/hosted', async (c) => {
otpEveryPaidAction,
walletStatus,
providers,
credentialExposure: stored.displayPayload.credentialExposure === true,
error: `${provider.label} requires ${field.label.toLowerCase()}.`,
}));
}
Expand Down
30 changes: 12 additions & 18 deletions apps/api-edge/src/routes/merchants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,22 +28,18 @@ 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 }>();

// ---------------------------------------------------------------------------
// 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);
Expand Down Expand Up @@ -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,
Expand All @@ -336,7 +332,7 @@ router.post('/register', async (c) => {
<p style="margin:0 0 6px;font-size:11px;color:#64748b;text-transform:uppercase;letter-spacing:0.05em;">API Key</p>
<code style="font-family:monospace;font-size:13px;color:#4ade80;word-break:break-all;">${apiKey}</code>
</div>
<p style="margin:0 0 8px;font-size:13px;color:#475569;">Your merchant ID: <code style="font-family:monospace;background:#f1f5f9;padding:2px 6px;border-radius:4px;">${merchantId}</code></p>
<p style="margin:0 0 8px;font-size:13px;color:#475569;">Your merchant ID: <code style="font-family:monospace;background:#f1f5f9;padding:2px 6px;border-radius:4px;">${publicMerchantId}</code></p>
<p style="margin:16px 0 0;font-size:13px;color:#64748b;">Add this to your MCP server config as <code style="font-family:monospace;">AGENTPAY_API_KEY</code>. If you lose this key, use the account recovery flow to get a new one.</p>
</div>
</body></html>`,
Expand All @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);

Expand Down
35 changes: 34 additions & 1 deletion apps/api-edge/src/routes/receipt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(() => {});
Expand Down
Loading
Loading