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 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: counter-signed OK (seller tersign-first, seq 1 …) VALID
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> [--ledger url]` recovers the EIP-712 signature **locally**. A bare digest is then checked against the Tersign ledger unless `--ledger` names another; a receipt file verifies offline and touches no chain at all. The ledger consulted is always printed. Prefer raw HTTP? The same proof, no CLI:
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. First call self-provisions a free signer-keyed account bound set-once to your key (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.
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
- Refunds chain back to the original receipt via `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.
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 so every paid call issues a signed, chained receipt. The full register:
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 — a receipt per paid call |
77
- | Compliance exports | EU Art-226b minimal tier · EN 16931 full tier · HK IRO s.51C retention |
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 | Chained to the original receipt via `refundOf` |
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` | yes | 0x-prefixed private key that signs your receipts and records |
127
- | `TERSIGN_LEDGER_URL` | no | hosted ledger for counter-signing + chain checks |
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 `issue_receipt`, then check the issued receipt's digest with `npx tersign verify <digest> --ledger <url>`.
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; the first call self-provisions a signer-keyed account |
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/tersign-js/blob/main/test/fixtures/canonical-vectors.json |
160
- | Sample action record + digests | https://github.com/tersignhq/tersign-js/blob/main/test/fixtures/compliance-record.json |
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> reserved on PyPI</sub></p>
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
 
@@ -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, issue the signed
26
- * receipt + compliance record on the way out (only when the settlement header reports
27
- * success and the response body is JSON). */
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 {};
@@ -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 response header. */
9
- /** x402 v2 header names, with v1 fallbacks. Re-verify at integration (CLAUDE.md rule 1). */
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(atob(value));
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
- export function extractSettlement(headers) {
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 || typeof parsed !== 'object')
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
- /** Wrap an x402-protected handler: enforce idempotency on the way in, issue the signed
49
- * receipt + compliance record on the way out (only when the settlement header reports
50
- * success and the response body is JSON). */
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
- const settlement = extractSettlement(res.headers);
78
- if (settlement?.success && (res.headers.get('content-type') ?? '').includes('application/json')) {
79
- const url = new URL(req.url);
80
- const overrides = cfg.toSettlementContext?.(req, settlement) ?? {};
81
- const ctx = {
82
- network: settlement.network ?? 'eip155:8453',
83
- resourceUrl: url.origin + url.pathname,
84
- payer: settlement.payer ?? 'unknown',
85
- settledAt: now(),
86
- supplyDescription: cfg.describeSupply?.(req) ?? url.pathname,
87
- ...(settlement.transaction !== undefined ? { txHash: settlement.transaction } : {}),
88
- ...overrides,
89
- };
90
- const issued = await cfg.assure.issueFor(ctx);
91
- const body = (await res.json());
92
- const decorated = attachToExtensions(body, issued);
93
- const headers = new Headers(res.headers);
94
- headers.delete('content-length');
95
- res = new Response(JSON.stringify(decorated), { status: res.status, headers });
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 base receipt
34
- * (merged offer-receipt extension, EIP-712) plus the Tersign compliance record bound to it,
35
- * and counter-sign into the hosted ledger when configured. Attach the result to the
36
- * SettlementResponse via `attachToExtensions`. */
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
- /** Decorate an x402 SettlementResponse body with the receipt at the spec-defined placement
44
- * (`extensions["offer-receipt"].info.receipt`) and the Tersign record alongside it. */
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
  };