tersign 0.4.11 → 0.6.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/README.md +44 -20
- package/dist/adapter/x402.d.ts +59 -3
- package/dist/adapter/x402.js +168 -34
- package/dist/assure.d.ts +69 -7
- package/dist/assure.js +74 -16
- package/dist/canonical.d.ts +27 -0
- package/dist/canonical.js +46 -5
- package/dist/cli.js +7 -3
- package/dist/compliance/record.d.ts +18 -6
- package/dist/compliance/record.js +38 -14
- package/dist/index.d.ts +7 -4
- package/dist/index.js +6 -3
- package/dist/ledgerClient.d.ts +3 -0
- package/dist/ledgerClient.js +2 -0
- package/dist/mcp/server.d.ts +1 -1
- package/dist/mcp/server.js +28 -24
- package/dist/mcp/tools.d.ts +22 -4
- package/dist/mcp/tools.js +53 -5
- package/dist/receipt/binding.d.ts +75 -0
- package/dist/receipt/binding.js +161 -0
- package/dist/receipt/eip712.d.ts +23 -5
- package/dist/receipt/eip712.js +74 -9
- package/dist/receipt/known-keys.d.ts +23 -0
- package/dist/receipt/known-keys.js +59 -0
- package/dist/receipt/network.d.ts +31 -0
- package/dist/receipt/network.js +71 -0
- package/dist/types.d.ts +25 -12
- package/dist/types.js +3 -3
- package/dist/verify-bin.js +212 -46
- package/dist/verify-report.d.ts +75 -0
- package/dist/verify-report.js +241 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,17 +17,26 @@
|
|
|
17
17
|
|
|
18
18
|
## Verify a Real Entry — Right Now
|
|
19
19
|
|
|
20
|
-
No account. No API key. This is the genesis receipt, `seq 1` on the production chain:
|
|
20
|
+
No account. No API key. This is the genesis receipt, `seq 1` on the production chain. It is a self-signed demo: Tersign signed it as seller and named its own key as payer, so its EIP-712 payload signature (domain `{name: "x402 receipt", version: "1", chainId: 1}`, per the x402 offer-and-receipt extension) recovers to the payload's own payer, `0x36f82906859E5B0bd076069f8cdfAea355358b14`. [`/v1/genesis`](https://tersign.ai/v1/genesis) serves the record with the recipe for each of its signatures.
|
|
21
21
|
|
|
22
22
|
```sh
|
|
23
23
|
npx tersign verify 0xe5874f1ffe87f0a6dd9eb157730f67b86ee4538b125fe30fcc4e165213dd3fc4
|
|
24
24
|
```
|
|
25
25
|
|
|
26
26
|
```text
|
|
27
|
-
ledger:
|
|
27
|
+
ledger: https://tersign.ai
|
|
28
|
+
reports: found, counter-signed chain intact (seller tersign-first, seq 1, …) — not checked locally
|
|
29
|
+
VALID (ledger-reported) — https://tersign.ai reports the record and its counter-signed chain; nothing was verified locally
|
|
28
30
|
```
|
|
29
31
|
|
|
30
|
-
`npx tersign verify <receipt.json | 0xdigest> [--
|
|
32
|
+
`npx tersign verify <receipt.json | 0xdigest> [--signer 0xaddr] [--ledger url]` takes a receipt file or a digest.
|
|
33
|
+
|
|
34
|
+
- A **receipt file** is checked locally and touches no network unless you add `--ledger`. The EIP-712 signature is recovered, and `--signer` compares it with the issuer's address, which you take from the issuer through a channel you trust, never from the receipt. Without `--signer` the signer is reported `UNAUTHENTICATED` and the verdict reads `VALID (signer UNAUTHENTICATED)`: recovery yields an address for any payload, so an edited receipt reaches that same line with a different address.
|
|
35
|
+
- A receipt signed with a **published test key** (the 20 Hardhat/Anvil default dev-mnemonic accounts, or private key 1, 2 or 3) is flagged `published test key` either way: anyone can produce that signature. Fields in the file that the signature does not cover are listed by name, and a signed field in another JSON type (`"version": "1"`) is refused. The signature must be the one canonical encoding (`0x` + 130 lower-case hex digits, v 27/28, low-s): a re-encoding of a genuine signature (v 0/1, upper-case hex, the high-s twin) also recovers the issuer, under a different digest, so it is refused.
|
|
36
|
+
- The file is a receipt, `{receipt, record}`, or an evidence-bundle record file (`records/NNNNNN.json`, verdict qualified `record artifact only`: its chain fields are not checked here). Anything else that nests a receipt — a second receipt at the top level, or any other field beside it — is refused, because a reader would take those fields for the receipt that was checked. Duplicate keys and non-integer numbers are refused too.
|
|
37
|
+
- A **digest** is looked up on the Tersign ledger unless `--ledger` names another; the answer is that ledger's own counter-signed chain check, nothing in it is re-verified locally, and the verdict says so (`VALID (ledger-reported)`). The ledger consulted is always printed. The local check is the receipt file with `--signer`.
|
|
38
|
+
|
|
39
|
+
Exit status: `0` valid (read the last line — for a file, a bare `VALID` means a bound, non-test-key signer on a receipt or `{receipt, record}`; a digest is always `VALID (ledger-reported)`), `1` invalid, `2` usage (an unknown or valueless flag, `--signer` with a digest, a missing or non-JSON file). Prefer raw HTTP? The same proof, no CLI:
|
|
31
40
|
|
|
32
41
|
```sh
|
|
33
42
|
curl https://tersign.ai/v1/receipts/0xe5874f1ffe87f0a6dd9eb157730f67b86ee4538b125fe30fcc4e165213dd3fc4/verify
|
|
@@ -41,7 +50,7 @@ Counter-signed evidence that your agent presented a disclosure — one command,
|
|
|
41
50
|
npx tersign disclose "You are chatting with an AI assistant." --medium chat --agent-id my-agent
|
|
42
51
|
```
|
|
43
52
|
|
|
44
|
-
The text is digested **locally** (only the digest travels — data-minimization by construction). Your key signs the record; the ledger counter-signs it into a per-signer hash chain whose head is submitted for Bitcoin anchoring on a six-hourly cron.
|
|
53
|
+
The text is digested **locally** (only the digest travels — data-minimization by construction). Your key signs the record; the ledger counter-signs it into a per-signer hash chain whose head is submitted for Bitcoin anchoring on a six-hourly cron. The first call self-provisions a free signer-keyed account bound set-once to your key, unless that key is already registered to an API-key ledger account (409: submit through that account) or the day's provisioning caps are reached (429). Key resolution: `TERSIGN_SELLER_KEY` env → macOS keychain `tersign-signer` → `~/.tersign/signer.key`, created on first use. Free tier is quota- and rate-limited — [limits](https://tersign.ai/pricing). What this is: independently verifiable evidence the disclosure was attested at that time. What it is not: a compliance certification.
|
|
45
54
|
|
|
46
55
|
## Chain of Custody
|
|
47
56
|
|
|
@@ -60,7 +69,7 @@ graph LR
|
|
|
60
69
|
|
|
61
70
|
<sub>Diagram renders on GitHub. On npm, the paragraph above IS the diagram.</sub>
|
|
62
71
|
|
|
63
|
-
|
|
72
|
+
A refund record references the record it corrects by digest (`refundOf`). Disputes attach to the digest with objective reason codes. Party statements are structurally segregated behind an `UNVERIFIED` marker — the evidence stays prompt-injection-hardened.
|
|
64
73
|
|
|
65
74
|
## Enter the Record
|
|
66
75
|
|
|
@@ -68,15 +77,28 @@ Refunds chain back to the original receipt via `refundOf`. Disputes attach to th
|
|
|
68
77
|
npm i tersign
|
|
69
78
|
```
|
|
70
79
|
|
|
71
|
-
`withAssure()` wraps your x402 fetch handler
|
|
80
|
+
`withAssure()` wraps your x402 fetch handler. On a settled call it signs a receipt and a compliance-fields record with your key and merges both into the `PAYMENT-RESPONSE` header, the base64 JSON settlement response x402 wallets already read; the response body is left alone. When it cannot issue them (a settlement network that is neither CAIP-2 nor a v1 name in its table, such as `"bsc"`, or a ledger error), the paid response goes out exactly as your handler returned it, with no receipt, and the error goes to `onError` (default: `console.error`); with idempotency on, a retry with the same payment id replays that response. On a 402 it adds `compliance-fields` to the `PAYMENT-REQUIRED` header. When the `Assure` is built with a ledger, the ledger counter-signs the receipt into your chain; the compliance-fields record is not counter-signed, and is bound to its receipt only by your own signature, which covers the receipt's digest. Without a ledger, both carry your signature alone.
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
import { privateKeyToAccount } from 'viem/accounts';
|
|
84
|
+
import { Assure, withAssure } from 'tersign';
|
|
85
|
+
|
|
86
|
+
const signer = privateKeyToAccount(process.env.TERSIGN_SELLER_KEY as `0x${string}`);
|
|
87
|
+
const assure = new Assure({ signer, issuer: { name: 'Example API Ltd', jurisdiction: 'HK' } });
|
|
88
|
+
export default { fetch: withAssure(app.fetch, { assure }) }; // app: your x402-protected Hono app
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
A wallet finds the receipt at `extensions["offer-receipt"].info.receipt` and the record at `extensions["compliance-fields"].info.record` of the decoded `PAYMENT-RESPONSE` (`X-PAYMENT-RESPONSE` on x402 v1). Sellers whose readers still parse the JSON body can pass `legacyBodyPlacement: true` for one more minor release.
|
|
92
|
+
|
|
93
|
+
The full register:
|
|
72
94
|
|
|
73
95
|
| Capability | In the record |
|
|
74
96
|
|---|---|
|
|
75
97
|
| Receipts | Seller-signed EIP-712 (x402 offer-receipt extension), keccak256 canonical digests |
|
|
76
|
-
| `withAssure()` | x402 fetch-handler adapter —
|
|
77
|
-
| Compliance
|
|
98
|
+
| `withAssure()` | x402 fetch-handler adapter — receipt + compliance-fields record in `PAYMENT-RESPONSE` on each paid call it can issue for, the paid response passed through unchanged when it cannot; `compliance-fields` advertised on 402 |
|
|
99
|
+
| Compliance-fields records | MINIMAL tier (Art 226b simplified-invoice content) via `buildMinimalRecord` · ledger exports `format=art226b` · `format=s51c` — mappings, not certifications |
|
|
78
100
|
| Action records | `ActionRecordV1` — GDPR-minimized; captures the content of an Art-50 disclosure so the disclosure itself is independently attested, not self-reported |
|
|
79
|
-
| Refunds |
|
|
101
|
+
| Refunds | A refund record carries `refundOf`, the digest of the record it corrects · `record_refund` logs a pending refund entry on the ledger (not counter-signed) |
|
|
80
102
|
| Disputes v0 | Objective reason codes, evidence submission, adjudication |
|
|
81
103
|
| Venue envelopes | Internet Court (5,000-char slot) · Kleros ERC-1497 · UMA · generic |
|
|
82
104
|
| Evidence packs | `format=art50` · `format=safr` (beta) |
|
|
@@ -112,25 +134,27 @@ when that stabilizes. It makes no conformance claim to that draft.
|
|
|
112
134
|
"mcpServers": {
|
|
113
135
|
"tersign": {
|
|
114
136
|
"command": "npx",
|
|
115
|
-
"args": ["tersign"]
|
|
116
|
-
"env": { "TERSIGN_SELLER_KEY": "0x<your-seller-key>" }
|
|
137
|
+
"args": ["tersign"]
|
|
117
138
|
}
|
|
118
139
|
}
|
|
119
140
|
}
|
|
120
141
|
```
|
|
121
142
|
|
|
143
|
+
No configuration is needed. With `TERSIGN_SELLER_KEY` unset or empty, the server signs with the key in the macOS keychain (`tersign-signer`) or `~/.tersign/signer.key`, and generates one there on first run; the key never leaves your machine. Set `TERSIGN_SELLER_KEY` only to bring your own.
|
|
144
|
+
|
|
122
145
|
**Tools** — `issue_receipt` · `verify_receipt` · `verify_compliance_record` · `record_disclosure` · `record_refund` · `open_dispute` · `submit_dispute_evidence` · `adjudicate_dispute` · `get_dispute`
|
|
123
146
|
|
|
124
147
|
| Env var | Required | Purpose |
|
|
125
148
|
|---|---|---|
|
|
126
|
-
| `TERSIGN_SELLER_KEY` |
|
|
127
|
-
| `TERSIGN_LEDGER_URL` | no |
|
|
128
|
-
| `TERSIGN_LEDGER_API_KEY` | no | your seller API key on that ledger |
|
|
129
|
-
| `TERSIGN_LEDGER_SELLER_ID` | no | your seller id on that ledger |
|
|
149
|
+
| `TERSIGN_SELLER_KEY` | no | your own 0x-prefixed signing key; unset or empty, the keychain or keyfile key is used (generated on first run). If your ledger account has a registered signing key, set that key here: the ledger rejects respondent dispute evidence signed by any other key |
|
|
150
|
+
| `TERSIGN_LEDGER_URL` | no | ledger for counter-signing + chain checks; needed by the dispute tools; `record_disclosure` defaults to `https://tersign.ai` |
|
|
151
|
+
| `TERSIGN_LEDGER_API_KEY` | no | your seller API key on that ledger; with the seller id, enables `record_refund` and chained `issue_receipt`; also authenticates respondent dispute evidence |
|
|
152
|
+
| `TERSIGN_LEDGER_SELLER_ID` | no | your seller id on that ledger; with the API key, enables `record_refund` and chained `issue_receipt` |
|
|
130
153
|
| `TERSIGN_ISSUER_NAME` | no | issuer name stamped on action records |
|
|
131
154
|
| `TERSIGN_ISSUER_JURISDICTION` | no | issuer jurisdiction stamped on action records |
|
|
155
|
+
| `TERSIGN_ISSUER_TAX_ID` | no | issuer tax / business-registration id stamped on action records |
|
|
132
156
|
|
|
133
|
-
Cold to counter-signed in one session: call `
|
|
157
|
+
Cold to counter-signed in one session, with no configuration: call `record_disclosure`, then check the `digest` it returns with `npx tersign verify <digest>` (a bare digest checks against https://tersign.ai). `issue_receipt` counter-signs as well once `TERSIGN_LEDGER_URL`, `TERSIGN_LEDGER_API_KEY` and `TERSIGN_LEDGER_SELLER_ID` are set; without them it returns a locally signed, unchained receipt.
|
|
134
158
|
|
|
135
159
|
The agent skill `tersign-evidence` ships at [tersignhq/skills](https://github.com/tersignhq/skills).
|
|
136
160
|
|
|
@@ -148,7 +172,7 @@ Full URLs, readable without auth. If you are an agent, start here.
|
|
|
148
172
|
| Surface | Address |
|
|
149
173
|
|---|---|
|
|
150
174
|
| npm package | `tersign` — https://www.npmjs.com/package/tersign |
|
|
151
|
-
| MCP registry | `io.github.tersignhq/evidence` — `npx tersign` needs no configuration;
|
|
175
|
+
| MCP registry | `io.github.tersignhq/evidence` — `npx tersign` needs no configuration; `record_disclosure`'s first call self-provisions a signer-keyed account, unless the key is registered to an API-key account (409) or the daily provisioning caps are reached (429) |
|
|
152
176
|
| ARD catalog (Agentic Resource Discovery) | https://tersign.ai/.well-known/ai-catalog.json |
|
|
153
177
|
| Verify API | `GET https://tersign.ai/v1/receipts/{digest}/verify` |
|
|
154
178
|
| Envelope API | `GET https://tersign.ai/v1/receipts/{digest}/envelope?venue={internet-court\|kleros\|uma\|generic}` |
|
|
@@ -156,8 +180,8 @@ Full URLs, readable without auth. If you are an agent, start here.
|
|
|
156
180
|
| Ledger signer | `GET https://tersign.ai/v1/ledger` |
|
|
157
181
|
| Bundle verifier, out-of-band | https://tersign.ai/verify/v1/ — `verify_bundle.py` · `keccak.py` · `secp256k1.py` · `SHA256SUMS`. A bundle ships its own checker; for evidence from an interested party fetch this copy and diff the two. |
|
|
158
182
|
| llms.txt | https://raw.githubusercontent.com/tersignhq/tersign-js/main/llms.txt |
|
|
159
|
-
| Conformance vectors (RFC 8785 + keccak256) | https://github.com/tersignhq/
|
|
160
|
-
| Sample
|
|
183
|
+
| Conformance vectors (RFC 8785 + keccak256, two-sided) | https://github.com/tersignhq/evidence-record-conformance |
|
|
184
|
+
| Sample compliance-fields record + digests | https://github.com/tersignhq/tersign-js/blob/main/test/fixtures/compliance-record.json |
|
|
161
185
|
| Genesis verify | `npx tersign verify 0xe5874f1ffe87f0a6dd9eb157730f67b86ee4538b125fe30fcc4e165213dd3fc4` |
|
|
162
186
|
|
|
163
187
|
---
|
|
@@ -189,7 +213,7 @@ If a maintainer ever asks you to reopen work elsewhere because of this, that is
|
|
|
189
213
|
<img src="https://raw.githubusercontent.com/tersignhq/.github/main/assets/seal.svg" alt="Tersign seal" width="72">
|
|
190
214
|
</p>
|
|
191
215
|
|
|
192
|
-
<p align="center"><sub>MIT · built and published from <a href="https://github.com/tersignhq/tersign-js">tersignhq/tersign-js</a> via trusted-publishing CI, provenance attested · <code>tersign</code
|
|
216
|
+
<p align="center"><sub>MIT · built and published from <a href="https://github.com/tersignhq/tersign-js">tersignhq/tersign-js</a> via trusted-publishing CI, provenance attested · Python verifier: <code>pip install tersign</code></sub></p>
|
|
193
217
|
|
|
194
218
|
<p align="center"><sub><b>Venues rotate. The transcript endures.</b></sub></p>
|
|
195
219
|
|
package/dist/adapter/x402.d.ts
CHANGED
|
@@ -6,8 +6,42 @@ export interface SettlementInfo {
|
|
|
6
6
|
network?: string | undefined;
|
|
7
7
|
payer?: string | undefined;
|
|
8
8
|
}
|
|
9
|
+
/** Encode a JSON value the way an x402 HTTP header carries it (base64 over UTF-8 JSON). */
|
|
10
|
+
export declare function encodeX402Header(value: unknown): string;
|
|
11
|
+
/** Decode an x402 HTTP header value; `undefined` when it is not base64 over UTF-8 JSON. */
|
|
12
|
+
export declare function decodeX402Header(value: string): unknown;
|
|
9
13
|
export declare function extractPaymentPayload(headers: Headers): unknown;
|
|
10
14
|
export declare function extractSettlement(headers: Headers): SettlementInfo | undefined;
|
|
15
|
+
/** What a 402 advertises under `extensions["compliance-fields"].info`. Each member is a claim
|
|
16
|
+
* about the records this server emits, so set one only when it is true of every record:
|
|
17
|
+
* `tiers: ['minimal']` needs every record to carry each MINIMAL member, including `tax.amount`
|
|
18
|
+
* whenever `tax.scheme` is not `none`. The default advertises the extension with no tier or
|
|
19
|
+
* jurisdiction claim. */
|
|
20
|
+
export interface ComplianceAdvertisement {
|
|
21
|
+
tiers?: ReadonlyArray<'minimal' | 'full'>;
|
|
22
|
+
jurisdictions?: ReadonlyArray<string>;
|
|
23
|
+
}
|
|
24
|
+
/** JSON Schema for the advertised `info` (the core v2 spec makes `schema` a required member of
|
|
25
|
+
* every PaymentRequired extension entry). */
|
|
26
|
+
export declare const COMPLIANCE_FIELDS_ADVERTISEMENT_SCHEMA: {
|
|
27
|
+
readonly $schema: "https://json-schema.org/draft/2020-12/schema";
|
|
28
|
+
readonly type: "object";
|
|
29
|
+
readonly properties: {
|
|
30
|
+
readonly tiers: {
|
|
31
|
+
readonly type: "array";
|
|
32
|
+
readonly items: {
|
|
33
|
+
readonly type: "string";
|
|
34
|
+
readonly enum: readonly ["minimal", "full"];
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
readonly jurisdictions: {
|
|
38
|
+
readonly type: "array";
|
|
39
|
+
readonly items: {
|
|
40
|
+
readonly type: "string";
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
};
|
|
11
45
|
export interface WithAssureConfig {
|
|
12
46
|
assure: Assure;
|
|
13
47
|
/** describe the supply for the receipt; defaults to the request path */
|
|
@@ -20,10 +54,32 @@ export interface WithAssureConfig {
|
|
|
20
54
|
required: boolean;
|
|
21
55
|
scope: string;
|
|
22
56
|
};
|
|
57
|
+
/** Advertise `compliance-fields` in a 402's `PAYMENT-REQUIRED` header (x402 v2). Default: on,
|
|
58
|
+
* with an empty claim set. `false` leaves 402 responses untouched. An entry the handler
|
|
59
|
+
* already put there is kept as it is. */
|
|
60
|
+
advertise?: false | ComplianceAdvertisement;
|
|
61
|
+
/** @deprecated Also decorate a JSON response BODY with the receipt and record (the ≤0.5
|
|
62
|
+
* placement). The `PAYMENT-RESPONSE` header carries them either way. Scheduled for removal in
|
|
63
|
+
* the next minor release after the one that introduced header placement. A body that does not
|
|
64
|
+
* parse as a JSON object is left as it is. */
|
|
65
|
+
legacyBodyPlacement?: boolean;
|
|
66
|
+
/** Called when a settled call's receipt cannot be issued: a settlement network that is neither
|
|
67
|
+
* CAIP-2 nor in the v1 table (`toCaip2Network`), a ledger error, a throwing `toSettlementContext`
|
|
68
|
+
* or `describeSupply`. The paid response then goes out exactly as the handler returned it, with
|
|
69
|
+
* no receipt or record in it. Default: one `console.error` line per failure. An error thrown by
|
|
70
|
+
* this callback, or a rejection of a promise it returns, is ignored. */
|
|
71
|
+
onError?: (err: unknown) => void;
|
|
23
72
|
}
|
|
24
73
|
type FetchHandler = (req: Request) => Response | Promise<Response>;
|
|
25
|
-
/** Wrap an x402-protected handler: enforce idempotency on the way in
|
|
26
|
-
*
|
|
27
|
-
*
|
|
74
|
+
/** Wrap an x402-protected handler: enforce idempotency on the way in; on the way out, advertise
|
|
75
|
+
* `compliance-fields` on a 402, and — when the settlement header reports success — issue the
|
|
76
|
+
* signed receipt + compliance-fields record and merge both into that same settlement header
|
|
77
|
+
* (`PAYMENT-RESPONSE`, or `X-PAYMENT-RESPONSE` for v1), where x402 wallets read them. The
|
|
78
|
+
* response body is left untouched unless `legacyBodyPlacement` is set.
|
|
79
|
+
*
|
|
80
|
+
* Once the settlement header reports success the buyer has paid, so a failure to issue does not
|
|
81
|
+
* throw: the response goes out exactly as the handler returned it, the error goes to `onError`,
|
|
82
|
+
* and with idempotency on the payment id is completed with that delivered response, so a retry
|
|
83
|
+
* replays it instead of finding the id stuck in flight. */
|
|
28
84
|
export declare function withAssure(handler: FetchHandler, cfg: WithAssureConfig): FetchHandler;
|
|
29
85
|
export {};
|
package/dist/adapter/x402.js
CHANGED
|
@@ -1,22 +1,57 @@
|
|
|
1
|
-
import { attachToExtensions } from '../assure.js';
|
|
1
|
+
import { attachToExtensions, attachToSettlementResponse } from '../assure.js';
|
|
2
2
|
import { checkIdempotency, extractPaymentId, fingerprint, REPLAY_HEADER, } from '../idempotency/middleware.js';
|
|
3
3
|
/** Adapter for x402-protected fetch-style handlers ((Request) => Response) — this is the
|
|
4
4
|
* shape of a Hono app (`app.fetch`), a Workers export, and Next.js route handlers, so one
|
|
5
5
|
* wrapper covers the common seller stacks. An adapter pinned to the official x402 SDK's
|
|
6
6
|
* middleware internals is deliberately deferred until we integrate against a pinned
|
|
7
7
|
* version (its surface is still churning); this wrapper only touches the WIRE contract:
|
|
8
|
-
* the payment payload request header and the settlement
|
|
9
|
-
|
|
8
|
+
* the payment payload request header, the payment-required header and the settlement
|
|
9
|
+
* response header. */
|
|
10
|
+
/** x402 HTTP transport headers, v2 names first with v1 fallbacks. Verified 2026-09-29 against
|
|
11
|
+
* x402-foundation/x402 specs/transports-v2/http.md (main 5eee1e3c35): `PAYMENT-REQUIRED`
|
|
12
|
+
* (server → client, PaymentRequired), `PAYMENT-SIGNATURE` (client → server, PaymentPayload),
|
|
13
|
+
* `PAYMENT-RESPONSE` (server → client, SettlementResponse), each base64 over UTF-8 JSON; "All
|
|
14
|
+
* x402 protocol information is communicated through headers". Re-verify at integration
|
|
15
|
+
* (CLAUDE.md rule 1). */
|
|
10
16
|
const PAYMENT_PAYLOAD_HEADERS = ['payment-signature', 'x-payment'];
|
|
11
17
|
const SETTLEMENT_HEADERS = ['payment-response', 'x-payment-response'];
|
|
18
|
+
const PAYMENT_REQUIRED_HEADER = 'payment-required';
|
|
19
|
+
/** base64 → UTF-8 text, as the upstream reference decodes (`safeBase64Decode`), so a record
|
|
20
|
+
* carrying a non-ASCII issuer name or supply description survives the header round trip. Unlike
|
|
21
|
+
* the reference, invalid UTF-8 is refused rather than replaced with U+FFFD: a header this
|
|
22
|
+
* wrapper rewrites must decode exactly, or it is left alone. */
|
|
23
|
+
function b64decodeUtf8(value) {
|
|
24
|
+
const binary = atob(value);
|
|
25
|
+
const bytes = new Uint8Array(binary.length);
|
|
26
|
+
for (let i = 0; i < binary.length; i++)
|
|
27
|
+
bytes[i] = binary.charCodeAt(i);
|
|
28
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
29
|
+
}
|
|
30
|
+
/** UTF-8 text → base64, as the upstream reference encodes (`safeBase64Encode`). Plain `btoa`
|
|
31
|
+
* throws on any character above U+00FF. */
|
|
32
|
+
function b64encodeUtf8(text) {
|
|
33
|
+
const bytes = new TextEncoder().encode(text);
|
|
34
|
+
let binary = '';
|
|
35
|
+
for (const b of bytes)
|
|
36
|
+
binary += String.fromCharCode(b);
|
|
37
|
+
return btoa(binary);
|
|
38
|
+
}
|
|
12
39
|
function b64json(value) {
|
|
13
40
|
try {
|
|
14
|
-
return JSON.parse(
|
|
41
|
+
return JSON.parse(b64decodeUtf8(value));
|
|
15
42
|
}
|
|
16
43
|
catch {
|
|
17
44
|
return undefined;
|
|
18
45
|
}
|
|
19
46
|
}
|
|
47
|
+
/** Encode a JSON value the way an x402 HTTP header carries it (base64 over UTF-8 JSON). */
|
|
48
|
+
export function encodeX402Header(value) {
|
|
49
|
+
return b64encodeUtf8(JSON.stringify(value));
|
|
50
|
+
}
|
|
51
|
+
/** Decode an x402 HTTP header value; `undefined` when it is not base64 over UTF-8 JSON. */
|
|
52
|
+
export function decodeX402Header(value) {
|
|
53
|
+
return b64json(value);
|
|
54
|
+
}
|
|
20
55
|
export function extractPaymentPayload(headers) {
|
|
21
56
|
for (const name of PAYMENT_PAYLOAD_HEADERS) {
|
|
22
57
|
const raw = headers.get(name);
|
|
@@ -25,29 +60,105 @@ export function extractPaymentPayload(headers) {
|
|
|
25
60
|
}
|
|
26
61
|
return undefined;
|
|
27
62
|
}
|
|
28
|
-
|
|
63
|
+
function findSettlement(headers) {
|
|
29
64
|
for (const name of SETTLEMENT_HEADERS) {
|
|
30
65
|
const raw = headers.get(name);
|
|
31
66
|
if (!raw)
|
|
32
67
|
continue;
|
|
33
68
|
const parsed = b64json(raw);
|
|
34
|
-
if (!parsed
|
|
69
|
+
if (!isPlainRecord(parsed))
|
|
35
70
|
continue;
|
|
36
|
-
return {
|
|
37
|
-
success: parsed.success === true,
|
|
38
|
-
transaction: str(parsed.transaction) ?? str(parsed.txHash),
|
|
39
|
-
network: str(parsed.network) ?? str(parsed.networkId),
|
|
40
|
-
payer: str(parsed.payer) ?? str(parsed.from),
|
|
41
|
-
};
|
|
71
|
+
return { name, response: parsed };
|
|
42
72
|
}
|
|
43
73
|
return undefined;
|
|
44
74
|
}
|
|
75
|
+
export function extractSettlement(headers) {
|
|
76
|
+
const found = findSettlement(headers);
|
|
77
|
+
return found ? settlementInfo(found.response) : undefined;
|
|
78
|
+
}
|
|
79
|
+
function settlementInfo(parsed) {
|
|
80
|
+
return {
|
|
81
|
+
success: parsed.success === true,
|
|
82
|
+
transaction: str(parsed.transaction) ?? str(parsed.txHash),
|
|
83
|
+
network: str(parsed.network) ?? str(parsed.networkId),
|
|
84
|
+
payer: str(parsed.payer) ?? str(parsed.from),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
45
87
|
function str(v) {
|
|
46
88
|
return typeof v === 'string' && v.length > 0 ? v : undefined;
|
|
47
89
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
90
|
+
function isPlainRecord(v) {
|
|
91
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
92
|
+
}
|
|
93
|
+
/** JSON Schema for the advertised `info` (the core v2 spec makes `schema` a required member of
|
|
94
|
+
* every PaymentRequired extension entry). */
|
|
95
|
+
export const COMPLIANCE_FIELDS_ADVERTISEMENT_SCHEMA = {
|
|
96
|
+
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
97
|
+
type: 'object',
|
|
98
|
+
properties: {
|
|
99
|
+
tiers: { type: 'array', items: { type: 'string', enum: ['minimal', 'full'] } },
|
|
100
|
+
jurisdictions: { type: 'array', items: { type: 'string' } },
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
function reportIssueError(err) {
|
|
104
|
+
let message;
|
|
105
|
+
try {
|
|
106
|
+
message = String(err instanceof Error ? err.message : err);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
message = 'unprintable error';
|
|
110
|
+
}
|
|
111
|
+
console.error(`tersign withAssure: receipt not issued, response delivered unchanged: ${message.replace(/\s+/g, ' ')}`);
|
|
112
|
+
}
|
|
113
|
+
/** The legacy body placement reads a clone, so a body that is not a JSON object stays readable
|
|
114
|
+
* and goes out as it came. */
|
|
115
|
+
async function jsonObjectBody(res) {
|
|
116
|
+
try {
|
|
117
|
+
const parsed = await res.clone().json();
|
|
118
|
+
return isPlainRecord(parsed) ? parsed : undefined;
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
function withHeaders(res, headers, body = res.body) {
|
|
125
|
+
return new Response(body, { status: res.status, statusText: res.statusText, headers });
|
|
126
|
+
}
|
|
127
|
+
function advertiseCompliance(res, adv) {
|
|
128
|
+
if (res.status !== 402)
|
|
129
|
+
return res;
|
|
130
|
+
const raw = res.headers.get(PAYMENT_REQUIRED_HEADER);
|
|
131
|
+
if (!raw)
|
|
132
|
+
return res;
|
|
133
|
+
const required = b64json(raw);
|
|
134
|
+
if (!isPlainRecord(required))
|
|
135
|
+
return res;
|
|
136
|
+
const prior = isPlainRecord(required.extensions) ? required.extensions : {};
|
|
137
|
+
if (prior['compliance-fields'] !== undefined)
|
|
138
|
+
return res;
|
|
139
|
+
const info = {};
|
|
140
|
+
if (adv.tiers !== undefined)
|
|
141
|
+
info.tiers = [...adv.tiers];
|
|
142
|
+
if (adv.jurisdictions !== undefined)
|
|
143
|
+
info.jurisdictions = [...adv.jurisdictions];
|
|
144
|
+
const decorated = {
|
|
145
|
+
...required,
|
|
146
|
+
extensions: { ...prior, 'compliance-fields': { info, schema: COMPLIANCE_FIELDS_ADVERTISEMENT_SCHEMA } },
|
|
147
|
+
};
|
|
148
|
+
const headers = new Headers(res.headers);
|
|
149
|
+
headers.set(PAYMENT_REQUIRED_HEADER, encodeX402Header(decorated));
|
|
150
|
+
return withHeaders(res, headers);
|
|
151
|
+
}
|
|
152
|
+
/** Wrap an x402-protected handler: enforce idempotency on the way in; on the way out, advertise
|
|
153
|
+
* `compliance-fields` on a 402, and — when the settlement header reports success — issue the
|
|
154
|
+
* signed receipt + compliance-fields record and merge both into that same settlement header
|
|
155
|
+
* (`PAYMENT-RESPONSE`, or `X-PAYMENT-RESPONSE` for v1), where x402 wallets read them. The
|
|
156
|
+
* response body is left untouched unless `legacyBodyPlacement` is set.
|
|
157
|
+
*
|
|
158
|
+
* Once the settlement header reports success the buyer has paid, so a failure to issue does not
|
|
159
|
+
* throw: the response goes out exactly as the handler returned it, the error goes to `onError`,
|
|
160
|
+
* and with idempotency on the payment id is completed with that delivered response, so a retry
|
|
161
|
+
* replays it instead of finding the id stuck in flight. */
|
|
51
162
|
export function withAssure(handler, cfg) {
|
|
52
163
|
const now = cfg.clock ?? (() => Math.floor(Date.now() / 1000));
|
|
53
164
|
return async (req) => {
|
|
@@ -74,25 +185,48 @@ export function withAssure(handler, cfg) {
|
|
|
74
185
|
}
|
|
75
186
|
}
|
|
76
187
|
let res = await handler(req);
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
188
|
+
if (cfg.advertise !== false)
|
|
189
|
+
res = advertiseCompliance(res, cfg.advertise ?? {});
|
|
190
|
+
const found = findSettlement(res.headers);
|
|
191
|
+
const settlement = found ? settlementInfo(found.response) : undefined;
|
|
192
|
+
if (found && settlement?.success) {
|
|
193
|
+
// Paid: nothing from here may cost the buyer the response. On any failure `res` stays the
|
|
194
|
+
// handler's own, and the idempotency entry below is completed with it.
|
|
195
|
+
try {
|
|
196
|
+
const url = new URL(req.url);
|
|
197
|
+
const overrides = cfg.toSettlementContext?.(req, settlement) ?? {};
|
|
198
|
+
const ctx = {
|
|
199
|
+
network: settlement.network ?? 'eip155:8453',
|
|
200
|
+
resourceUrl: url.origin + url.pathname,
|
|
201
|
+
payer: settlement.payer ?? 'unknown',
|
|
202
|
+
settledAt: now(),
|
|
203
|
+
supplyDescription: cfg.describeSupply?.(req) ?? url.pathname,
|
|
204
|
+
...(settlement.transaction !== undefined ? { txHash: settlement.transaction } : {}),
|
|
205
|
+
...overrides,
|
|
206
|
+
};
|
|
207
|
+
const issued = await cfg.assure.issueFor(ctx);
|
|
208
|
+
const headers = new Headers(res.headers);
|
|
209
|
+
headers.set(found.name, encodeX402Header(attachToSettlementResponse(found.response, issued)));
|
|
210
|
+
const body = cfg.legacyBodyPlacement && (res.headers.get('content-type') ?? '').includes('application/json')
|
|
211
|
+
? await jsonObjectBody(res)
|
|
212
|
+
: undefined;
|
|
213
|
+
if (body) {
|
|
214
|
+
headers.delete('content-length');
|
|
215
|
+
res = withHeaders(res, headers, JSON.stringify(attachToExtensions(body, issued)));
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
res = withHeaders(res, headers);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
try {
|
|
223
|
+
// An async reporter's rejection is caught too: left unhandled it would end the process.
|
|
224
|
+
void Promise.resolve((cfg.onError ?? reportIssueError)(err)).catch(() => { });
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
// a throwing reporter must not cost the paid response either
|
|
228
|
+
}
|
|
229
|
+
}
|
|
96
230
|
}
|
|
97
231
|
if (onComplete) {
|
|
98
232
|
const body = await res.clone().text();
|
package/dist/assure.d.ts
CHANGED
|
@@ -9,7 +9,8 @@ export interface AssureConfig {
|
|
|
9
9
|
ledger?: LedgerConfig;
|
|
10
10
|
}
|
|
11
11
|
export interface SettlementContext {
|
|
12
|
-
/** CAIP-2
|
|
12
|
+
/** CAIP-2 (`"eip155:8453"`), or an x402 v1 name (`"base"`), which the receipt payload carries as
|
|
13
|
+
* CAIP-2; an unknown name throws. */
|
|
13
14
|
network: string;
|
|
14
15
|
resourceUrl: string;
|
|
15
16
|
payer: string;
|
|
@@ -30,18 +31,79 @@ export interface IssuedReceipt {
|
|
|
30
31
|
compliance: SignedComplianceRecord;
|
|
31
32
|
ledger?: CountersignResult;
|
|
32
33
|
}
|
|
33
|
-
/** The core primitive: after a settled x402 payment, issue the signed
|
|
34
|
-
* (
|
|
35
|
-
*
|
|
36
|
-
*
|
|
34
|
+
/** The core primitive: after a settled x402 payment, issue the seller-signed receipt
|
|
35
|
+
* (offer-receipt extension, EIP-712) and the Tersign compliance record. With a ledger configured,
|
|
36
|
+
* the ledger counter-signs the receipt into the seller's hash chain; the compliance-fields record is not
|
|
37
|
+
* counter-signed, and is bound to its receipt only by the seller's own signature, which covers the
|
|
38
|
+
* receipt's digest. Merge the result into the x402 SettlementResponse (the `PAYMENT-RESPONSE`
|
|
39
|
+
* header) with `attachToSettlementResponse`; `withAssure` does this for you. */
|
|
37
40
|
export declare class Assure {
|
|
38
41
|
private cfg;
|
|
39
42
|
private ledger?;
|
|
40
43
|
constructor(cfg: AssureConfig);
|
|
41
44
|
issueFor(ctx: SettlementContext): Promise<IssuedReceipt>;
|
|
42
45
|
}
|
|
43
|
-
/**
|
|
44
|
-
*
|
|
46
|
+
/** JSON Schema for `extensions["offer-receipt"]` on a SettlementResponse — the same shape the
|
|
47
|
+
* upstream reference server attaches (x402-foundation/x402 typescript/packages/extensions/src/
|
|
48
|
+
* offer-receipt/server.ts RECEIPT_SCHEMA, main 5eee1e3c35, read 2026-09-29). */
|
|
49
|
+
export declare const OFFER_RECEIPT_RESPONSE_SCHEMA: {
|
|
50
|
+
readonly $schema: "https://json-schema.org/draft/2020-12/schema";
|
|
51
|
+
readonly type: "object";
|
|
52
|
+
readonly properties: {
|
|
53
|
+
readonly receipt: {
|
|
54
|
+
readonly type: "object";
|
|
55
|
+
readonly properties: {
|
|
56
|
+
readonly format: {
|
|
57
|
+
readonly type: "string";
|
|
58
|
+
};
|
|
59
|
+
readonly payload: {
|
|
60
|
+
readonly type: "object";
|
|
61
|
+
readonly properties: {
|
|
62
|
+
readonly version: {
|
|
63
|
+
readonly type: "integer";
|
|
64
|
+
};
|
|
65
|
+
readonly network: {
|
|
66
|
+
readonly type: "string";
|
|
67
|
+
};
|
|
68
|
+
readonly resourceUrl: {
|
|
69
|
+
readonly type: "string";
|
|
70
|
+
};
|
|
71
|
+
readonly payer: {
|
|
72
|
+
readonly type: "string";
|
|
73
|
+
};
|
|
74
|
+
readonly issuedAt: {
|
|
75
|
+
readonly type: "integer";
|
|
76
|
+
};
|
|
77
|
+
readonly transaction: {
|
|
78
|
+
readonly type: "string";
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
readonly required: readonly ["version", "network", "resourceUrl", "payer", "issuedAt"];
|
|
82
|
+
};
|
|
83
|
+
readonly signature: {
|
|
84
|
+
readonly type: "string";
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
readonly required: readonly ["format", "signature"];
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
readonly required: readonly ["receipt"];
|
|
91
|
+
};
|
|
92
|
+
/** Merge the receipt and the compliance record into an x402 `SettlementResponse` — the object
|
|
93
|
+
* the HTTP transport carries, base64-encoded, in the `PAYMENT-RESPONSE` header. The receipt goes
|
|
94
|
+
* to `extensions["offer-receipt"].info.receipt` (offer-receipt spec §5.1, same for v1 and v2)
|
|
95
|
+
* with its schema; the record and its attestation to `extensions["compliance-fields"].info`
|
|
96
|
+
* (placement proposed in x402-foundation/x402#2853, open). Every other member of the settlement
|
|
97
|
+
* response, and every other extension, is kept; an `offer-receipt` entry already present is
|
|
98
|
+
* replaced, because the record binds to THIS receipt's digest. */
|
|
99
|
+
export declare function attachToSettlementResponse<T extends Record<string, unknown>>(settlement: T, issued: IssuedReceipt): T & {
|
|
100
|
+
extensions: Record<string, unknown>;
|
|
101
|
+
};
|
|
102
|
+
/** LEGACY: decorate the resource's JSON response BODY with the same extension entries. The x402
|
|
103
|
+
* v2 HTTP transport carries protocol data in headers only ("Response bodies are a server
|
|
104
|
+
* implementation concern"), so a wallet reading `PAYMENT-RESPONSE` never sees a body placement.
|
|
105
|
+
* Kept for readers built against tersign ≤0.5; `withAssure` uses it only with
|
|
106
|
+
* `legacyBodyPlacement: true`. */
|
|
45
107
|
export declare function attachToExtensions<T extends Record<string, unknown>>(responseBody: T, issued: IssuedReceipt): T & {
|
|
46
108
|
extensions: Record<string, unknown>;
|
|
47
109
|
};
|