taximeter 0.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/CHANGELOG.md +9 -0
- package/CODE_OF_CONDUCT.md +31 -0
- package/CONTRIBUTING.md +141 -0
- package/DECISIONS.md +54 -0
- package/LICENSE +21 -0
- package/README.md +207 -0
- package/SECURITY.md +69 -0
- package/SPEC-NOTES.md +369 -0
- package/SPEC.md +474 -0
- package/VERIFICATION.md +943 -0
- package/dist/chunk-I7YXGTRZ.js +1167 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +494 -0
- package/dist/index.d.ts +404 -0
- package/dist/index.js +182 -0
- package/dist/ui/assets/index-DOE5bD9P.js +101 -0
- package/dist/ui/assets/index-De4zKRDL.css +1 -0
- package/dist/ui/index.html +15 -0
- package/docs/RECORDING.md +75 -0
- package/docs/SDK.md +192 -0
- package/docs/demo.gif +0 -0
- package/docs/demo.mjs +145 -0
- package/docs/demo.tape +15 -0
- package/package.json +79 -0
- package/taximeter.config.example.json +16 -0
|
@@ -0,0 +1,1167 @@
|
|
|
1
|
+
// src/model.ts
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
var integerStringSchema = z.string().regex(/^(0|[1-9][0-9]*)$/);
|
|
4
|
+
var amountSchema = integerStringSchema.max(78);
|
|
5
|
+
var httpUrlSchema = z.url().refine((value) => {
|
|
6
|
+
const url = new URL(value);
|
|
7
|
+
return ["http:", "https:"].includes(url.protocol) && !url.username && !url.password;
|
|
8
|
+
}, "Expected an HTTP(S) URL without credentials");
|
|
9
|
+
var labelSchema = z.string().min(1).max(256);
|
|
10
|
+
var headersSchema = z.record(z.string(), z.string());
|
|
11
|
+
var requestSchema = z.object({
|
|
12
|
+
url: httpUrlSchema,
|
|
13
|
+
method: z.string().min(1).max(64),
|
|
14
|
+
headers: headersSchema
|
|
15
|
+
});
|
|
16
|
+
var responseSchema = z.object({
|
|
17
|
+
status: z.number().int().min(100).max(599),
|
|
18
|
+
headers: headersSchema,
|
|
19
|
+
body: z.instanceof(Uint8Array).optional()
|
|
20
|
+
});
|
|
21
|
+
var paymentEventSchema = z.object({
|
|
22
|
+
id: z.uuidv7(),
|
|
23
|
+
ts: z.iso.datetime(),
|
|
24
|
+
rail: z.literal("x402"),
|
|
25
|
+
attemptedAt: z.iso.datetime().optional(),
|
|
26
|
+
status: z.enum(["observed", "blocked"]),
|
|
27
|
+
reason: z.string().optional(),
|
|
28
|
+
amount: amountSchema,
|
|
29
|
+
decimals: z.number().int().min(0).max(255),
|
|
30
|
+
decimalsKnown: z.boolean(),
|
|
31
|
+
asset: labelSchema,
|
|
32
|
+
assetSymbol: labelSchema.optional(),
|
|
33
|
+
network: labelSchema,
|
|
34
|
+
payTo: labelSchema,
|
|
35
|
+
payer: labelSchema.optional(),
|
|
36
|
+
resource: httpUrlSchema,
|
|
37
|
+
host: labelSchema,
|
|
38
|
+
txHash: z.string().optional(),
|
|
39
|
+
taskId: labelSchema.optional(),
|
|
40
|
+
agentId: labelSchema.optional(),
|
|
41
|
+
raw: z.string(),
|
|
42
|
+
paymentKey: z.string().min(1),
|
|
43
|
+
settlementStatus: z.enum(["unknown", "confirmed", "failed"]).default("unknown"),
|
|
44
|
+
settlement_unknown: z.boolean().default(true)
|
|
45
|
+
});
|
|
46
|
+
var outcomeSchema = z.object({
|
|
47
|
+
id: z.uuidv7(),
|
|
48
|
+
ts: z.iso.datetime(),
|
|
49
|
+
paymentId: z.uuidv7(),
|
|
50
|
+
attemptId: z.uuidv7().optional(),
|
|
51
|
+
attemptedAt: z.iso.datetime().optional(),
|
|
52
|
+
status: z.enum(["unknown", "confirmed", "failed"]),
|
|
53
|
+
txHash: z.string().optional(),
|
|
54
|
+
reason: z.string().optional()
|
|
55
|
+
});
|
|
56
|
+
var diagnosticSchema = z.object({
|
|
57
|
+
id: z.uuidv7(),
|
|
58
|
+
ts: z.iso.datetime(),
|
|
59
|
+
code: z.enum(["parse_failed", "settlement_unknown", "tls_unmetered", "storage_failed"]),
|
|
60
|
+
resource: z.string(),
|
|
61
|
+
message: z.string()
|
|
62
|
+
});
|
|
63
|
+
var blockedBodySchema = z.object({
|
|
64
|
+
error: z.literal("blocked_by_taximeter"),
|
|
65
|
+
reason: z.string(),
|
|
66
|
+
budget: amountSchema.nullable(),
|
|
67
|
+
spent: integerStringSchema,
|
|
68
|
+
remaining: amountSchema.nullable()
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// src/config.ts
|
|
72
|
+
import { z as z2 } from "zod";
|
|
73
|
+
var budgetSchema = z2.strictObject({
|
|
74
|
+
amount: amountSchema,
|
|
75
|
+
asset: labelSchema,
|
|
76
|
+
network: labelSchema.optional(),
|
|
77
|
+
window: z2.enum(["1h", "24h", "7d", "30d"]).optional()
|
|
78
|
+
});
|
|
79
|
+
var policySchema = z2.strictObject({
|
|
80
|
+
allowHosts: z2.array(labelSchema).default([]),
|
|
81
|
+
denyHosts: z2.array(labelSchema).default([]),
|
|
82
|
+
allowPayTo: z2.array(labelSchema).default([]),
|
|
83
|
+
maxSinglePayment: amountSchema.nullable().default("1000000"),
|
|
84
|
+
maxSingleAsset: labelSchema.default("USDC")
|
|
85
|
+
});
|
|
86
|
+
var portsSchema = z2.strictObject({
|
|
87
|
+
proxy: z2.number().int().min(0).max(65535).default(8402),
|
|
88
|
+
dashboard: z2.number().int().min(0).max(65535).default(8403)
|
|
89
|
+
});
|
|
90
|
+
var configSchema = z2.strictObject({
|
|
91
|
+
budgets: z2.strictObject({
|
|
92
|
+
perTask: budgetSchema.nullable().default({ amount: "5000000", asset: "USDC" }),
|
|
93
|
+
perAgent: budgetSchema.nullable().default({ amount: "50000000", asset: "USDC", window: "24h" }),
|
|
94
|
+
global: budgetSchema.nullable().default({ amount: "100000000", asset: "USDC", window: "24h" })
|
|
95
|
+
}).prefault({}),
|
|
96
|
+
policy: policySchema.prefault({}),
|
|
97
|
+
ports: portsSchema.prefault({}),
|
|
98
|
+
db: z2.string().min(1).default("~/.taximeter/ledger.db"),
|
|
99
|
+
upstream: httpUrlSchema.optional()
|
|
100
|
+
});
|
|
101
|
+
var configPatchSchema = z2.strictObject({
|
|
102
|
+
budgets: z2.strictObject({
|
|
103
|
+
perTask: budgetSchema.partial().nullable().optional(),
|
|
104
|
+
perAgent: budgetSchema.partial().nullable().optional(),
|
|
105
|
+
global: budgetSchema.partial().nullable().optional()
|
|
106
|
+
}).optional(),
|
|
107
|
+
policy: z2.strictObject({
|
|
108
|
+
allowHosts: z2.array(labelSchema).optional(),
|
|
109
|
+
denyHosts: z2.array(labelSchema).optional(),
|
|
110
|
+
allowPayTo: z2.array(labelSchema).optional(),
|
|
111
|
+
maxSinglePayment: amountSchema.nullable().optional(),
|
|
112
|
+
maxSingleAsset: labelSchema.optional()
|
|
113
|
+
}).optional(),
|
|
114
|
+
ports: z2.strictObject({
|
|
115
|
+
proxy: z2.number().int().min(0).max(65535).optional(),
|
|
116
|
+
dashboard: z2.number().int().min(0).max(65535).optional()
|
|
117
|
+
}).optional(),
|
|
118
|
+
db: z2.string().min(1).optional(),
|
|
119
|
+
upstream: httpUrlSchema.optional()
|
|
120
|
+
});
|
|
121
|
+
function parseConfig(...layers) {
|
|
122
|
+
let current = configSchema.parse({});
|
|
123
|
+
for (const input of layers) {
|
|
124
|
+
const layer = configPatchSchema.parse(input);
|
|
125
|
+
const budgets = { ...current.budgets };
|
|
126
|
+
for (const key of ["perTask", "perAgent", "global"]) {
|
|
127
|
+
const next = layer.budgets?.[key];
|
|
128
|
+
if (next !== void 0)
|
|
129
|
+
budgets[key] = next === null ? null : budgetSchema.parse({ ...budgets[key], ...next });
|
|
130
|
+
}
|
|
131
|
+
current = configSchema.parse({
|
|
132
|
+
...current,
|
|
133
|
+
...layer,
|
|
134
|
+
budgets,
|
|
135
|
+
policy: { ...current.policy, ...layer.policy },
|
|
136
|
+
ports: { ...current.ports, ...layer.ports }
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return current;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// src/config/io.ts
|
|
143
|
+
import { existsSync, readFileSync, realpathSync } from "fs";
|
|
144
|
+
import { homedir } from "os";
|
|
145
|
+
import { basename, dirname, isAbsolute, join, resolve } from "path";
|
|
146
|
+
import { z as z3 } from "zod";
|
|
147
|
+
function expandPath(input, home = homedir(), cwd = process.cwd()) {
|
|
148
|
+
let path = z3.string().min(1).parse(input);
|
|
149
|
+
if (path === ":memory:") return path;
|
|
150
|
+
if (path === "~") path = home;
|
|
151
|
+
else if (path.startsWith("~/") || path.startsWith("~\\")) path = join(home, path.slice(2));
|
|
152
|
+
const absolute = isAbsolute(path) ? path : resolve(cwd, path);
|
|
153
|
+
return existsSync(absolute) ? realpathSync(absolute) : existsSync(dirname(absolute)) ? join(realpathSync(dirname(absolute)), basename(absolute)) : absolute;
|
|
154
|
+
}
|
|
155
|
+
function readConfig(path) {
|
|
156
|
+
if (!existsSync(path)) return {};
|
|
157
|
+
try {
|
|
158
|
+
return configPatchSchema.parse(JSON.parse(readFileSync(path, "utf8")));
|
|
159
|
+
} catch {
|
|
160
|
+
throw new Error(`Invalid Taximeter configuration: ${path}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
var portInputSchema = z3.string().regex(/^[0-9]{1,5}$/).pipe(z3.coerce.number().int().min(0).max(65535));
|
|
164
|
+
var environmentSchema = z3.object({
|
|
165
|
+
TAXIMETER_DB: z3.string().min(1).optional(),
|
|
166
|
+
TAXIMETER_PORT: portInputSchema.optional(),
|
|
167
|
+
TAXIMETER_DASHBOARD_PORT: portInputSchema.optional()
|
|
168
|
+
});
|
|
169
|
+
function loadConfig(flags = {}, options = {}) {
|
|
170
|
+
const cwd = options.cwd ?? process.cwd();
|
|
171
|
+
const home = options.home ?? homedir();
|
|
172
|
+
const env = environmentSchema.parse(options.env ?? process.env);
|
|
173
|
+
const environment = {
|
|
174
|
+
...env.TAXIMETER_DB ? { db: env.TAXIMETER_DB } : {},
|
|
175
|
+
ports: {
|
|
176
|
+
...env.TAXIMETER_PORT !== void 0 ? { proxy: env.TAXIMETER_PORT } : {},
|
|
177
|
+
...env.TAXIMETER_DASHBOARD_PORT !== void 0 ? { dashboard: env.TAXIMETER_DASHBOARD_PORT } : {}
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
if (options.configFile && !existsSync(resolve(cwd, options.configFile)))
|
|
181
|
+
throw new Error(`Configuration file not found: ${options.configFile}`);
|
|
182
|
+
const config = parseConfig(
|
|
183
|
+
readConfig(join(home, ".taximeter", "config.json")),
|
|
184
|
+
readConfig(join(cwd, "taximeter.config.json")),
|
|
185
|
+
...options.configFile ? [readConfig(resolve(cwd, options.configFile))] : [],
|
|
186
|
+
environment,
|
|
187
|
+
flags
|
|
188
|
+
);
|
|
189
|
+
return { ...config, db: expandPath(config.db, home, cwd) };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// src/ledger/derive.ts
|
|
193
|
+
import { z as z4 } from "zod";
|
|
194
|
+
var totalSchema = z4.object({
|
|
195
|
+
network: z4.string(),
|
|
196
|
+
asset: z4.string(),
|
|
197
|
+
assetSymbol: z4.string().optional(),
|
|
198
|
+
decimals: z4.number().int(),
|
|
199
|
+
decimalsKnown: z4.boolean(),
|
|
200
|
+
amount: integerStringSchema,
|
|
201
|
+
confirmedAmount: integerStringSchema,
|
|
202
|
+
unknownAmount: integerStringSchema
|
|
203
|
+
});
|
|
204
|
+
function assetKey(event) {
|
|
205
|
+
return `${event.network}/${event.asset.toLowerCase()}`;
|
|
206
|
+
}
|
|
207
|
+
function order(a, b) {
|
|
208
|
+
return a.ts.localeCompare(b.ts) || a.id.localeCompare(b.id);
|
|
209
|
+
}
|
|
210
|
+
function deriveEvents(events, outcomes = []) {
|
|
211
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
212
|
+
const seenPayments = /* @__PURE__ */ new Set();
|
|
213
|
+
const latest = /* @__PURE__ */ new Map();
|
|
214
|
+
const attempted = /* @__PURE__ */ new Map();
|
|
215
|
+
for (const outcome of [...outcomes].sort(order)) {
|
|
216
|
+
const attempts = latest.get(outcome.paymentId) ?? /* @__PURE__ */ new Map();
|
|
217
|
+
const attemptId = outcome.attemptId ?? outcome.paymentId;
|
|
218
|
+
if (attempts.get(attemptId)?.status !== "confirmed" || outcome.status === "confirmed") {
|
|
219
|
+
attempts.set(attemptId, outcome);
|
|
220
|
+
}
|
|
221
|
+
latest.set(outcome.paymentId, attempts);
|
|
222
|
+
if (outcome.attemptedAt && outcome.attemptedAt > (attempted.get(outcome.paymentId) ?? ""))
|
|
223
|
+
attempted.set(outcome.paymentId, outcome.attemptedAt);
|
|
224
|
+
}
|
|
225
|
+
const result = [];
|
|
226
|
+
for (const event of [...events].sort(order)) {
|
|
227
|
+
if (seenIds.has(event.id)) continue;
|
|
228
|
+
seenIds.add(event.id);
|
|
229
|
+
if (event.status === "observed") {
|
|
230
|
+
if (seenPayments.has(event.paymentKey)) continue;
|
|
231
|
+
seenPayments.add(event.paymentKey);
|
|
232
|
+
}
|
|
233
|
+
const attempts = [...latest.get(event.id)?.values() ?? []];
|
|
234
|
+
const outcome = attempts.find((value) => value.status === "confirmed") ?? attempts.find((value) => value.status === "unknown") ?? attempts.at(-1);
|
|
235
|
+
const status = event.settlementStatus === "confirmed" ? "confirmed" : outcome?.status ?? event.settlementStatus;
|
|
236
|
+
result.push({
|
|
237
|
+
...event,
|
|
238
|
+
...attempted.has(event.id) ? { attemptedAt: attempted.get(event.id) } : {},
|
|
239
|
+
settlementStatus: status,
|
|
240
|
+
settlement_unknown: event.status === "observed" && status === "unknown",
|
|
241
|
+
...outcome?.txHash ? { txHash: outcome.txHash } : {}
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
return result;
|
|
245
|
+
}
|
|
246
|
+
function countsAsSpend(event) {
|
|
247
|
+
return event.status === "observed" && event.settlementStatus !== "failed";
|
|
248
|
+
}
|
|
249
|
+
function totals(events) {
|
|
250
|
+
const groups = /* @__PURE__ */ new Map();
|
|
251
|
+
for (const event of deriveEvents(events)) {
|
|
252
|
+
if (!countsAsSpend(event)) continue;
|
|
253
|
+
const key = assetKey(event);
|
|
254
|
+
const current = groups.get(key) ?? {
|
|
255
|
+
network: event.network,
|
|
256
|
+
asset: event.asset.toLowerCase(),
|
|
257
|
+
assetSymbol: event.assetSymbol,
|
|
258
|
+
decimals: event.decimals,
|
|
259
|
+
decimalsKnown: event.decimalsKnown,
|
|
260
|
+
amount: "0",
|
|
261
|
+
confirmedAmount: "0",
|
|
262
|
+
unknownAmount: "0"
|
|
263
|
+
};
|
|
264
|
+
const amount = BigInt(event.amount);
|
|
265
|
+
current.amount = (BigInt(current.amount) + amount).toString();
|
|
266
|
+
if (event.settlementStatus === "confirmed") {
|
|
267
|
+
current.confirmedAmount = (BigInt(current.confirmedAmount) + amount).toString();
|
|
268
|
+
} else {
|
|
269
|
+
current.unknownAmount = (BigInt(current.unknownAmount) + amount).toString();
|
|
270
|
+
}
|
|
271
|
+
groups.set(key, current);
|
|
272
|
+
}
|
|
273
|
+
return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([, total]) => total);
|
|
274
|
+
}
|
|
275
|
+
function formatAmount(amount, decimals) {
|
|
276
|
+
const integer = BigInt(amount).toString();
|
|
277
|
+
if (decimals === 0) return integer;
|
|
278
|
+
const padded = integer.padStart(decimals + 1, "0");
|
|
279
|
+
return `${padded.slice(0, -decimals)}.${padded.slice(-decimals)}`;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// src/assets.ts
|
|
283
|
+
var usdc = {
|
|
284
|
+
"eip155:8453": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
|
|
285
|
+
"eip155:84532": "0x036cbd53842c5426634e7929541ec2318f3dcf7e"
|
|
286
|
+
};
|
|
287
|
+
function assetMetadata(network, asset) {
|
|
288
|
+
return usdc[network] === asset.toLowerCase() ? { decimals: 6, decimalsKnown: true, assetSymbol: "USDC" } : { decimals: 0, decimalsKnown: false };
|
|
289
|
+
}
|
|
290
|
+
function matchesAsset(selector, payment) {
|
|
291
|
+
return selector.toLowerCase() === payment.asset.toLowerCase() || selector === "USDC" && assetMetadata(payment.network, payment.asset).assetSymbol === "USDC";
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// src/policy/index.ts
|
|
295
|
+
var windows = { "1h": 36e5, "24h": 864e5, "7d": 6048e5, "30d": 2592e6 };
|
|
296
|
+
function deny(reason, budget = null, spent = "0") {
|
|
297
|
+
const difference = budget === null ? null : BigInt(budget) - BigInt(spent);
|
|
298
|
+
return {
|
|
299
|
+
allowed: false,
|
|
300
|
+
body: {
|
|
301
|
+
error: "blocked_by_taximeter",
|
|
302
|
+
reason,
|
|
303
|
+
budget,
|
|
304
|
+
spent,
|
|
305
|
+
remaining: difference === null ? null : (difference < 0n ? 0n : difference).toString()
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
function spentForBudget(proposed, events, budget, scope, now) {
|
|
310
|
+
const start = budget.window ? now - windows[budget.window] : -Infinity;
|
|
311
|
+
let spent = 0n;
|
|
312
|
+
for (const event of deriveEvents(events)) {
|
|
313
|
+
if (!countsAsSpend(event) || assetKey(event) !== assetKey(proposed)) continue;
|
|
314
|
+
const ts = Date.parse(event.attemptedAt ?? event.ts);
|
|
315
|
+
if (ts < start || ts > now) continue;
|
|
316
|
+
if (scope === "perTask" && event.taskId !== proposed.taskId) continue;
|
|
317
|
+
if (scope === "perAgent" && event.agentId !== proposed.agentId) continue;
|
|
318
|
+
spent += BigInt(event.amount);
|
|
319
|
+
}
|
|
320
|
+
return spent.toString();
|
|
321
|
+
}
|
|
322
|
+
function evaluate(proposed, events, config, now) {
|
|
323
|
+
const { policy } = config;
|
|
324
|
+
const host = proposed.host.toLowerCase();
|
|
325
|
+
if (policy.denyHosts.some((value) => value.toLowerCase() === host)) return deny("host_denied");
|
|
326
|
+
if (policy.allowHosts.length > 0 && !policy.allowHosts.some((value) => value.toLowerCase() === host))
|
|
327
|
+
return deny("host_not_allowed");
|
|
328
|
+
if (policy.allowPayTo.length > 0 && !policy.allowPayTo.some((value) => value.toLowerCase() === proposed.payTo.toLowerCase()))
|
|
329
|
+
return deny("recipient_not_allowed");
|
|
330
|
+
if (policy.maxSinglePayment !== null && matchesAsset(policy.maxSingleAsset, proposed) && BigInt(proposed.amount) > BigInt(policy.maxSinglePayment))
|
|
331
|
+
return deny("max_single_payment", policy.maxSinglePayment);
|
|
332
|
+
const reserved = events.find(
|
|
333
|
+
(event) => event.paymentKey === proposed.paymentKey && countsAsSpend(event)
|
|
334
|
+
);
|
|
335
|
+
for (const scope of ["perTask", "perAgent", "global"]) {
|
|
336
|
+
const budget = config.budgets[scope];
|
|
337
|
+
if (!budget || !matchesAsset(budget.asset, proposed) || budget.network && budget.network !== proposed.network)
|
|
338
|
+
continue;
|
|
339
|
+
const spent = spentForBudget(proposed, events, budget, scope, now);
|
|
340
|
+
const reservedTime = reserved ? Date.parse(reserved.attemptedAt ?? reserved.ts) : -Infinity;
|
|
341
|
+
const inWindow = reservedTime <= now && (!budget.window || reservedTime >= now - windows[budget.window]);
|
|
342
|
+
const increment = reserved && (reserved.settlementStatus === "confirmed" || inWindow) ? 0n : BigInt(proposed.amount);
|
|
343
|
+
if (BigInt(spent) + increment > BigInt(budget.amount)) {
|
|
344
|
+
const reason = scope === "perTask" ? "per_task_budget" : scope === "perAgent" ? "per_agent_budget" : "global_budget";
|
|
345
|
+
return deny(reason, budget.amount, spent);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return { allowed: true };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// src/rails/x402.ts
|
|
352
|
+
import { createHash } from "crypto";
|
|
353
|
+
import { v7 } from "uuid";
|
|
354
|
+
import { z as z6 } from "zod";
|
|
355
|
+
|
|
356
|
+
// src/rails/schemas.ts
|
|
357
|
+
import { z as z5 } from "zod";
|
|
358
|
+
var metadata = z5.record(z5.string(), z5.unknown()).nullish();
|
|
359
|
+
var text = z5.string().nullish();
|
|
360
|
+
var wireAmountSchema = z5.string().max(256).regex(/^[0-9]+$/).transform((value) => BigInt(value).toString());
|
|
361
|
+
var addressSchema = z5.string().regex(/^0x[0-9a-fA-F]{40}$/);
|
|
362
|
+
var authorizationSchema = z5.looseObject({
|
|
363
|
+
from: addressSchema,
|
|
364
|
+
to: addressSchema,
|
|
365
|
+
value: wireAmountSchema,
|
|
366
|
+
validAfter: wireAmountSchema,
|
|
367
|
+
validBefore: wireAmountSchema,
|
|
368
|
+
nonce: z5.string().regex(/^0x[0-9a-fA-F]{64}$/)
|
|
369
|
+
});
|
|
370
|
+
var eip3009Schema = z5.looseObject({
|
|
371
|
+
signature: z5.string().regex(/^0x(?:[0-9a-fA-F]{2})*$/).optional(),
|
|
372
|
+
authorization: authorizationSchema
|
|
373
|
+
});
|
|
374
|
+
var resourceSchema = z5.looseObject({
|
|
375
|
+
url: z5.string().min(1),
|
|
376
|
+
description: text,
|
|
377
|
+
mimeType: text
|
|
378
|
+
});
|
|
379
|
+
var common = {
|
|
380
|
+
scheme: z5.string(),
|
|
381
|
+
network: z5.string(),
|
|
382
|
+
asset: z5.string().min(1),
|
|
383
|
+
payTo: z5.string().min(1),
|
|
384
|
+
maxTimeoutSeconds: z5.number().positive(),
|
|
385
|
+
extra: metadata
|
|
386
|
+
};
|
|
387
|
+
var requirementsV1Schema = z5.looseObject({
|
|
388
|
+
...common,
|
|
389
|
+
maxAmountRequired: wireAmountSchema,
|
|
390
|
+
resource: z5.string().min(1),
|
|
391
|
+
description: z5.string(),
|
|
392
|
+
mimeType: text,
|
|
393
|
+
outputSchema: metadata
|
|
394
|
+
});
|
|
395
|
+
var requirementsV2Schema = z5.looseObject({ ...common, amount: wireAmountSchema });
|
|
396
|
+
var requiredV1Schema = z5.looseObject({
|
|
397
|
+
x402Version: z5.literal(1),
|
|
398
|
+
error: text,
|
|
399
|
+
accepts: z5.array(requirementsV1Schema).min(1)
|
|
400
|
+
});
|
|
401
|
+
var requiredV2Schema = z5.looseObject({
|
|
402
|
+
x402Version: z5.literal(2),
|
|
403
|
+
error: text,
|
|
404
|
+
resource: resourceSchema,
|
|
405
|
+
accepts: z5.array(requirementsV2Schema).min(1),
|
|
406
|
+
extensions: metadata
|
|
407
|
+
});
|
|
408
|
+
var payloadV1Schema = z5.looseObject({
|
|
409
|
+
x402Version: z5.literal(1),
|
|
410
|
+
scheme: z5.literal("exact"),
|
|
411
|
+
network: z5.string(),
|
|
412
|
+
payload: eip3009Schema
|
|
413
|
+
});
|
|
414
|
+
var payloadV2Schema = z5.looseObject({
|
|
415
|
+
x402Version: z5.literal(2),
|
|
416
|
+
accepted: requirementsV2Schema.extend({ scheme: z5.literal("exact") }),
|
|
417
|
+
resource: resourceSchema.nullish(),
|
|
418
|
+
payload: eip3009Schema,
|
|
419
|
+
extensions: metadata
|
|
420
|
+
});
|
|
421
|
+
var settlementSchema = z5.looseObject({
|
|
422
|
+
success: z5.boolean(),
|
|
423
|
+
transaction: z5.string(),
|
|
424
|
+
network: z5.string(),
|
|
425
|
+
payer: z5.string().optional(),
|
|
426
|
+
errorReason: z5.string().optional(),
|
|
427
|
+
errorMessage: z5.string().optional(),
|
|
428
|
+
amount: wireAmountSchema.optional()
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
// src/rails/x402.ts
|
|
432
|
+
var headerSchema = z6.string().max(65536).regex(/^[A-Za-z0-9+/]+={0,2}$/);
|
|
433
|
+
function decodeHeader(input) {
|
|
434
|
+
const encoded = headerSchema.parse(input);
|
|
435
|
+
const bytes = Buffer.from(encoded, "base64");
|
|
436
|
+
if (bytes.toString("base64").replace(/=+$/, "") !== encoded.replace(/=+$/, ""))
|
|
437
|
+
throw new Error("Invalid base64");
|
|
438
|
+
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
439
|
+
}
|
|
440
|
+
function normalizeNetwork(input) {
|
|
441
|
+
if (input === "base") return "eip155:8453";
|
|
442
|
+
if (input === "base-sepolia") return "eip155:84532";
|
|
443
|
+
return /^eip155:[1-9][0-9]*$/.test(input) ? input : null;
|
|
444
|
+
}
|
|
445
|
+
function contextKey(request) {
|
|
446
|
+
return createHash("sha256").update(
|
|
447
|
+
JSON.stringify([
|
|
448
|
+
request.method,
|
|
449
|
+
request.url,
|
|
450
|
+
request.headers["taximeter-task"],
|
|
451
|
+
request.headers["taximeter-agent"],
|
|
452
|
+
request.headers.authorization,
|
|
453
|
+
request.headers.cookie
|
|
454
|
+
])
|
|
455
|
+
).digest("hex");
|
|
456
|
+
}
|
|
457
|
+
var X402Rail = class {
|
|
458
|
+
name = "x402";
|
|
459
|
+
cache = /* @__PURE__ */ new Map();
|
|
460
|
+
now;
|
|
461
|
+
diagnostic;
|
|
462
|
+
constructor(options = {}) {
|
|
463
|
+
this.now = options.now ?? Date.now;
|
|
464
|
+
this.diagnostic = options.diagnostic ?? (() => {
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
report(resource, message, code = "parse_failed") {
|
|
468
|
+
try {
|
|
469
|
+
this.diagnostic(code, resource, message);
|
|
470
|
+
} catch {
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
detect(request, response) {
|
|
474
|
+
return Boolean(
|
|
475
|
+
request.headers["payment-signature"] !== void 0 || request.headers["x-payment"] !== void 0 || response?.status === 402 || response?.headers["payment-required"] || response?.headers["payment-response"] || response?.headers["x-payment-response"]
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
attribution(request, header) {
|
|
479
|
+
const value = labelSchema.optional().safeParse(request.headers[header]);
|
|
480
|
+
if (value.success) return value.data;
|
|
481
|
+
this.report(
|
|
482
|
+
request.url,
|
|
483
|
+
"Invalid attribution label; payment assigned to the unattributed bucket."
|
|
484
|
+
);
|
|
485
|
+
return void 0;
|
|
486
|
+
}
|
|
487
|
+
parse(input, reply) {
|
|
488
|
+
try {
|
|
489
|
+
const request = requestSchema.parse(input);
|
|
490
|
+
if (reply) {
|
|
491
|
+
const response = responseSchema.parse(reply);
|
|
492
|
+
if (response.status !== 402) return null;
|
|
493
|
+
if (response.headers["payment-required"]) {
|
|
494
|
+
const challenge = requiredV2Schema.parse(
|
|
495
|
+
decodeHeader(response.headers["payment-required"])
|
|
496
|
+
);
|
|
497
|
+
if (!challenge.accepts.some(
|
|
498
|
+
(option) => option.scheme === "exact" && normalizeNetwork(option.network)
|
|
499
|
+
))
|
|
500
|
+
this.report(request.url, "Unsupported payment requirements; forwarded unchanged.");
|
|
501
|
+
} else {
|
|
502
|
+
if (!response.body || response.body.byteLength > 65536 || response.headers["content-encoding"])
|
|
503
|
+
throw new Error("Unavailable v1 body");
|
|
504
|
+
const challenge = requiredV1Schema.parse(
|
|
505
|
+
JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(response.body))
|
|
506
|
+
);
|
|
507
|
+
const key = contextKey(request);
|
|
508
|
+
this.cache.delete(key);
|
|
509
|
+
this.cache.set(key, { challenge, expires: this.now() + 3e5 });
|
|
510
|
+
if (this.cache.size > 1e3) {
|
|
511
|
+
const oldest = this.cache.keys().next().value;
|
|
512
|
+
if (oldest !== void 0) this.cache.delete(oldest);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
const v2Header = request.headers["payment-signature"];
|
|
518
|
+
const v1Header = request.headers["x-payment"];
|
|
519
|
+
if (v2Header === void 0 && v1Header === void 0) return null;
|
|
520
|
+
if (v2Header !== void 0 && v1Header !== void 0)
|
|
521
|
+
throw new Error("Ambiguous payment headers");
|
|
522
|
+
const original = decodeHeader(v2Header ?? v1Header);
|
|
523
|
+
const raw = JSON.stringify(original);
|
|
524
|
+
const parsed = v2Header ? payloadV2Schema.parse(original) : payloadV1Schema.parse(original);
|
|
525
|
+
const payload = parsed.payload;
|
|
526
|
+
if ("permit2Authorization" in payload || "delegationManager" in payload)
|
|
527
|
+
throw new Error("Unsupported mixed transfer methods");
|
|
528
|
+
const auth = payload.authorization;
|
|
529
|
+
let asset;
|
|
530
|
+
let payTo;
|
|
531
|
+
let amount;
|
|
532
|
+
let network;
|
|
533
|
+
let extra;
|
|
534
|
+
if (parsed.x402Version === 2) {
|
|
535
|
+
if (!/^eip155:[1-9][0-9]*$/.test(parsed.accepted.network))
|
|
536
|
+
throw new Error("Unsupported v2 network");
|
|
537
|
+
network = parsed.accepted.network;
|
|
538
|
+
({ asset, payTo, amount, extra } = parsed.accepted);
|
|
539
|
+
} else {
|
|
540
|
+
if (!["base", "base-sepolia"].includes(parsed.network))
|
|
541
|
+
throw new Error("Unsupported v1 network");
|
|
542
|
+
network = normalizeNetwork(parsed.network);
|
|
543
|
+
const cached = this.cache.get(contextKey(request));
|
|
544
|
+
if (!cached || cached.expires < this.now()) throw new Error("Missing v1 challenge");
|
|
545
|
+
const matches = cached.challenge.accepts.filter(
|
|
546
|
+
(option) => option.scheme === "exact" && option.network === parsed.network && option.payTo.toLowerCase() === auth.to.toLowerCase() && option.maxAmountRequired === auth.value
|
|
547
|
+
);
|
|
548
|
+
const identities = new Set(matches.map((option) => option.asset.toLowerCase()));
|
|
549
|
+
const selected = matches[0];
|
|
550
|
+
if (!selected || identities.size !== 1) throw new Error("Ambiguous v1 requirements");
|
|
551
|
+
({ asset, payTo, extra } = selected);
|
|
552
|
+
amount = selected.maxAmountRequired;
|
|
553
|
+
}
|
|
554
|
+
if (extra?.assetTransferMethod !== void 0 && extra.assetTransferMethod !== "eip3009")
|
|
555
|
+
throw new Error("Unsupported transfer method");
|
|
556
|
+
if (!network || amount !== auth.value || payTo.toLowerCase() !== auth.to.toLowerCase())
|
|
557
|
+
throw new Error("Inconsistent exact authorization");
|
|
558
|
+
asset = addressSchema.parse(asset).toLowerCase();
|
|
559
|
+
const payer = auth.from.toLowerCase();
|
|
560
|
+
payTo = auth.to.toLowerCase();
|
|
561
|
+
const paymentKey = createHash("sha256").update(
|
|
562
|
+
JSON.stringify([
|
|
563
|
+
network,
|
|
564
|
+
asset,
|
|
565
|
+
payer,
|
|
566
|
+
auth.nonce.toLowerCase(),
|
|
567
|
+
amount,
|
|
568
|
+
payTo,
|
|
569
|
+
auth.validAfter,
|
|
570
|
+
auth.validBefore
|
|
571
|
+
])
|
|
572
|
+
).digest("hex");
|
|
573
|
+
return paymentEventSchema.parse({
|
|
574
|
+
id: v7(),
|
|
575
|
+
ts: new Date(this.now()).toISOString(),
|
|
576
|
+
rail: "x402",
|
|
577
|
+
status: "observed",
|
|
578
|
+
amount,
|
|
579
|
+
asset,
|
|
580
|
+
network,
|
|
581
|
+
payTo,
|
|
582
|
+
payer,
|
|
583
|
+
...assetMetadata(network, asset),
|
|
584
|
+
resource: request.url,
|
|
585
|
+
host: new URL(request.url).hostname.toLowerCase(),
|
|
586
|
+
taskId: this.attribution(request, "taximeter-task"),
|
|
587
|
+
agentId: this.attribution(request, "taximeter-agent"),
|
|
588
|
+
paymentKey,
|
|
589
|
+
raw
|
|
590
|
+
});
|
|
591
|
+
} catch {
|
|
592
|
+
const context = z6.object({ url: z6.string() }).safeParse(input);
|
|
593
|
+
this.report(
|
|
594
|
+
context.data?.url ?? "unknown",
|
|
595
|
+
"Unrecognized or inconsistent x402 data; forwarded unchanged."
|
|
596
|
+
);
|
|
597
|
+
return null;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
settlement(event, input) {
|
|
601
|
+
try {
|
|
602
|
+
const response = responseSchema.parse(input);
|
|
603
|
+
const header = response.headers["payment-response"] ?? response.headers["x-payment-response"];
|
|
604
|
+
if (!header) return { status: "unknown", reason: "settlement_unknown" };
|
|
605
|
+
const settlement = settlementSchema.parse(decodeHeader(header));
|
|
606
|
+
if (normalizeNetwork(settlement.network) !== event.network || settlement.payer && settlement.payer.toLowerCase() !== event.payer || settlement.amount && settlement.amount !== event.amount)
|
|
607
|
+
throw new Error("Settlement mismatch");
|
|
608
|
+
if (settlement.success && settlement.transaction)
|
|
609
|
+
return { status: "confirmed", txHash: settlement.transaction };
|
|
610
|
+
if (!settlement.success && response.status < 500)
|
|
611
|
+
return { status: "failed", reason: settlement.errorReason ?? "settlement_failed" };
|
|
612
|
+
return { status: "unknown", reason: "settlement_unknown" };
|
|
613
|
+
} catch {
|
|
614
|
+
this.report(
|
|
615
|
+
event.resource,
|
|
616
|
+
"Unrecognized settlement evidence; authorization remains reserved."
|
|
617
|
+
);
|
|
618
|
+
return { status: "unknown", reason: "settlement_unknown" };
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
// src/core.ts
|
|
624
|
+
import { v7 as v72 } from "uuid";
|
|
625
|
+
var Meter = class {
|
|
626
|
+
constructor(ledger, config) {
|
|
627
|
+
this.ledger = ledger;
|
|
628
|
+
this.config = parseConfig(config);
|
|
629
|
+
this.rail = new X402Rail({
|
|
630
|
+
diagnostic: (code, resource, message) => this.diagnose(code, resource, message)
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
ledger;
|
|
634
|
+
rail;
|
|
635
|
+
config;
|
|
636
|
+
diagnose(code, resource, message) {
|
|
637
|
+
try {
|
|
638
|
+
this.ledger.diagnose(code, resource, message);
|
|
639
|
+
} catch {
|
|
640
|
+
process.stderr.write(
|
|
641
|
+
"Taximeter: storage unavailable; traffic is passing without reliable budget enforcement.\n"
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
begin(request) {
|
|
646
|
+
const proposed = this.rail.parse(request);
|
|
647
|
+
if (!proposed) return {};
|
|
648
|
+
try {
|
|
649
|
+
return this.ledger.transaction(() => {
|
|
650
|
+
const events = this.ledger.view();
|
|
651
|
+
const decision = evaluate(proposed, events, this.config, Date.now());
|
|
652
|
+
if (!decision.allowed) {
|
|
653
|
+
this.ledger.append(
|
|
654
|
+
paymentEventSchema.parse({
|
|
655
|
+
...proposed,
|
|
656
|
+
status: "blocked",
|
|
657
|
+
reason: decision.body.reason,
|
|
658
|
+
settlement_unknown: false
|
|
659
|
+
})
|
|
660
|
+
);
|
|
661
|
+
return { body: decision.body };
|
|
662
|
+
}
|
|
663
|
+
const previous = events.find(
|
|
664
|
+
(event) => event.status === "observed" && event.paymentKey === proposed.paymentKey
|
|
665
|
+
);
|
|
666
|
+
const payment = previous ?? proposed;
|
|
667
|
+
if (!previous) this.ledger.append(payment);
|
|
668
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString();
|
|
669
|
+
const attempt = {
|
|
670
|
+
id: v72(),
|
|
671
|
+
ts,
|
|
672
|
+
paymentId: payment.id,
|
|
673
|
+
attemptId: v72(),
|
|
674
|
+
status: "unknown",
|
|
675
|
+
attemptedAt: previous?.settlementStatus === "confirmed" ? previous.attemptedAt ?? previous.ts : ts
|
|
676
|
+
};
|
|
677
|
+
this.ledger.appendOutcome(attempt);
|
|
678
|
+
return { payment, attempt };
|
|
679
|
+
});
|
|
680
|
+
} catch {
|
|
681
|
+
this.diagnose(
|
|
682
|
+
"storage_failed",
|
|
683
|
+
request.url,
|
|
684
|
+
"Could not reserve budget; request forwarded without metering."
|
|
685
|
+
);
|
|
686
|
+
return {};
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
observe(request, response) {
|
|
690
|
+
this.rail.parse(request, response);
|
|
691
|
+
}
|
|
692
|
+
complete(intake, response) {
|
|
693
|
+
if (!intake.payment || !intake.attempt) return;
|
|
694
|
+
const result = response ? this.rail.settlement(intake.payment, response) : { status: "unknown", reason: "settlement_unknown" };
|
|
695
|
+
try {
|
|
696
|
+
this.ledger.appendOutcome({
|
|
697
|
+
...intake.attempt,
|
|
698
|
+
...result,
|
|
699
|
+
id: v72(),
|
|
700
|
+
ts: (/* @__PURE__ */ new Date()).toISOString()
|
|
701
|
+
});
|
|
702
|
+
if (result.status === "unknown")
|
|
703
|
+
this.diagnose(
|
|
704
|
+
"settlement_unknown",
|
|
705
|
+
intake.payment.resource,
|
|
706
|
+
"No conclusive settlement response; authorization remains reserved."
|
|
707
|
+
);
|
|
708
|
+
} catch {
|
|
709
|
+
this.diagnose(
|
|
710
|
+
"storage_failed",
|
|
711
|
+
intake.payment.resource,
|
|
712
|
+
"Could not record settlement outcome; original authorization remains reserved."
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
};
|
|
717
|
+
|
|
718
|
+
// src/export/index.ts
|
|
719
|
+
function csvCell(value) {
|
|
720
|
+
const safe = /^[\s]*[=+@-]/.test(value) || /^[\t\r\n]/.test(value) ? `'${value}` : value;
|
|
721
|
+
return `"${safe.replaceAll('"', '""')}"`;
|
|
722
|
+
}
|
|
723
|
+
function toCsv(input) {
|
|
724
|
+
const columns = [
|
|
725
|
+
"id",
|
|
726
|
+
"ts",
|
|
727
|
+
"rail",
|
|
728
|
+
"status",
|
|
729
|
+
"settlementStatus",
|
|
730
|
+
"amount",
|
|
731
|
+
"authorizedAmount",
|
|
732
|
+
"asset",
|
|
733
|
+
"network",
|
|
734
|
+
"decimals",
|
|
735
|
+
"decimalsKnown",
|
|
736
|
+
"assetSymbol",
|
|
737
|
+
"payTo",
|
|
738
|
+
"payer",
|
|
739
|
+
"resource",
|
|
740
|
+
"host",
|
|
741
|
+
"taskId",
|
|
742
|
+
"agentId",
|
|
743
|
+
"txHash",
|
|
744
|
+
"reason"
|
|
745
|
+
];
|
|
746
|
+
const rows = deriveEvents(input).map((event) => [
|
|
747
|
+
event.id,
|
|
748
|
+
event.ts,
|
|
749
|
+
event.rail,
|
|
750
|
+
event.status,
|
|
751
|
+
event.settlementStatus,
|
|
752
|
+
countsAsSpend(event) ? event.amount : "0",
|
|
753
|
+
event.amount,
|
|
754
|
+
event.asset.toLowerCase(),
|
|
755
|
+
event.network,
|
|
756
|
+
event.decimals.toString(),
|
|
757
|
+
event.decimalsKnown.toString(),
|
|
758
|
+
event.assetSymbol ?? "",
|
|
759
|
+
event.payTo,
|
|
760
|
+
event.payer ?? "",
|
|
761
|
+
event.resource,
|
|
762
|
+
event.host,
|
|
763
|
+
event.taskId ?? "",
|
|
764
|
+
event.agentId ?? "",
|
|
765
|
+
event.txHash ?? "",
|
|
766
|
+
event.reason ?? ""
|
|
767
|
+
]);
|
|
768
|
+
return `${[columns, ...rows].map((row) => row.map(csvCell).join(",")).join("\r\n")}\r
|
|
769
|
+
`;
|
|
770
|
+
}
|
|
771
|
+
function toJson(input) {
|
|
772
|
+
const events = deriveEvents(input);
|
|
773
|
+
return `${JSON.stringify({ events, totals: totals(events) }, null, 2)}
|
|
774
|
+
`;
|
|
775
|
+
}
|
|
776
|
+
function html(value) {
|
|
777
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
778
|
+
}
|
|
779
|
+
function toInvoice(input) {
|
|
780
|
+
const events = deriveEvents(input);
|
|
781
|
+
const totalRows = totals(events).map(
|
|
782
|
+
(total) => `<tr><td>${html(total.assetSymbol ?? total.asset)}<small>${html(total.network)} \xB7 ${html(total.asset)}</small></td><td class="number">${total.decimalsKnown ? formatAmount(total.amount, total.decimals) : `${total.amount} atomic units`}</td><td class="number">${total.unknownAmount}</td></tr>`
|
|
783
|
+
).join("");
|
|
784
|
+
const rows = events.map(
|
|
785
|
+
(event) => `<tr><td>${html(event.ts)}</td><td>${html(event.taskId ?? "Unattributed")}</td><td>${html(event.host)}</td><td>${html(event.status === "blocked" ? "Blocked" : event.settlementStatus)}</td><td class="number">${countsAsSpend(event) ? event.amount : "0"}</td><td>${html(event.network)} / ${html(event.asset)}</td></tr>`
|
|
786
|
+
).join("");
|
|
787
|
+
return `<!doctype html>
|
|
788
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Taximeter payment statement</title><style>
|
|
789
|
+
body{font:14px Arial,Helvetica,sans-serif;color:#182b27;background:#fff;margin:40px}h1{font-size:30px;font-weight:500}p,small{color:#4a5e58}small{display:block;font-size:11px;overflow-wrap:anywhere}table{border-collapse:collapse;width:100%;margin:24px 0}th,td{text-align:left;vertical-align:top;padding:8px 4px;border-bottom:1px solid #d5e0db;font-size:12px;overflow-wrap:anywhere}th{font-weight:600}.number{text-align:right;font-family:Consolas,monospace;font-variant-numeric:tabular-nums}@media print{body{margin:12mm}thead{display:table-header-group}}
|
|
790
|
+
</style></head><body><h1>Taximeter payment statement</h1><p>Local ledger \xB7 ${events.length} recorded events</p><p>Amounts remain separate by network and asset. Totals include unresolved authorizations; settlement is reported by the upstream and is not independently verified.</p><table><thead><tr><th>Asset / network</th><th>Total</th><th>Uncertain (atomic units)</th></tr></thead><tbody>${totalRows || '<tr><td colspan="3">No payments recorded.</td></tr>'}</tbody></table><table><thead><tr><th>Observed (UTC)</th><th>Task</th><th>Host</th><th>State</th><th>Counted amount (atomic units)</th><th>Network / asset</th></tr></thead><tbody>${rows}</tbody></table><p>Blocked and explicitly failed payments contribute zero. Taximeter never holds funds, signs a payment, or settles a transaction.</p></body></html>
|
|
791
|
+
`;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// src/ledger/store.ts
|
|
795
|
+
import { mkdirSync } from "fs";
|
|
796
|
+
import { dirname as dirname2 } from "path";
|
|
797
|
+
import Database from "better-sqlite3";
|
|
798
|
+
import { v7 as v73 } from "uuid";
|
|
799
|
+
import { z as z7 } from "zod";
|
|
800
|
+
|
|
801
|
+
// migrations/001.sql
|
|
802
|
+
var __default = "CREATE TABLE schema_version (version INTEGER PRIMARY KEY);\nINSERT INTO schema_version VALUES (1);\nCREATE TABLE events (\n id TEXT PRIMARY KEY,\n ts TEXT NOT NULL,\n taskId TEXT,\n agentId TEXT,\n host TEXT NOT NULL,\n status TEXT NOT NULL CHECK (status IN ('observed', 'blocked')),\n paymentKey TEXT NOT NULL,\n payload TEXT NOT NULL CHECK (json_valid(payload))\n);\nCREATE INDEX events_ts ON events(ts);\nCREATE INDEX events_task ON events(taskId);\nCREATE INDEX events_agent ON events(agentId);\nCREATE INDEX events_host ON events(host);\nCREATE UNIQUE INDEX events_payment ON events(paymentKey) WHERE status = 'observed';\nCREATE TABLE outcomes (\n id TEXT PRIMARY KEY,\n ts TEXT NOT NULL,\n paymentId TEXT NOT NULL REFERENCES events(id),\n payload TEXT NOT NULL CHECK (json_valid(payload))\n);\nCREATE INDEX outcomes_payment ON outcomes(paymentId);\nCREATE TABLE diagnostics (\n id TEXT PRIMARY KEY,\n ts TEXT NOT NULL,\n payload TEXT NOT NULL CHECK (json_valid(payload))\n);\nCREATE TRIGGER events_no_update BEFORE UPDATE ON events BEGIN SELECT RAISE(ABORT, 'append-only events'); END;\nCREATE TRIGGER events_no_delete BEFORE DELETE ON events BEGIN SELECT RAISE(ABORT, 'append-only events'); END;\nCREATE TRIGGER outcomes_no_update BEFORE UPDATE ON outcomes BEGIN SELECT RAISE(ABORT, 'append-only outcomes'); END;\nCREATE TRIGGER outcomes_no_delete BEFORE DELETE ON outcomes BEGIN SELECT RAISE(ABORT, 'append-only outcomes'); END;\nCREATE TRIGGER diagnostics_no_update BEFORE UPDATE ON diagnostics BEGIN SELECT RAISE(ABORT, 'append-only diagnostics'); END;\nCREATE TRIGGER diagnostics_no_delete BEFORE DELETE ON diagnostics BEGIN SELECT RAISE(ABORT, 'append-only diagnostics'); END;\n";
|
|
803
|
+
|
|
804
|
+
// src/ledger/store.ts
|
|
805
|
+
var rowSchema = z7.object({ payload: z7.string() });
|
|
806
|
+
var Ledger = class {
|
|
807
|
+
database;
|
|
808
|
+
constructor(input) {
|
|
809
|
+
const path = z7.string().min(1).parse(input);
|
|
810
|
+
if (path !== ":memory:") mkdirSync(dirname2(path), { recursive: true, mode: 448 });
|
|
811
|
+
this.database = new Database(path);
|
|
812
|
+
try {
|
|
813
|
+
this.database.pragma("journal_mode = WAL");
|
|
814
|
+
this.database.pragma("foreign_keys = ON");
|
|
815
|
+
this.database.pragma("busy_timeout = 5000");
|
|
816
|
+
this.database.transaction(() => {
|
|
817
|
+
const table = this.database.prepare(
|
|
818
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'schema_version'"
|
|
819
|
+
).get();
|
|
820
|
+
if (!table) this.database.exec(__default);
|
|
821
|
+
const version = z7.object({ version: z7.literal(1) }).parse(
|
|
822
|
+
this.database.prepare("SELECT MAX(version) AS version FROM schema_version").get()
|
|
823
|
+
);
|
|
824
|
+
return version.version;
|
|
825
|
+
}).immediate();
|
|
826
|
+
} catch (error) {
|
|
827
|
+
this.database.close();
|
|
828
|
+
throw error;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
transaction(operation) {
|
|
832
|
+
return this.database.transaction(operation).immediate();
|
|
833
|
+
}
|
|
834
|
+
append(input) {
|
|
835
|
+
const event = paymentEventSchema.parse(input);
|
|
836
|
+
return this.database.prepare(
|
|
837
|
+
"INSERT OR IGNORE INTO events (id, ts, taskId, agentId, host, status, paymentKey, payload) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
|
838
|
+
).run(
|
|
839
|
+
event.id,
|
|
840
|
+
event.ts,
|
|
841
|
+
event.taskId ?? null,
|
|
842
|
+
event.agentId ?? null,
|
|
843
|
+
event.host,
|
|
844
|
+
event.status,
|
|
845
|
+
event.paymentKey,
|
|
846
|
+
JSON.stringify(event)
|
|
847
|
+
).changes > 0;
|
|
848
|
+
}
|
|
849
|
+
appendOutcome(input) {
|
|
850
|
+
const outcome = outcomeSchema.parse(input);
|
|
851
|
+
return this.database.prepare("INSERT OR IGNORE INTO outcomes (id, ts, paymentId, payload) VALUES (?, ?, ?, ?)").run(outcome.id, outcome.ts, outcome.paymentId, JSON.stringify(outcome)).changes > 0;
|
|
852
|
+
}
|
|
853
|
+
events() {
|
|
854
|
+
return this.database.prepare("SELECT payload FROM events ORDER BY ts, id").all().map((row) => paymentEventSchema.parse(JSON.parse(rowSchema.parse(row).payload)));
|
|
855
|
+
}
|
|
856
|
+
outcomes() {
|
|
857
|
+
return this.database.prepare("SELECT payload FROM outcomes ORDER BY ts, id").all().map((row) => outcomeSchema.parse(JSON.parse(rowSchema.parse(row).payload)));
|
|
858
|
+
}
|
|
859
|
+
view() {
|
|
860
|
+
return deriveEvents(this.events(), this.outcomes());
|
|
861
|
+
}
|
|
862
|
+
diagnose(code, resource, message) {
|
|
863
|
+
const diagnostic = diagnosticSchema.parse({
|
|
864
|
+
id: v73(),
|
|
865
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
866
|
+
code,
|
|
867
|
+
resource,
|
|
868
|
+
message
|
|
869
|
+
});
|
|
870
|
+
this.database.prepare("INSERT INTO diagnostics (id, ts, payload) VALUES (?, ?, ?)").run(diagnostic.id, diagnostic.ts, JSON.stringify(diagnostic));
|
|
871
|
+
}
|
|
872
|
+
diagnostics() {
|
|
873
|
+
return this.database.prepare("SELECT payload FROM diagnostics ORDER BY ts, id").all().map((row) => diagnosticSchema.parse(JSON.parse(rowSchema.parse(row).payload)));
|
|
874
|
+
}
|
|
875
|
+
close() {
|
|
876
|
+
this.database.close();
|
|
877
|
+
}
|
|
878
|
+
};
|
|
879
|
+
|
|
880
|
+
// src/proxy/index.ts
|
|
881
|
+
import {
|
|
882
|
+
createServer,
|
|
883
|
+
request as httpRequest,
|
|
884
|
+
ServerResponse
|
|
885
|
+
} from "http";
|
|
886
|
+
import { request as httpsRequest } from "https";
|
|
887
|
+
import { connect, Socket } from "net";
|
|
888
|
+
import { z as z8 } from "zod";
|
|
889
|
+
var incomingHeadersSchema = z8.record(
|
|
890
|
+
z8.string(),
|
|
891
|
+
z8.union([z8.string(), z8.array(z8.string()), z8.undefined()])
|
|
892
|
+
);
|
|
893
|
+
var sockets = /* @__PURE__ */ new WeakMap();
|
|
894
|
+
function normalizedHeaders(input) {
|
|
895
|
+
const result = {};
|
|
896
|
+
for (const [name, value] of Object.entries(incomingHeadersSchema.parse(input))) {
|
|
897
|
+
if (value !== void 0)
|
|
898
|
+
result[name.toLowerCase()] = Array.isArray(value) ? value.join(", ") : value;
|
|
899
|
+
}
|
|
900
|
+
return result;
|
|
901
|
+
}
|
|
902
|
+
function forwardingHeaders(message, host, upgrade = false) {
|
|
903
|
+
const headers = normalizedHeaders(message.headers);
|
|
904
|
+
const connectionTokens = (headers.connection ?? "").toLowerCase().split(",").map((value) => value.trim());
|
|
905
|
+
const remove = /* @__PURE__ */ new Set([
|
|
906
|
+
"proxy-authorization",
|
|
907
|
+
"proxy-authenticate",
|
|
908
|
+
"proxy-connection",
|
|
909
|
+
"keep-alive",
|
|
910
|
+
"transfer-encoding",
|
|
911
|
+
"te",
|
|
912
|
+
...!upgrade ? ["connection", "upgrade", ...connectionTokens] : [],
|
|
913
|
+
...host ? ["host"] : []
|
|
914
|
+
]);
|
|
915
|
+
const result = [];
|
|
916
|
+
for (let i = 0; i < message.rawHeaders.length; i += 2) {
|
|
917
|
+
const name = message.rawHeaders[i];
|
|
918
|
+
const value = message.rawHeaders[i + 1];
|
|
919
|
+
if (name && value !== void 0 && !remove.has(name.toLowerCase())) result.push(name, value);
|
|
920
|
+
}
|
|
921
|
+
if (host) result.push("Host", host);
|
|
922
|
+
return result;
|
|
923
|
+
}
|
|
924
|
+
function targetFor(request, config) {
|
|
925
|
+
const reference = z8.string().min(1).parse(request.url);
|
|
926
|
+
const absolute = /^https?:\/\//i.test(reference);
|
|
927
|
+
const url = new URL(
|
|
928
|
+
httpUrlSchema.parse(
|
|
929
|
+
absolute ? reference : config.upstream ? reference.startsWith("/") ? `${new URL(config.upstream).origin}${reference}` : new URL(reference, config.upstream).href : ""
|
|
930
|
+
)
|
|
931
|
+
);
|
|
932
|
+
const suffix = absolute ? reference.replace(/^https?:\/\/[^/?#]*/i, "") : reference.startsWith("/") ? reference : `${url.pathname}${url.search}`;
|
|
933
|
+
const withoutFragment = suffix.split("#", 1)[0] ?? "";
|
|
934
|
+
const path = z8.string().min(1).parse(withoutFragment.startsWith("/") ? withoutFragment : `/${withoutFragment}`);
|
|
935
|
+
return { url, path, resource: httpUrlSchema.parse(`${url.origin}${path}`) };
|
|
936
|
+
}
|
|
937
|
+
function wire(request, target) {
|
|
938
|
+
return {
|
|
939
|
+
url: target.resource,
|
|
940
|
+
method: z8.string().parse(request.method),
|
|
941
|
+
headers: normalizedHeaders(request.headers)
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
function json(response, status, body) {
|
|
945
|
+
response.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
946
|
+
response.end(JSON.stringify(body));
|
|
947
|
+
}
|
|
948
|
+
function createProxy(options) {
|
|
949
|
+
const config = parseConfig(options.config);
|
|
950
|
+
const meter = new Meter(options.ledger, config);
|
|
951
|
+
const server = createServer({ requestTimeout: 0 }, (request, response) => {
|
|
952
|
+
let target;
|
|
953
|
+
try {
|
|
954
|
+
target = targetFor(request, config);
|
|
955
|
+
} catch {
|
|
956
|
+
json(response, 400, {
|
|
957
|
+
error: "upstream_required",
|
|
958
|
+
hint: "Use an HTTP forward-proxy client or start with --upstream https://your-api.example."
|
|
959
|
+
});
|
|
960
|
+
request.resume();
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
const input = wire(request, target);
|
|
964
|
+
const intake = meter.begin(input);
|
|
965
|
+
if (intake.body) {
|
|
966
|
+
json(response, 402, intake.body);
|
|
967
|
+
request.resume();
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
const transport = target.url.protocol === "https:" ? httpsRequest : httpRequest;
|
|
971
|
+
const outgoing = transport(
|
|
972
|
+
target.url,
|
|
973
|
+
{
|
|
974
|
+
method: request.method,
|
|
975
|
+
path: target.path,
|
|
976
|
+
headers: forwardingHeaders(request, target.url.host)
|
|
977
|
+
},
|
|
978
|
+
(reply) => {
|
|
979
|
+
const headers = normalizedHeaders(reply.headers);
|
|
980
|
+
const status = reply.statusCode ?? 502;
|
|
981
|
+
const upstream = { status, headers };
|
|
982
|
+
meter.complete(intake, upstream);
|
|
983
|
+
const needsBody = status === 402 && !headers["payment-required"];
|
|
984
|
+
const chunks = [];
|
|
985
|
+
let length = 0;
|
|
986
|
+
if (status === 402 && !needsBody) meter.observe(input, upstream);
|
|
987
|
+
if (needsBody)
|
|
988
|
+
reply.on("data", (chunk) => {
|
|
989
|
+
const bytes = z8.instanceof(Buffer).parse(chunk);
|
|
990
|
+
length += bytes.length;
|
|
991
|
+
if (length <= 65536) chunks.push(bytes);
|
|
992
|
+
else chunks.length = 0;
|
|
993
|
+
});
|
|
994
|
+
response.writeHead(status, reply.statusMessage, forwardingHeaders(reply));
|
|
995
|
+
reply.on("end", () => {
|
|
996
|
+
if (needsBody)
|
|
997
|
+
meter.observe(input, {
|
|
998
|
+
...upstream,
|
|
999
|
+
body: length <= 65536 ? Buffer.concat(chunks) : void 0
|
|
1000
|
+
});
|
|
1001
|
+
response.addTrailers(reply.trailers);
|
|
1002
|
+
response.end();
|
|
1003
|
+
});
|
|
1004
|
+
reply.on("error", () => response.destroy());
|
|
1005
|
+
reply.on("aborted", () => response.destroy());
|
|
1006
|
+
reply.pipe(response, { end: false });
|
|
1007
|
+
}
|
|
1008
|
+
);
|
|
1009
|
+
outgoing.on("error", () => {
|
|
1010
|
+
meter.complete(intake);
|
|
1011
|
+
if (!response.headersSent) json(response, 502, { error: "upstream_unavailable" });
|
|
1012
|
+
else response.destroy();
|
|
1013
|
+
});
|
|
1014
|
+
request.on("aborted", () => outgoing.destroy());
|
|
1015
|
+
request.on("error", () => outgoing.destroy());
|
|
1016
|
+
request.on("end", () => outgoing.addTrailers(request.trailers));
|
|
1017
|
+
response.on("close", () => {
|
|
1018
|
+
if (!response.writableEnded) outgoing.destroy();
|
|
1019
|
+
});
|
|
1020
|
+
request.pipe(outgoing);
|
|
1021
|
+
});
|
|
1022
|
+
const connections = /* @__PURE__ */ new Set();
|
|
1023
|
+
sockets.set(server, connections);
|
|
1024
|
+
server.on("connection", (socket) => {
|
|
1025
|
+
connections.add(socket);
|
|
1026
|
+
socket.on("close", () => connections.delete(socket));
|
|
1027
|
+
});
|
|
1028
|
+
server.on("connect", (request, client, head) => {
|
|
1029
|
+
try {
|
|
1030
|
+
const address = z8.string().regex(/^(?:\[[0-9a-fA-F:]+\]|[^\s/:?#@]+):[0-9]+$/).parse(request.url);
|
|
1031
|
+
const target = new URL(`http://${address}`);
|
|
1032
|
+
const port = z8.coerce.number().int().min(1).max(65535).parse(target.port || "80");
|
|
1033
|
+
const host = target.hostname.replace(/^\[|\]$/g, "");
|
|
1034
|
+
meter.diagnose(
|
|
1035
|
+
"tls_unmetered",
|
|
1036
|
+
address,
|
|
1037
|
+
"Encrypted CONNECT tunnel forwarded without payment visibility. Use the SDK or explicit upstream mode to meter HTTPS."
|
|
1038
|
+
);
|
|
1039
|
+
const upstream = connect(port, host, () => {
|
|
1040
|
+
client.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
|
1041
|
+
upstream.write(head);
|
|
1042
|
+
upstream.pipe(client);
|
|
1043
|
+
client.pipe(upstream);
|
|
1044
|
+
});
|
|
1045
|
+
upstream.on("error", () => client.destroy());
|
|
1046
|
+
client.on("error", () => upstream.destroy());
|
|
1047
|
+
client.on("close", () => upstream.destroy());
|
|
1048
|
+
upstream.on("close", () => client.destroy());
|
|
1049
|
+
} catch {
|
|
1050
|
+
client.end("HTTP/1.1 400 Bad Request\r\n\r\n");
|
|
1051
|
+
}
|
|
1052
|
+
});
|
|
1053
|
+
server.on("upgrade", (request, client, head) => {
|
|
1054
|
+
try {
|
|
1055
|
+
const target = targetFor(request, config);
|
|
1056
|
+
const input = wire(request, target);
|
|
1057
|
+
const intake = meter.begin(input);
|
|
1058
|
+
if (intake.body) {
|
|
1059
|
+
client.end(
|
|
1060
|
+
`HTTP/1.1 402 Payment Required\r
|
|
1061
|
+
Content-Type: application/json\r
|
|
1062
|
+
Connection: close\r
|
|
1063
|
+
\r
|
|
1064
|
+
${JSON.stringify(intake.body)}`
|
|
1065
|
+
);
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
meter.diagnose(
|
|
1069
|
+
"parse_failed",
|
|
1070
|
+
target.resource,
|
|
1071
|
+
"HTTP upgrade forwarded; subsequent stream frames are unmetered."
|
|
1072
|
+
);
|
|
1073
|
+
const outgoing = (target.url.protocol === "https:" ? httpsRequest : httpRequest)(target.url, {
|
|
1074
|
+
method: request.method,
|
|
1075
|
+
path: target.path,
|
|
1076
|
+
headers: forwardingHeaders(request, target.url.host, true)
|
|
1077
|
+
});
|
|
1078
|
+
const handshake = (reply, stream, responseHead) => {
|
|
1079
|
+
meter.complete(intake, {
|
|
1080
|
+
status: reply.statusCode ?? 101,
|
|
1081
|
+
headers: normalizedHeaders(reply.headers)
|
|
1082
|
+
});
|
|
1083
|
+
const headers = forwardingHeaders(reply, void 0, true);
|
|
1084
|
+
let text2 = `HTTP/1.1 ${reply.statusCode} ${reply.statusMessage}\r
|
|
1085
|
+
`;
|
|
1086
|
+
for (let i = 0; i < headers.length; i += 2) text2 += `${headers[i]}: ${headers[i + 1]}\r
|
|
1087
|
+
`;
|
|
1088
|
+
client.write(`${text2}\r
|
|
1089
|
+
`);
|
|
1090
|
+
client.write(responseHead);
|
|
1091
|
+
stream.write(head);
|
|
1092
|
+
stream.pipe(client);
|
|
1093
|
+
client.pipe(stream);
|
|
1094
|
+
stream.on("error", () => client.destroy());
|
|
1095
|
+
client.on("error", () => stream.destroy());
|
|
1096
|
+
client.on("close", () => stream.destroy());
|
|
1097
|
+
stream.on("close", () => client.destroy());
|
|
1098
|
+
};
|
|
1099
|
+
outgoing.on("upgrade", handshake);
|
|
1100
|
+
outgoing.on("response", (reply) => {
|
|
1101
|
+
meter.complete(intake, {
|
|
1102
|
+
status: reply.statusCode ?? 502,
|
|
1103
|
+
headers: normalizedHeaders(reply.headers)
|
|
1104
|
+
});
|
|
1105
|
+
const response = new ServerResponse(request);
|
|
1106
|
+
if (!(client instanceof Socket)) {
|
|
1107
|
+
client.destroy();
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
response.assignSocket(client);
|
|
1111
|
+
response.writeHead(reply.statusCode ?? 502, reply.statusMessage, forwardingHeaders(reply));
|
|
1112
|
+
reply.on("end", () => {
|
|
1113
|
+
response.addTrailers(reply.trailers);
|
|
1114
|
+
response.end();
|
|
1115
|
+
});
|
|
1116
|
+
reply.on("error", () => response.destroy());
|
|
1117
|
+
reply.on("aborted", () => response.destroy());
|
|
1118
|
+
reply.pipe(response, { end: false });
|
|
1119
|
+
});
|
|
1120
|
+
outgoing.on("error", () => {
|
|
1121
|
+
meter.complete(intake);
|
|
1122
|
+
client.destroy();
|
|
1123
|
+
});
|
|
1124
|
+
client.on("close", () => outgoing.destroy());
|
|
1125
|
+
outgoing.end();
|
|
1126
|
+
} catch {
|
|
1127
|
+
client.end("HTTP/1.1 400 Bad Request\r\n\r\n");
|
|
1128
|
+
}
|
|
1129
|
+
});
|
|
1130
|
+
return server;
|
|
1131
|
+
}
|
|
1132
|
+
async function closeProxy(server) {
|
|
1133
|
+
for (const socket of sockets.get(server) ?? []) socket.destroy();
|
|
1134
|
+
await new Promise(
|
|
1135
|
+
(resolve2, reject) => server.close((error) => error ? reject(error) : resolve2())
|
|
1136
|
+
);
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
export {
|
|
1140
|
+
integerStringSchema,
|
|
1141
|
+
httpUrlSchema,
|
|
1142
|
+
labelSchema,
|
|
1143
|
+
paymentEventSchema,
|
|
1144
|
+
diagnosticSchema,
|
|
1145
|
+
budgetSchema,
|
|
1146
|
+
configPatchSchema,
|
|
1147
|
+
parseConfig,
|
|
1148
|
+
expandPath,
|
|
1149
|
+
portInputSchema,
|
|
1150
|
+
loadConfig,
|
|
1151
|
+
matchesAsset,
|
|
1152
|
+
totalSchema,
|
|
1153
|
+
deriveEvents,
|
|
1154
|
+
countsAsSpend,
|
|
1155
|
+
totals,
|
|
1156
|
+
formatAmount,
|
|
1157
|
+
spentForBudget,
|
|
1158
|
+
evaluate,
|
|
1159
|
+
X402Rail,
|
|
1160
|
+
Meter,
|
|
1161
|
+
toCsv,
|
|
1162
|
+
toJson,
|
|
1163
|
+
toInvoice,
|
|
1164
|
+
Ledger,
|
|
1165
|
+
createProxy,
|
|
1166
|
+
closeProxy
|
|
1167
|
+
};
|