xytara 3.2.2 → 3.3.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
@@ -22,6 +22,7 @@ This package ships:
22
22
 
23
23
  - `CommerceClient` — discover the catalog, quote, run the paid command path, read results
24
24
  - the delivery-receipt verifier — `verifyDeliveryReceipt`, `canonicalize`, `hashCanonical`, `keyRef`
25
+ - the attestation verifier — `verifyAttestation`, `attestationRecipe`, `ATTESTATION_VERSION`
25
26
  - x402 header helpers — `encodeX402PaymentHeader`, `decodeX402Header`
26
27
  - the `xytara` / `xytara-run` CLIs (quote-only first, then funded execution)
27
28
  - `endpoints` (runtime, mcp, website) and `version`
@@ -117,6 +118,36 @@ tools/call -> 402 Payment Required with a price
117
118
  Point any MCP client at the endpoint; the public integration guide is
118
119
  [github.com/naxytra/naxytra-mcp](https://github.com/naxytra/naxytra-mcp).
119
120
 
121
+ ## Teams: one owner key, many agent keys
122
+
123
+ The spend credential a top-up returns is the **owner key** for its account. It can mint capped,
124
+ revocable **agent keys** for each of your agents; every spend is audited. All routes take a spend
125
+ credential as `Authorization: Bearer xytara_spend_...`.
126
+
127
+ ```
128
+ POST /v1/credits/agents owner key only -> 201 { agent, bearer_token } (token shown once)
129
+ { label, max_units_per_call?, max_units_per_day?, max_units_total?, expires_at_iso?,
130
+ allowed_tools?: ["keccak256", ...], agent_public_key?: <ed25519 SPKI PEM> }
131
+ GET /v1/credits/agents owner: every agent key (never tokens) with bounds, status,
132
+ spent_today_units / spent_total_units / last_used_at_iso;
133
+ an agent key sees only itself
134
+ POST /v1/credits/agents/:id/revoke owner key only; immediate; { reason } is stored
135
+ GET /v1/credits/activity?limit=100&since=<iso>
136
+ recent spends with agent id/label, units, task_ref, tool_name,
137
+ credit_spend_id; owner sees the account, an agent sees its own
138
+ ```
139
+
140
+ An agent key spends exactly like the owner key (`/v1/economics/rails/credits/consume`, or
141
+ `credits_first` execution), but every spend is checked against its per-call, per-UTC-day and
142
+ lifetime caps, its tool allow-list, its expiry and revocation. Refusals are explicit and never
143
+ debit: `agent_call_cap_exceeded`, `agent_daily_cap_exceeded`, `agent_total_cap_exceeded`,
144
+ `agent_tool_not_allowed`, `spend_credential_expired`, `spend_credential_inactive`. Owner keys have
145
+ no caps. Agent keys cannot mint (`agent_key_cannot_mint`) or see other agents. A key minted with
146
+ `agent_public_key` is key-bound: each spend must also carry `x-agent-signature` /
147
+ `x-agent-timestamp` over `naxytra-spend-auth:<agent_credential_id>:<ts>:<sha256(JSON
148
+ ["naxytra-spend-binding/v1", account_id, String(units), task_ref])>`, so a captured signature cannot
149
+ be replayed or stretched to a larger spend.
150
+
120
151
  ## Signed delivery receipts
121
152
 
122
153
  Every paid call returns a `naxytra-delivery-receipt/1`: the payment leg (network, asset, amount,
@@ -144,6 +175,50 @@ POST /v1/delivery-receipts/verify # free signature check
144
175
  A receipt proves who delivered what for which paid call, unaltered since signing — **not** whether
145
176
  the output was correct or good. Check the settlement transaction on-chain yourself.
146
177
 
178
+ ## Attestations (verified answers)
179
+
180
+ The MCP server's `x402.verify-receipt` and `tx.status` tools cost **$0.02 per call** (20000 USDC
181
+ base units on Base mainnet, 2000 on Base Sepolia; 6 credits with a spend key — the free allowance
182
+ covers them too) and return, next to the tool output, a signed `naxytra-attestation/1` in
183
+ `result._meta["naxytra.com/attestation"]`: a statement of what the server observed on-chain from a
184
+ live RPC read, that you can hand to another party.
185
+
186
+ ```json
187
+ { "version": "naxytra-attestation/1", "attestation_id": "att_…", "issued_at_iso": "…", "issuer": "xytara-mcp-executor",
188
+ "subject": { "kind": "x402_settlement", "network": "eip155:8453", "tx": "0x…", "payer": "0x…", "pay_to": "0x…", "asset": "0x…", "amount": "10000" },
189
+ "claim": { "verified": true, "method": "base-rpc-logs/1", "observed_at_iso": "…", "block_number": 4080, "status": "success", "details": { "verdict": "confirmed", "checks": { … }, "transfer": { … } } },
190
+ "evidence_sha256": "<sha256 of JCS { tool, arguments, output }>", "bindings": [],
191
+ "sig": { "suite": ["EdDSA", "ML-DSA-65"], "kids": { "EdDSA": "ed25519-spki-sha256:…", "ML-DSA-65": "ml-dsa-65-spki-sha256:…" }, "signatures": { "EdDSA": "…", "ML-DSA-65": "…" } } }
192
+ ```
193
+
194
+ `subject.kind` is `x402_settlement` (method `base-rpc-logs/1`) or `transaction_status` (method
195
+ `base-rpc-receipt/1`, with `confirmations`). A negative answer (`claim.verified: false` — the
196
+ receipt does not match the chain, the transaction is unknown) is attested too; a tool error (RPC
197
+ unavailable) carries no attestation and is not charged. `bindings` is reserved for external
198
+ bindings (for example `{ "scheme": "ctp/1", "q": "…" }`) and must be carried unchanged — it is
199
+ inside the signed bytes. Signed bytes = JCS of the attestation with `sig.signatures` removed
200
+ (`sig.suite` and `sig.kids` stay signed), with the same keys as the delivery receipts.
201
+
202
+ ```js
203
+ const { verifyAttestation, hashCanonical, attestationRecipe } = require("xytara");
204
+
205
+ const keys = (await fetch("https://xytara-mcp-executor.onrender.com/.well-known/delivery-receipt-keys").then((r) => r.json())).keys;
206
+ const check = verifyAttestation(attestation, keys); // { valid, claim_verified, signatures: [{ alg, kid, valid }], ... }
207
+ const evidenceMatches = hashCanonical({ tool, arguments, output }) === attestation.evidence_sha256;
208
+ console.log(attestationRecipe("/.well-known/delivery-receipt-keys").steps);
209
+ ```
210
+
211
+ ```
212
+ GET /.well-known/delivery-receipt-keys # keys + attestation_recipe (same keys as receipts)
213
+ GET /attestations/:attestation_id # one of the last 5000 issued
214
+ POST /attestations/verify # free signature check: { attestation } -> { valid, kids_known, ... }
215
+ ```
216
+
217
+ Key-bound spend credentials: send your `X-Agent-Signature` / `X-Agent-Timestamp` headers with the
218
+ MCP call; the executor forwards them to the credits debit unchanged. A request-bound signature
219
+ commits to `{ account_id, units, task_ref: "adapter.mcp.invoke" }` with `units` = the tool's
220
+ `credits_per_call` (3, or 6 for an attesting tool).
221
+
147
222
  ## Verify the package
148
223
 
149
224
  ```bash
package/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,6 @@
1
- `xytara` 3.2.2 adds a per-request opt-out from the MCP server's free allowance and repairs the registry publish.
1
+ `xytara` 3.3.0 introduces the first paid products beyond pay-per-call tools: signed attestations and team spend authority.
2
2
 
3
- - **`X-Naxytra-Allowance: off`** (or `?allowance=off` on the MCP URL) skips the free daily allowance for that request, so x402-native clients, payment-flow tests and release probes get deterministic pay-per-call behaviour (the payment-required result). Opting out never spends the allowance.
4
- - The MCP registry publish workflow now waits for the live deploy without tripping on transient errors and probes the payment challenge with the allowance switched off.
5
- - Client package (thin): unchanged surface.
3
+ - **Signed attestations (verified answers)** — `x402.verify-receipt` and `tx.status` now return, in `_meta["naxytra.com/attestation"]`, a signed `naxytra-attestation/1` statement of the on-chain fact they verified (settlement matched or not; transaction status, block, confirmations), JCS-canonical and signed Ed25519 + ML-DSA-65 with the same keys as delivery receipts, with an `evidence_sha256` and a reserved `bindings` field. Negative findings are attested too; nothing is attested without a live chain read. Price $0.02 per call via x402 (20000 units on Base mainnet, 2000 on Base Sepolia) or 6 credits with a spend key; still within the free daily allowance. Free: `GET /attestations/<id>`, `POST /attestations/verify`, and offline verification with the client (`verifyAttestation`).
4
+ - **Teams (beta)** — an owner key (the key a top-up returns) mints capped agent keys: `POST /v1/credits/agents` (per-call, per-day and total caps, expiry, tool allow-list, optional key-binding so every spend needs a fresh request-bound signature), `GET /v1/credits/agents`, `POST /v1/credits/agents/:id/revoke`, `GET /v1/credits/activity` (audit trail). Caps are enforced before any provider work runs and again at commit; an agent key cannot mint keys, see other agents, or borrow another agent's caps.
5
+ - **Fixes** — an uncapped authority binding no longer refuses every delegated spend; the daily report lists the most common detail behind each frequent failure reason and names unidentified callers by their user-agent token.
6
+ - Client package (thin): adds `verifyAttestation`, `attestationRecipe`, `attestationSignedBytes`, `ATTESTATION_VERSION`; tarball otherwise unchanged.
package/client.js CHANGED
@@ -7,11 +7,15 @@
7
7
  const commerceClient = require("./lib/commerce_client");
8
8
  const {
9
9
  RECEIPT_VERSION,
10
+ ATTESTATION_VERSION,
10
11
  canonicalize,
11
12
  hashCanonical,
12
13
  keyRef,
13
14
  verifyDeliveryReceipt,
14
- verificationRecipe
15
+ verificationRecipe,
16
+ attestationSignedBytes,
17
+ verifyAttestation,
18
+ attestationRecipe
15
19
  } = require("./lib/delivery_receipt_verify");
16
20
  const { version } = require("./package.json");
17
21
 
@@ -70,6 +74,12 @@ module.exports = {
70
74
  keyRef,
71
75
  verifyDeliveryReceipt,
72
76
  verificationRecipe,
77
+ // Signed attestations (the MCP executor's verified answers): offline verification with the
78
+ // same published keys.
79
+ ATTESTATION_VERSION,
80
+ attestationSignedBytes,
81
+ verifyAttestation,
82
+ attestationRecipe,
73
83
  // x402 header helpers for the paid command path.
74
84
  X402_PAYMENT_HEADER,
75
85
  X402_PAYMENT_REQUIRED_HEADER,
@@ -12,6 +12,12 @@
12
12
  // Key ids = "<key type>-spki-sha256:<hex>", the same key-ref format the xoonya verifier
13
13
  // reports, so a receipt verifies with xoonya's verifyExternalProofSignature too.
14
14
  //
15
+ // Signed attestations ("naxytra-attestation/1") share the same keys, kid format and verifier
16
+ // pattern: a short, signed statement of an on-chain fact (an x402 settlement, a transaction's
17
+ // status) that the MCP executor observed from a live RPC read, which the buyer can hand to a third
18
+ // party who verifies it offline. The verification side (verifyAttestation, attestationRecipe) is
19
+ // here too; issuing lives in delivery_receipt.js.
20
+ //
15
21
  // Dependency-free (node:crypto). This file is shared verbatim by the xytara runtime
16
22
  // (lib/delivery_receipt_verify.js) and the MCP executor
17
23
  // (services/mcp-execution-target/delivery_receipt_verify.js); a verifier asserts the two copies
@@ -23,6 +29,12 @@ const RECEIPT_VERSION = "naxytra-delivery-receipt/1";
23
29
  const CANONICALIZATION = "JCS (RFC 8785) of the receipt without `sig`";
24
30
  const SUPPORTED_ALGS = { ed25519: "EdDSA", "ml-dsa-65": "ML-DSA-65" };
25
31
 
32
+ const ATTESTATION_VERSION = "naxytra-attestation/1";
33
+ // Attestation signed bytes: the attestation with `sig.signatures` removed. `sig.suite` and
34
+ // `sig.kids` stay INSIDE the signed bytes, so a stripped algorithm or a swapped key id fails.
35
+ const ATTESTATION_CANONICALIZATION = "JCS (RFC 8785) of the attestation with `sig.signatures` removed (`sig.suite` and `sig.kids` stay signed)";
36
+ const ATTESTATION_ID_PATTERN = /^att_[0-9a-f]{12}$/;
37
+
26
38
  // JCS for the JSON values a receipt carries (strings, finite numbers, booleans, null, arrays,
27
39
  // objects). Object keys sorted by UTF-16 code units; `undefined` members dropped like JSON.
28
40
  function canonicalize(value) {
@@ -124,10 +136,86 @@ function verificationRecipe(keysUrl) {
124
136
  };
125
137
  }
126
138
 
139
+ // --- attestations ------------------------------------------------------------------------------
140
+
141
+ function isPlainObject(value) {
142
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
143
+ }
144
+
145
+ // The bytes every attestation signature covers (see ATTESTATION_CANONICALIZATION).
146
+ function attestationSignedBytes(attestation) {
147
+ const { sig, ...rest } = attestation;
148
+ const { signatures: _omit, ...signedSig } = isPlainObject(sig) ? sig : {};
149
+ return Buffer.from(canonicalize({ ...rest, sig: signedSig }), "utf8");
150
+ }
151
+
152
+ // Verifies an attestation against a set of trusted public keys ([{ kid, alg, public_key_spki_der_base64 }]).
153
+ // Valid only if the version is known, `bindings` is an array (reserved; carried unchanged), every
154
+ // algorithm in the SIGNED suite has a kid in sig.kids and a signature in sig.signatures, every such
155
+ // kid is in the trusted set, and every signature verifies over the signed bytes.
156
+ function verifyAttestation(attestation, trustedKeys) {
157
+ const a = isPlainObject(attestation) ? attestation : null;
158
+ if (!a) return { valid: false, reason: "attestation_required" };
159
+ if (a.version !== ATTESTATION_VERSION) return { valid: false, reason: "unsupported_attestation_version", version: a.version || null };
160
+ if (!Array.isArray(a.bindings)) return { valid: false, reason: "bindings_must_be_an_array" };
161
+ const sig = isPlainObject(a.sig) ? a.sig : null;
162
+ if (!sig || !Array.isArray(sig.suite) || sig.suite.length === 0 || !isPlainObject(sig.kids) || !isPlainObject(sig.signatures)) {
163
+ return { valid: false, reason: "attestation_unsigned" };
164
+ }
165
+ const signedBytes = attestationSignedBytes(a);
166
+ const trusted = new Map((Array.isArray(trustedKeys) ? trustedKeys : []).map((key) => [key.kid, key]));
167
+ const results = sig.suite.map((alg) => {
168
+ const kid = typeof sig.kids[alg] === "string" ? sig.kids[alg] : null;
169
+ const value = typeof sig.signatures[alg] === "string" ? sig.signatures[alg] : null;
170
+ if (!kid) return { alg, kid: null, valid: false, reason: "kid_missing_for_signed_suite" };
171
+ if (!value) return { alg, kid, valid: false, reason: "signature_missing_for_signed_suite" };
172
+ const key = trusted.get(kid);
173
+ if (!key || key.alg !== alg) return { alg, kid, valid: false, reason: "kid_not_in_trusted_key_set" };
174
+ try {
175
+ const publicKey = crypto.createPublicKey({ key: Buffer.from(key.public_key_spki_der_base64, "base64"), format: "der", type: "spki" });
176
+ if (keyRef(publicKey) !== kid) return { alg, kid, valid: false, reason: "kid_does_not_match_key" };
177
+ const ok = crypto.verify(null, signedBytes, publicKey, Buffer.from(value, "base64"));
178
+ return { alg, kid, valid: ok, reason: ok ? null : "signature_invalid" };
179
+ } catch (error) {
180
+ return { alg, kid, valid: false, reason: "verification_error", detail: error && error.message ? error.message.slice(0, 120) : null };
181
+ }
182
+ });
183
+ const valid = results.length > 0 && results.every((item) => item.valid);
184
+ return {
185
+ valid,
186
+ reason: valid ? null : "signature_check_failed",
187
+ attestation_id: typeof a.attestation_id === "string" ? a.attestation_id : null,
188
+ issuer: typeof a.issuer === "string" ? a.issuer : null,
189
+ subject_kind: isPlainObject(a.subject) && typeof a.subject.kind === "string" ? a.subject.kind : null,
190
+ claim_verified: isPlainObject(a.claim) && typeof a.claim.verified === "boolean" ? a.claim.verified : null,
191
+ signatures: results,
192
+ signed_bytes_sha256: sha256Hex(signedBytes)
193
+ };
194
+ }
195
+
196
+ // A short, self-contained recipe for verifying an attestation without trusting the issuer's server.
197
+ function attestationRecipe(keysUrl) {
198
+ return {
199
+ version: ATTESTATION_VERSION,
200
+ signed_bytes: `${ATTESTATION_CANONICALIZATION} (UTF-8).`,
201
+ keys: keysUrl || null,
202
+ steps: [
203
+ "1. Fetch the issuer's public keys; check every kid in attestation.sig.kids is listed (and pin them).",
204
+ "2. Remove attestation.sig.signatures (keep sig.suite and sig.kids) and JCS-canonicalize what is left: these are the signed bytes.",
205
+ "3. For every alg in sig.suite, verify sig.signatures[alg] over the signed bytes with the key whose kid is sig.kids[alg] (node:crypto crypto.verify(null, bytes, spkiPublicKey, signature), or xoonya's verifyExternalProofSignature({ material, signature, algorithm, public_key, expected_key_ref: kid })).",
206
+ "4. Read subject (what was checked), claim.verified / claim.status / claim.details (what was observed) and claim.observed_at_iso (when). evidence_sha256 = sha256(JCS({ tool, arguments, output })) of the tool call that produced it; recompute it when you hold that call. bindings is reserved for external bindings and must be carried unchanged."
207
+ ],
208
+ boundary: "Proves this issuer observed this claim about this subject from a live chain read at observed_at_iso, unaltered since signing — not that the chain cannot reorganize, and nothing beyond the stated claim."
209
+ };
210
+ }
211
+
127
212
  module.exports = {
128
213
  RECEIPT_VERSION,
129
214
  CANONICALIZATION,
130
215
  SUPPORTED_ALGS,
216
+ ATTESTATION_VERSION,
217
+ ATTESTATION_CANONICALIZATION,
218
+ ATTESTATION_ID_PATTERN,
131
219
  canonicalize,
132
220
  sha256Hex,
133
221
  hashCanonical,
@@ -135,5 +223,8 @@ module.exports = {
135
223
  runtimeSupportsMlDsa,
136
224
  publicKeySet,
137
225
  verifyDeliveryReceipt,
138
- verificationRecipe
226
+ verificationRecipe,
227
+ attestationSignedBytes,
228
+ verifyAttestation,
229
+ attestationRecipe
139
230
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xytara",
3
- "version": "3.2.2",
3
+ "version": "3.3.0",
4
4
  "description": "Client for the hosted naxytra runtime: discover a capability, pay per call with x402 (gasless USDC on Base) or credits, get the deliverable and a signed delivery receipt you can verify offline. The runtime is a hosted service; this package is the client.",
5
5
  "main": "client.js",
6
6
  "exports": {
@@ -95,7 +95,7 @@
95
95
  "verify:production-readiness": "node scripts/verify_production_readiness.js",
96
96
  "verify:final-activation": "node scripts/verify_final_activation.js",
97
97
  "verify:x402-facilitator": "node scripts/verify_x402_facilitator.js",
98
- "verify:all": "node scripts/verify_all.js && node scripts/verify_integrations.js && node scripts/verify_adapters.js && node scripts/verify_control_plane_contracts.js && node scripts/verify_control_plane_routes.js && node scripts/verify_framework_provider_promotion.js && node scripts/verify_operator_observability_boundary.js && node scripts/verify_pricing_experiment_plan.js && node scripts/verify_treasury_public_boundary.js && node scripts/verify_market_lanes.js && node scripts/verify_market_lane_depth.js && node scripts/verify_market_lane_traces.js && node scripts/verify_market_lane_promotion.js && node scripts/verify_provider_runtime_breadth.js && node scripts/verify_provider_runtime_journeys.js && node scripts/verify_lifecycle_continuity.js && node scripts/verify_first_run_conversions.js && node scripts/verify_delivery_summary.js && node scripts/verify_runtime_durability.js && node scripts/verify_revenue_launch_paths.js && node scripts/verify_full_catalog.js && node scripts/verify_tooling.js && node scripts/verify_examples.js && node scripts/verify_service.js && node scripts/verify_x402_facilitator.js && node scripts/verify_cdp_facilitator.js && node scripts/verify_a2a_rpc.js && node scripts/verify_checkout_processor.js && node scripts/verify_l402_adapter.js && node scripts/verify_bolt_adapter.js && node scripts/verify_mpp_adapter.js && node scripts/verify_rate_limiter.js && node scripts/verify_execution_surfacing.js && node scripts/verify_agent_identity.js && node scripts/verify_verify_execute_settle.js && node scripts/verify_execution_dedupe.js && node scripts/verify_refund_credit.js && node scripts/verify_commerce_bridge.js && node scripts/verify_settlement.js && node scripts/verify_provider_network.js && node scripts/verify_external_execution_retry.js && node scripts/verify_runtime_state_guard.js && node scripts/state_backup_offsite.js selftest && node scripts/verify_lifecycle_spend_binding.js && node scripts/verify_buyer_safety.js && node scripts/verify_buyer_event_log.js && node scripts/verify_x402_settlement_recovery.js",
98
+ "verify:all": "node scripts/verify_all.js && node scripts/verify_integrations.js && node scripts/verify_adapters.js && node scripts/verify_control_plane_contracts.js && node scripts/verify_control_plane_routes.js && node scripts/verify_framework_provider_promotion.js && node scripts/verify_operator_observability_boundary.js && node scripts/verify_pricing_experiment_plan.js && node scripts/verify_treasury_public_boundary.js && node scripts/verify_market_lanes.js && node scripts/verify_market_lane_depth.js && node scripts/verify_market_lane_traces.js && node scripts/verify_market_lane_promotion.js && node scripts/verify_provider_runtime_breadth.js && node scripts/verify_provider_runtime_journeys.js && node scripts/verify_lifecycle_continuity.js && node scripts/verify_first_run_conversions.js && node scripts/verify_delivery_summary.js && node scripts/verify_runtime_durability.js && node scripts/verify_revenue_launch_paths.js && node scripts/verify_full_catalog.js && node scripts/verify_tooling.js && node scripts/verify_examples.js && node scripts/verify_service.js && node scripts/verify_x402_facilitator.js && node scripts/verify_cdp_facilitator.js && node scripts/verify_a2a_rpc.js && node scripts/verify_checkout_processor.js && node scripts/verify_l402_adapter.js && node scripts/verify_bolt_adapter.js && node scripts/verify_mpp_adapter.js && node scripts/verify_rate_limiter.js && node scripts/verify_execution_surfacing.js && node scripts/verify_agent_identity.js && node scripts/verify_verify_execute_settle.js && node scripts/verify_execution_dedupe.js && node scripts/verify_refund_credit.js && node scripts/verify_commerce_bridge.js && node scripts/verify_settlement.js && node scripts/verify_provider_network.js && node scripts/verify_external_execution_retry.js && node scripts/verify_runtime_state_guard.js && node scripts/state_backup_offsite.js selftest && node scripts/verify_lifecycle_spend_binding.js && node scripts/verify_buyer_safety.js && node scripts/verify_buyer_event_log.js && node scripts/verify_x402_settlement_recovery.js && node scripts/verify_team_spend.js",
99
99
  "prepublishOnly": "npm run verify:release-candidate",
100
100
  "verify:cdp-facilitator": "node scripts/verify_cdp_facilitator.js",
101
101
  "verify:a2a-rpc": "node scripts/verify_a2a_rpc.js",
@@ -119,6 +119,7 @@
119
119
  "verify:buyer-safety": "node scripts/verify_buyer_safety.js",
120
120
  "verify:buyer-event-log": "node scripts/verify_buyer_event_log.js",
121
121
  "verify:x402-settlement-recovery": "node scripts/verify_x402_settlement_recovery.js",
122
+ "verify:team-spend": "node scripts/verify_team_spend.js",
122
123
  "verify:client": "node scripts/verify_client.js"
123
124
  }
124
125
  }
@@ -6,6 +6,8 @@
6
6
  // the hosted endpoints and the package version.
7
7
  // 2. Signed delivery receipts: a receipt built in the documented canonical form and signed with an
8
8
  // Ed25519 key generated here verifies; tampered, stripped, unsigned and wrong-key copies do not.
9
+ // Signed attestations (naxytra-attestation/1): the same round trip, including the reserved
10
+ // `bindings` member surviving canonicalization and verification unchanged.
9
11
  // 3. The paid command path against a local stub of the hosted runtime: discover -> 402 challenge
10
12
  // with x402 accepts -> pay with an x402 payment header -> 200 with the result, the settlement
11
13
  // response and a signed delivery receipt that verifies against the stub's published keys.
@@ -25,11 +27,15 @@ const client = require(path.join(PACKAGE_DIR, "client.js"));
25
27
  const {
26
28
  CommerceClient,
27
29
  RECEIPT_VERSION,
30
+ ATTESTATION_VERSION,
28
31
  canonicalize,
29
32
  hashCanonical,
30
33
  keyRef,
31
34
  verifyDeliveryReceipt,
32
35
  verificationRecipe,
36
+ attestationSignedBytes,
37
+ verifyAttestation,
38
+ attestationRecipe,
33
39
  encodeX402PaymentHeader,
34
40
  decodeX402Header,
35
41
  summarizeCommandResultPayload,
@@ -67,6 +73,22 @@ function signReceipt(body, signer) {
67
73
  };
68
74
  }
69
75
 
76
+ // Attestation issuing, as the MCP executor does it (kept in-test: the client never issues them).
77
+ // Signed bytes = JCS of the attestation with sig.signatures removed (sig.suite / sig.kids signed).
78
+ function signAttestation(body, signer) {
79
+ const unsigned = {
80
+ version: ATTESTATION_VERSION,
81
+ bindings: [],
82
+ ...body,
83
+ sig: { suite: [signer.alg], kids: { [signer.alg]: signer.kid } }
84
+ };
85
+ const signedBytes = Buffer.from(canonicalize(unsigned), "utf8");
86
+ return {
87
+ ...unsigned,
88
+ sig: { ...unsigned.sig, signatures: { [signer.alg]: crypto.sign(null, signedBytes, signer.privateKey).toString("base64") } }
89
+ };
90
+ }
91
+
70
92
  function makeSigner() {
71
93
  const { privateKey, publicKey } = crypto.generateKeyPairSync("ed25519");
72
94
  const kid = keyRef(publicKey);
@@ -230,10 +252,11 @@ function runCliAsync(args) {
230
252
  async function main() {
231
253
  check("package_surface", () => {
232
254
  assert.strictEqual(typeof CommerceClient, "function", "CommerceClient export missing");
233
- for (const name of ["verifyDeliveryReceipt", "canonicalize", "hashCanonical", "keyRef", "verificationRecipe", "encodeX402PaymentHeader", "decodeX402Header", "summarizeCommandResultPayload", "summarizeCatalogPayload", "summarizeReceiptPayload"]) {
255
+ for (const name of ["verifyDeliveryReceipt", "canonicalize", "hashCanonical", "keyRef", "verificationRecipe", "verifyAttestation", "attestationRecipe", "attestationSignedBytes", "encodeX402PaymentHeader", "decodeX402Header", "summarizeCommandResultPayload", "summarizeCatalogPayload", "summarizeReceiptPayload"]) {
234
256
  assert.strictEqual(typeof client[name], "function", `${name} export missing`);
235
257
  }
236
258
  assert.strictEqual(RECEIPT_VERSION, "naxytra-delivery-receipt/1", "receipt version mismatch");
259
+ assert.strictEqual(ATTESTATION_VERSION, "naxytra-attestation/1", "attestation version mismatch");
237
260
  assert.strictEqual(endpoints.runtime, "https://xytara.onrender.com", "runtime endpoint mismatch");
238
261
  assert.strictEqual(endpoints.mcp, "https://xytara-mcp-executor.onrender.com/mcp", "mcp endpoint mismatch");
239
262
  assert.strictEqual(endpoints.website, "https://www.naxytra.com", "website endpoint mismatch");
@@ -283,6 +306,61 @@ async function main() {
283
306
  assert.strictEqual(verifyDeliveryReceipt(null, [signer.trustedKey]).reason, "receipt_required", "missing receipt must fail");
284
307
  });
285
308
 
309
+ check("attestation_offline_round_trip", () => {
310
+ const issuer = makeSigner();
311
+ const toolCall = { tool: "tx.status", arguments: { tx_hash: "0x" + "9b".repeat(32), network: "base" }, output: { ok: true, found: true, status: "success", block_number: 4080, confirmations: 17 } };
312
+ const bindings = [{ scheme: "ctp/1", q: "cid:example" }];
313
+ const attestation = signAttestation({
314
+ attestation_id: "att_0123456789ab",
315
+ issued_at_iso: "2026-01-01T00:00:02.000Z",
316
+ issuer: "xytara-mcp-executor",
317
+ subject: { kind: "transaction_status", network: "eip155:8453", tx: toolCall.arguments.tx_hash },
318
+ claim: { verified: true, method: "base-rpc-receipt/1", observed_at_iso: "2026-01-01T00:00:01.000Z", block_number: 4080, confirmations: 17, status: "success", details: { from: "0x" + "11".repeat(20), to: null } },
319
+ evidence_sha256: hashCanonical(toolCall),
320
+ bindings
321
+ }, issuer);
322
+ assert.strictEqual(attestation.version, ATTESTATION_VERSION, "attestation version");
323
+ assert.deepStrictEqual(Object.keys(attestation.sig).sort(), ["kids", "signatures", "suite"], "sig = { suite, kids, signatures }");
324
+ const result = verifyAttestation(attestation, [issuer.trustedKey]);
325
+ assert.strictEqual(result.valid, true, `genuine attestation must verify: ${JSON.stringify(result)}`);
326
+ assert.deepStrictEqual([result.attestation_id, result.subject_kind, result.claim_verified, result.signatures[0].alg, result.signed_bytes_sha256.length], ["att_0123456789ab", "transaction_status", true, "EdDSA", 64], "verify result reports id, subject kind, claim and signature");
327
+ assert.strictEqual(result.signed_bytes_sha256, crypto.createHash("sha256").update(attestationSignedBytes(attestation)).digest("hex"), "signed bytes are the attestation with sig.signatures removed");
328
+ assert.ok(canonicalize({ ...attestation, sig: { suite: attestation.sig.suite, kids: attestation.sig.kids } }) === attestationSignedBytes(attestation).toString("utf8") && canonicalize(attestation).includes("\"suite\":[\"EdDSA\"]"), "sig.suite and sig.kids are inside the signed bytes");
329
+ assert.strictEqual(hashCanonical(toolCall), attestation.evidence_sha256, "evidence hash recomputes from { tool, arguments, output }");
330
+ // bindings: reserved, round-trips unchanged through canonicalization + verification
331
+ const reordered = JSON.parse(JSON.stringify({ sig: attestation.sig, bindings: attestation.bindings, claim: attestation.claim, ...attestation }));
332
+ assert.strictEqual(verifyAttestation(reordered, [issuer.trustedKey]).valid, true, "key order must not matter (JCS)");
333
+ assert.deepStrictEqual(reordered.bindings, bindings, "bindings survive the round trip unchanged");
334
+ const negative = signAttestation({ ...attestation, claim: { ...attestation.claim, verified: false, status: "not_found" } }, issuer);
335
+ assert.strictEqual(verifyAttestation(negative, [issuer.trustedKey]).claim_verified, false, "a negative claim (verified:false) is a valid signed attestation");
336
+ // tamper -> reject
337
+ const tamperedClaim = JSON.parse(JSON.stringify(attestation));
338
+ tamperedClaim.claim.verified = false;
339
+ assert.strictEqual(verifyAttestation(tamperedClaim, [issuer.trustedKey]).valid, false, "tampered claim must fail");
340
+ const tamperedBindings = JSON.parse(JSON.stringify(attestation));
341
+ tamperedBindings.bindings.push({ scheme: "ctp/1", q: "added-later" });
342
+ assert.strictEqual(verifyAttestation(tamperedBindings, [issuer.trustedKey]).valid, false, "bindings are signed: adding one later must fail");
343
+ const removedBindings = JSON.parse(JSON.stringify(attestation));
344
+ removedBindings.bindings = [];
345
+ assert.strictEqual(verifyAttestation(removedBindings, [issuer.trustedKey]).valid, false, "removing a binding must fail");
346
+ assert.strictEqual(verifyAttestation({ ...attestation, bindings: null }, [issuer.trustedKey]).reason, "bindings_must_be_an_array", "bindings must be an array");
347
+ const swappedKid = JSON.parse(JSON.stringify(attestation));
348
+ swappedKid.sig.kids.EdDSA = makeSigner().kid;
349
+ assert.strictEqual(verifyAttestation(swappedKid, [issuer.trustedKey]).signatures[0].reason, "kid_not_in_trusted_key_set", "swapped kid must fail");
350
+ assert.strictEqual(verifyAttestation(attestation, [makeSigner().trustedKey]).signatures[0].reason, "kid_not_in_trusted_key_set", "untrusted key set must fail");
351
+ const stripped = JSON.parse(JSON.stringify(attestation));
352
+ stripped.sig.signatures = {};
353
+ assert.strictEqual(verifyAttestation(stripped, [issuer.trustedKey]).signatures[0].reason, "signature_missing_for_signed_suite", "stripped signature must fail");
354
+ const noSuite = JSON.parse(JSON.stringify(attestation));
355
+ delete noSuite.sig.suite;
356
+ assert.strictEqual(verifyAttestation(noSuite, [issuer.trustedKey]).reason, "attestation_unsigned", "missing suite must fail");
357
+ assert.strictEqual(verifyAttestation({ ...attestation, version: "naxytra-attestation/0" }, [issuer.trustedKey]).reason, "unsupported_attestation_version", "unknown version must fail");
358
+ assert.strictEqual(verifyAttestation(null, [issuer.trustedKey]).reason, "attestation_required", "missing attestation must fail");
359
+ const recipe = attestationRecipe("/.well-known/delivery-receipt-keys");
360
+ assert.deepStrictEqual([recipe.version, recipe.keys, recipe.steps.length], [ATTESTATION_VERSION, "/.well-known/delivery-receipt-keys", 4], "attestation recipe");
361
+ assert.ok(recipe.signed_bytes.includes("sig.signatures"), "recipe states the signed form");
362
+ });
363
+
286
364
  check("x402_header_helpers", () => {
287
365
  const authorization = { from: PAYER, to: ACCEPTS[0].payTo, value: ACCEPTS[0].amount, validAfter: "0", validBefore: "9999999999", nonce: "0x" + "00".repeat(32) };
288
366
  const header = encodeX402PaymentHeader({ accepted: ACCEPTS[0], authorization, signature: "0x" + "11".repeat(65) });