aipp-node 1.1.0 → 1.2.1
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/dist/client.d.ts +29 -0
- package/dist/client.js +93 -0
- package/dist/index.d.ts +3 -58
- package/dist/index.js +16 -200
- package/dist/jwt.d.ts +2 -0
- package/dist/jwt.js +43 -0
- package/dist/middleware.d.ts +16 -0
- package/dist/middleware.js +136 -0
- package/dist/types.d.ts +75 -0
- package/dist/types.js +2 -0
- package/package.json +32 -32
- package/src/client.ts +101 -76
- package/src/index.ts +3 -3
- package/src/jwt.ts +48 -48
- package/src/middleware.ts +159 -86
- package/src/types.ts +86 -32
- package/tsconfig.json +14 -14
- package/dist/index.d.mts +0 -58
- package/dist/index.mjs +0 -165
package/package.json
CHANGED
|
@@ -1,32 +1,32 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "aipp-node",
|
|
3
|
-
"version": "1.1
|
|
4
|
-
"description": "Official Node.js SDK for AIPP - The Lightning Network Split-Payment Gateway",
|
|
5
|
-
"main": "dist/index.js",
|
|
6
|
-
"module": "dist/index.mjs",
|
|
7
|
-
"types": "dist/index.d.ts",
|
|
8
|
-
"scripts": {
|
|
9
|
-
"build": "tsup src/index.ts --format cjs,esm --dts",
|
|
10
|
-
"test": "vitest run"
|
|
11
|
-
},
|
|
12
|
-
"keywords": [
|
|
13
|
-
"aipp",
|
|
14
|
-
"bitcoin",
|
|
15
|
-
"lightning",
|
|
16
|
-
"payments",
|
|
17
|
-
"l402",
|
|
18
|
-
"ai"
|
|
19
|
-
],
|
|
20
|
-
"author": "AIPP",
|
|
21
|
-
"license": "MIT",
|
|
22
|
-
"devDependencies": {
|
|
23
|
-
"@types/node": "^20.0.0",
|
|
24
|
-
"tsup": "^8.0.0",
|
|
25
|
-
"typescript": "^5.0.0",
|
|
26
|
-
"vitest": "^1.0.0"
|
|
27
|
-
},
|
|
28
|
-
"repository": {
|
|
29
|
-
"type": "git",
|
|
30
|
-
"url": "https://github.com/aippde/aipp-key.git"
|
|
31
|
-
}
|
|
32
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "aipp-node",
|
|
3
|
+
"version": "1.2.1",
|
|
4
|
+
"description": "Official Node.js SDK for AIPP - The Lightning Network Split-Payment Gateway",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"module": "dist/index.mjs",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "tsup src/index.ts --format cjs,esm --dts",
|
|
10
|
+
"test": "vitest run"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"aipp",
|
|
14
|
+
"bitcoin",
|
|
15
|
+
"lightning",
|
|
16
|
+
"payments",
|
|
17
|
+
"l402",
|
|
18
|
+
"ai"
|
|
19
|
+
],
|
|
20
|
+
"author": "AIPP",
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/node": "^20.0.0",
|
|
24
|
+
"tsup": "^8.0.0",
|
|
25
|
+
"typescript": "^5.0.0",
|
|
26
|
+
"vitest": "^1.0.0"
|
|
27
|
+
},
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "https://github.com/aippde/aipp-key.git"
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/client.ts
CHANGED
|
@@ -1,76 +1,101 @@
|
|
|
1
|
-
import { AippConfig, ChargeParams, ChargeResponse, ChargeStatus, AippErrorResponse } from './types';
|
|
2
|
-
|
|
3
|
-
export class Aipp {
|
|
4
|
-
private apiKey: string;
|
|
5
|
-
private baseUrl: string;
|
|
6
|
-
|
|
7
|
-
constructor(config: AippConfig) {
|
|
8
|
-
if (!config.apiKey) {
|
|
9
|
-
throw new Error('AIPP: apiKey is required');
|
|
10
|
-
}
|
|
11
|
-
this.apiKey = config.apiKey;
|
|
12
|
-
this.baseUrl = config.baseUrl || 'https://aipp.dev';
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
|
16
|
-
const url = `${this.baseUrl}${endpoint}`;
|
|
17
|
-
const headers = {
|
|
18
|
-
'Content-Type': 'application/json',
|
|
19
|
-
'X-Api-Key': this.apiKey,
|
|
20
|
-
...options.headers,
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
const response = await fetch(url, { ...options, headers });
|
|
24
|
-
|
|
25
|
-
if (!response.ok) {
|
|
26
|
-
let errorData: AippErrorResponse;
|
|
27
|
-
try {
|
|
28
|
-
errorData = await response.json();
|
|
29
|
-
} catch (err) {
|
|
30
|
-
throw new Error(`AIPP API Error: ${response.status} ${response.statusText}`);
|
|
31
|
-
}
|
|
32
|
-
throw new Error(`AIPP API Error: ${errorData.error || response.statusText}`);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
return response.json() as Promise<T>;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Creates a new
|
|
40
|
-
*/
|
|
41
|
-
async createCharge(params: ChargeParams): Promise<ChargeResponse> {
|
|
42
|
-
if (!params.amountSats && !params.amountUsd) {
|
|
43
|
-
throw new Error('AIPP: Either amountSats or amountUsd is required');
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const body: any = { memo: params.memo };
|
|
47
|
-
if (params.amountSats) body.amount_sats = params.amountSats;
|
|
48
|
-
if (params.amountUsd) body.amount_usd = params.amountUsd;
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
1
|
+
import { AippConfig, ChargeParams, ChargeResponse, ChargeStatus, AippErrorResponse, ReceiptResponse, MarketplaceManifest } from './types';
|
|
2
|
+
|
|
3
|
+
export class Aipp {
|
|
4
|
+
private apiKey: string;
|
|
5
|
+
private baseUrl: string;
|
|
6
|
+
|
|
7
|
+
constructor(config: AippConfig) {
|
|
8
|
+
if (!config.apiKey) {
|
|
9
|
+
throw new Error('AIPP: apiKey is required');
|
|
10
|
+
}
|
|
11
|
+
this.apiKey = config.apiKey;
|
|
12
|
+
this.baseUrl = config.baseUrl || 'https://aipp.dev';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
|
16
|
+
const url = `${this.baseUrl}${endpoint}`;
|
|
17
|
+
const headers = {
|
|
18
|
+
'Content-Type': 'application/json',
|
|
19
|
+
'X-Api-Key': this.apiKey,
|
|
20
|
+
...options.headers,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const response = await fetch(url, { ...options, headers });
|
|
24
|
+
|
|
25
|
+
if (!response.ok) {
|
|
26
|
+
let errorData: AippErrorResponse;
|
|
27
|
+
try {
|
|
28
|
+
errorData = await response.json();
|
|
29
|
+
} catch (err) {
|
|
30
|
+
throw new Error(`AIPP API Error: ${response.status} ${response.statusText}`);
|
|
31
|
+
}
|
|
32
|
+
throw new Error(`AIPP API Error: ${errorData.error || response.statusText}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return response.json() as Promise<T>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Creates a new Invoice (either L402 or x402)
|
|
40
|
+
*/
|
|
41
|
+
async createCharge(params: ChargeParams): Promise<ChargeResponse> {
|
|
42
|
+
if (!params.amountSats && !params.amountUsd) {
|
|
43
|
+
throw new Error('AIPP: Either amountSats or amountUsd is required');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const body: any = { memo: params.memo };
|
|
47
|
+
if (params.amountSats) body.amount_sats = params.amountSats;
|
|
48
|
+
if (params.amountUsd) body.amount_usd = params.amountUsd;
|
|
49
|
+
if (params.protocol) body.protocol = params.protocol;
|
|
50
|
+
|
|
51
|
+
return this.request<ChargeResponse>('/invoice/create', {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
body: JSON.stringify(body),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Checks the status of an existing charge
|
|
59
|
+
*/
|
|
60
|
+
async getCharge(paymentHash: string, txHash?: string): Promise<ChargeStatus> {
|
|
61
|
+
if (!paymentHash) {
|
|
62
|
+
throw new Error('AIPP: paymentHash is required');
|
|
63
|
+
}
|
|
64
|
+
const query = txHash ? `?tx_hash=${txHash}` : '';
|
|
65
|
+
return this.request<ChargeStatus>(`/invoice/status/${paymentHash}${query}`, {
|
|
66
|
+
method: 'GET',
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Triggers a manual withdrawal of your merchant balance
|
|
72
|
+
*/
|
|
73
|
+
async payout(): Promise<import('./types').PayoutResponse> {
|
|
74
|
+
return this.request<import('./types').PayoutResponse>('/merchant/payout', {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Retrieves an EU AI Act Article 26 compliant receipt for a settled invoice.
|
|
81
|
+
* Only available for invoices with status = 'settled'.
|
|
82
|
+
*/
|
|
83
|
+
async getReceipt(paymentHash: string): Promise<ReceiptResponse> {
|
|
84
|
+
if (!paymentHash) {
|
|
85
|
+
throw new Error('AIPP: paymentHash is required');
|
|
86
|
+
}
|
|
87
|
+
return this.request<ReceiptResponse>(`/invoice/receipt/${paymentHash}`, {
|
|
88
|
+
method: 'GET',
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Returns the PaidMCP.dev compatible marketplace manifest for this merchant.
|
|
94
|
+
* Use this JSON to list your AIPP-protected endpoints on AI agent directories.
|
|
95
|
+
*/
|
|
96
|
+
async getMarketplaceManifest(): Promise<MarketplaceManifest> {
|
|
97
|
+
return this.request<MarketplaceManifest>('/paidmcp.json', {
|
|
98
|
+
method: 'GET',
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export * from './client';
|
|
2
|
-
export * from './types';
|
|
3
|
-
export * from './middleware';
|
|
1
|
+
export * from './client';
|
|
2
|
+
export * from './types';
|
|
3
|
+
export * from './middleware';
|
package/src/jwt.ts
CHANGED
|
@@ -1,48 +1,48 @@
|
|
|
1
|
-
import crypto from 'crypto';
|
|
2
|
-
|
|
3
|
-
function base64urlEncode(str: string | Buffer): string {
|
|
4
|
-
const buf = Buffer.isBuffer(str) ? str : Buffer.from(str);
|
|
5
|
-
return buf.toString('base64url');
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
function base64urlDecode(str: string): string {
|
|
9
|
-
return Buffer.from(str, 'base64url').toString('utf8');
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export function signJwt(payload: any, secret: string): string {
|
|
13
|
-
const header = { alg: 'HS256', typ: 'JWT' };
|
|
14
|
-
const encodedHeader = base64urlEncode(JSON.stringify(header));
|
|
15
|
-
const encodedPayload = base64urlEncode(JSON.stringify(payload));
|
|
16
|
-
const data = `${encodedHeader}.${encodedPayload}`;
|
|
17
|
-
|
|
18
|
-
const signature = crypto.createHmac('sha256', secret).update(data).digest('base64url');
|
|
19
|
-
return `${data}.${signature}`;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function verifyJwt(token: string, secret: string): any {
|
|
23
|
-
const parts = token.split('.');
|
|
24
|
-
if (parts.length !== 3) {
|
|
25
|
-
throw new Error('Invalid JWT format');
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const [encodedHeader, encodedPayload, signature] = parts;
|
|
29
|
-
const data = `${encodedHeader}.${encodedPayload}`;
|
|
30
|
-
|
|
31
|
-
const expectedSignature = crypto.createHmac('sha256', secret).update(data).digest('base64url');
|
|
32
|
-
|
|
33
|
-
// Constant time comparison to prevent timing attacks
|
|
34
|
-
const signatureBuffer = Buffer.from(signature);
|
|
35
|
-
const expectedBuffer = Buffer.from(expectedSignature);
|
|
36
|
-
|
|
37
|
-
if (signatureBuffer.length !== expectedBuffer.length || !crypto.timingSafeEqual(signatureBuffer, expectedBuffer)) {
|
|
38
|
-
throw new Error('Invalid JWT signature');
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const payload = JSON.parse(base64urlDecode(encodedPayload));
|
|
42
|
-
|
|
43
|
-
if (payload.exp && Date.now() >= payload.exp * 1000) {
|
|
44
|
-
throw new Error('JWT expired');
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
return payload;
|
|
48
|
-
}
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
|
|
3
|
+
function base64urlEncode(str: string | Buffer): string {
|
|
4
|
+
const buf = Buffer.isBuffer(str) ? str : Buffer.from(str);
|
|
5
|
+
return buf.toString('base64url');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function base64urlDecode(str: string): string {
|
|
9
|
+
return Buffer.from(str, 'base64url').toString('utf8');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function signJwt(payload: any, secret: string): string {
|
|
13
|
+
const header = { alg: 'HS256', typ: 'JWT' };
|
|
14
|
+
const encodedHeader = base64urlEncode(JSON.stringify(header));
|
|
15
|
+
const encodedPayload = base64urlEncode(JSON.stringify(payload));
|
|
16
|
+
const data = `${encodedHeader}.${encodedPayload}`;
|
|
17
|
+
|
|
18
|
+
const signature = crypto.createHmac('sha256', secret).update(data).digest('base64url');
|
|
19
|
+
return `${data}.${signature}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function verifyJwt(token: string, secret: string): any {
|
|
23
|
+
const parts = token.split('.');
|
|
24
|
+
if (parts.length !== 3) {
|
|
25
|
+
throw new Error('Invalid JWT format');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const [encodedHeader, encodedPayload, signature] = parts;
|
|
29
|
+
const data = `${encodedHeader}.${encodedPayload}`;
|
|
30
|
+
|
|
31
|
+
const expectedSignature = crypto.createHmac('sha256', secret).update(data).digest('base64url');
|
|
32
|
+
|
|
33
|
+
// Constant time comparison to prevent timing attacks
|
|
34
|
+
const signatureBuffer = Buffer.from(signature);
|
|
35
|
+
const expectedBuffer = Buffer.from(expectedSignature);
|
|
36
|
+
|
|
37
|
+
if (signatureBuffer.length !== expectedBuffer.length || !crypto.timingSafeEqual(signatureBuffer, expectedBuffer)) {
|
|
38
|
+
throw new Error('Invalid JWT signature');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const payload = JSON.parse(base64urlDecode(encodedPayload));
|
|
42
|
+
|
|
43
|
+
if (payload.exp && Date.now() >= payload.exp * 1000) {
|
|
44
|
+
throw new Error('JWT expired');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return payload;
|
|
48
|
+
}
|
package/src/middleware.ts
CHANGED
|
@@ -1,86 +1,159 @@
|
|
|
1
|
-
import crypto from 'crypto';
|
|
2
|
-
import { Aipp } from './client';
|
|
3
|
-
import { signJwt, verifyJwt } from './jwt';
|
|
4
|
-
|
|
5
|
-
export interface L402Options {
|
|
6
|
-
client: Aipp;
|
|
7
|
-
jwtSecret: string;
|
|
8
|
-
resourceId: string;
|
|
9
|
-
amountSats?: number;
|
|
10
|
-
amountUsd?: number;
|
|
11
|
-
expiresInSeconds?: number;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function l402Paywall(options: L402Options) {
|
|
15
|
-
if (!options.amountSats && !options.amountUsd) {
|
|
16
|
-
throw new Error('Either amountSats or amountUsd must be provided for l402Paywall');
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
return async (req: any, res: any, next: any) => {
|
|
20
|
-
const authHeader = req.headers.authorization || req.headers.Authorization;
|
|
21
|
-
let valid = false;
|
|
22
|
-
|
|
23
|
-
if (authHeader && typeof authHeader === 'string' && authHeader.startsWith('L402 ')) {
|
|
24
|
-
const parts = authHeader.substring(5).split(':');
|
|
25
|
-
if (parts.length === 2) {
|
|
26
|
-
const [macaroonStr, preimage] = parts;
|
|
27
|
-
try {
|
|
28
|
-
// Verify JWT signature and expiration
|
|
29
|
-
const payload = verifyJwt(macaroonStr, options.jwtSecret);
|
|
30
|
-
|
|
31
|
-
if (payload.resource_id !== options.resourceId) {
|
|
32
|
-
throw new Error('Invalid resource');
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// Verify preimage hash matches payment_hash securely
|
|
36
|
-
const preimageHash = crypto.createHash('sha256').update(Buffer.from(preimage, 'hex')).digest('hex');
|
|
37
|
-
const expectedHash = payload.payment_hash;
|
|
38
|
-
|
|
39
|
-
if (
|
|
40
|
-
typeof expectedHash === 'string' &&
|
|
41
|
-
expectedHash.length === preimageHash.length &&
|
|
42
|
-
crypto.timingSafeEqual(Buffer.from(preimageHash, 'hex'), Buffer.from(expectedHash, 'hex'))
|
|
43
|
-
) {
|
|
44
|
-
valid = true;
|
|
45
|
-
}
|
|
46
|
-
} catch (e) {
|
|
47
|
-
// Invalid or expired JWT -> Fall through to 402 and issue new invoice
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
if (valid) {
|
|
53
|
-
return next();
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
try {
|
|
57
|
-
// Create invoice via AIPP
|
|
58
|
-
const charge = await options.client.createCharge({
|
|
59
|
-
amountSats: options.amountSats,
|
|
60
|
-
amountUsd: options.amountUsd,
|
|
61
|
-
memo: `L402 Payment for ${options.resourceId}`
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
// Issue JWT
|
|
65
|
-
const expiresIn = options.expiresInSeconds || 3600; // default 1 hour
|
|
66
|
-
const payload = {
|
|
67
|
-
payment_hash: charge.payment_hash,
|
|
68
|
-
resource_id: options.resourceId,
|
|
69
|
-
exp: Math.floor(Date.now() / 1000) + expiresIn
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
const jwtToken = signJwt(payload, options.jwtSecret);
|
|
73
|
-
|
|
74
|
-
res.status(402);
|
|
75
|
-
res.setHeader('Www-Authenticate', `L402 macaroon="${jwtToken}" invoice="${charge.payment_request}"`);
|
|
76
|
-
res.json({
|
|
77
|
-
error: "Payment Required",
|
|
78
|
-
code: "L402",
|
|
79
|
-
payment_request: charge.payment_request,
|
|
80
|
-
macaroon: jwtToken
|
|
81
|
-
});
|
|
82
|
-
} catch (err: any) {
|
|
83
|
-
res.status(500).json({ error: "Failed to generate L402 challenge", details: err.message || err });
|
|
84
|
-
}
|
|
85
|
-
};
|
|
86
|
-
}
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import { Aipp } from './client';
|
|
3
|
+
import { signJwt, verifyJwt } from './jwt';
|
|
4
|
+
|
|
5
|
+
export interface L402Options {
|
|
6
|
+
client: Aipp;
|
|
7
|
+
jwtSecret: string;
|
|
8
|
+
resourceId: string;
|
|
9
|
+
amountSats?: number;
|
|
10
|
+
amountUsd?: number;
|
|
11
|
+
expiresInSeconds?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function l402Paywall(options: L402Options) {
|
|
15
|
+
if (!options.amountSats && !options.amountUsd) {
|
|
16
|
+
throw new Error('Either amountSats or amountUsd must be provided for l402Paywall');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return async (req: any, res: any, next: any) => {
|
|
20
|
+
const authHeader = req.headers.authorization || req.headers.Authorization;
|
|
21
|
+
let valid = false;
|
|
22
|
+
|
|
23
|
+
if (authHeader && typeof authHeader === 'string' && authHeader.startsWith('L402 ')) {
|
|
24
|
+
const parts = authHeader.substring(5).split(':');
|
|
25
|
+
if (parts.length === 2) {
|
|
26
|
+
const [macaroonStr, preimage] = parts;
|
|
27
|
+
try {
|
|
28
|
+
// Verify JWT signature and expiration
|
|
29
|
+
const payload = verifyJwt(macaroonStr, options.jwtSecret);
|
|
30
|
+
|
|
31
|
+
if (payload.resource_id !== options.resourceId) {
|
|
32
|
+
throw new Error('Invalid resource');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Verify preimage hash matches payment_hash securely
|
|
36
|
+
const preimageHash = crypto.createHash('sha256').update(Buffer.from(preimage, 'hex')).digest('hex');
|
|
37
|
+
const expectedHash = payload.payment_hash;
|
|
38
|
+
|
|
39
|
+
if (
|
|
40
|
+
typeof expectedHash === 'string' &&
|
|
41
|
+
expectedHash.length === preimageHash.length &&
|
|
42
|
+
crypto.timingSafeEqual(Buffer.from(preimageHash, 'hex'), Buffer.from(expectedHash, 'hex'))
|
|
43
|
+
) {
|
|
44
|
+
valid = true;
|
|
45
|
+
}
|
|
46
|
+
} catch (e) {
|
|
47
|
+
// Invalid or expired JWT -> Fall through to 402 and issue new invoice
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (valid) {
|
|
53
|
+
return next();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
// Create invoice via AIPP
|
|
58
|
+
const charge = await options.client.createCharge({
|
|
59
|
+
amountSats: options.amountSats,
|
|
60
|
+
amountUsd: options.amountUsd,
|
|
61
|
+
memo: `L402 Payment for ${options.resourceId}`
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Issue JWT
|
|
65
|
+
const expiresIn = options.expiresInSeconds || 3600; // default 1 hour
|
|
66
|
+
const payload = {
|
|
67
|
+
payment_hash: charge.payment_hash,
|
|
68
|
+
resource_id: options.resourceId,
|
|
69
|
+
exp: Math.floor(Date.now() / 1000) + expiresIn
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const jwtToken = signJwt(payload, options.jwtSecret);
|
|
73
|
+
|
|
74
|
+
res.status(402);
|
|
75
|
+
res.setHeader('Www-Authenticate', `L402 macaroon="${jwtToken}" invoice="${charge.payment_request}"`);
|
|
76
|
+
res.json({
|
|
77
|
+
error: "Payment Required",
|
|
78
|
+
code: "L402",
|
|
79
|
+
payment_request: charge.payment_request,
|
|
80
|
+
macaroon: jwtToken
|
|
81
|
+
});
|
|
82
|
+
} catch (err: any) {
|
|
83
|
+
res.status(500).json({ error: "Failed to generate L402 challenge", details: err.message || err });
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface X402Options {
|
|
89
|
+
client: Aipp;
|
|
90
|
+
resourceId: string;
|
|
91
|
+
amountUsd: number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function x402Paywall(options: X402Options) {
|
|
95
|
+
return async (req: any, res: any, next: any) => {
|
|
96
|
+
const authHeader = req.headers.authorization || req.headers.Authorization;
|
|
97
|
+
let txHash = (req.query.tx_hash || req.headers['payment-signature'] || req.headers['x-payment-signature']) as string;
|
|
98
|
+
let paymentHash = (req.query.payment_hash || req.headers['x-payment-hash']) as string;
|
|
99
|
+
|
|
100
|
+
if (!txHash && authHeader && typeof authHeader === 'string') {
|
|
101
|
+
if (authHeader.startsWith('Bearer ')) {
|
|
102
|
+
txHash = authHeader.substring(7).trim();
|
|
103
|
+
} else if (authHeader.startsWith('x402 ')) {
|
|
104
|
+
txHash = authHeader.substring(5).trim();
|
|
105
|
+
} else if (authHeader.startsWith('L402 ')) {
|
|
106
|
+
// Fallback or double format check
|
|
107
|
+
const parts = authHeader.substring(5).split(':');
|
|
108
|
+
if (parts.length === 2) {
|
|
109
|
+
paymentHash = parts[0];
|
|
110
|
+
txHash = parts[1];
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (paymentHash && txHash) {
|
|
116
|
+
try {
|
|
117
|
+
const chargeStatus = await options.client.getCharge(paymentHash, txHash);
|
|
118
|
+
if (chargeStatus.status === 'settled') {
|
|
119
|
+
return next();
|
|
120
|
+
}
|
|
121
|
+
} catch (e) {
|
|
122
|
+
// Validation failed, proceed to challenge
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const charge = await options.client.createCharge({
|
|
128
|
+
amountUsd: options.amountUsd,
|
|
129
|
+
protocol: 'x402',
|
|
130
|
+
memo: `x402 Payment for ${options.resourceId}`
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const challengeObj = {
|
|
134
|
+
scheme: 'exact',
|
|
135
|
+
network: 'base',
|
|
136
|
+
payTo: charge.pay_to || '',
|
|
137
|
+
price: options.amountUsd.toFixed(2),
|
|
138
|
+
token: charge.token || '',
|
|
139
|
+
payment_hash: charge.payment_hash
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const challengeBase64 = Buffer.from(JSON.stringify(challengeObj), 'utf8').toString('base64');
|
|
143
|
+
res.setHeader('PAYMENT-REQUIRED', challengeBase64);
|
|
144
|
+
|
|
145
|
+
res.status(402);
|
|
146
|
+
res.json({
|
|
147
|
+
error: "Payment Required",
|
|
148
|
+
code: "x402",
|
|
149
|
+
payment_hash: charge.payment_hash,
|
|
150
|
+
pay_to: challengeObj.payTo,
|
|
151
|
+
price: challengeObj.price,
|
|
152
|
+
token: challengeObj.token,
|
|
153
|
+
network: challengeObj.network
|
|
154
|
+
});
|
|
155
|
+
} catch (err: any) {
|
|
156
|
+
res.status(500).json({ error: "Failed to generate x402 challenge", details: err.message || err });
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
}
|