@metr-sdk/express 0.1.0 → 0.2.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/dist/index.d.ts +13 -7
- package/dist/index.js +199 -73
- package/package.json +11 -2
- package/src/index.ts +219 -80
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @metr/express - One line of middleware to monetize any Express.js API
|
|
2
|
+
* @metr-sdk/express - One line of middleware to monetize any Express.js API
|
|
3
3
|
*
|
|
4
4
|
* @example
|
|
5
5
|
* ```typescript
|
|
6
|
-
* import { metr } from '@metr/express';
|
|
6
|
+
* import { metr } from '@metr-sdk/express';
|
|
7
7
|
*
|
|
8
8
|
* app.post('/api/summarize', metr({ price: 0.001 }), (req, res) => {
|
|
9
9
|
* res.json({ summary: 'Your text summary...' });
|
|
@@ -24,11 +24,17 @@ export interface MetrConfig {
|
|
|
24
24
|
endpointId?: string;
|
|
25
25
|
/** metr gateway URL. Falls back to METR_GATEWAY_URL env var */
|
|
26
26
|
gatewayUrl?: string;
|
|
27
|
+
/** Ed25519 public key (base64). Falls back to METR_PUBLIC_KEY env var.
|
|
28
|
+
* If not set, fetched from gateway on first request. */
|
|
29
|
+
publicKey?: string;
|
|
30
|
+
/** Max token age in seconds. Reject tokens older than this even if not expired. */
|
|
31
|
+
maxTokenAge?: number;
|
|
32
|
+
/** Require IP whitelist in JWT. If true, requests without ips claim are rejected. */
|
|
33
|
+
requireIp?: boolean;
|
|
27
34
|
}
|
|
28
35
|
export interface MetrPaymentInfo {
|
|
29
|
-
sessionToken: string;
|
|
30
36
|
buyerId: string;
|
|
31
|
-
|
|
37
|
+
tokenIssuedAt: number;
|
|
32
38
|
}
|
|
33
39
|
declare global {
|
|
34
40
|
namespace Express {
|
|
@@ -40,9 +46,9 @@ declare global {
|
|
|
40
46
|
/**
|
|
41
47
|
* metr middleware - add to any Express route to require payment.
|
|
42
48
|
*
|
|
43
|
-
*
|
|
44
|
-
* If missing
|
|
45
|
-
*
|
|
49
|
+
* Verifies buyer JWT locally (Ed25519, ~0.05ms, zero network calls).
|
|
50
|
+
* If missing/invalid, returns 402 Payment Required with deposit URL.
|
|
51
|
+
* After handler completes, fires usage event to gateway (async, non-blocking).
|
|
46
52
|
*/
|
|
47
53
|
export declare function metr(config: MetrConfig): (req: Request, res: Response, next: NextFunction) => Promise<void>;
|
|
48
54
|
export default metr;
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
* @metr/express - One line of middleware to monetize any Express.js API
|
|
3
|
+
* @metr-sdk/express - One line of middleware to monetize any Express.js API
|
|
4
4
|
*
|
|
5
5
|
* @example
|
|
6
6
|
* ```typescript
|
|
7
|
-
* import { metr } from '@metr/express';
|
|
7
|
+
* import { metr } from '@metr-sdk/express';
|
|
8
8
|
*
|
|
9
9
|
* app.post('/api/summarize', metr({ price: 0.001 }), (req, res) => {
|
|
10
10
|
* res.json({ summary: 'Your text summary...' });
|
|
@@ -13,103 +13,229 @@
|
|
|
13
13
|
*
|
|
14
14
|
* @packageDocumentation
|
|
15
15
|
*/
|
|
16
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
17
|
+
if (k2 === undefined) k2 = k;
|
|
18
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
19
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
20
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
21
|
+
}
|
|
22
|
+
Object.defineProperty(o, k2, desc);
|
|
23
|
+
}) : (function(o, m, k, k2) {
|
|
24
|
+
if (k2 === undefined) k2 = k;
|
|
25
|
+
o[k2] = m[k];
|
|
26
|
+
}));
|
|
27
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
28
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
29
|
+
}) : function(o, v) {
|
|
30
|
+
o["default"] = v;
|
|
31
|
+
});
|
|
32
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
33
|
+
var ownKeys = function(o) {
|
|
34
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
35
|
+
var ar = [];
|
|
36
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
37
|
+
return ar;
|
|
38
|
+
};
|
|
39
|
+
return ownKeys(o);
|
|
40
|
+
};
|
|
41
|
+
return function (mod) {
|
|
42
|
+
if (mod && mod.__esModule) return mod;
|
|
43
|
+
var result = {};
|
|
44
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
45
|
+
__setModuleDefault(result, mod);
|
|
46
|
+
return result;
|
|
47
|
+
};
|
|
48
|
+
})();
|
|
16
49
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
50
|
exports.metr = metr;
|
|
51
|
+
const crypto_1 = require("crypto");
|
|
52
|
+
let cachedPublicKey = null;
|
|
53
|
+
let publicKeyFetchPromise = null;
|
|
54
|
+
async function getPublicKey(config) {
|
|
55
|
+
if (cachedPublicKey)
|
|
56
|
+
return cachedPublicKey;
|
|
57
|
+
if (config.publicKey) {
|
|
58
|
+
cachedPublicKey = base64UrlDecode(config.publicKey);
|
|
59
|
+
return cachedPublicKey;
|
|
60
|
+
}
|
|
61
|
+
// Fetch from gateway (once)
|
|
62
|
+
if (!publicKeyFetchPromise) {
|
|
63
|
+
publicKeyFetchPromise = (async () => {
|
|
64
|
+
const res = await fetch(`${config.gatewayUrl}/.well-known/metr.json`);
|
|
65
|
+
const data = await res.json();
|
|
66
|
+
cachedPublicKey = base64UrlDecode(data.public_key);
|
|
67
|
+
})();
|
|
68
|
+
}
|
|
69
|
+
await publicKeyFetchPromise;
|
|
70
|
+
return cachedPublicKey;
|
|
71
|
+
}
|
|
72
|
+
function base64UrlDecode(str) {
|
|
73
|
+
const padded = str.replace(/-/g, '+').replace(/_/g, '/');
|
|
74
|
+
const binary = Buffer.from(padded, 'base64');
|
|
75
|
+
return new Uint8Array(binary);
|
|
76
|
+
}
|
|
77
|
+
async function verifyJwt(token, publicKey) {
|
|
78
|
+
const parts = token.split('.');
|
|
79
|
+
if (parts.length !== 3)
|
|
80
|
+
throw new Error('invalid JWT format');
|
|
81
|
+
const message = `${parts[0]}.${parts[1]}`;
|
|
82
|
+
const signatureBytes = base64UrlDecode(parts[2]);
|
|
83
|
+
// Ed25519 verify using Node.js crypto
|
|
84
|
+
const { createPublicKey, verify } = await Promise.resolve().then(() => __importStar(require('crypto')));
|
|
85
|
+
const key = createPublicKey({
|
|
86
|
+
key: Buffer.concat([
|
|
87
|
+
// Ed25519 DER prefix for public key
|
|
88
|
+
Buffer.from('302a300506032b6570032100', 'hex'),
|
|
89
|
+
Buffer.from(publicKey),
|
|
90
|
+
]),
|
|
91
|
+
format: 'der',
|
|
92
|
+
type: 'spki',
|
|
93
|
+
});
|
|
94
|
+
const isValid = verify(null, Buffer.from(message), key, Buffer.from(signatureBytes));
|
|
95
|
+
if (!isValid)
|
|
96
|
+
throw new Error('invalid signature');
|
|
97
|
+
// Decode claims
|
|
98
|
+
const payloadJson = Buffer.from(base64UrlDecode(parts[1])).toString('utf-8');
|
|
99
|
+
const claims = JSON.parse(payloadJson);
|
|
100
|
+
// Check expiry
|
|
101
|
+
const now = Math.floor(Date.now() / 1000);
|
|
102
|
+
if (now > claims.exp)
|
|
103
|
+
throw new Error('token expired');
|
|
104
|
+
return claims;
|
|
105
|
+
}
|
|
106
|
+
function extractTokenSig(token) {
|
|
107
|
+
const parts = token.split('.');
|
|
108
|
+
if (parts.length !== 3)
|
|
109
|
+
return '';
|
|
110
|
+
const sigBytes = base64UrlDecode(parts[2]);
|
|
111
|
+
return Buffer.from(sigBytes.slice(0, 16)).toString('hex');
|
|
112
|
+
}
|
|
18
113
|
const DEFAULT_GATEWAY_URL = 'https://api.metr.dev';
|
|
114
|
+
function resolveConfig(config) {
|
|
115
|
+
const apiKey = config.apiKey || process.env.METR_API_KEY;
|
|
116
|
+
if (!apiKey)
|
|
117
|
+
throw new Error('metr: API key required. Set config.apiKey or METR_API_KEY env var.');
|
|
118
|
+
return {
|
|
119
|
+
price: config.price,
|
|
120
|
+
unitType: config.unitType || 'request',
|
|
121
|
+
apiKey,
|
|
122
|
+
endpointId: config.endpointId || process.env.METR_ENDPOINT_ID || '',
|
|
123
|
+
gatewayUrl: config.gatewayUrl || process.env.METR_GATEWAY_URL || DEFAULT_GATEWAY_URL,
|
|
124
|
+
publicKey: config.publicKey || process.env.METR_PUBLIC_KEY,
|
|
125
|
+
maxTokenAge: config.maxTokenAge,
|
|
126
|
+
requireIp: config.requireIp || false,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
// ── Middleware ──
|
|
19
130
|
/**
|
|
20
131
|
* metr middleware - add to any Express route to require payment.
|
|
21
132
|
*
|
|
22
|
-
*
|
|
23
|
-
* If missing
|
|
24
|
-
*
|
|
133
|
+
* Verifies buyer JWT locally (Ed25519, ~0.05ms, zero network calls).
|
|
134
|
+
* If missing/invalid, returns 402 Payment Required with deposit URL.
|
|
135
|
+
* After handler completes, fires usage event to gateway (async, non-blocking).
|
|
25
136
|
*/
|
|
26
137
|
function metr(config) {
|
|
27
|
-
const
|
|
28
|
-
const endpointId = config.endpointId || process.env.METR_ENDPOINT_ID;
|
|
29
|
-
const gatewayUrl = config.gatewayUrl || process.env.METR_GATEWAY_URL || DEFAULT_GATEWAY_URL;
|
|
30
|
-
if (!apiKey) {
|
|
31
|
-
throw new Error('metr: API key required. Set config.apiKey or METR_API_KEY env var.');
|
|
32
|
-
}
|
|
138
|
+
const resolved = resolveConfig(config);
|
|
33
139
|
return async (req, res, next) => {
|
|
34
|
-
//
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
headers: {
|
|
45
|
-
'Content-Type': 'application/json',
|
|
46
|
-
'X-Metr-Api-Key': apiKey,
|
|
47
|
-
},
|
|
48
|
-
body: JSON.stringify({
|
|
49
|
-
endpoint_id: endpointId,
|
|
50
|
-
amount_usd: config.price,
|
|
51
|
-
}),
|
|
52
|
-
});
|
|
53
|
-
const checkout = await checkoutRes.json();
|
|
54
|
-
res.status(402).json({
|
|
55
|
-
error: 'payment_required',
|
|
56
|
-
message: 'This API requires payment. Complete checkout to get access.',
|
|
57
|
-
checkout_url: checkout.checkout_url,
|
|
58
|
-
price: config.price,
|
|
59
|
-
unit_type: config.unitType || 'request',
|
|
60
|
-
});
|
|
61
|
-
}
|
|
62
|
-
catch (err) {
|
|
63
|
-
res.status(500).json({ error: 'Failed to create checkout session' });
|
|
64
|
-
}
|
|
140
|
+
// Read X-Metr-Token header (never Authorization)
|
|
141
|
+
const token = req.headers['x-metr-token'];
|
|
142
|
+
if (!token) {
|
|
143
|
+
res.status(402).json({
|
|
144
|
+
error: 'payment_required',
|
|
145
|
+
message: 'This API requires payment. Deposit funds at metr.dev to get a token.',
|
|
146
|
+
deposit_url: `${resolved.gatewayUrl}/api/v1/wallet/deposit`,
|
|
147
|
+
price: resolved.price,
|
|
148
|
+
unit_type: resolved.unitType,
|
|
149
|
+
});
|
|
65
150
|
return;
|
|
66
151
|
}
|
|
67
|
-
// Verify
|
|
152
|
+
// Verify JWT locally (Ed25519 — no network call)
|
|
153
|
+
let claims;
|
|
68
154
|
try {
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
155
|
+
const publicKey = await getPublicKey(resolved);
|
|
156
|
+
claims = await verifyJwt(token, publicKey);
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
const isExpired = err.message === 'token expired';
|
|
160
|
+
res.status(402).json({
|
|
161
|
+
error: isExpired ? 'token_expired' : 'invalid_token',
|
|
162
|
+
message: isExpired
|
|
163
|
+
? 'Your token has expired. Refresh it at the metr gateway.'
|
|
164
|
+
: 'Invalid metr token.',
|
|
165
|
+
refresh_url: `${resolved.gatewayUrl}/api/v1/token/refresh`,
|
|
76
166
|
});
|
|
77
|
-
|
|
78
|
-
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
// Check maxTokenAge (provider can enforce shorter lifetime than JWT exp)
|
|
170
|
+
if (resolved.maxTokenAge) {
|
|
171
|
+
const now = Math.floor(Date.now() / 1000);
|
|
172
|
+
const tokenAge = now - claims.iat;
|
|
173
|
+
if (tokenAge > resolved.maxTokenAge) {
|
|
79
174
|
res.status(402).json({
|
|
80
|
-
error: '
|
|
81
|
-
message:
|
|
175
|
+
error: 'token_too_old',
|
|
176
|
+
message: `Token is ${tokenAge}s old. This endpoint requires tokens < ${resolved.maxTokenAge}s.`,
|
|
177
|
+
refresh_url: `${resolved.gatewayUrl}/api/v1/token/refresh`,
|
|
82
178
|
});
|
|
83
179
|
return;
|
|
84
180
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
181
|
+
}
|
|
182
|
+
// Check IP whitelist
|
|
183
|
+
if (resolved.requireIp) {
|
|
184
|
+
if (!claims.ips || claims.ips.length === 0) {
|
|
185
|
+
res.status(403).json({
|
|
186
|
+
error: 'ip_required',
|
|
187
|
+
message: 'This endpoint requires IP-bound tokens. Issue a token with ips claim.',
|
|
188
|
+
});
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const clientIp = req.ip || req.socket.remoteAddress || '';
|
|
192
|
+
const allowed = claims.ips.some(ip => {
|
|
193
|
+
if (ip.includes('/')) {
|
|
194
|
+
// CIDR check (simplified — exact match for now)
|
|
195
|
+
return clientIp.startsWith(ip.split('/')[0].split('.').slice(0, 3).join('.'));
|
|
196
|
+
}
|
|
197
|
+
return ip === clientIp;
|
|
198
|
+
});
|
|
199
|
+
if (!allowed) {
|
|
200
|
+
res.status(403).json({
|
|
201
|
+
error: 'ip_not_allowed',
|
|
202
|
+
message: `Request from ${clientIp} not in token's IP whitelist.`,
|
|
203
|
+
});
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// Attach buyer info to request
|
|
208
|
+
req.metr = {
|
|
209
|
+
buyerId: claims.sub,
|
|
210
|
+
tokenIssuedAt: claims.iat,
|
|
211
|
+
};
|
|
212
|
+
// Run the handler
|
|
213
|
+
next();
|
|
214
|
+
// Post-response: fire-and-forget usage metering
|
|
215
|
+
const tokenSig = extractTokenSig(token);
|
|
216
|
+
const minuteBucket = new Date().toISOString().slice(0, 16).replace(/[-:T]/g, '');
|
|
217
|
+
const idempotencyKey = (0, crypto_1.createHash)('sha256')
|
|
218
|
+
.update(`${tokenSig}${resolved.endpointId}${minuteBucket}`)
|
|
219
|
+
.digest('hex');
|
|
220
|
+
res.on('finish', () => {
|
|
221
|
+
fetch(`${resolved.gatewayUrl}/api/v1/meter`, {
|
|
95
222
|
method: 'POST',
|
|
96
223
|
headers: {
|
|
97
224
|
'Content-Type': 'application/json',
|
|
98
|
-
'X-Metr-Api-Key': apiKey,
|
|
225
|
+
'X-Metr-Api-Key': resolved.apiKey,
|
|
99
226
|
},
|
|
100
227
|
body: JSON.stringify({
|
|
101
|
-
endpoint_id: endpointId,
|
|
102
|
-
|
|
228
|
+
endpoint_id: resolved.endpointId,
|
|
229
|
+
buyer_token_sig: tokenSig,
|
|
230
|
+
buyer_id: claims.sub,
|
|
103
231
|
units: 1,
|
|
104
|
-
unit_type:
|
|
232
|
+
unit_type: resolved.unitType,
|
|
233
|
+
idempotency_key: idempotencyKey,
|
|
105
234
|
}),
|
|
106
235
|
}).catch((err) => {
|
|
107
|
-
console.error('[metr] Failed to record usage:', err);
|
|
236
|
+
console.error('[metr] Failed to record usage:', err.message);
|
|
108
237
|
});
|
|
109
|
-
}
|
|
110
|
-
catch (err) {
|
|
111
|
-
res.status(500).json({ error: 'Failed to verify session' });
|
|
112
|
-
}
|
|
238
|
+
});
|
|
113
239
|
};
|
|
114
240
|
}
|
|
115
241
|
exports.default = metr;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@metr-sdk/express",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "One line of middleware to monetize any Express.js API",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -10,7 +10,16 @@
|
|
|
10
10
|
"dev": "tsc --watch",
|
|
11
11
|
"prepublishOnly": "npm run build"
|
|
12
12
|
},
|
|
13
|
-
"keywords": [
|
|
13
|
+
"keywords": [
|
|
14
|
+
"metr",
|
|
15
|
+
"api",
|
|
16
|
+
"monetization",
|
|
17
|
+
"metering",
|
|
18
|
+
"billing",
|
|
19
|
+
"stripe",
|
|
20
|
+
"middleware",
|
|
21
|
+
"express"
|
|
22
|
+
],
|
|
14
23
|
"license": "MIT",
|
|
15
24
|
"peerDependencies": {
|
|
16
25
|
"express": "^4.0.0 || ^5.0.0"
|
package/src/index.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @metr/express - One line of middleware to monetize any Express.js API
|
|
2
|
+
* @metr-sdk/express - One line of middleware to monetize any Express.js API
|
|
3
3
|
*
|
|
4
4
|
* @example
|
|
5
5
|
* ```typescript
|
|
6
|
-
* import { metr } from '@metr/express';
|
|
6
|
+
* import { metr } from '@metr-sdk/express';
|
|
7
7
|
*
|
|
8
8
|
* app.post('/api/summarize', metr({ price: 0.001 }), (req, res) => {
|
|
9
9
|
* res.json({ summary: 'Your text summary...' });
|
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { Request, Response, NextFunction } from 'express';
|
|
17
|
+
import { createHash } from 'crypto';
|
|
18
|
+
|
|
19
|
+
// ── Configuration ──
|
|
17
20
|
|
|
18
21
|
export interface MetrConfig {
|
|
19
22
|
/** Price per unit in USD (e.g., 0.001 = $0.001 per request) */
|
|
@@ -30,12 +33,21 @@ export interface MetrConfig {
|
|
|
30
33
|
|
|
31
34
|
/** metr gateway URL. Falls back to METR_GATEWAY_URL env var */
|
|
32
35
|
gatewayUrl?: string;
|
|
36
|
+
|
|
37
|
+
/** Ed25519 public key (base64). Falls back to METR_PUBLIC_KEY env var.
|
|
38
|
+
* If not set, fetched from gateway on first request. */
|
|
39
|
+
publicKey?: string;
|
|
40
|
+
|
|
41
|
+
/** Max token age in seconds. Reject tokens older than this even if not expired. */
|
|
42
|
+
maxTokenAge?: number;
|
|
43
|
+
|
|
44
|
+
/** Require IP whitelist in JWT. If true, requests without ips claim are rejected. */
|
|
45
|
+
requireIp?: boolean;
|
|
33
46
|
}
|
|
34
47
|
|
|
35
48
|
export interface MetrPaymentInfo {
|
|
36
|
-
sessionToken: string;
|
|
37
49
|
buyerId: string;
|
|
38
|
-
|
|
50
|
+
tokenIssuedAt: number;
|
|
39
51
|
}
|
|
40
52
|
|
|
41
53
|
declare global {
|
|
@@ -46,115 +58,242 @@ declare global {
|
|
|
46
58
|
}
|
|
47
59
|
}
|
|
48
60
|
|
|
61
|
+
// ── Ed25519 JWT Verification (local, no network) ──
|
|
62
|
+
|
|
63
|
+
interface JwtClaims {
|
|
64
|
+
sub: string;
|
|
65
|
+
exp: number;
|
|
66
|
+
iat: number;
|
|
67
|
+
ips?: string[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let cachedPublicKey: Uint8Array | null = null;
|
|
71
|
+
let publicKeyFetchPromise: Promise<void> | null = null;
|
|
72
|
+
|
|
73
|
+
async function getPublicKey(config: ResolvedConfig): Promise<Uint8Array> {
|
|
74
|
+
if (cachedPublicKey) return cachedPublicKey;
|
|
75
|
+
|
|
76
|
+
if (config.publicKey) {
|
|
77
|
+
cachedPublicKey = base64UrlDecode(config.publicKey);
|
|
78
|
+
return cachedPublicKey;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Fetch from gateway (once)
|
|
82
|
+
if (!publicKeyFetchPromise) {
|
|
83
|
+
publicKeyFetchPromise = (async () => {
|
|
84
|
+
const res = await fetch(`${config.gatewayUrl}/.well-known/metr.json`);
|
|
85
|
+
const data = await res.json() as { public_key: string };
|
|
86
|
+
cachedPublicKey = base64UrlDecode(data.public_key);
|
|
87
|
+
})();
|
|
88
|
+
}
|
|
89
|
+
await publicKeyFetchPromise;
|
|
90
|
+
return cachedPublicKey!;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function base64UrlDecode(str: string): Uint8Array {
|
|
94
|
+
const padded = str.replace(/-/g, '+').replace(/_/g, '/');
|
|
95
|
+
const binary = Buffer.from(padded, 'base64');
|
|
96
|
+
return new Uint8Array(binary);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function verifyJwt(token: string, publicKey: Uint8Array): Promise<JwtClaims> {
|
|
100
|
+
const parts = token.split('.');
|
|
101
|
+
if (parts.length !== 3) throw new Error('invalid JWT format');
|
|
102
|
+
|
|
103
|
+
const message = `${parts[0]}.${parts[1]}`;
|
|
104
|
+
const signatureBytes = base64UrlDecode(parts[2]);
|
|
105
|
+
|
|
106
|
+
// Ed25519 verify using Node.js crypto
|
|
107
|
+
const { createPublicKey, verify } = await import('crypto');
|
|
108
|
+
const key = createPublicKey({
|
|
109
|
+
key: Buffer.concat([
|
|
110
|
+
// Ed25519 DER prefix for public key
|
|
111
|
+
Buffer.from('302a300506032b6570032100', 'hex'),
|
|
112
|
+
Buffer.from(publicKey),
|
|
113
|
+
]),
|
|
114
|
+
format: 'der',
|
|
115
|
+
type: 'spki',
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const isValid = verify(
|
|
119
|
+
null,
|
|
120
|
+
Buffer.from(message),
|
|
121
|
+
key,
|
|
122
|
+
Buffer.from(signatureBytes),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
if (!isValid) throw new Error('invalid signature');
|
|
126
|
+
|
|
127
|
+
// Decode claims
|
|
128
|
+
const payloadJson = Buffer.from(base64UrlDecode(parts[1])).toString('utf-8');
|
|
129
|
+
const claims: JwtClaims = JSON.parse(payloadJson);
|
|
130
|
+
|
|
131
|
+
// Check expiry
|
|
132
|
+
const now = Math.floor(Date.now() / 1000);
|
|
133
|
+
if (now > claims.exp) throw new Error('token expired');
|
|
134
|
+
|
|
135
|
+
return claims;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function extractTokenSig(token: string): string {
|
|
139
|
+
const parts = token.split('.');
|
|
140
|
+
if (parts.length !== 3) return '';
|
|
141
|
+
const sigBytes = base64UrlDecode(parts[2]);
|
|
142
|
+
return Buffer.from(sigBytes.slice(0, 16)).toString('hex');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── Resolved Config ──
|
|
146
|
+
|
|
147
|
+
interface ResolvedConfig {
|
|
148
|
+
price: number;
|
|
149
|
+
unitType: string;
|
|
150
|
+
apiKey: string;
|
|
151
|
+
endpointId: string;
|
|
152
|
+
gatewayUrl: string;
|
|
153
|
+
publicKey?: string;
|
|
154
|
+
maxTokenAge?: number;
|
|
155
|
+
requireIp: boolean;
|
|
156
|
+
}
|
|
157
|
+
|
|
49
158
|
const DEFAULT_GATEWAY_URL = 'https://api.metr.dev';
|
|
50
159
|
|
|
160
|
+
function resolveConfig(config: MetrConfig): ResolvedConfig {
|
|
161
|
+
const apiKey = config.apiKey || process.env.METR_API_KEY;
|
|
162
|
+
if (!apiKey) throw new Error('metr: API key required. Set config.apiKey or METR_API_KEY env var.');
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
price: config.price,
|
|
166
|
+
unitType: config.unitType || 'request',
|
|
167
|
+
apiKey,
|
|
168
|
+
endpointId: config.endpointId || process.env.METR_ENDPOINT_ID || '',
|
|
169
|
+
gatewayUrl: config.gatewayUrl || process.env.METR_GATEWAY_URL || DEFAULT_GATEWAY_URL,
|
|
170
|
+
publicKey: config.publicKey || process.env.METR_PUBLIC_KEY,
|
|
171
|
+
maxTokenAge: config.maxTokenAge,
|
|
172
|
+
requireIp: config.requireIp || false,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── Middleware ──
|
|
177
|
+
|
|
51
178
|
/**
|
|
52
179
|
* metr middleware - add to any Express route to require payment.
|
|
53
180
|
*
|
|
54
|
-
*
|
|
55
|
-
* If missing
|
|
56
|
-
*
|
|
181
|
+
* Verifies buyer JWT locally (Ed25519, ~0.05ms, zero network calls).
|
|
182
|
+
* If missing/invalid, returns 402 Payment Required with deposit URL.
|
|
183
|
+
* After handler completes, fires usage event to gateway (async, non-blocking).
|
|
57
184
|
*/
|
|
58
185
|
export function metr(config: MetrConfig) {
|
|
59
|
-
const
|
|
60
|
-
const endpointId = config.endpointId || process.env.METR_ENDPOINT_ID;
|
|
61
|
-
const gatewayUrl = config.gatewayUrl || process.env.METR_GATEWAY_URL || DEFAULT_GATEWAY_URL;
|
|
62
|
-
|
|
63
|
-
if (!apiKey) {
|
|
64
|
-
throw new Error('metr: API key required. Set config.apiKey or METR_API_KEY env var.');
|
|
65
|
-
}
|
|
186
|
+
const resolved = resolveConfig(config);
|
|
66
187
|
|
|
67
188
|
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
|
68
|
-
//
|
|
69
|
-
const
|
|
70
|
-
const sessionToken = authHeader?.startsWith('Bearer metr_sess_')
|
|
71
|
-
? authHeader.slice(7)
|
|
72
|
-
: null;
|
|
73
|
-
|
|
74
|
-
if (!sessionToken) {
|
|
75
|
-
// No valid session — return 402 with checkout URL
|
|
76
|
-
try {
|
|
77
|
-
const checkoutRes = await fetch(`${gatewayUrl}/api/v1/checkout/create`, {
|
|
78
|
-
method: 'POST',
|
|
79
|
-
headers: {
|
|
80
|
-
'Content-Type': 'application/json',
|
|
81
|
-
'X-Metr-Api-Key': apiKey,
|
|
82
|
-
},
|
|
83
|
-
body: JSON.stringify({
|
|
84
|
-
endpoint_id: endpointId,
|
|
85
|
-
amount_usd: config.price,
|
|
86
|
-
}),
|
|
87
|
-
});
|
|
189
|
+
// Read X-Metr-Token header (never Authorization)
|
|
190
|
+
const token = req.headers['x-metr-token'] as string | undefined;
|
|
88
191
|
|
|
89
|
-
|
|
192
|
+
if (!token) {
|
|
193
|
+
res.status(402).json({
|
|
194
|
+
error: 'payment_required',
|
|
195
|
+
message: 'This API requires payment. Deposit funds at metr.dev to get a token.',
|
|
196
|
+
deposit_url: `${resolved.gatewayUrl}/api/v1/wallet/deposit`,
|
|
197
|
+
price: resolved.price,
|
|
198
|
+
unit_type: resolved.unitType,
|
|
199
|
+
});
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Verify JWT locally (Ed25519 — no network call)
|
|
204
|
+
let claims: JwtClaims;
|
|
205
|
+
try {
|
|
206
|
+
const publicKey = await getPublicKey(resolved);
|
|
207
|
+
claims = await verifyJwt(token, publicKey);
|
|
208
|
+
} catch (err: any) {
|
|
209
|
+
const isExpired = err.message === 'token expired';
|
|
210
|
+
res.status(402).json({
|
|
211
|
+
error: isExpired ? 'token_expired' : 'invalid_token',
|
|
212
|
+
message: isExpired
|
|
213
|
+
? 'Your token has expired. Refresh it at the metr gateway.'
|
|
214
|
+
: 'Invalid metr token.',
|
|
215
|
+
refresh_url: `${resolved.gatewayUrl}/api/v1/token/refresh`,
|
|
216
|
+
});
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
90
219
|
|
|
220
|
+
// Check maxTokenAge (provider can enforce shorter lifetime than JWT exp)
|
|
221
|
+
if (resolved.maxTokenAge) {
|
|
222
|
+
const now = Math.floor(Date.now() / 1000);
|
|
223
|
+
const tokenAge = now - claims.iat;
|
|
224
|
+
if (tokenAge > resolved.maxTokenAge) {
|
|
91
225
|
res.status(402).json({
|
|
92
|
-
error: '
|
|
93
|
-
message:
|
|
94
|
-
|
|
95
|
-
price: config.price,
|
|
96
|
-
unit_type: config.unitType || 'request',
|
|
226
|
+
error: 'token_too_old',
|
|
227
|
+
message: `Token is ${tokenAge}s old. This endpoint requires tokens < ${resolved.maxTokenAge}s.`,
|
|
228
|
+
refresh_url: `${resolved.gatewayUrl}/api/v1/token/refresh`,
|
|
97
229
|
});
|
|
98
|
-
|
|
99
|
-
res.status(500).json({ error: 'Failed to create checkout session' });
|
|
230
|
+
return;
|
|
100
231
|
}
|
|
101
|
-
return;
|
|
102
232
|
}
|
|
103
233
|
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
'
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
});
|
|
234
|
+
// Check IP whitelist
|
|
235
|
+
if (resolved.requireIp) {
|
|
236
|
+
if (!claims.ips || claims.ips.length === 0) {
|
|
237
|
+
res.status(403).json({
|
|
238
|
+
error: 'ip_required',
|
|
239
|
+
message: 'This endpoint requires IP-bound tokens. Issue a token with ips claim.',
|
|
240
|
+
});
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
114
243
|
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
244
|
+
const clientIp = req.ip || req.socket.remoteAddress || '';
|
|
245
|
+
const allowed = claims.ips.some(ip => {
|
|
246
|
+
if (ip.includes('/')) {
|
|
247
|
+
// CIDR check (simplified — exact match for now)
|
|
248
|
+
return clientIp.startsWith(ip.split('/')[0].split('.').slice(0, 3).join('.'));
|
|
249
|
+
}
|
|
250
|
+
return ip === clientIp;
|
|
251
|
+
});
|
|
120
252
|
|
|
121
|
-
if (!
|
|
122
|
-
res.status(
|
|
123
|
-
error: '
|
|
124
|
-
message:
|
|
253
|
+
if (!allowed) {
|
|
254
|
+
res.status(403).json({
|
|
255
|
+
error: 'ip_not_allowed',
|
|
256
|
+
message: `Request from ${clientIp} not in token's IP whitelist.`,
|
|
125
257
|
});
|
|
126
258
|
return;
|
|
127
259
|
}
|
|
260
|
+
}
|
|
128
261
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
};
|
|
262
|
+
// Attach buyer info to request
|
|
263
|
+
req.metr = {
|
|
264
|
+
buyerId: claims.sub,
|
|
265
|
+
tokenIssuedAt: claims.iat,
|
|
266
|
+
};
|
|
135
267
|
|
|
136
|
-
|
|
137
|
-
|
|
268
|
+
// Run the handler
|
|
269
|
+
next();
|
|
138
270
|
|
|
139
|
-
|
|
140
|
-
|
|
271
|
+
// Post-response: fire-and-forget usage metering
|
|
272
|
+
const tokenSig = extractTokenSig(token);
|
|
273
|
+
const minuteBucket = new Date().toISOString().slice(0, 16).replace(/[-:T]/g, '');
|
|
274
|
+
const idempotencyKey = createHash('sha256')
|
|
275
|
+
.update(`${tokenSig}${resolved.endpointId}${minuteBucket}`)
|
|
276
|
+
.digest('hex');
|
|
277
|
+
|
|
278
|
+
res.on('finish', () => {
|
|
279
|
+
fetch(`${resolved.gatewayUrl}/api/v1/meter`, {
|
|
141
280
|
method: 'POST',
|
|
142
281
|
headers: {
|
|
143
282
|
'Content-Type': 'application/json',
|
|
144
|
-
'X-Metr-Api-Key': apiKey,
|
|
283
|
+
'X-Metr-Api-Key': resolved.apiKey,
|
|
145
284
|
},
|
|
146
285
|
body: JSON.stringify({
|
|
147
|
-
endpoint_id: endpointId,
|
|
148
|
-
|
|
286
|
+
endpoint_id: resolved.endpointId,
|
|
287
|
+
buyer_token_sig: tokenSig,
|
|
288
|
+
buyer_id: claims.sub,
|
|
149
289
|
units: 1,
|
|
150
|
-
unit_type:
|
|
290
|
+
unit_type: resolved.unitType,
|
|
291
|
+
idempotency_key: idempotencyKey,
|
|
151
292
|
}),
|
|
152
293
|
}).catch((err) => {
|
|
153
|
-
console.error('[metr] Failed to record usage:', err);
|
|
294
|
+
console.error('[metr] Failed to record usage:', err.message);
|
|
154
295
|
});
|
|
155
|
-
}
|
|
156
|
-
res.status(500).json({ error: 'Failed to verify session' });
|
|
157
|
-
}
|
|
296
|
+
});
|
|
158
297
|
};
|
|
159
298
|
}
|
|
160
299
|
|