taximeter 0.1.2 → 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/CHANGELOG.md +8 -0
- package/README.md +22 -6
- package/SECURITY.md +4 -1
- package/dist/{chunk-DWBUWM3G.js → chunk-NXK2MCRO.js} +380 -43
- package/dist/cli/index.js +1 -1
- package/dist/index.d.ts +27 -9
- package/dist/index.js +1 -1
- package/dist/ui/assets/{index-Bts-0doc.js → index-BFxfiuVt.js} +9 -9
- package/dist/ui/index.html +1 -1
- package/docs/SDK.md +18 -2
- package/package.json +1 -1
- package/taximeter.config.example.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Taximeter
|
|
2
2
|
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Default to rejecting parsed payments for unknown assets. Set `policy.unknownAsset` to `allow` to opt in and configure exact contract-address budgets for custom tokens.
|
|
8
|
+
|
|
9
|
+
Replace full ledger replay during payment gating with transactional, rebuildable payment projections and exact timestamp-indexed budget sums. Preserve append-only source records, retry and settlement semantics, and precise rolling-window boundaries. Existing databases migrate to schema version 2 when opened; older 0.1.x clients cannot reopen the migrated schema.
|
|
10
|
+
|
|
3
11
|
## 0.1.2
|
|
4
12
|
|
|
5
13
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -51,7 +51,7 @@ Open a terminal in this source checkout, with Node 20+ and npm installed.
|
|
|
51
51
|
You should now see:
|
|
52
52
|
|
|
53
53
|
```text
|
|
54
|
-
Taximeter 0.
|
|
54
|
+
Taximeter 0.2.0
|
|
55
55
|
Proxy: http://127.0.0.1:8402
|
|
56
56
|
Dashboard: http://127.0.0.1:8403
|
|
57
57
|
Point an HTTP-proxy-aware agent at http://127.0.0.1:8402.
|
|
@@ -116,6 +116,8 @@ their amount before forwarding the payment replay.
|
|
|
116
116
|
Policy evaluation and reservation share one SQLite transaction, so concurrent
|
|
117
117
|
payments cannot all consume the same remaining capacity.
|
|
118
118
|
Append-only payment, outcome, and diagnostic records derive every total with BigInt.
|
|
119
|
+
Payment checks use an incrementally maintained index of exact budget sums, avoiding
|
|
120
|
+
a replay of all prior payments. The index can be rebuilt from the original log.
|
|
119
121
|
The local dashboard polls that ledger every second and exports the same counted amounts.
|
|
120
122
|
|
|
121
123
|
An initial 402 challenge is an offer, not spend. Reported settlement counts as
|
|
@@ -138,6 +140,11 @@ as text to prevent a spreadsheet application from rounding them.
|
|
|
138
140
|
|
|
139
141
|
## Configuration
|
|
140
142
|
|
|
143
|
+
When upgrading from 0.1.x, upgrade all writers together. Opening an existing
|
|
144
|
+
ledger builds the new budget index once and upgrades its schema; the source log
|
|
145
|
+
is preserved. Older 0.1.x clients cannot reopen that migrated database. Parsed
|
|
146
|
+
unknown assets now require an explicit opt-in as described below.
|
|
147
|
+
|
|
141
148
|
No file is required. Start from [the example](taximeter.config.example.json) when
|
|
142
149
|
needed. Precedence, highest first: flags, environment, explicit `--config` file,
|
|
143
150
|
`taximeter.config.json` in the working directory, `~/.taximeter/config.json`, defaults.
|
|
@@ -158,6 +165,7 @@ invalid values are rejected with Zod.
|
|
|
158
165
|
| `policy.allowPayTo` | `[]` | Empty allows all recipients; otherwise exact case-insensitive addresses. |
|
|
159
166
|
| `policy.maxSinglePayment` | `"1000000"` | One USDC per authorization by default; `null` disables the cap. |
|
|
160
167
|
| `policy.maxSingleAsset` | `USDC` | Asset to which the single-payment cap applies. |
|
|
168
|
+
| `policy.unknownAsset` | `"deny"` | `deny` blocks parsed payments whose network and contract are absent from the offline asset registry; `allow` opts in to those assets. |
|
|
161
169
|
| `ports.proxy` | `8402` | Loopback HTTP listener; `0` selects an available port. |
|
|
162
170
|
| `ports.dashboard` | `8403` | Loopback dashboard listener; `0` selects an available port. |
|
|
163
171
|
| `db` | `~/.taximeter/ledger.db` | SQLite path; relative paths resolve against the working directory. |
|
|
@@ -168,9 +176,16 @@ All commands accept `--db` and `--config`. Start also accepts `--proxy-port`,
|
|
|
168
176
|
`TAXIMETER_PORT`, and `TAXIMETER_DASHBOARD_PORT`.
|
|
169
177
|
|
|
170
178
|
Missing task/agent labels share an **Unattributed** bucket. Supply `Taximeter-Task`
|
|
171
|
-
and `Taximeter-Agent` headers, or SDK options. The
|
|
172
|
-
|
|
173
|
-
|
|
179
|
+
and `Taximeter-Agent` headers, or SDK options. The offline asset registry recognizes
|
|
180
|
+
USDC by its network and contract on Base and Base Sepolia. Parsed payments for
|
|
181
|
+
other assets are denied by default with `unknown_asset`; supplied symbols or
|
|
182
|
+
decimal metadata cannot make a token known. Unsupported or unparseable payment
|
|
183
|
+
formats still pass through with a diagnostic.
|
|
184
|
+
|
|
185
|
+
Custom-token budgets require `policy.unknownAsset: "allow"`. This opts in to parsed
|
|
186
|
+
unknown assets, which remain separate atomic-unit balances with unknown decimals.
|
|
187
|
+
Configure budgets using the exact contract address and, when needed, its network.
|
|
188
|
+
Default USDC budgets do not cap these assets; only matching budgets and caps apply.
|
|
174
189
|
|
|
175
190
|
A denied replay receives HTTP 402 before it reaches the upstream. For a cap of
|
|
176
191
|
140 atomic units already fully consumed, the response is:
|
|
@@ -185,9 +200,10 @@ A denied replay receives HTTP 402 before it reaches the upstream. For a cap of
|
|
|
185
200
|
}
|
|
186
201
|
```
|
|
187
202
|
|
|
188
|
-
Reasons are `host_denied`, `host_not_allowed`, `recipient_not_allowed`,
|
|
203
|
+
Reasons are `host_denied`, `host_not_allowed`, `recipient_not_allowed`, `unknown_asset`,
|
|
189
204
|
`max_single_payment`, `per_task_budget`, `per_agent_budget`, and `global_budget`.
|
|
190
|
-
Host
|
|
205
|
+
Host, recipient, and unknown-asset denials have `budget: null`, `spent: "0"`, and
|
|
206
|
+
`remaining: null`.
|
|
191
207
|
|
|
192
208
|
## What this is not
|
|
193
209
|
|
package/SECURITY.md
CHANGED
|
@@ -7,7 +7,7 @@ on-chain settlement.
|
|
|
7
7
|
|
|
8
8
|
## Supported version
|
|
9
9
|
|
|
10
|
-
Security fixes currently target the `0.
|
|
10
|
+
Security fixes currently target the `0.2.x` release line. No older release line
|
|
11
11
|
is maintained. This policy describes support; it is not a claim that the package
|
|
12
12
|
has already been published or independently audited.
|
|
13
13
|
|
|
@@ -32,6 +32,9 @@ against a hostile agent or another process running as the same user.
|
|
|
32
32
|
- Unsupported or malformed payment forms pass through with a diagnostic.
|
|
33
33
|
Supported parsing is narrower than the full x402 protocol; see
|
|
34
34
|
[protocol notes](https://github.com/Ding808/taximeter/blob/main/SPEC-NOTES.md).
|
|
35
|
+
- Parsed payments outside the offline asset registry are denied by default.
|
|
36
|
+
Setting `policy.unknownAsset` to `allow` permits them; only matching contract
|
|
37
|
+
budgets and caps then apply. Metadata supplied with a payment does not make its asset trusted.
|
|
35
38
|
- HTTPS CONNECT tunnels encrypted bytes without metering their payments.
|
|
36
39
|
Taximeter does not install a certificate authority or intercept TLS.
|
|
37
40
|
- The SDK must wrap the transport inside the payment wrapper. Redirects and
|
|
@@ -81,7 +81,8 @@ var policySchema = z2.strictObject({
|
|
|
81
81
|
denyHosts: z2.array(labelSchema).default([]),
|
|
82
82
|
allowPayTo: z2.array(labelSchema).default([]),
|
|
83
83
|
maxSinglePayment: amountSchema.nullable().default("1000000"),
|
|
84
|
-
maxSingleAsset: labelSchema.default("USDC")
|
|
84
|
+
maxSingleAsset: labelSchema.default("USDC"),
|
|
85
|
+
unknownAsset: z2.enum(["allow", "deny"]).default("deny")
|
|
85
86
|
});
|
|
86
87
|
var portsSchema = z2.strictObject({
|
|
87
88
|
proxy: z2.number().int().min(0).max(65535).default(8402),
|
|
@@ -109,7 +110,8 @@ var configPatchSchema = z2.strictObject({
|
|
|
109
110
|
denyHosts: z2.array(labelSchema).optional(),
|
|
110
111
|
allowPayTo: z2.array(labelSchema).optional(),
|
|
111
112
|
maxSinglePayment: amountSchema.nullable().optional(),
|
|
112
|
-
maxSingleAsset: labelSchema.optional()
|
|
113
|
+
maxSingleAsset: labelSchema.optional(),
|
|
114
|
+
unknownAsset: z2.enum(["allow", "deny"]).optional()
|
|
113
115
|
}).optional(),
|
|
114
116
|
ports: z2.strictObject({
|
|
115
117
|
proxy: z2.number().int().min(0).max(65535).optional(),
|
|
@@ -320,6 +322,23 @@ function spentForBudget(proposed, events, budget, scope, now) {
|
|
|
320
322
|
return spent.toString();
|
|
321
323
|
}
|
|
322
324
|
function evaluate(proposed, events, config, now) {
|
|
325
|
+
return evaluateWithState(
|
|
326
|
+
proposed,
|
|
327
|
+
{
|
|
328
|
+
reserved: events.find(
|
|
329
|
+
(event) => event.paymentKey === proposed.paymentKey && countsAsSpend(event)
|
|
330
|
+
),
|
|
331
|
+
spent: {
|
|
332
|
+
perTask: config.budgets.perTask ? spentForBudget(proposed, events, config.budgets.perTask, "perTask", now) : "0",
|
|
333
|
+
perAgent: config.budgets.perAgent ? spentForBudget(proposed, events, config.budgets.perAgent, "perAgent", now) : "0",
|
|
334
|
+
global: config.budgets.global ? spentForBudget(proposed, events, config.budgets.global, "global", now) : "0"
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
config,
|
|
338
|
+
now
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
function evaluateWithState(proposed, state, config, now) {
|
|
323
342
|
const { policy } = config;
|
|
324
343
|
const host = proposed.host.toLowerCase();
|
|
325
344
|
if (policy.denyHosts.some((value) => value.toLowerCase() === host)) return deny("host_denied");
|
|
@@ -327,16 +346,16 @@ function evaluate(proposed, events, config, now) {
|
|
|
327
346
|
return deny("host_not_allowed");
|
|
328
347
|
if (policy.allowPayTo.length > 0 && !policy.allowPayTo.some((value) => value.toLowerCase() === proposed.payTo.toLowerCase()))
|
|
329
348
|
return deny("recipient_not_allowed");
|
|
349
|
+
if (policy.unknownAsset === "deny" && !assetMetadata(proposed.network, proposed.asset).decimalsKnown)
|
|
350
|
+
return deny("unknown_asset");
|
|
330
351
|
if (policy.maxSinglePayment !== null && matchesAsset(policy.maxSingleAsset, proposed) && BigInt(proposed.amount) > BigInt(policy.maxSinglePayment))
|
|
331
352
|
return deny("max_single_payment", policy.maxSinglePayment);
|
|
332
|
-
const reserved =
|
|
333
|
-
(event) => event.paymentKey === proposed.paymentKey && countsAsSpend(event)
|
|
334
|
-
);
|
|
353
|
+
const reserved = state.reserved && countsAsSpend(state.reserved) ? state.reserved : void 0;
|
|
335
354
|
for (const scope of ["perTask", "perAgent", "global"]) {
|
|
336
355
|
const budget = config.budgets[scope];
|
|
337
356
|
if (!budget || !matchesAsset(budget.asset, proposed) || budget.network && budget.network !== proposed.network)
|
|
338
357
|
continue;
|
|
339
|
-
const spent =
|
|
358
|
+
const spent = state.spent[scope];
|
|
340
359
|
const reservedTime = reserved ? Date.parse(reserved.attemptedAt ?? reserved.ts) : -Infinity;
|
|
341
360
|
const inWindow = reservedTime <= now && (!budget.window || reservedTime >= now - windows[budget.window]);
|
|
342
361
|
const increment = reserved && (reserved.settlementStatus === "confirmed" || inWindow) ? 0n : BigInt(proposed.amount);
|
|
@@ -647,8 +666,9 @@ var Meter = class {
|
|
|
647
666
|
if (!proposed) return {};
|
|
648
667
|
try {
|
|
649
668
|
return this.ledger.transaction(() => {
|
|
650
|
-
const
|
|
651
|
-
const
|
|
669
|
+
const now = Date.now();
|
|
670
|
+
const state = this.ledger.policyState(proposed, this.config.budgets, now);
|
|
671
|
+
const decision = evaluateWithState(proposed, state, this.config, now);
|
|
652
672
|
if (!decision.allowed) {
|
|
653
673
|
this.ledger.append(
|
|
654
674
|
paymentEventSchema.parse({
|
|
@@ -660,9 +680,7 @@ var Meter = class {
|
|
|
660
680
|
);
|
|
661
681
|
return { body: decision.body };
|
|
662
682
|
}
|
|
663
|
-
const previous =
|
|
664
|
-
(event) => event.status === "observed" && event.paymentKey === proposed.paymentKey
|
|
665
|
-
);
|
|
683
|
+
const previous = state.reserved;
|
|
666
684
|
const payment = previous ?? proposed;
|
|
667
685
|
if (!previous) this.ledger.append(payment);
|
|
668
686
|
const ts = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -796,32 +814,324 @@ import { mkdirSync } from "fs";
|
|
|
796
814
|
import { dirname as dirname2 } from "path";
|
|
797
815
|
import Database from "better-sqlite3";
|
|
798
816
|
import { v7 as v73 } from "uuid";
|
|
799
|
-
import { z as
|
|
817
|
+
import { z as z8 } from "zod";
|
|
800
818
|
|
|
801
819
|
// migrations/001.sql
|
|
802
820
|
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
821
|
|
|
822
|
+
// migrations/002.sql
|
|
823
|
+
var __default2 = "CREATE TABLE cache_cursors (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n eventRowid INTEGER NOT NULL CHECK (eventRowid >= 0),\n outcomeRowid INTEGER NOT NULL CHECK (outcomeRowid >= 0)\n);\nINSERT INTO cache_cursors VALUES (1, 0, 0);\n\nCREATE TABLE cache_payments (\n paymentId TEXT PRIMARY KEY REFERENCES events(id),\n paymentKey TEXT NOT NULL UNIQUE,\n payload TEXT NOT NULL CHECK (json_valid(payload)),\n maxAttemptedAt TEXT\n);\n\nCREATE TABLE cache_attempts (\n paymentId TEXT NOT NULL REFERENCES cache_payments(paymentId),\n attemptId TEXT NOT NULL,\n firstTs TEXT NOT NULL,\n firstId TEXT NOT NULL,\n winnerTs TEXT NOT NULL,\n winnerId TEXT NOT NULL,\n status TEXT NOT NULL CHECK (status IN ('unknown', 'confirmed', 'failed')),\n payload TEXT NOT NULL CHECK (json_valid(payload)),\n PRIMARY KEY (paymentId, attemptId)\n);\nCREATE INDEX cache_attempt_selection ON cache_attempts (\n paymentId, status, firstTs, firstId COLLATE NOCASE, firstId DESC\n);\n\nCREATE TABLE cache_prefix (\n partitionKey TEXT NOT NULL,\n prefix TEXT NOT NULL,\n amount TEXT NOT NULL CHECK (amount <> '' AND amount NOT GLOB '*[^0-9]*'),\n PRIMARY KEY (partitionKey, prefix)\n) WITHOUT ROWID;\n\nINSERT INTO schema_version VALUES (2);\n";
|
|
824
|
+
|
|
825
|
+
// src/ledger/cache.ts
|
|
826
|
+
import { z as z7 } from "zod";
|
|
827
|
+
var cursorSchema = z7.object({
|
|
828
|
+
eventRowid: z7.number().int().nonnegative(),
|
|
829
|
+
outcomeRowid: z7.number().int().nonnegative()
|
|
830
|
+
});
|
|
831
|
+
var sourceSchema = z7.object({
|
|
832
|
+
sourceRowid: z7.number().int().positive(),
|
|
833
|
+
id: z7.string(),
|
|
834
|
+
payload: z7.string()
|
|
835
|
+
});
|
|
836
|
+
var payloadSchema = z7.object({ payload: z7.string() });
|
|
837
|
+
var paymentRowSchema = z7.object({
|
|
838
|
+
payload: z7.string(),
|
|
839
|
+
originalPayload: z7.string(),
|
|
840
|
+
maxAttemptedAt: z7.iso.datetime().nullable()
|
|
841
|
+
});
|
|
842
|
+
var attemptRowSchema = z7.object({
|
|
843
|
+
firstTs: z7.iso.datetime(),
|
|
844
|
+
firstId: z7.uuidv7(),
|
|
845
|
+
winnerTs: z7.iso.datetime(),
|
|
846
|
+
winnerId: z7.uuidv7(),
|
|
847
|
+
status: z7.enum(["unknown", "confirmed", "failed"])
|
|
848
|
+
});
|
|
849
|
+
var prefixRowSchema = z7.object({
|
|
850
|
+
prefix: z7.string().regex(/^[0-9a-f]{0,16}$/),
|
|
851
|
+
amount: integerStringSchema
|
|
852
|
+
});
|
|
853
|
+
var scopeSchema = z7.enum(["perTask", "perAgent", "global"]);
|
|
854
|
+
var windowSchema = z7.enum(["1h", "24h", "7d", "30d"]);
|
|
855
|
+
var timeSchema = z7.number().int().min(-864e13).max(864e13);
|
|
856
|
+
var windows2 = {
|
|
857
|
+
"1h": 36e5,
|
|
858
|
+
"24h": 864e5,
|
|
859
|
+
"7d": 6048e5,
|
|
860
|
+
"30d": 2592e6
|
|
861
|
+
};
|
|
862
|
+
var digits = "0123456789abcdef";
|
|
863
|
+
var bias = 1n << 63n;
|
|
864
|
+
function timeKey(milliseconds) {
|
|
865
|
+
const value = z7.number().int().parse(milliseconds);
|
|
866
|
+
const encoded = bias + BigInt(value);
|
|
867
|
+
if (encoded < 0n || encoded >= 1n << 64n) throw new Error("Timestamp outside cache index range");
|
|
868
|
+
return encoded.toString(16).padStart(16, "0");
|
|
869
|
+
}
|
|
870
|
+
function partition(payment, scope) {
|
|
871
|
+
const attribution = scope === "perTask" ? payment.taskId : scope === "perAgent" ? payment.agentId : null;
|
|
872
|
+
return JSON.stringify([scope, attribution ?? null, payment.network, payment.asset.toLowerCase()]);
|
|
873
|
+
}
|
|
874
|
+
function order2(left, right) {
|
|
875
|
+
return left.ts.localeCompare(right.ts) || left.id.localeCompare(right.id);
|
|
876
|
+
}
|
|
877
|
+
var LedgerCache = class {
|
|
878
|
+
constructor(database) {
|
|
879
|
+
this.database = database;
|
|
880
|
+
}
|
|
881
|
+
database;
|
|
882
|
+
statements = /* @__PURE__ */ new Map();
|
|
883
|
+
statement(sql) {
|
|
884
|
+
let statement = this.statements.get(sql);
|
|
885
|
+
if (!statement) {
|
|
886
|
+
statement = this.database.prepare(sql);
|
|
887
|
+
this.statements.set(sql, statement);
|
|
888
|
+
}
|
|
889
|
+
return statement;
|
|
890
|
+
}
|
|
891
|
+
/** The caller holds an immediate transaction throughout synchronization and budget gating. */
|
|
892
|
+
synchronize() {
|
|
893
|
+
const cursor = cursorSchema.parse(
|
|
894
|
+
this.statement("SELECT eventRowid, outcomeRowid FROM cache_cursors WHERE id = 1").get()
|
|
895
|
+
);
|
|
896
|
+
let eventRowid = cursor.eventRowid;
|
|
897
|
+
let outcomeRowid = cursor.outcomeRowid;
|
|
898
|
+
const eventQuery = this.statement(
|
|
899
|
+
"SELECT rowid AS sourceRowid, id, payload FROM events WHERE rowid > ? ORDER BY rowid LIMIT 256"
|
|
900
|
+
);
|
|
901
|
+
for (let batch = eventQuery.all(eventRowid); batch.length > 0; batch = eventQuery.all(eventRowid)) {
|
|
902
|
+
for (const input of batch) {
|
|
903
|
+
const row = sourceSchema.parse(input);
|
|
904
|
+
const payment = paymentEventSchema.parse(JSON.parse(row.payload));
|
|
905
|
+
if (payment.id !== row.id)
|
|
906
|
+
throw new Error("Payment row identity does not match its payload");
|
|
907
|
+
if (payment.status === "observed") {
|
|
908
|
+
const derived = paymentEventSchema.parse({
|
|
909
|
+
...payment,
|
|
910
|
+
settlement_unknown: payment.settlementStatus === "unknown"
|
|
911
|
+
});
|
|
912
|
+
const inserted = this.statement(
|
|
913
|
+
"INSERT OR IGNORE INTO cache_payments (paymentId, paymentKey, payload, maxAttemptedAt) VALUES (?, ?, ?, NULL)"
|
|
914
|
+
).run(payment.id, payment.paymentKey, JSON.stringify(derived));
|
|
915
|
+
if (inserted.changes > 0) this.changeContribution(void 0, derived);
|
|
916
|
+
}
|
|
917
|
+
eventRowid = row.sourceRowid;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
const outcomeQuery = this.statement(
|
|
921
|
+
"SELECT rowid AS sourceRowid, id, payload FROM outcomes WHERE rowid > ? ORDER BY rowid LIMIT 256"
|
|
922
|
+
);
|
|
923
|
+
for (let batch = outcomeQuery.all(outcomeRowid); batch.length > 0; batch = outcomeQuery.all(outcomeRowid)) {
|
|
924
|
+
for (const input of batch) {
|
|
925
|
+
const row = sourceSchema.parse(input);
|
|
926
|
+
const outcome = outcomeSchema.parse(JSON.parse(row.payload));
|
|
927
|
+
if (outcome.id !== row.id)
|
|
928
|
+
throw new Error("Outcome row identity does not match its payload");
|
|
929
|
+
this.applyOutcome(outcome);
|
|
930
|
+
outcomeRowid = row.sourceRowid;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
if (eventRowid !== cursor.eventRowid || outcomeRowid !== cursor.outcomeRowid) {
|
|
934
|
+
this.statement("UPDATE cache_cursors SET eventRowid = ?, outcomeRowid = ? WHERE id = 1").run(
|
|
935
|
+
eventRowid,
|
|
936
|
+
outcomeRowid
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
rebuild() {
|
|
941
|
+
this.database.exec(
|
|
942
|
+
"DELETE FROM cache_prefix; DELETE FROM cache_attempts; DELETE FROM cache_payments; UPDATE cache_cursors SET eventRowid = 0, outcomeRowid = 0 WHERE id = 1"
|
|
943
|
+
);
|
|
944
|
+
this.synchronize();
|
|
945
|
+
}
|
|
946
|
+
reservation(paymentKey) {
|
|
947
|
+
const key = z7.string().min(1).parse(paymentKey);
|
|
948
|
+
const row = payloadSchema.optional().parse(this.statement("SELECT payload FROM cache_payments WHERE paymentKey = ?").get(key));
|
|
949
|
+
return row ? paymentEventSchema.parse(JSON.parse(row.payload)) : void 0;
|
|
950
|
+
}
|
|
951
|
+
spent(proposed, inputScope, inputWindow, now) {
|
|
952
|
+
const payment = paymentEventSchema.parse(proposed);
|
|
953
|
+
const scope = scopeSchema.parse(inputScope);
|
|
954
|
+
const window = windowSchema.optional().parse(inputWindow);
|
|
955
|
+
const end = timeSchema.parse(now);
|
|
956
|
+
const key = partition(payment, scope);
|
|
957
|
+
const upper = this.prefixTotal(key, end);
|
|
958
|
+
const lower = window ? this.prefixTotal(key, end - windows2[window] - 1) : 0n;
|
|
959
|
+
const result = upper - lower;
|
|
960
|
+
if (result < 0n) throw new Error("Cached budget total is inconsistent");
|
|
961
|
+
return result.toString();
|
|
962
|
+
}
|
|
963
|
+
applyOutcome(outcome) {
|
|
964
|
+
const row = paymentRowSchema.optional().parse(
|
|
965
|
+
this.statement(
|
|
966
|
+
"SELECT c.payload, c.maxAttemptedAt, e.payload AS originalPayload FROM cache_payments c JOIN events e ON e.id = c.paymentId WHERE c.paymentId = ?"
|
|
967
|
+
).get(outcome.paymentId)
|
|
968
|
+
);
|
|
969
|
+
if (!row) return;
|
|
970
|
+
const previous = paymentEventSchema.parse(JSON.parse(row.payload));
|
|
971
|
+
const original = paymentEventSchema.parse(JSON.parse(row.originalPayload));
|
|
972
|
+
const attemptId = outcome.attemptId ?? outcome.paymentId;
|
|
973
|
+
const attempt = attemptRowSchema.optional().parse(
|
|
974
|
+
this.statement(
|
|
975
|
+
"SELECT firstTs, firstId, winnerTs, winnerId, status FROM cache_attempts WHERE paymentId = ? AND attemptId = ?"
|
|
976
|
+
).get(outcome.paymentId, attemptId)
|
|
977
|
+
);
|
|
978
|
+
if (!attempt) {
|
|
979
|
+
this.statement(
|
|
980
|
+
"INSERT INTO cache_attempts (paymentId, attemptId, firstTs, firstId, winnerTs, winnerId, status, payload) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
|
981
|
+
).run(
|
|
982
|
+
outcome.paymentId,
|
|
983
|
+
attemptId,
|
|
984
|
+
outcome.ts,
|
|
985
|
+
outcome.id,
|
|
986
|
+
outcome.ts,
|
|
987
|
+
outcome.id,
|
|
988
|
+
outcome.status,
|
|
989
|
+
JSON.stringify(outcome)
|
|
990
|
+
);
|
|
991
|
+
} else {
|
|
992
|
+
if (order2(outcome, { ts: attempt.firstTs, id: attempt.firstId }) < 0) {
|
|
993
|
+
this.statement(
|
|
994
|
+
"UPDATE cache_attempts SET firstTs = ?, firstId = ? WHERE paymentId = ? AND attemptId = ?"
|
|
995
|
+
).run(outcome.ts, outcome.id, outcome.paymentId, attemptId);
|
|
996
|
+
}
|
|
997
|
+
const newer = order2(outcome, { ts: attempt.winnerTs, id: attempt.winnerId }) > 0;
|
|
998
|
+
const wins = attempt.status === "confirmed" ? outcome.status === "confirmed" && newer : outcome.status === "confirmed" || newer;
|
|
999
|
+
if (wins) {
|
|
1000
|
+
this.statement(
|
|
1001
|
+
"UPDATE cache_attempts SET winnerTs = ?, winnerId = ?, status = ?, payload = ? WHERE paymentId = ? AND attemptId = ?"
|
|
1002
|
+
).run(
|
|
1003
|
+
outcome.ts,
|
|
1004
|
+
outcome.id,
|
|
1005
|
+
outcome.status,
|
|
1006
|
+
JSON.stringify(outcome),
|
|
1007
|
+
outcome.paymentId,
|
|
1008
|
+
attemptId
|
|
1009
|
+
);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
const maxAttemptedAt = outcome.attemptedAt && (!row.maxAttemptedAt || outcome.attemptedAt > row.maxAttemptedAt) ? outcome.attemptedAt : row.maxAttemptedAt;
|
|
1013
|
+
const selected = this.selectedOutcome(outcome.paymentId);
|
|
1014
|
+
const status = original.settlementStatus === "confirmed" ? "confirmed" : selected?.status ?? original.settlementStatus;
|
|
1015
|
+
const current = paymentEventSchema.parse({
|
|
1016
|
+
...original,
|
|
1017
|
+
...maxAttemptedAt ? { attemptedAt: maxAttemptedAt } : {},
|
|
1018
|
+
settlementStatus: status,
|
|
1019
|
+
settlement_unknown: status === "unknown",
|
|
1020
|
+
...selected?.txHash ? { txHash: selected.txHash } : {}
|
|
1021
|
+
});
|
|
1022
|
+
this.changeContribution(previous, current);
|
|
1023
|
+
this.statement(
|
|
1024
|
+
"UPDATE cache_payments SET payload = ?, maxAttemptedAt = ? WHERE paymentId = ?"
|
|
1025
|
+
).run(JSON.stringify(current), maxAttemptedAt, outcome.paymentId);
|
|
1026
|
+
}
|
|
1027
|
+
selectedOutcome(paymentId) {
|
|
1028
|
+
for (const status of ["confirmed", "unknown"]) {
|
|
1029
|
+
const row2 = payloadSchema.optional().parse(
|
|
1030
|
+
this.statement(
|
|
1031
|
+
"SELECT payload FROM cache_attempts WHERE paymentId = ? AND status = ? ORDER BY firstTs, firstId COLLATE NOCASE, firstId DESC LIMIT 1"
|
|
1032
|
+
).get(paymentId, status)
|
|
1033
|
+
);
|
|
1034
|
+
if (row2) return outcomeSchema.parse(JSON.parse(row2.payload));
|
|
1035
|
+
}
|
|
1036
|
+
const row = payloadSchema.optional().parse(
|
|
1037
|
+
this.statement(
|
|
1038
|
+
"SELECT payload FROM cache_attempts WHERE paymentId = ? AND status = 'failed' ORDER BY firstTs DESC, firstId COLLATE NOCASE DESC, firstId ASC LIMIT 1"
|
|
1039
|
+
).get(paymentId)
|
|
1040
|
+
);
|
|
1041
|
+
return row ? outcomeSchema.parse(JSON.parse(row.payload)) : void 0;
|
|
1042
|
+
}
|
|
1043
|
+
changeContribution(previous, current) {
|
|
1044
|
+
const changes = /* @__PURE__ */ new Map();
|
|
1045
|
+
const add = (payment, sign) => {
|
|
1046
|
+
if (!countsAsSpend(payment)) return;
|
|
1047
|
+
const amount = BigInt(payment.amount) * sign;
|
|
1048
|
+
if (amount === 0n) return;
|
|
1049
|
+
const timestamp = timeKey(Date.parse(payment.attemptedAt ?? payment.ts));
|
|
1050
|
+
for (const scope of ["perTask", "perAgent", "global"]) {
|
|
1051
|
+
const key = partition(payment, scope);
|
|
1052
|
+
const nodes = changes.get(key) ?? /* @__PURE__ */ new Map();
|
|
1053
|
+
for (let length = 0; length <= timestamp.length; length += 1) {
|
|
1054
|
+
const prefix = timestamp.slice(0, length);
|
|
1055
|
+
nodes.set(prefix, (nodes.get(prefix) ?? 0n) + amount);
|
|
1056
|
+
}
|
|
1057
|
+
changes.set(key, nodes);
|
|
1058
|
+
}
|
|
1059
|
+
};
|
|
1060
|
+
if (previous) add(previous, -1n);
|
|
1061
|
+
add(current, 1n);
|
|
1062
|
+
for (const [key, nodes] of changes) {
|
|
1063
|
+
const pending = [...nodes].filter(([, amount]) => amount !== 0n);
|
|
1064
|
+
if (pending.length === 0) continue;
|
|
1065
|
+
const existing = /* @__PURE__ */ new Map();
|
|
1066
|
+
for (const input of this.statement(
|
|
1067
|
+
"SELECT prefix, amount FROM cache_prefix WHERE partitionKey = ? AND prefix IN (SELECT value FROM json_each(?))"
|
|
1068
|
+
).iterate(key, JSON.stringify(pending.map(([prefix]) => prefix)))) {
|
|
1069
|
+
const row = prefixRowSchema.parse(input);
|
|
1070
|
+
existing.set(row.prefix, BigInt(row.amount));
|
|
1071
|
+
}
|
|
1072
|
+
for (const [prefix, delta] of pending) {
|
|
1073
|
+
const next = (existing.get(prefix) ?? 0n) + delta;
|
|
1074
|
+
if (next < 0n) throw new Error("Cached monetary contribution underflow");
|
|
1075
|
+
if (next === 0n) {
|
|
1076
|
+
this.statement("DELETE FROM cache_prefix WHERE partitionKey = ? AND prefix = ?").run(
|
|
1077
|
+
key,
|
|
1078
|
+
prefix
|
|
1079
|
+
);
|
|
1080
|
+
} else {
|
|
1081
|
+
this.statement(
|
|
1082
|
+
"INSERT INTO cache_prefix (partitionKey, prefix, amount) VALUES (?, ?, ?) ON CONFLICT(partitionKey, prefix) DO UPDATE SET amount = excluded.amount"
|
|
1083
|
+
).run(key, prefix, next.toString());
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
prefixTotal(key, time) {
|
|
1089
|
+
const timestamp = timeKey(time);
|
|
1090
|
+
const prefixes = [];
|
|
1091
|
+
for (let index = 0; index < timestamp.length; index += 1) {
|
|
1092
|
+
const digit = timestamp[index];
|
|
1093
|
+
if (digit === void 0) throw new Error("Missing timestamp digit");
|
|
1094
|
+
const limit = digits.indexOf(digit);
|
|
1095
|
+
for (let child = 0; child < limit; child += 1) {
|
|
1096
|
+
prefixes.push(`${timestamp.slice(0, index)}${digits[child]}`);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
prefixes.push(timestamp);
|
|
1100
|
+
let sum = 0n;
|
|
1101
|
+
for (const input of this.statement(
|
|
1102
|
+
"SELECT prefix, amount FROM cache_prefix WHERE partitionKey = ? AND prefix IN (SELECT value FROM json_each(?))"
|
|
1103
|
+
).iterate(key, JSON.stringify(prefixes))) {
|
|
1104
|
+
sum += BigInt(prefixRowSchema.parse(input).amount);
|
|
1105
|
+
}
|
|
1106
|
+
return sum;
|
|
1107
|
+
}
|
|
1108
|
+
};
|
|
1109
|
+
|
|
804
1110
|
// src/ledger/store.ts
|
|
805
|
-
var rowSchema =
|
|
1111
|
+
var rowSchema = z8.object({ payload: z8.string() });
|
|
806
1112
|
var Ledger = class {
|
|
807
1113
|
database;
|
|
1114
|
+
cache;
|
|
808
1115
|
constructor(input) {
|
|
809
|
-
const path =
|
|
1116
|
+
const path = z8.string().min(1).parse(input);
|
|
810
1117
|
if (path !== ":memory:") mkdirSync(dirname2(path), { recursive: true, mode: 448 });
|
|
811
1118
|
this.database = new Database(path);
|
|
812
1119
|
try {
|
|
813
1120
|
this.database.pragma("journal_mode = WAL");
|
|
814
1121
|
this.database.pragma("foreign_keys = ON");
|
|
815
1122
|
this.database.pragma("busy_timeout = 5000");
|
|
816
|
-
this.database.transaction(() => {
|
|
1123
|
+
this.cache = this.database.transaction(() => {
|
|
817
1124
|
const table = this.database.prepare(
|
|
818
1125
|
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'schema_version'"
|
|
819
1126
|
).get();
|
|
820
1127
|
if (!table) this.database.exec(__default);
|
|
821
|
-
const version3 =
|
|
1128
|
+
const version3 = z8.object({ version: z8.union([z8.literal(1), z8.literal(2)]) }).parse(
|
|
822
1129
|
this.database.prepare("SELECT MAX(version) AS version FROM schema_version").get()
|
|
823
1130
|
);
|
|
824
|
-
|
|
1131
|
+
if (version3.version === 1) this.database.exec(__default2);
|
|
1132
|
+
const cache = new LedgerCache(this.database);
|
|
1133
|
+
cache.synchronize();
|
|
1134
|
+
return cache;
|
|
825
1135
|
}).immediate();
|
|
826
1136
|
} catch (error) {
|
|
827
1137
|
this.database.close();
|
|
@@ -833,22 +1143,49 @@ var Ledger = class {
|
|
|
833
1143
|
}
|
|
834
1144
|
append(input) {
|
|
835
1145
|
const event = paymentEventSchema.parse(input);
|
|
836
|
-
return this.
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
1146
|
+
return this.transaction(() => {
|
|
1147
|
+
const inserted = this.database.prepare(
|
|
1148
|
+
"INSERT OR IGNORE INTO events (id, ts, taskId, agentId, host, status, paymentKey, payload) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
|
1149
|
+
).run(
|
|
1150
|
+
event.id,
|
|
1151
|
+
event.ts,
|
|
1152
|
+
event.taskId ?? null,
|
|
1153
|
+
event.agentId ?? null,
|
|
1154
|
+
event.host,
|
|
1155
|
+
event.status,
|
|
1156
|
+
event.paymentKey,
|
|
1157
|
+
JSON.stringify(event)
|
|
1158
|
+
).changes > 0;
|
|
1159
|
+
this.cache.synchronize();
|
|
1160
|
+
return inserted;
|
|
1161
|
+
});
|
|
848
1162
|
}
|
|
849
1163
|
appendOutcome(input) {
|
|
850
1164
|
const outcome = outcomeSchema.parse(input);
|
|
851
|
-
return this.
|
|
1165
|
+
return this.transaction(() => {
|
|
1166
|
+
const inserted = this.database.prepare(
|
|
1167
|
+
"INSERT OR IGNORE INTO outcomes (id, ts, paymentId, payload) VALUES (?, ?, ?, ?)"
|
|
1168
|
+
).run(outcome.id, outcome.ts, outcome.paymentId, JSON.stringify(outcome)).changes > 0;
|
|
1169
|
+
this.cache.synchronize();
|
|
1170
|
+
return inserted;
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
policyState(proposed, budgets, now) {
|
|
1174
|
+
return this.transaction(() => {
|
|
1175
|
+
this.cache.synchronize();
|
|
1176
|
+
return {
|
|
1177
|
+
reserved: this.cache.reservation(proposed.paymentKey),
|
|
1178
|
+
spent: {
|
|
1179
|
+
perTask: budgets.perTask ? this.cache.spent(proposed, "perTask", budgets.perTask.window, now) : "0",
|
|
1180
|
+
perAgent: budgets.perAgent ? this.cache.spent(proposed, "perAgent", budgets.perAgent.window, now) : "0",
|
|
1181
|
+
global: budgets.global ? this.cache.spent(proposed, "global", budgets.global.window, now) : "0"
|
|
1182
|
+
}
|
|
1183
|
+
};
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
/** Rebuild disposable projections without modifying the append-only audit log. */
|
|
1187
|
+
rebuildCache() {
|
|
1188
|
+
this.transaction(() => this.cache.rebuild());
|
|
852
1189
|
}
|
|
853
1190
|
events() {
|
|
854
1191
|
return this.database.prepare("SELECT payload FROM events ORDER BY ts, id").all().map((row) => paymentEventSchema.parse(JSON.parse(rowSchema.parse(row).payload)));
|
|
@@ -885,10 +1222,10 @@ import {
|
|
|
885
1222
|
} from "http";
|
|
886
1223
|
import { request as httpsRequest } from "https";
|
|
887
1224
|
import { connect, Socket } from "net";
|
|
888
|
-
import { z as
|
|
889
|
-
var incomingHeadersSchema =
|
|
890
|
-
|
|
891
|
-
|
|
1225
|
+
import { z as z9 } from "zod";
|
|
1226
|
+
var incomingHeadersSchema = z9.record(
|
|
1227
|
+
z9.string(),
|
|
1228
|
+
z9.union([z9.string(), z9.array(z9.string()), z9.undefined()])
|
|
892
1229
|
);
|
|
893
1230
|
var sockets = /* @__PURE__ */ new WeakMap();
|
|
894
1231
|
function normalizedHeaders(input) {
|
|
@@ -922,7 +1259,7 @@ function forwardingHeaders(message, host, upgrade = false) {
|
|
|
922
1259
|
return result;
|
|
923
1260
|
}
|
|
924
1261
|
function targetFor(request, config) {
|
|
925
|
-
const reference =
|
|
1262
|
+
const reference = z9.string().min(1).parse(request.url);
|
|
926
1263
|
const absolute = /^https?:\/\//i.test(reference);
|
|
927
1264
|
const url = new URL(
|
|
928
1265
|
httpUrlSchema.parse(
|
|
@@ -931,13 +1268,13 @@ function targetFor(request, config) {
|
|
|
931
1268
|
);
|
|
932
1269
|
const suffix = absolute ? reference.replace(/^https?:\/\/[^/?#]*/i, "") : reference.startsWith("/") ? reference : `${url.pathname}${url.search}`;
|
|
933
1270
|
const withoutFragment = suffix.split("#", 1)[0] ?? "";
|
|
934
|
-
const path =
|
|
1271
|
+
const path = z9.string().min(1).parse(withoutFragment.startsWith("/") ? withoutFragment : `/${withoutFragment}`);
|
|
935
1272
|
return { url, path, resource: httpUrlSchema.parse(`${url.origin}${path}`) };
|
|
936
1273
|
}
|
|
937
1274
|
function wire(request, target) {
|
|
938
1275
|
return {
|
|
939
1276
|
url: target.resource,
|
|
940
|
-
method:
|
|
1277
|
+
method: z9.string().parse(request.method),
|
|
941
1278
|
headers: normalizedHeaders(request.headers)
|
|
942
1279
|
};
|
|
943
1280
|
}
|
|
@@ -986,7 +1323,7 @@ function createProxy(options) {
|
|
|
986
1323
|
if (status === 402 && !needsBody) meter.observe(input, upstream);
|
|
987
1324
|
if (needsBody)
|
|
988
1325
|
reply.on("data", (chunk) => {
|
|
989
|
-
const bytes =
|
|
1326
|
+
const bytes = z9.instanceof(Buffer).parse(chunk);
|
|
990
1327
|
length += bytes.length;
|
|
991
1328
|
if (length <= 65536) chunks.push(bytes);
|
|
992
1329
|
else chunks.length = 0;
|
|
@@ -1027,9 +1364,9 @@ function createProxy(options) {
|
|
|
1027
1364
|
});
|
|
1028
1365
|
server.on("connect", (request, client, head) => {
|
|
1029
1366
|
try {
|
|
1030
|
-
const address =
|
|
1367
|
+
const address = z9.string().regex(/^(?:\[[0-9a-fA-F:]+\]|[^\s/:?#@]+):[0-9]+$/).parse(request.url);
|
|
1031
1368
|
const target = new URL(`http://${address}`);
|
|
1032
|
-
const port =
|
|
1369
|
+
const port = z9.coerce.number().int().min(1).max(65535).parse(target.port || "80");
|
|
1033
1370
|
const host = target.hostname.replace(/^\[|\]$/g, "");
|
|
1034
1371
|
meter.diagnose(
|
|
1035
1372
|
"tls_unmetered",
|
|
@@ -1137,13 +1474,13 @@ async function closeProxy(server) {
|
|
|
1137
1474
|
}
|
|
1138
1475
|
|
|
1139
1476
|
// src/version.ts
|
|
1140
|
-
import { z as
|
|
1477
|
+
import { z as z10 } from "zod";
|
|
1141
1478
|
|
|
1142
1479
|
// package.json
|
|
1143
|
-
var version = "0.
|
|
1480
|
+
var version = "0.2.0";
|
|
1144
1481
|
|
|
1145
1482
|
// src/version.ts
|
|
1146
|
-
var version2 =
|
|
1483
|
+
var version2 = z10.string().regex(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/).parse(version);
|
|
1147
1484
|
|
|
1148
1485
|
export {
|
|
1149
1486
|
integerStringSchema,
|