@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 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
- balanceUsd: number;
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
- * Checks for a valid billing session token in the `Authorization` header.
44
- * If missing or invalid, returns 402 Payment Required with a checkout URL.
45
- * If valid, meters the usage and passes control to your handler.
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
- * Checks for a valid billing session token in the `Authorization` header.
23
- * If missing or invalid, returns 402 Payment Required with a checkout URL.
24
- * If valid, meters the usage and passes control to your handler.
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 apiKey = config.apiKey || process.env.METR_API_KEY;
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
- // Extract session token from Authorization header
35
- const authHeader = req.headers.authorization;
36
- const sessionToken = authHeader?.startsWith('Bearer metr_sess_')
37
- ? authHeader.slice(7)
38
- : null;
39
- if (!sessionToken) {
40
- // No valid session — return 402 with checkout URL
41
- try {
42
- const checkoutRes = await fetch(`${gatewayUrl}/api/v1/checkout/create`, {
43
- method: 'POST',
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 session is valid
152
+ // Verify JWT locally (Ed25519 — no network call)
153
+ let claims;
68
154
  try {
69
- const verifyRes = await fetch(`${gatewayUrl}/api/v1/verify`, {
70
- method: 'POST',
71
- headers: {
72
- 'Content-Type': 'application/json',
73
- 'X-Metr-Api-Key': apiKey,
74
- },
75
- body: JSON.stringify({ session_token: sessionToken }),
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
- const session = await verifyRes.json();
78
- if (!session.valid) {
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: 'session_expired',
81
- message: 'Your billing session has expired. Please create a new checkout.',
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
- // Attach payment info to request
86
- req.metr = {
87
- sessionToken,
88
- buyerId: session.buyer_id || '',
89
- balanceUsd: session.remaining_balance_usd || 0,
90
- };
91
- // Let the handler execute
92
- next();
93
- // After handler completes, record usage (fire and forget)
94
- fetch(`${gatewayUrl}/api/v1/meter`, {
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
- session_token: sessionToken,
228
+ endpoint_id: resolved.endpointId,
229
+ buyer_token_sig: tokenSig,
230
+ buyer_id: claims.sub,
103
231
  units: 1,
104
- unit_type: config.unitType || 'request',
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.1.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": ["metr", "api", "monetization", "metering", "billing", "stripe", "middleware", "express"],
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
- balanceUsd: number;
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
- * Checks for a valid billing session token in the `Authorization` header.
55
- * If missing or invalid, returns 402 Payment Required with a checkout URL.
56
- * If valid, meters the usage and passes control to your handler.
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 apiKey = config.apiKey || process.env.METR_API_KEY;
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
- // Extract session token from Authorization header
69
- const authHeader = req.headers.authorization;
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
- const checkout = await checkoutRes.json() as { checkout_url: string; session_token: string };
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: 'payment_required',
93
- message: 'This API requires payment. Complete checkout to get access.',
94
- checkout_url: checkout.checkout_url,
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
- } catch (err) {
99
- res.status(500).json({ error: 'Failed to create checkout session' });
230
+ return;
100
231
  }
101
- return;
102
232
  }
103
233
 
104
- // Verify session is valid
105
- try {
106
- const verifyRes = await fetch(`${gatewayUrl}/api/v1/verify`, {
107
- method: 'POST',
108
- headers: {
109
- 'Content-Type': 'application/json',
110
- 'X-Metr-Api-Key': apiKey,
111
- },
112
- body: JSON.stringify({ session_token: sessionToken }),
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 session = await verifyRes.json() as {
116
- valid: boolean;
117
- buyer_id?: string;
118
- remaining_balance_usd?: number;
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 (!session.valid) {
122
- res.status(402).json({
123
- error: 'session_expired',
124
- message: 'Your billing session has expired. Please create a new checkout.',
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
- // Attach payment info to request
130
- req.metr = {
131
- sessionToken,
132
- buyerId: session.buyer_id || '',
133
- balanceUsd: session.remaining_balance_usd || 0,
134
- };
262
+ // Attach buyer info to request
263
+ req.metr = {
264
+ buyerId: claims.sub,
265
+ tokenIssuedAt: claims.iat,
266
+ };
135
267
 
136
- // Let the handler execute
137
- next();
268
+ // Run the handler
269
+ next();
138
270
 
139
- // After handler completes, record usage (fire and forget)
140
- fetch(`${gatewayUrl}/api/v1/meter`, {
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
- session_token: sessionToken,
286
+ endpoint_id: resolved.endpointId,
287
+ buyer_token_sig: tokenSig,
288
+ buyer_id: claims.sub,
149
289
  units: 1,
150
- unit_type: config.unitType || 'request',
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
- } catch (err) {
156
- res.status(500).json({ error: 'Failed to verify session' });
157
- }
296
+ });
158
297
  };
159
298
  }
160
299