@mcpaid/sdk 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +179 -8
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +139 -3
- package/dist/cli.js.map +1 -1
- package/dist/edge-receipts.d.ts +6 -0
- package/dist/edge-receipts.d.ts.map +1 -0
- package/dist/edge-receipts.js +6 -0
- package/dist/edge-receipts.js.map +1 -0
- package/dist/gateway/toolpay-gateway.d.ts +22 -1
- package/dist/gateway/toolpay-gateway.d.ts.map +1 -1
- package/dist/gateway/toolpay-gateway.js +75 -4
- package/dist/gateway/toolpay-gateway.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/settlement/edge-receipts.d.ts +173 -0
- package/dist/settlement/edge-receipts.d.ts.map +1 -0
- package/dist/settlement/edge-receipts.js +472 -0
- package/dist/settlement/edge-receipts.js.map +1 -0
- package/dist/utils/units.js +1 -1
- package/dist/utils/units.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCPaid Edge Receipts Engine
|
|
3
|
+
* Cryptographic minting and verification of tamper-proof single-use payment receipts
|
|
4
|
+
* for downstream backend enforcement (e.g. database writes, webhooks, microservices).
|
|
5
|
+
*/
|
|
6
|
+
import type { D1Database } from '../gateway/d1-store.js';
|
|
7
|
+
export interface EdgeReceiptPayload {
|
|
8
|
+
v: 1;
|
|
9
|
+
serverId: string;
|
|
10
|
+
toolName: string;
|
|
11
|
+
challengeNonce: string;
|
|
12
|
+
amountMicro: string;
|
|
13
|
+
recipient: string;
|
|
14
|
+
treasury: string;
|
|
15
|
+
feeBps: number;
|
|
16
|
+
chainId: number;
|
|
17
|
+
settledAt: number;
|
|
18
|
+
exp: number;
|
|
19
|
+
agentWallet?: string;
|
|
20
|
+
/**
|
|
21
|
+
* Ledger transaction id of the settlement this receipt attests to.
|
|
22
|
+
* Present on edge-minted receipts. Lets a downstream backend attribute the
|
|
23
|
+
* receipt to a ledger entry (and, in future, check it was not refunded —
|
|
24
|
+
* a receipt proves settlement at time T, not final non-refunded settlement).
|
|
25
|
+
*/
|
|
26
|
+
txId?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface EdgeReceipt extends EdgeReceiptPayload {
|
|
29
|
+
sig: string;
|
|
30
|
+
}
|
|
31
|
+
export interface MintEdgeReceiptParams {
|
|
32
|
+
serverId: string;
|
|
33
|
+
toolName: string;
|
|
34
|
+
amountMicro: bigint | string | number;
|
|
35
|
+
challengeNonce?: string;
|
|
36
|
+
recipient?: string;
|
|
37
|
+
treasury?: string;
|
|
38
|
+
feeBps?: number;
|
|
39
|
+
chainId?: number;
|
|
40
|
+
agentWallet?: string;
|
|
41
|
+
txId?: string;
|
|
42
|
+
settledAt?: number;
|
|
43
|
+
ttlSeconds?: number;
|
|
44
|
+
}
|
|
45
|
+
export interface NonceClaimStore {
|
|
46
|
+
/**
|
|
47
|
+
* Atomically claims a challenge nonce.
|
|
48
|
+
* Returns true if successfully claimed (first time).
|
|
49
|
+
* Returns false if already claimed (replay attack detected).
|
|
50
|
+
*/
|
|
51
|
+
claimNonce(nonce: string, meta: {
|
|
52
|
+
serverId: string;
|
|
53
|
+
toolName: string;
|
|
54
|
+
exp: number;
|
|
55
|
+
}): Promise<boolean>;
|
|
56
|
+
/**
|
|
57
|
+
* Checks if a nonce has already been claimed.
|
|
58
|
+
*/
|
|
59
|
+
hasNonce(nonce: string): Promise<boolean>;
|
|
60
|
+
}
|
|
61
|
+
export interface VerifyReceiptEnv {
|
|
62
|
+
secret: string;
|
|
63
|
+
previousSecret?: string;
|
|
64
|
+
store?: NonceClaimStore;
|
|
65
|
+
}
|
|
66
|
+
export type VerifyReceiptInput = VerifyReceiptEnv | string;
|
|
67
|
+
export interface VerifyReceiptOptions {
|
|
68
|
+
receipt: string | null | undefined;
|
|
69
|
+
expectedTool?: string;
|
|
70
|
+
expectedServer?: string;
|
|
71
|
+
minAmountMicro?: bigint | string | number;
|
|
72
|
+
maxAgeSeconds?: number;
|
|
73
|
+
clockSkewSeconds?: number;
|
|
74
|
+
nowSec?: number;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Portable base64url encoding.
|
|
78
|
+
*/
|
|
79
|
+
export declare function toBase64Url(input: string): string;
|
|
80
|
+
/**
|
|
81
|
+
* Portable base64url decoding.
|
|
82
|
+
*/
|
|
83
|
+
export declare function fromBase64Url(input: string): string;
|
|
84
|
+
/**
|
|
85
|
+
* Derives a deterministic per-server receipt secret from a master secret using HKDF-SHA256.
|
|
86
|
+
* Limits the blast radius of any individual compromised server secret.
|
|
87
|
+
* Format: `mcpaid_sec_${64-char hex}`
|
|
88
|
+
*/
|
|
89
|
+
export declare function deriveServerReceiptSecret(masterSecret: string, serverId: string, salt?: string): Promise<string>;
|
|
90
|
+
/**
|
|
91
|
+
* Mints an authenticated Edge Receipt for a settled tool call.
|
|
92
|
+
* Returns the receipt object and its base64url-encoded string representation.
|
|
93
|
+
*/
|
|
94
|
+
export declare function mintEdgeReceipt(secret: string, params: MintEdgeReceiptParams): Promise<{
|
|
95
|
+
receipt: EdgeReceipt;
|
|
96
|
+
encoded: string;
|
|
97
|
+
}>;
|
|
98
|
+
/**
|
|
99
|
+
* Validates and verifies an Edge Receipt string against expected tool/server rules and replay claim store.
|
|
100
|
+
* Returns { valid: true, receipt } if valid, or { valid: false, error } on failure.
|
|
101
|
+
*/
|
|
102
|
+
export declare function parseAndVerifyEdgeReceipt(envInput: VerifyReceiptInput, opts: VerifyReceiptOptions): Promise<{
|
|
103
|
+
valid: true;
|
|
104
|
+
receipt: EdgeReceipt;
|
|
105
|
+
} | {
|
|
106
|
+
valid: false;
|
|
107
|
+
error: string;
|
|
108
|
+
}>;
|
|
109
|
+
/**
|
|
110
|
+
* 5-line verification helper for downstream backends.
|
|
111
|
+
* Returns null if valid and nonce claimed, or an error string describing the failure.
|
|
112
|
+
*
|
|
113
|
+
* Note: store infrastructure failures (e.g. D1 outage) THROW rather than
|
|
114
|
+
* returning an error string, so backends answer 500 (not 402) and the outage
|
|
115
|
+
* is distinguishable from a replay in logs and monitoring.
|
|
116
|
+
*
|
|
117
|
+
* Usage:
|
|
118
|
+
* ```typescript
|
|
119
|
+
* const error = await verifyEdgeReceipt(env, {
|
|
120
|
+
* receipt: request.headers.get('X-MCPaid-Receipt'),
|
|
121
|
+
* expectedTool: 'contextwise_cloud_push',
|
|
122
|
+
* });
|
|
123
|
+
* if (error) return new Response(JSON.stringify({ error }), { status: 402 });
|
|
124
|
+
* ```
|
|
125
|
+
*/
|
|
126
|
+
export declare function verifyEdgeReceipt(env: VerifyReceiptInput, opts: VerifyReceiptOptions): Promise<string | null>;
|
|
127
|
+
/**
|
|
128
|
+
* In-memory NonceClaimStore for local development, unit tests, and single-instance Node.js backends.
|
|
129
|
+
*/
|
|
130
|
+
export declare class MemoryReceiptStore implements NonceClaimStore {
|
|
131
|
+
private claimed;
|
|
132
|
+
/** Hard cap: oldest entries are evicted FIFO past this size (Map preserves insertion order). */
|
|
133
|
+
private readonly maxSize;
|
|
134
|
+
constructor(maxSize?: number);
|
|
135
|
+
claimNonce(nonce: string, meta: {
|
|
136
|
+
serverId: string;
|
|
137
|
+
toolName: string;
|
|
138
|
+
exp: number;
|
|
139
|
+
}): Promise<boolean>;
|
|
140
|
+
hasNonce(nonce: string): Promise<boolean>;
|
|
141
|
+
pruneExpired(nowSec?: number): number;
|
|
142
|
+
clear(): void;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Cloudflare D1-backed NonceClaimStore for edge workers and serverless backends.
|
|
146
|
+
* Uses atomic SQLite INSERT ... ON CONFLICT to guarantee anti-replay under high concurrency.
|
|
147
|
+
*/
|
|
148
|
+
export declare class D1ReceiptStore implements NonceClaimStore {
|
|
149
|
+
private db;
|
|
150
|
+
private tableName;
|
|
151
|
+
constructor(db: D1Database, tableName?: string);
|
|
152
|
+
claimNonce(nonce: string, meta: {
|
|
153
|
+
serverId: string;
|
|
154
|
+
toolName: string;
|
|
155
|
+
exp: number;
|
|
156
|
+
}): Promise<boolean>;
|
|
157
|
+
hasNonce(nonce: string): Promise<boolean>;
|
|
158
|
+
pruneExpired(nowSec?: number): Promise<number>;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Drop-in Express / Connect / Node.js HTTP middleware for validating Edge Receipts.
|
|
162
|
+
*/
|
|
163
|
+
export declare function createReceiptMiddleware(options: {
|
|
164
|
+
secret: string;
|
|
165
|
+
previousSecret?: string;
|
|
166
|
+
expectedTool?: string;
|
|
167
|
+
expectedServer?: string;
|
|
168
|
+
minAmountMicro?: bigint | string | number;
|
|
169
|
+
maxAgeSeconds?: number;
|
|
170
|
+
store?: NonceClaimStore;
|
|
171
|
+
headerName?: string;
|
|
172
|
+
}): (req: any, res: any, next: (err?: any) => void) => Promise<any>;
|
|
173
|
+
//# sourceMappingURL=edge-receipts.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"edge-receipts.d.ts","sourceRoot":"","sources":["../../src/settlement/edge-receipts.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCPaid Edge Receipts Engine
|
|
3
|
+
* Cryptographic minting and verification of tamper-proof single-use payment receipts
|
|
4
|
+
* for downstream backend enforcement (e.g. database writes, webhooks, microservices).
|
|
5
|
+
*/
|
|
6
|
+
import { canonicalJsonStringify } from '../utils/units.js';
|
|
7
|
+
/**
|
|
8
|
+
* Portable base64url encoding.
|
|
9
|
+
*/
|
|
10
|
+
export function toBase64Url(input) {
|
|
11
|
+
if (typeof Buffer !== 'undefined') {
|
|
12
|
+
return Buffer.from(input, 'utf-8').toString('base64url');
|
|
13
|
+
}
|
|
14
|
+
const bytes = new TextEncoder().encode(input);
|
|
15
|
+
let bin = '';
|
|
16
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
17
|
+
bin += String.fromCharCode(bytes[i]);
|
|
18
|
+
}
|
|
19
|
+
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Portable base64url decoding.
|
|
23
|
+
*/
|
|
24
|
+
export function fromBase64Url(input) {
|
|
25
|
+
if (typeof Buffer !== 'undefined') {
|
|
26
|
+
return Buffer.from(input, 'base64url').toString('utf-8');
|
|
27
|
+
}
|
|
28
|
+
let base64 = input.replace(/-/g, '+').replace(/_/g, '/');
|
|
29
|
+
while (base64.length % 4 !== 0) {
|
|
30
|
+
base64 += '=';
|
|
31
|
+
}
|
|
32
|
+
const bin = atob(base64);
|
|
33
|
+
const bytes = new Uint8Array(bin.length);
|
|
34
|
+
for (let i = 0; i < bin.length; i++) {
|
|
35
|
+
bytes[i] = bin.charCodeAt(i);
|
|
36
|
+
}
|
|
37
|
+
return new TextDecoder().decode(bytes);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Computes an HMAC-SHA256 hex signature using standard Web Crypto API.
|
|
41
|
+
*/
|
|
42
|
+
async function computeHmacSha256(keyString, message) {
|
|
43
|
+
const enc = new TextEncoder();
|
|
44
|
+
const keyData = enc.encode(keyString);
|
|
45
|
+
const msgData = enc.encode(message);
|
|
46
|
+
const cryptoKey = await crypto.subtle.importKey('raw', keyData, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
47
|
+
const sigBuffer = await crypto.subtle.sign('HMAC', cryptoKey, msgData);
|
|
48
|
+
const bytes = new Uint8Array(sigBuffer);
|
|
49
|
+
let hex = '';
|
|
50
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
51
|
+
hex += bytes[i].toString(16).padStart(2, '0');
|
|
52
|
+
}
|
|
53
|
+
return hex;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Constant-time cryptographic verification of HMAC-SHA256 signature using Web Crypto.
|
|
57
|
+
*/
|
|
58
|
+
async function verifyHmacSha256(keyString, expectedSigHex, message) {
|
|
59
|
+
if (!expectedSigHex || expectedSigHex.length !== 64 || !/^[0-9a-fA-F]+$/.test(expectedSigHex)) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
const enc = new TextEncoder();
|
|
63
|
+
const keyData = enc.encode(keyString);
|
|
64
|
+
const msgData = enc.encode(message);
|
|
65
|
+
try {
|
|
66
|
+
const cryptoKey = await crypto.subtle.importKey('raw', keyData, { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
|
|
67
|
+
const sigBytes = new Uint8Array(32);
|
|
68
|
+
for (let i = 0; i < 32; i++) {
|
|
69
|
+
sigBytes[i] = parseInt(expectedSigHex.slice(i * 2, i * 2 + 2), 16);
|
|
70
|
+
}
|
|
71
|
+
return await crypto.subtle.verify('HMAC', cryptoKey, sigBytes, msgData);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Derives a deterministic per-server receipt secret from a master secret using HKDF-SHA256.
|
|
79
|
+
* Limits the blast radius of any individual compromised server secret.
|
|
80
|
+
* Format: `mcpaid_sec_${64-char hex}`
|
|
81
|
+
*/
|
|
82
|
+
export async function deriveServerReceiptSecret(masterSecret, serverId, salt) {
|
|
83
|
+
if (!masterSecret) {
|
|
84
|
+
throw new Error('Master secret is required for server receipt secret derivation');
|
|
85
|
+
}
|
|
86
|
+
if (!serverId) {
|
|
87
|
+
throw new Error('serverId is required for server receipt secret derivation');
|
|
88
|
+
}
|
|
89
|
+
if (!/^[a-zA-Z0-9_\-\.]{3,64}$/.test(serverId)) {
|
|
90
|
+
throw new Error('Invalid serverId: must be 3-64 alphanumeric, dash, period, or underscore characters');
|
|
91
|
+
}
|
|
92
|
+
const enc = new TextEncoder();
|
|
93
|
+
const masterKey = await crypto.subtle.importKey('raw', enc.encode(masterSecret), 'HKDF', false, ['deriveBits']);
|
|
94
|
+
const derivedBits = await crypto.subtle.deriveBits({
|
|
95
|
+
name: 'HKDF',
|
|
96
|
+
hash: 'SHA-256',
|
|
97
|
+
salt: salt ? enc.encode(salt) : new Uint8Array(0),
|
|
98
|
+
info: enc.encode(`mcpaid-edge-receipt-v1:${serverId}`),
|
|
99
|
+
}, masterKey, 256 // 32 bytes (256 bits)
|
|
100
|
+
);
|
|
101
|
+
const bytes = new Uint8Array(derivedBits);
|
|
102
|
+
let hex = '';
|
|
103
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
104
|
+
hex += bytes[i].toString(16).padStart(2, '0');
|
|
105
|
+
}
|
|
106
|
+
return `mcpaid_sec_${hex}`;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Mints an authenticated Edge Receipt for a settled tool call.
|
|
110
|
+
* Returns the receipt object and its base64url-encoded string representation.
|
|
111
|
+
*/
|
|
112
|
+
export async function mintEdgeReceipt(secret, params) {
|
|
113
|
+
if (!secret) {
|
|
114
|
+
throw new Error('Receipt secret is required to mint an Edge Receipt');
|
|
115
|
+
}
|
|
116
|
+
const settledAt = params.settledAt ?? Math.floor(Date.now() / 1000);
|
|
117
|
+
const ttl = params.ttlSeconds ?? 300; // 5 minutes
|
|
118
|
+
const exp = settledAt + ttl;
|
|
119
|
+
const challengeNonce = params.challengeNonce ?? (typeof crypto !== 'undefined' && crypto.randomUUID
|
|
120
|
+
? `rcpt_${crypto.randomUUID().replace(/-/g, '')}`
|
|
121
|
+
: `rcpt_${Date.now().toString(36)}_${Math.random().toString(36).substring(2)}`);
|
|
122
|
+
const recipient = (params.recipient || '0x0000000000000000000000000000000000000000').toLowerCase();
|
|
123
|
+
const treasury = (params.treasury || '0x0000000000000000000000000000000000000000').toLowerCase();
|
|
124
|
+
const feeBps = params.feeBps ?? 300;
|
|
125
|
+
const chainId = params.chainId ?? 8453;
|
|
126
|
+
const payload = {
|
|
127
|
+
v: 1,
|
|
128
|
+
serverId: params.serverId,
|
|
129
|
+
toolName: params.toolName,
|
|
130
|
+
challengeNonce,
|
|
131
|
+
amountMicro: params.amountMicro.toString(),
|
|
132
|
+
recipient,
|
|
133
|
+
treasury,
|
|
134
|
+
feeBps,
|
|
135
|
+
chainId,
|
|
136
|
+
settledAt,
|
|
137
|
+
exp,
|
|
138
|
+
...(params.agentWallet ? { agentWallet: params.agentWallet.toLowerCase() } : {}),
|
|
139
|
+
...(params.txId ? { txId: params.txId } : {}),
|
|
140
|
+
};
|
|
141
|
+
const canonical = canonicalJsonStringify(payload);
|
|
142
|
+
const sig = await computeHmacSha256(secret, canonical);
|
|
143
|
+
const receipt = {
|
|
144
|
+
...payload,
|
|
145
|
+
sig,
|
|
146
|
+
};
|
|
147
|
+
const jsonStr = JSON.stringify(receipt);
|
|
148
|
+
const encoded = toBase64Url(jsonStr);
|
|
149
|
+
return { receipt, encoded };
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Validates and verifies an Edge Receipt string against expected tool/server rules and replay claim store.
|
|
153
|
+
* Returns { valid: true, receipt } if valid, or { valid: false, error } on failure.
|
|
154
|
+
*/
|
|
155
|
+
export async function parseAndVerifyEdgeReceipt(envInput, opts) {
|
|
156
|
+
const env = typeof envInput === 'string' ? { secret: envInput } : envInput;
|
|
157
|
+
if (!env.secret && !env.previousSecret) {
|
|
158
|
+
return { valid: false, error: 'Verification error: No verification secret configured' };
|
|
159
|
+
}
|
|
160
|
+
const raw = opts.receipt?.trim();
|
|
161
|
+
if (!raw) {
|
|
162
|
+
return { valid: false, error: 'Missing receipt: X-MCPaid-Receipt header not provided' };
|
|
163
|
+
}
|
|
164
|
+
let receipt;
|
|
165
|
+
try {
|
|
166
|
+
const jsonStr = fromBase64Url(raw);
|
|
167
|
+
receipt = JSON.parse(jsonStr);
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
return { valid: false, error: `Malformed receipt format: ${err.message}` };
|
|
171
|
+
}
|
|
172
|
+
// Version check
|
|
173
|
+
if (receipt.v !== 1) {
|
|
174
|
+
return { valid: false, error: `Unsupported receipt version: ${receipt.v}` };
|
|
175
|
+
}
|
|
176
|
+
// Required field checks
|
|
177
|
+
if (!receipt.serverId ||
|
|
178
|
+
!receipt.toolName ||
|
|
179
|
+
!receipt.challengeNonce ||
|
|
180
|
+
!receipt.amountMicro ||
|
|
181
|
+
!receipt.sig ||
|
|
182
|
+
receipt.settledAt === undefined ||
|
|
183
|
+
receipt.exp === undefined) {
|
|
184
|
+
return { valid: false, error: 'Malformed receipt: missing required fields' };
|
|
185
|
+
}
|
|
186
|
+
const nowSec = opts.nowSec ?? Math.floor(Date.now() / 1000);
|
|
187
|
+
const skew = opts.clockSkewSeconds ?? 60;
|
|
188
|
+
// Expiry check (with clock skew allowance)
|
|
189
|
+
if (nowSec > receipt.exp + skew) {
|
|
190
|
+
return { valid: false, error: `Receipt expired at ${receipt.exp} (current time: ${nowSec})` };
|
|
191
|
+
}
|
|
192
|
+
// Max age check
|
|
193
|
+
const maxAge = opts.maxAgeSeconds ?? 300;
|
|
194
|
+
if (nowSec - receipt.settledAt > maxAge + skew) {
|
|
195
|
+
return { valid: false, error: `Receipt age exceeds maximum allowed age (${nowSec - receipt.settledAt}s > ${maxAge}s)` };
|
|
196
|
+
}
|
|
197
|
+
// Future timestamp check (prevent pre-dated receipts)
|
|
198
|
+
if (receipt.settledAt > nowSec + skew) {
|
|
199
|
+
return { valid: false, error: `Receipt settledAt is in the future (${receipt.settledAt} > ${nowSec})` };
|
|
200
|
+
}
|
|
201
|
+
// Server binding check
|
|
202
|
+
if (opts.expectedServer && receipt.serverId !== opts.expectedServer) {
|
|
203
|
+
return {
|
|
204
|
+
valid: false,
|
|
205
|
+
error: `Receipt server mismatch: expected "${opts.expectedServer}", got "${receipt.serverId}"`,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
// Tool binding check
|
|
209
|
+
if (opts.expectedTool && receipt.toolName !== opts.expectedTool) {
|
|
210
|
+
return {
|
|
211
|
+
valid: false,
|
|
212
|
+
error: `Receipt tool mismatch: expected "${opts.expectedTool}", got "${receipt.toolName}"`,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
// Minimum amount check
|
|
216
|
+
if (opts.minAmountMicro !== undefined) {
|
|
217
|
+
const required = BigInt(opts.minAmountMicro);
|
|
218
|
+
const actual = BigInt(receipt.amountMicro);
|
|
219
|
+
if (actual < required) {
|
|
220
|
+
return {
|
|
221
|
+
valid: false,
|
|
222
|
+
error: `Receipt underpaid: required ${required} micro-units, receipt contains ${actual}`,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// Recompute HMAC on canonical JSON without `sig`
|
|
227
|
+
const { sig, ...payload } = receipt;
|
|
228
|
+
const canonical = canonicalJsonStringify(payload);
|
|
229
|
+
let isSigValid = false;
|
|
230
|
+
if (env.secret) {
|
|
231
|
+
isSigValid = await verifyHmacSha256(env.secret, sig, canonical);
|
|
232
|
+
}
|
|
233
|
+
if (!isSigValid && env.previousSecret) {
|
|
234
|
+
isSigValid = await verifyHmacSha256(env.previousSecret, sig, canonical);
|
|
235
|
+
}
|
|
236
|
+
if (!isSigValid) {
|
|
237
|
+
return { valid: false, error: 'Cryptographic signature mismatch: receipt tampered or secret invalid' };
|
|
238
|
+
}
|
|
239
|
+
// Atomic replay defense via NonceClaimStore
|
|
240
|
+
if (env.store) {
|
|
241
|
+
const claimed = await env.store.claimNonce(receipt.challengeNonce, {
|
|
242
|
+
serverId: receipt.serverId,
|
|
243
|
+
toolName: receipt.toolName,
|
|
244
|
+
exp: receipt.exp,
|
|
245
|
+
});
|
|
246
|
+
if (!claimed) {
|
|
247
|
+
return {
|
|
248
|
+
valid: false,
|
|
249
|
+
error: `Receipt replay attack detected: challenge nonce "${receipt.challengeNonce}" has already been claimed`,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
warnMissingStoreOnce();
|
|
255
|
+
}
|
|
256
|
+
return { valid: true, receipt };
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* One-time warning when receipts are verified without a replay store.
|
|
260
|
+
* Signature-only verification cannot detect reuse within the TTL window —
|
|
261
|
+
* pass a NonceClaimStore (MemoryReceiptStore / D1ReceiptStore) for enforcement.
|
|
262
|
+
*/
|
|
263
|
+
let warnedMissingStore = false;
|
|
264
|
+
function warnMissingStoreOnce() {
|
|
265
|
+
if (warnedMissingStore)
|
|
266
|
+
return;
|
|
267
|
+
warnedMissingStore = true;
|
|
268
|
+
if (typeof console !== 'undefined' && typeof console.warn === 'function') {
|
|
269
|
+
console.warn('[MCPaid] verifyEdgeReceipt called without a NonceClaimStore: ' +
|
|
270
|
+
'signature is checked but replays are NOT rejected. ' +
|
|
271
|
+
'Pass store: new MemoryReceiptStore() or new D1ReceiptStore(db) for enforcement.');
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* 5-line verification helper for downstream backends.
|
|
276
|
+
* Returns null if valid and nonce claimed, or an error string describing the failure.
|
|
277
|
+
*
|
|
278
|
+
* Note: store infrastructure failures (e.g. D1 outage) THROW rather than
|
|
279
|
+
* returning an error string, so backends answer 500 (not 402) and the outage
|
|
280
|
+
* is distinguishable from a replay in logs and monitoring.
|
|
281
|
+
*
|
|
282
|
+
* Usage:
|
|
283
|
+
* ```typescript
|
|
284
|
+
* const error = await verifyEdgeReceipt(env, {
|
|
285
|
+
* receipt: request.headers.get('X-MCPaid-Receipt'),
|
|
286
|
+
* expectedTool: 'contextwise_cloud_push',
|
|
287
|
+
* });
|
|
288
|
+
* if (error) return new Response(JSON.stringify({ error }), { status: 402 });
|
|
289
|
+
* ```
|
|
290
|
+
*/
|
|
291
|
+
export async function verifyEdgeReceipt(env, opts) {
|
|
292
|
+
const result = await parseAndVerifyEdgeReceipt(env, opts);
|
|
293
|
+
return result.valid ? null : result.error;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* In-memory NonceClaimStore for local development, unit tests, and single-instance Node.js backends.
|
|
297
|
+
*/
|
|
298
|
+
export class MemoryReceiptStore {
|
|
299
|
+
claimed = new Map();
|
|
300
|
+
/** Hard cap: oldest entries are evicted FIFO past this size (Map preserves insertion order). */
|
|
301
|
+
maxSize;
|
|
302
|
+
constructor(maxSize = 5000) {
|
|
303
|
+
this.maxSize = maxSize;
|
|
304
|
+
}
|
|
305
|
+
async claimNonce(nonce, meta) {
|
|
306
|
+
const key = nonce.toLowerCase().trim();
|
|
307
|
+
if (this.claimed.has(key)) {
|
|
308
|
+
return false; // Already claimed
|
|
309
|
+
}
|
|
310
|
+
// Prefer dropping expired entries; if still over budget, evict oldest.
|
|
311
|
+
if (this.claimed.size >= this.maxSize) {
|
|
312
|
+
this.pruneExpired();
|
|
313
|
+
while (this.claimed.size >= this.maxSize) {
|
|
314
|
+
const oldest = this.claimed.keys().next();
|
|
315
|
+
if (oldest.done)
|
|
316
|
+
break;
|
|
317
|
+
this.claimed.delete(oldest.value);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
this.claimed.set(key, meta);
|
|
321
|
+
return true;
|
|
322
|
+
}
|
|
323
|
+
async hasNonce(nonce) {
|
|
324
|
+
return this.claimed.has(nonce.toLowerCase().trim());
|
|
325
|
+
}
|
|
326
|
+
pruneExpired(nowSec = Math.floor(Date.now() / 1000)) {
|
|
327
|
+
let count = 0;
|
|
328
|
+
for (const [k, v] of this.claimed.entries()) {
|
|
329
|
+
if (v.exp < nowSec) {
|
|
330
|
+
this.claimed.delete(k);
|
|
331
|
+
count++;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return count;
|
|
335
|
+
}
|
|
336
|
+
clear() {
|
|
337
|
+
this.claimed.clear();
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Cloudflare D1-backed NonceClaimStore for edge workers and serverless backends.
|
|
342
|
+
* Uses atomic SQLite INSERT ... ON CONFLICT to guarantee anti-replay under high concurrency.
|
|
343
|
+
*/
|
|
344
|
+
export class D1ReceiptStore {
|
|
345
|
+
db;
|
|
346
|
+
tableName;
|
|
347
|
+
constructor(db, tableName = 'mcpaid_receipt_nonces') {
|
|
348
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(tableName)) {
|
|
349
|
+
throw new Error(`Invalid receipt nonce table name: "${tableName}"`);
|
|
350
|
+
}
|
|
351
|
+
this.db = db;
|
|
352
|
+
this.tableName = tableName;
|
|
353
|
+
}
|
|
354
|
+
async claimNonce(nonce, meta) {
|
|
355
|
+
const key = nonce.toLowerCase().trim();
|
|
356
|
+
try {
|
|
357
|
+
const stmt = this.db
|
|
358
|
+
.prepare(`INSERT INTO ${this.tableName} (nonce, server_id, tool_name, exp)
|
|
359
|
+
VALUES (?, ?, ?, ?)
|
|
360
|
+
ON CONFLICT(nonce) DO NOTHING`)
|
|
361
|
+
.bind(key, meta.serverId, meta.toolName, meta.exp);
|
|
362
|
+
const res = await stmt.run();
|
|
363
|
+
// Opportunistic pruning: ~2% of claims sweep expired rows so the
|
|
364
|
+
// table does not grow by one row per paid call forever. A scheduled
|
|
365
|
+
// `DELETE WHERE exp < now()` is still recommended for high volume.
|
|
366
|
+
if (Math.random() < 0.02) {
|
|
367
|
+
await this.pruneExpired().catch(() => { });
|
|
368
|
+
}
|
|
369
|
+
const changes = res.meta?.changes ?? res.meta?.rows_written;
|
|
370
|
+
if (changes !== undefined) {
|
|
371
|
+
return changes > 0;
|
|
372
|
+
}
|
|
373
|
+
return !(await this.hasNonce(key));
|
|
374
|
+
}
|
|
375
|
+
catch (err) {
|
|
376
|
+
// Fail closed, but distinguish a genuine replay (unique violation)
|
|
377
|
+
// from an infrastructure outage so the two are observable separately.
|
|
378
|
+
if (isUniqueViolation(err)) {
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
381
|
+
throw new Error(`Receipt nonce claim failed: ${err?.message || err}`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
async hasNonce(nonce) {
|
|
385
|
+
const key = nonce.toLowerCase().trim();
|
|
386
|
+
const row = await this.db
|
|
387
|
+
.prepare(`SELECT nonce FROM ${this.tableName} WHERE nonce = ?`)
|
|
388
|
+
.bind(key)
|
|
389
|
+
.first();
|
|
390
|
+
return Boolean(row);
|
|
391
|
+
}
|
|
392
|
+
async pruneExpired(nowSec = Math.floor(Date.now() / 1000)) {
|
|
393
|
+
const res = await this.db
|
|
394
|
+
.prepare(`DELETE FROM ${this.tableName} WHERE exp < ?`)
|
|
395
|
+
.bind(nowSec)
|
|
396
|
+
.run();
|
|
397
|
+
return res.meta?.changes ?? 0;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Detects unique-constraint violations across SQLite/D1 drivers so a genuine
|
|
402
|
+
* replay is distinguishable from a transport outage (see D1ReceiptStore).
|
|
403
|
+
*/
|
|
404
|
+
function isUniqueViolation(err) {
|
|
405
|
+
const msg = `${err?.message || err || ''}`.toLowerCase();
|
|
406
|
+
return (msg.includes('unique') ||
|
|
407
|
+
msg.includes('constraint') ||
|
|
408
|
+
msg.includes('conflict') ||
|
|
409
|
+
msg.includes('already exists') ||
|
|
410
|
+
(typeof err?.code === 'string' && /constraint|conflict|unique/i.test(err.code)));
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Drop-in Express / Connect / Node.js HTTP middleware for validating Edge Receipts.
|
|
414
|
+
*/
|
|
415
|
+
export function createReceiptMiddleware(options) {
|
|
416
|
+
// Tool binding is load-bearing: without it a receipt for a cheap tool
|
|
417
|
+
// authorizes an expensive one. Refuse to construct an unbound middleware
|
|
418
|
+
// instead of silently falling back to body-sniffing (which fails open
|
|
419
|
+
// whenever the body is absent or not a tools/call).
|
|
420
|
+
if (!options.expectedTool || typeof options.expectedTool !== 'string') {
|
|
421
|
+
throw new Error('createReceiptMiddleware requires expectedTool: a receipt for one tool must never authorize another');
|
|
422
|
+
}
|
|
423
|
+
const header = options.headerName || 'x-mcpaid-receipt';
|
|
424
|
+
const prevSecret = options.previousSecret ||
|
|
425
|
+
(typeof process !== 'undefined' ? process.env?.MCPAID_PREVIOUS_RECEIPT_SECRET : undefined);
|
|
426
|
+
return async (req, res, next) => {
|
|
427
|
+
try {
|
|
428
|
+
let receiptHeader;
|
|
429
|
+
if (typeof req.get === 'function') {
|
|
430
|
+
receiptHeader = req.get(header);
|
|
431
|
+
}
|
|
432
|
+
if (!receiptHeader && req.headers && typeof req.headers === 'object') {
|
|
433
|
+
const target = header.toLowerCase();
|
|
434
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
435
|
+
if (k.toLowerCase() === target) {
|
|
436
|
+
receiptHeader = Array.isArray(v) ? v[0] : v;
|
|
437
|
+
break;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
// expectedTool is guaranteed by the constructor guard above.
|
|
442
|
+
const result = await parseAndVerifyEdgeReceipt({
|
|
443
|
+
secret: options.secret,
|
|
444
|
+
previousSecret: prevSecret,
|
|
445
|
+
store: options.store,
|
|
446
|
+
}, {
|
|
447
|
+
receipt: receiptHeader,
|
|
448
|
+
expectedTool: options.expectedTool,
|
|
449
|
+
expectedServer: options.expectedServer,
|
|
450
|
+
minAmountMicro: options.minAmountMicro,
|
|
451
|
+
maxAgeSeconds: options.maxAgeSeconds,
|
|
452
|
+
});
|
|
453
|
+
if (!result.valid) {
|
|
454
|
+
if (typeof res.status === 'function' && typeof res.json === 'function') {
|
|
455
|
+
return res.status(402).json({
|
|
456
|
+
error: 'payment_required',
|
|
457
|
+
message: result.error,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
res.writeHead(402, { 'Content-Type': 'application/json' });
|
|
461
|
+
res.end(JSON.stringify({ error: 'payment_required', message: result.error }));
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
req.mcpaidReceipt = result.receipt;
|
|
465
|
+
next();
|
|
466
|
+
}
|
|
467
|
+
catch (err) {
|
|
468
|
+
next(err);
|
|
469
|
+
}
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
//# sourceMappingURL=edge-receipts.js.map
|