xytara 3.1.10 → 3.1.12
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/.env.example +61 -1
- package/README.md +1 -1
- package/RELEASE_NOTES.md +4 -6
- package/lib/adapter_depth_contract.js +70 -8
- package/lib/capability_execution_truth.js +8 -4
- package/lib/command_flow.js +74 -8
- package/lib/external_execution_policy.js +143 -0
- package/lib/external_execution_runtime.js +48 -8
- package/lib/provider_network_contract.js +14 -9
- package/lib/provider_payout_execution.js +20 -2
- package/lib/provider_registry.js +186 -10
- package/package.json +2 -2
- package/scripts/verify_provider_network.js +255 -3
- package/server.js +34 -8
- package/examples/generated.carried-handoff.json +0 -30
package/.env.example
CHANGED
|
@@ -17,6 +17,7 @@ XYTARA_X402_TOPUP_UNITS=1000
|
|
|
17
17
|
# The runtime mints a fresh CDP Bearer JWT per request when both are set.
|
|
18
18
|
CDP_API_KEY_ID=
|
|
19
19
|
CDP_API_KEY_SECRET=
|
|
20
|
+
# (XYTARA_X402_CDP_KEY_ID / XYTARA_X402_CDP_KEY_SECRET are accepted as aliases for the two above)
|
|
20
21
|
XYTARA_PAYMENT_MAX_AGE_SECONDS=300
|
|
21
22
|
XYTARA_WALLET_REGISTRY_JSON=[]
|
|
22
23
|
XYTARA_TREASURY_DESTINATION_ID=
|
|
@@ -24,6 +25,9 @@ XYTARA_TREASURY_DESTINATION_KIND=
|
|
|
24
25
|
XYTARA_TREASURY_ACCOUNT_ID=
|
|
25
26
|
XYTARA_TREASURY_EXTERNAL_REF=
|
|
26
27
|
XYTARA_TREASURY_DESTINATIONS_JSON={}
|
|
28
|
+
# optional file-backed alternatives to the two *_JSON values above (blank = use the JSON values)
|
|
29
|
+
XYTARA_WALLET_REGISTRY_PATH=
|
|
30
|
+
XYTARA_TREASURY_DESTINATIONS_PATH=
|
|
27
31
|
XYTARA_BSV_TERANODE_NETWORK=mainnet
|
|
28
32
|
XYTARA_BSV_TERANODE_RPC_URL=
|
|
29
33
|
XYTARA_BSV_TERANODE_MODE=opreturn
|
|
@@ -37,6 +41,18 @@ XYTARA_BSV_TERANODE_SETTLEMENT_RUNTIME_MODE=mock
|
|
|
37
41
|
# operator write surfaces fail closed until both tokens are set
|
|
38
42
|
XYTARA_CREDIT_BRIDGE_BEARER_TOKEN=
|
|
39
43
|
XYTARA_ACCOUNT_AUTH_BEARER_TOKEN=
|
|
44
|
+
# commerce-bridge write surface (distinct from the credit bridge); also fails closed until set
|
|
45
|
+
XYTARA_COMMERCE_BRIDGE_BEARER_TOKEN=
|
|
46
|
+
# optional: require spend-proof binding on spend-credential use (blank = runtime default)
|
|
47
|
+
XYTARA_REQUIRE_SPEND_PROOF_BINDING=
|
|
48
|
+
# hosted-checkout webhook verification (blank = runtime defaults; set the secret before going live)
|
|
49
|
+
XYTARA_CHECKOUT_WEBHOOK_SECRET=
|
|
50
|
+
XYTARA_HOSTED_CHECKOUT_WEBHOOK_SECRET=
|
|
51
|
+
XYTARA_CHECKOUT_WEBHOOK_VERIFICATION_REQUIRED=
|
|
52
|
+
XYTARA_CHECKOUT_WEBHOOK_MAX_AGE_SECONDS=
|
|
53
|
+
# optional commerce pack map / prep TTL overrides (blank = runtime defaults)
|
|
54
|
+
XYTARA_COMMERCE_PACK_MAP_JSON=
|
|
55
|
+
XYTARA_COMMERCE_PREP_TTL_MS=
|
|
40
56
|
XYTARA_CALLBACK_DELIVERY_ENABLED=true
|
|
41
57
|
XYTARA_CALLBACK_SIGNING_SECRET=
|
|
42
58
|
XYTARA_CALLBACK_TIMEOUT_MS=5000
|
|
@@ -47,10 +63,52 @@ XYTARA_SETTLEMENT_REFRESH_INTERVAL_MS=1500
|
|
|
47
63
|
XYTARA_RATE_LIMIT_ENABLED=true
|
|
48
64
|
XYTARA_RATE_LIMIT_WINDOW_MS=60000
|
|
49
65
|
XYTARA_RATE_LIMIT_MAX_REQUESTS=600
|
|
50
|
-
|
|
66
|
+
# optional: scope the rate limit per route instead of globally (blank = runtime default)
|
|
67
|
+
XYTARA_RATE_LIMIT_ROUTE_SCOPED=
|
|
68
|
+
XYTARA_REQUEST_TIMEOUT_MS=30000
|
|
69
|
+
XYTARA_HEADERS_TIMEOUT_MS=15000
|
|
51
70
|
XYTARA_EXECUTION_TARGETS_JSON=[]
|
|
71
|
+
# External execution (env targets + registered providers). Cold-start ride-out retries ONLY on
|
|
72
|
+
# not-processed signals (408/429/503, connection refused/DNS). The budget is the TOTAL wall-clock for
|
|
73
|
+
# one buyer call across all retries AND failover candidates, kept inside the ~60s x402 authorization
|
|
74
|
+
# window so a ridden-out call still settles. Per-target timeout_ms is capped at 30000 for the same reason.
|
|
75
|
+
XYTARA_EXTERNAL_EXECUTION_RETRY_WINDOW_MS=45000
|
|
76
|
+
XYTARA_EXTERNAL_EXECUTION_MAX_RETRIES=29
|
|
77
|
+
XYTARA_EXTERNAL_EXECUTION_BUDGET_MS=50000
|
|
78
|
+
# Open execution-provider network (lib/provider_registry.js): providers self-register an endpoint +
|
|
79
|
+
# the capabilities they serve; the runtime routes paid calls to ACTIVE providers and splits revenue.
|
|
80
|
+
# Registration FAILS CLOSED until the bearer is set, UNLESS open registration is turned on. Every
|
|
81
|
+
# provider still lands pending until an operator activates it (the vetting gate). See the live
|
|
82
|
+
# machine contract at GET /v1/provider-network/contract.
|
|
83
|
+
XYTARA_PROVIDER_REGISTRY_BEARER_TOKEN=
|
|
84
|
+
# 1 = permissionless self-registration (no bearer); still rate-limited + pending-capped.
|
|
85
|
+
XYTARA_PROVIDER_OPEN_REGISTRATION=0
|
|
86
|
+
XYTARA_PROVIDER_REGISTRATION_RATE_PER_MIN=5
|
|
87
|
+
XYTARA_PROVIDER_REGISTRATION_RATE_GLOBAL_PER_MIN=60
|
|
88
|
+
XYTARA_PROVIDER_MAX_PENDING=100
|
|
89
|
+
# provider endpoint policy: strict (default when RENDER is set) = https only + no loopback/private/
|
|
90
|
+
# link-local/metadata hosts, re-checked by DNS at dispatch; permissive (default locally) for dev/tests
|
|
91
|
+
XYTARA_PROVIDER_ENDPOINT_POLICY=
|
|
92
|
+
# cap on a provider's revenue share, basis points (10000 = 100%)
|
|
93
|
+
XYTARA_PROVIDER_MAX_REVENUE_SHARE_BPS=10000
|
|
94
|
+
# health/failover: consecutive failures -> unhealthy; cooldown before a half-open retry; max candidates tried
|
|
95
|
+
XYTARA_PROVIDER_UNHEALTHY_THRESHOLD=3
|
|
96
|
+
XYTARA_PROVIDER_UNHEALTHY_COOLDOWN_MS=60000
|
|
97
|
+
XYTARA_PROVIDER_FAILOVER_MAX=3
|
|
98
|
+
# routing reputation recency decay, factor in (0,1): fades older observations so recent performance
|
|
99
|
+
# weighs more. 1 (default) = no decay (lifetime counts). e.g. 0.95 once the network has real history.
|
|
100
|
+
XYTARA_PROVIDER_REPUTATION_DECAY=1
|
|
101
|
+
# Provider payout auto-execution via an EXTERNAL signer (the runtime stays key-less). OFF unless the
|
|
102
|
+
# URL is set; otherwise payouts are recorded and the operator sends + confirms them. See services/payout-signer.
|
|
103
|
+
XYTARA_PAYOUT_SIGNER_URL=
|
|
104
|
+
XYTARA_PAYOUT_SIGNER_BEARER=
|
|
105
|
+
XYTARA_PAYOUT_SIGNER_TIMEOUT_MS=20000
|
|
52
106
|
XYTARA_L402_ENABLED=false
|
|
53
107
|
XYTARA_L402_SHARED_SECRET=
|
|
108
|
+
# optional L402 tuning (blank = runtime defaults)
|
|
109
|
+
XYTARA_L402_MAX_AGE_SECONDS=
|
|
110
|
+
XYTARA_L402_INVOICE_RATE_PER_MIN=
|
|
111
|
+
XYTARA_L402_INVOICE_RATE_GLOBAL_PER_MIN=
|
|
54
112
|
XYTARA_BOLT_ENABLED=false
|
|
55
113
|
XYTARA_BOLT_SHARED_SECRET=
|
|
56
114
|
XYTARA_BOLT_NETWORK=mainnet
|
|
@@ -61,6 +119,8 @@ XYTARA_MPP_SHARED_SECRET=
|
|
|
61
119
|
XYTARA_MPP_PROVIDER=stripe_tempo_compatible
|
|
62
120
|
XYTARA_MPP_SETTLEMENT_FAMILY=cards_or_stablecoin_or_lightning
|
|
63
121
|
XYTARA_MPP_MAX_AGE_SECONDS=300
|
|
122
|
+
# optional minimum MPP amount in minor units (blank = runtime default)
|
|
123
|
+
XYTARA_MPP_MIN_AMOUNT_MINOR=
|
|
64
124
|
XYTARA_NETWORK_DNS_RESULT_ORDER=verbatim
|
|
65
125
|
XYTARA_NETWORK_PREFERRED_IP_FAMILY=auto
|
|
66
126
|
XYTARA_EXTERNAL_EXECUTION_IP_FAMILY=auto
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# xytara
|
|
2
2
|
|
|
3
|
-
[
|
|
3
|
+
Listed on [Smithery](https://smithery.ai/server/naxytra/xytara-mcp-executor) · [Glama](https://glama.ai/mcp/servers/naxytra/xytara-mcp-executor) · the official MCP registry (`com.naxytra/xytara-mcp-executor`).
|
|
4
4
|
|
|
5
5
|
**x402 pay-per-call agent-commerce runtime** — the payment and execution layer of the naxytra
|
|
6
6
|
machine stack. An agent discovers a capability, pays once with x402 (gasless USDC on Base), and gets
|
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
`xytara` 3.1.
|
|
1
|
+
`xytara` 3.1.12 makes the public adapter-depth surface report open-network providers truthfully.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
-
|
|
6
|
-
- **Reference payout signer** (`services/payout-signer`) — a separately-deployed service that sends provider payouts on Base while the runtime stays key-less. Fail-closed and double-pay-safe, with an optional durable (Supabase) idempotency store so a free-tier restart cannot cause a re-send.
|
|
7
|
-
- Refreshed README and package metadata to lead with the proven, mainnet-demonstrated capabilities.
|
|
3
|
+
- `/v1/adapter-depth` and `/v1/adapter-depth/summary` now count ACTIVE open-network providers that declared an adapter task as provider-live paths, alongside operator env targets. Each is labelled `routing: open_provider_registry`, and the pack lists its `depth_sources`. A pending provider never counts, and a provider is shown as shadowed (not counted) when an operator env target pins the whole lane, because env targets are routed first.
|
|
4
|
+
- Previously the surface only looked at env targets, so it reported `provider_wiring_required` even while paid MCP calls were being served by the live registry provider. It now reports `initial_provider_live_path` for the task that provider serves.
|
|
5
|
+
- Internal callers without runtime state keep the env-only view, so no other surface changes.
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const packageJson = require("../package.json");
|
|
4
4
|
const { catalog } = require("../fixtures/catalog");
|
|
5
5
|
const { loadExecutionTargetRegistry } = require("./external_execution_runtime");
|
|
6
|
+
const { listProviders } = require("./provider_registry");
|
|
6
7
|
|
|
7
8
|
function normalizeString(value, fallback = "") {
|
|
8
9
|
const raw = typeof value === "string" ? value.trim() : "";
|
|
@@ -59,10 +60,60 @@ function summarizeTarget(target) {
|
|
|
59
60
|
};
|
|
60
61
|
}
|
|
61
62
|
|
|
62
|
-
|
|
63
|
-
|
|
63
|
+
// ACTIVE open-network providers that declared this task_ref. Env targets resolve before the
|
|
64
|
+
// registry at call time, so a provider is shadowed (not routed) when an enabled env target pins
|
|
65
|
+
// the WHOLE lane (task_ref with no tool/integration selector); it is then listed but not counted.
|
|
66
|
+
function listRegistryProvidersForTask(state, taskRef, envTargets) {
|
|
67
|
+
if (!state) return [];
|
|
68
|
+
const normalizedTaskRef = normalizeString(taskRef);
|
|
69
|
+
const wholeLanePinned = envTargets.some((target) =>
|
|
70
|
+
target.enabled !== false
|
|
71
|
+
&& normalizeString(target.task_ref) === normalizedTaskRef
|
|
72
|
+
&& !normalizeString(target.tool_name)
|
|
73
|
+
&& !normalizeString(target.integration_id)
|
|
74
|
+
);
|
|
75
|
+
return listProviders(state, { status: "active" })
|
|
76
|
+
.filter((provider) => Array.isArray(provider.task_refs) && provider.task_refs.includes(normalizedTaskRef))
|
|
77
|
+
.map((provider) => {
|
|
78
|
+
const health = (provider.health && provider.health.status) || "healthy";
|
|
79
|
+
return {
|
|
80
|
+
target_id: `registry:${provider.provider_id}`,
|
|
81
|
+
provider: provider.provider_id,
|
|
82
|
+
routing: "open_provider_registry",
|
|
83
|
+
task_ref: normalizedTaskRef,
|
|
84
|
+
tool_name: null,
|
|
85
|
+
tool_names: Array.isArray(provider.tool_names) ? provider.tool_names.slice() : [],
|
|
86
|
+
integration_id: null,
|
|
87
|
+
enabled: !wholeLanePinned,
|
|
88
|
+
shadowed_by_env_target: wholeLanePinned,
|
|
89
|
+
health_status: health,
|
|
90
|
+
timeout_ms: provider.timeout_ms,
|
|
91
|
+
endpoint_configured: Boolean(normalizeString(provider.endpoint_url)),
|
|
92
|
+
auth_configured: Boolean(normalizeString(provider.auth_token)),
|
|
93
|
+
operational_coverage: {
|
|
94
|
+
execute_endpoint: Boolean(normalizeString(provider.endpoint_url)),
|
|
95
|
+
bearer_auth: Boolean(normalizeString(provider.auth_token)),
|
|
96
|
+
bounded_timeout: Number(provider.timeout_ms || 0) >= 500,
|
|
97
|
+
// The provider-network execution contract fixes the deliverable shape
|
|
98
|
+
// (result.output / output_payload), and activation is operator-vetted against it.
|
|
99
|
+
proof_fact_compatible: true,
|
|
100
|
+
deterministic_retry_surface: true,
|
|
101
|
+
tool_level_binding: Array.isArray(provider.tool_names) && provider.tool_names.length > 0,
|
|
102
|
+
operator_health_surface: "runtime_tracked_provider_health",
|
|
103
|
+
operator_tool_catalog_surface: null
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function summarizeAdapterTaskDepth(task, state) {
|
|
110
|
+
const envTargets = listTargetsForTask(task.task_ref).map(summarizeTarget);
|
|
111
|
+
const registryTargets = listRegistryProvidersForTask(state, task.task_ref, loadExecutionTargetRegistry());
|
|
112
|
+
const targets = envTargets.concat(registryTargets);
|
|
64
113
|
const enabledTargets = targets.filter((target) => target.enabled);
|
|
65
|
-
const toolNames = Array.from(new Set(enabledTargets
|
|
114
|
+
const toolNames = Array.from(new Set(enabledTargets
|
|
115
|
+
.flatMap((target) => [target.tool_name].concat(target.tool_names || []))
|
|
116
|
+
.filter(Boolean))).sort();
|
|
66
117
|
const allTargetsCovered = enabledTargets.length > 0 && enabledTargets.every((target) =>
|
|
67
118
|
target.operational_coverage.execute_endpoint
|
|
68
119
|
&& target.operational_coverage.bounded_timeout
|
|
@@ -80,6 +131,8 @@ function summarizeAdapterTaskDepth(task) {
|
|
|
80
131
|
provider_live: enabledTargets.length > 0,
|
|
81
132
|
configured_target_count: targets.length,
|
|
82
133
|
enabled_target_count: enabledTargets.length,
|
|
134
|
+
env_target_count: envTargets.length,
|
|
135
|
+
registry_provider_count: registryTargets.filter((target) => target.enabled).length,
|
|
83
136
|
configured_tool_count: toolNames.length,
|
|
84
137
|
configured_tools: toolNames,
|
|
85
138
|
execution_targets: targets,
|
|
@@ -89,11 +142,17 @@ function summarizeAdapterTaskDepth(task) {
|
|
|
89
142
|
};
|
|
90
143
|
}
|
|
91
144
|
|
|
92
|
-
|
|
93
|
-
|
|
145
|
+
// `state` (optional): the live runtime state. When given, ACTIVE open-network providers count as
|
|
146
|
+
// provider-live paths alongside operator env targets (each labelled routing:open_provider_registry).
|
|
147
|
+
// Without it the pack is env-target-only, exactly as before.
|
|
148
|
+
function buildAdapterDepthPack(state) {
|
|
149
|
+
const adapterTasks = listAdapterTasks().map((task) => summarizeAdapterTaskDepth(task, state));
|
|
94
150
|
const providerLiveTasks = adapterTasks.filter((task) => task.provider_live);
|
|
95
151
|
const deepenedTasks = adapterTasks.filter((task) => task.provider_depth_state === "provider_live_path_deepened");
|
|
96
152
|
const configuredTargets = loadExecutionTargetRegistry().filter((target) => target && target.enabled !== false);
|
|
153
|
+
const registryProviderIds = new Set(adapterTasks.flatMap((task) =>
|
|
154
|
+
task.execution_targets.filter((target) => target.routing === "open_provider_registry" && target.enabled).map((target) => target.provider)
|
|
155
|
+
));
|
|
97
156
|
return {
|
|
98
157
|
ok: true,
|
|
99
158
|
product: packageJson.name,
|
|
@@ -110,8 +169,10 @@ function buildAdapterDepthPack() {
|
|
|
110
169
|
deepened_provider_live_task_count: deepenedTasks.length,
|
|
111
170
|
configured_execution_target_count: configuredTargets.length,
|
|
112
171
|
configured_provider_count: new Set(configuredTargets.map((target) => normalizeString(target.provider))).size,
|
|
113
|
-
configured_tool_count: new Set(configuredTargets.map((target) => normalizeString(target.tool_name)).filter(Boolean)).size
|
|
172
|
+
configured_tool_count: new Set(configuredTargets.map((target) => normalizeString(target.tool_name)).filter(Boolean)).size,
|
|
173
|
+
registry_provider_count: registryProviderIds.size
|
|
114
174
|
},
|
|
175
|
+
depth_sources: state ? ["operator_env_targets", "open_provider_registry"] : ["operator_env_targets"],
|
|
115
176
|
adapter_tasks: adapterTasks,
|
|
116
177
|
production_depth_requirements: [
|
|
117
178
|
"at_least_one_enabled_execution_target_for_provider_backed_task",
|
|
@@ -136,8 +197,8 @@ function buildAdapterDepthPack() {
|
|
|
136
197
|
};
|
|
137
198
|
}
|
|
138
199
|
|
|
139
|
-
function summarizeAdapterDepthPack() {
|
|
140
|
-
const pack = buildAdapterDepthPack();
|
|
200
|
+
function summarizeAdapterDepthPack(state) {
|
|
201
|
+
const pack = buildAdapterDepthPack(state);
|
|
141
202
|
return {
|
|
142
203
|
ok: true,
|
|
143
204
|
product: pack.product,
|
|
@@ -145,6 +206,7 @@ function summarizeAdapterDepthPack() {
|
|
|
145
206
|
summary_version: "xytara-adapter-depth-summary-v1",
|
|
146
207
|
adapter_depth_state: pack.adapter_depth_state,
|
|
147
208
|
counts: pack.counts,
|
|
209
|
+
depth_sources: pack.depth_sources,
|
|
148
210
|
public_claim_boundary: pack.public_claim_boundary,
|
|
149
211
|
linked_surfaces: pack.linked_surfaces
|
|
150
212
|
};
|
|
@@ -108,6 +108,9 @@ function classifyTask(task, settlementReadiness) {
|
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
if (normalizeString(task && task.public_category) === "adapter") {
|
|
111
|
+
// Only ENABLED targets make an adapter lane live — a bound-but-disabled target must not be
|
|
112
|
+
// reported as provider_live (adapter-depth filters the same way; the two surfaces must agree).
|
|
113
|
+
const enabledTargets = providerTargets.filter((target) => target.enabled);
|
|
111
114
|
return {
|
|
112
115
|
task_ref: task.public_task_ref,
|
|
113
116
|
internal_task_ref: task.task_ref,
|
|
@@ -115,10 +118,11 @@ function classifyTask(task, settlementReadiness) {
|
|
|
115
118
|
public_category: task.public_category,
|
|
116
119
|
execution_mode: task.execution_mode,
|
|
117
120
|
latency_class: task.latency_class,
|
|
118
|
-
execution_truth:
|
|
119
|
-
execution_origin:
|
|
120
|
-
commercialization_posture:
|
|
121
|
-
matching_execution_target_count:
|
|
121
|
+
execution_truth: enabledTargets.length > 0 ? "provider_live" : "provider_wiring_required",
|
|
122
|
+
execution_origin: enabledTargets.length > 0 ? "external_execution_target" : "adapter_lane_without_bound_target",
|
|
123
|
+
commercialization_posture: enabledTargets.length > 0 ? "sellable_live_now" : "sellable_by_integration",
|
|
124
|
+
matching_execution_target_count: enabledTargets.length,
|
|
125
|
+
disabled_execution_target_count: providerTargets.length - enabledTargets.length,
|
|
122
126
|
matching_execution_targets: providerTargets,
|
|
123
127
|
requirements: {
|
|
124
128
|
configured_provider_target_required: true
|
package/lib/command_flow.js
CHANGED
|
@@ -10,6 +10,7 @@ const { resolveSpendCredential, evaluateAuthorityBindingAction } = require("./ac
|
|
|
10
10
|
const { validateIntegrationSelection } = require("../integrations/dispatch");
|
|
11
11
|
const { markPaymentAccepted, settleDeferredPayment, verifyPaymentAcceptance } = require("./payment_verification");
|
|
12
12
|
const { executeExternalTarget, resolveExecutionTarget } = require("./external_execution_runtime");
|
|
13
|
+
const { externalExecutionBudgetMs } = require("./external_execution_policy");
|
|
13
14
|
const { resolveRegisteredTargets, hasActiveMatchingProvider, recordProviderHealth, recordProviderRouting, recordProviderSettlement } = require("./provider_registry");
|
|
14
15
|
const { claimDeferredExecution, releaseDeferredExecution } = require("./execution_dedupe");
|
|
15
16
|
|
|
@@ -94,12 +95,36 @@ function buildCreditRejectionResponse(creditDecision, quote) {
|
|
|
94
95
|
};
|
|
95
96
|
}
|
|
96
97
|
|
|
98
|
+
// What a provider is allowed to see of the buyer's payment context: the verification outcome and
|
|
99
|
+
// scalar payment facts only. NEVER the signed x402 authorization / deferred-settlement material
|
|
100
|
+
// (that would hand a third-party provider the buyer's signature before settlement), and never
|
|
101
|
+
// signature-, secret- or raw-looking keys or nested objects from any lane's payload.
|
|
102
|
+
const SENSITIVE_CONTEXT_KEY = /signature|secret|private|authorization|raw|base64|header|token/i;
|
|
103
|
+
function sanitizeAuthorizationContextForProvider(context) {
|
|
104
|
+
if (!context || typeof context !== "object") return null;
|
|
105
|
+
const payload = context.payment_payload && typeof context.payment_payload === "object" ? context.payment_payload : null;
|
|
106
|
+
const safePayload = payload
|
|
107
|
+
? Object.fromEntries(Object.entries(payload).filter(([key, value]) => !SENSITIVE_CONTEXT_KEY.test(key) && (value === null || typeof value !== "object")))
|
|
108
|
+
: null;
|
|
109
|
+
return {
|
|
110
|
+
ok: context.ok === true,
|
|
111
|
+
verification_mode: context.verification_mode || null,
|
|
112
|
+
wallet_id: context.wallet_id || null,
|
|
113
|
+
age_seconds: Number.isFinite(Number(context.age_seconds)) ? Number(context.age_seconds) : null,
|
|
114
|
+
payment_payload: safePayload
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
97
118
|
async function buildExternalExecutionOptions(state, payload, quote, authorizationContext) {
|
|
119
|
+
// One wall-clock budget for the whole call (every retry of every candidate), inside the x402
|
|
120
|
+
// authorization window — see external_execution_policy.js.
|
|
121
|
+
const executionOptions = { deadline_ms: Date.now() + externalExecutionBudgetMs() };
|
|
122
|
+
const forwardedContext = sanitizeAuthorizationContextForProvider(authorizationContext);
|
|
98
123
|
// Operator-pinned env target resolves first and is used as-is (an explicit single target,
|
|
99
124
|
// no failover). Empty env + empty registry -> {} (unchanged behavior).
|
|
100
125
|
const envTarget = resolveExecutionTarget(payload, quote);
|
|
101
126
|
if (envTarget) {
|
|
102
|
-
const execution = await executeExternalTarget(envTarget, payload, quote,
|
|
127
|
+
const execution = await executeExternalTarget(envTarget, payload, quote, forwardedContext, executionOptions);
|
|
103
128
|
if (!execution || !execution.ok) {
|
|
104
129
|
return { error: execution || { ok: false, status: 502, reason: "external_execution_failed" } };
|
|
105
130
|
}
|
|
@@ -122,7 +147,12 @@ async function buildExternalExecutionOptions(state, payload, quote, authorizatio
|
|
|
122
147
|
}
|
|
123
148
|
let lastError = null;
|
|
124
149
|
for (const target of candidates) {
|
|
125
|
-
|
|
150
|
+
if (Date.now() >= executionOptions.deadline_ms) {
|
|
151
|
+
// Budget spent on earlier candidates: stop here rather than start a call that cannot settle.
|
|
152
|
+
lastError = lastError || { ok: false, status: 504, reason: "external_execution_budget_exhausted" };
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
const execution = await executeExternalTarget(target, payload, quote, forwardedContext, executionOptions);
|
|
126
156
|
try { recordProviderRouting(state, target.registered_provider_id); } catch (_) { /* non-fatal */ }
|
|
127
157
|
if (execution && execution.ok) {
|
|
128
158
|
try { recordProviderHealth(state, target.registered_provider_id, { ok: true }); } catch (_) { /* non-fatal */ }
|
|
@@ -139,9 +169,38 @@ async function buildExternalExecutionOptions(state, payload, quote, authorizatio
|
|
|
139
169
|
return { error: lastError };
|
|
140
170
|
}
|
|
141
171
|
|
|
172
|
+
// Assets the x402 lane can settle in, with their on-chain decimals (needed to turn settled base
|
|
173
|
+
// units into the ledger's cents). Anything else cannot be reconciled and must not accrue on the quote.
|
|
174
|
+
const X402_ASSET_DECIMALS = new Map([
|
|
175
|
+
["0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", 6], // USDC, Base mainnet (eip155:8453)
|
|
176
|
+
["0x036cbd53842c5426634e7929541ec2318f3dcf7e", 6] // USDC, Base Sepolia (eip155:84532)
|
|
177
|
+
]);
|
|
178
|
+
|
|
179
|
+
// The amount a provider's revenue share is computed from. The quote is what we ASKED for; on the
|
|
180
|
+
// x402 lane the facilitator settles exactly the advertised `accepts` amount, which may differ from
|
|
181
|
+
// the catalog quote — so accrue on min(quote, settled) and never on money that was not collected.
|
|
182
|
+
// Other lanes bind the paid amount to the quote at verification time (local_signed) or charge the
|
|
183
|
+
// quote directly (credits / hosted checkout), so the quote is the settled amount there.
|
|
184
|
+
function reconcileProviderSettlementAmount(quote, verification) {
|
|
185
|
+
const quoted = quote && Number.isFinite(Number(quote.amount_minor)) ? Math.max(0, Math.round(Number(quote.amount_minor))) : 0;
|
|
186
|
+
const mode = verification && typeof verification.verification_mode === "string" ? verification.verification_mode : null;
|
|
187
|
+
if (mode !== "x402_facilitator") {
|
|
188
|
+
return { amount_minor: quoted, quoted_minor: quoted, settled_minor: null, settlement_basis: mode ? `${mode}_quote` : "quote", reconciled: true };
|
|
189
|
+
}
|
|
190
|
+
const paymentPayload = (verification && verification.payment_payload) || {};
|
|
191
|
+
const decimals = X402_ASSET_DECIMALS.get(String(paymentPayload.x402_asset || "").toLowerCase());
|
|
192
|
+
const baseUnits = /^\d{1,30}$/.test(String(paymentPayload.x402_amount)) ? Number(paymentPayload.x402_amount) : NaN;
|
|
193
|
+
const centsCurrency = /^USD(_CENTS)?$/i.test(String((quote && quote.currency) || "USD_CENTS"));
|
|
194
|
+
if (decimals === undefined || !Number.isFinite(baseUnits) || !centsCurrency) {
|
|
195
|
+
return { amount_minor: 0, quoted_minor: quoted, settled_minor: null, settlement_basis: "x402_unreconciled_asset", reconciled: false };
|
|
196
|
+
}
|
|
197
|
+
const settledCents = Math.floor(baseUnits / Math.pow(10, decimals - 2));
|
|
198
|
+
return { amount_minor: Math.min(quoted, settledCents), quoted_minor: quoted, settled_minor: settledCents, settlement_basis: "x402_settled_min_quote", reconciled: true };
|
|
199
|
+
}
|
|
200
|
+
|
|
142
201
|
// Records the provider revenue-share split for a settled call routed to a registered
|
|
143
202
|
// provider. Fail-safe: metering must NEVER break or reverse a settled/charged response.
|
|
144
|
-
function attributeProviderSettlement(state, externalExecutionOptions, payload, quote, extra) {
|
|
203
|
+
function attributeProviderSettlement(state, externalExecutionOptions, payload, quote, extra, verification) {
|
|
145
204
|
const execution = externalExecutionOptions && externalExecutionOptions.externalExecution;
|
|
146
205
|
const providerId = execution && execution.registered_provider_id;
|
|
147
206
|
if (!providerId) return;
|
|
@@ -149,9 +208,14 @@ function attributeProviderSettlement(state, externalExecutionOptions, payload, q
|
|
|
149
208
|
const taskRef = (payload && payload.task_ref)
|
|
150
209
|
|| (Array.isArray(payload && payload.tasks) && payload.tasks[0] && payload.tasks[0].task_ref)
|
|
151
210
|
|| null;
|
|
211
|
+
const reconciled = reconcileProviderSettlementAmount(quote, verification);
|
|
152
212
|
recordProviderSettlement(state, {
|
|
153
213
|
provider_id: providerId,
|
|
154
|
-
amount_minor:
|
|
214
|
+
amount_minor: reconciled.amount_minor,
|
|
215
|
+
quoted_minor: reconciled.quoted_minor,
|
|
216
|
+
settled_minor: reconciled.settled_minor,
|
|
217
|
+
settlement_basis: reconciled.settlement_basis,
|
|
218
|
+
reconciled: reconciled.reconciled,
|
|
155
219
|
currency: quote && quote.currency ? quote.currency : null,
|
|
156
220
|
transaction_id: (extra && extra.transaction_id) || (quote && quote.quote_id) || null,
|
|
157
221
|
settlement_ref: (extra && extra.settlement_ref) || null,
|
|
@@ -299,7 +363,7 @@ async function executeCommandRequest(state, payload, headers, options) {
|
|
|
299
363
|
};
|
|
300
364
|
attributeProviderSettlement(state, externalExecutionOptions, payload, quote, {
|
|
301
365
|
transaction_id: creditSpend.credit_spend.credit_spend_id
|
|
302
|
-
});
|
|
366
|
+
}, paymentVerification);
|
|
303
367
|
return {
|
|
304
368
|
status: 200,
|
|
305
369
|
payload: createCommandResult(state, {
|
|
@@ -486,7 +550,7 @@ async function executeCommandRequest(state, payload, headers, options) {
|
|
|
486
550
|
|
|
487
551
|
attributeProviderSettlement(state, externalExecutionOptions, payload, quote, {
|
|
488
552
|
settlement_ref: (settledVerification.settlement && (settledVerification.settlement.transaction || settledVerification.settlement.txid)) || null
|
|
489
|
-
});
|
|
553
|
+
}, settledVerification);
|
|
490
554
|
return {
|
|
491
555
|
status: 200,
|
|
492
556
|
payload: createCommandResult(state, payload, quote, {
|
|
@@ -544,7 +608,7 @@ async function executeAuthorizedCommandRequest(state, payload, authorizationCont
|
|
|
544
608
|
};
|
|
545
609
|
}
|
|
546
610
|
|
|
547
|
-
attributeProviderSettlement(state, externalExecutionOptions, payload, quote);
|
|
611
|
+
attributeProviderSettlement(state, externalExecutionOptions, payload, quote, undefined, paymentVerification);
|
|
548
612
|
return {
|
|
549
613
|
status: 200,
|
|
550
614
|
payload: createCommandResult(state, payload, quote, {
|
|
@@ -557,5 +621,7 @@ async function executeAuthorizedCommandRequest(state, payload, authorizationCont
|
|
|
557
621
|
module.exports = {
|
|
558
622
|
executeAuthorizedCommandRequest,
|
|
559
623
|
executeCommandRequest,
|
|
560
|
-
buildExternalExecutionOptions
|
|
624
|
+
buildExternalExecutionOptions,
|
|
625
|
+
reconcileProviderSettlementAmount,
|
|
626
|
+
sanitizeAuthorizationContextForProvider
|
|
561
627
|
};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const dns = require("dns").promises;
|
|
4
|
+
const net = require("net");
|
|
5
|
+
|
|
6
|
+
// Upper bound on a single provider call. The x402 (EIP-3009) authorization a buyer signs is valid
|
|
7
|
+
// for roughly a minute, so a provider allowed to run longer than this could do real work for a
|
|
8
|
+
// payment that then fails to settle (nobody pays). Applies to env targets and registered providers.
|
|
9
|
+
const MAX_TARGET_TIMEOUT_MS = 30000;
|
|
10
|
+
const DEFAULT_TARGET_TIMEOUT_MS = 10000;
|
|
11
|
+
const MIN_TARGET_TIMEOUT_MS = 500;
|
|
12
|
+
|
|
13
|
+
// Total wall-clock budget for ONE buyer call across cold-start ride-out retries AND failover
|
|
14
|
+
// candidates, shared so three candidates can never each consume a full window. Kept inside the
|
|
15
|
+
// x402 settlement window (~60s) with headroom for the settle call itself.
|
|
16
|
+
const DEFAULT_EXECUTION_BUDGET_MS = 50000;
|
|
17
|
+
const MAX_EXECUTION_BUDGET_MS = 55000;
|
|
18
|
+
|
|
19
|
+
function clampTargetTimeoutMs(value) {
|
|
20
|
+
const n = Number(value || DEFAULT_TARGET_TIMEOUT_MS) || DEFAULT_TARGET_TIMEOUT_MS;
|
|
21
|
+
return Math.min(MAX_TARGET_TIMEOUT_MS, Math.max(MIN_TARGET_TIMEOUT_MS, Math.round(n)));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function externalExecutionBudgetMs() {
|
|
25
|
+
const raw = process.env.XYTARA_EXTERNAL_EXECUTION_BUDGET_MS;
|
|
26
|
+
if (raw === undefined || raw === "") return DEFAULT_EXECUTION_BUDGET_MS;
|
|
27
|
+
const n = Number(raw);
|
|
28
|
+
if (!Number.isFinite(n) || n <= 0) return DEFAULT_EXECUTION_BUDGET_MS;
|
|
29
|
+
return Math.min(MAX_EXECUTION_BUDGET_MS, Math.max(1000, Math.round(n)));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Endpoint policy. "strict" (default on a hosted deploy, i.e. when RENDER is set) requires https
|
|
33
|
+
// and refuses endpoints at loopback / private / link-local / metadata addresses, so a registered
|
|
34
|
+
// provider cannot turn the runtime into a request proxy into its own network (SSRF). "permissive"
|
|
35
|
+
// (default locally) keeps http://127.0.0.1-style endpoints usable for tests and development.
|
|
36
|
+
function endpointPolicyMode() {
|
|
37
|
+
const raw = String(process.env.XYTARA_PROVIDER_ENDPOINT_POLICY || "").trim().toLowerCase();
|
|
38
|
+
if (raw === "strict" || raw === "permissive") return raw;
|
|
39
|
+
return process.env.RENDER ? "strict" : "permissive";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isPrivateIp(ip) {
|
|
43
|
+
const family = net.isIP(ip);
|
|
44
|
+
if (family === 4) {
|
|
45
|
+
const p = ip.split(".").map(Number);
|
|
46
|
+
if (p[0] === 10 || p[0] === 127 || p[0] === 0) return true;
|
|
47
|
+
if (p[0] === 169 && p[1] === 254) return true; // link-local + cloud metadata
|
|
48
|
+
if (p[0] === 172 && p[1] >= 16 && p[1] <= 31) return true;
|
|
49
|
+
if (p[0] === 192 && p[1] === 168) return true;
|
|
50
|
+
if (p[0] === 100 && p[1] >= 64 && p[1] <= 127) return true; // carrier-grade NAT
|
|
51
|
+
if (p[0] >= 224) return true; // multicast / reserved / broadcast
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
if (family === 6) {
|
|
55
|
+
const lower = ip.toLowerCase();
|
|
56
|
+
if (lower === "::" || lower === "::1") return true;
|
|
57
|
+
if (lower.startsWith("fe80:") || lower.startsWith("fc") || lower.startsWith("fd")) return true; // link-local, ULA
|
|
58
|
+
if (lower.startsWith("::ffff:")) return isPrivateIp(lower.slice(7)); // v4-mapped
|
|
59
|
+
if (lower.startsWith("ff")) return true; // multicast
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isBlockedHostname(hostname) {
|
|
66
|
+
const h = String(hostname || "").toLowerCase().replace(/\.$/, "");
|
|
67
|
+
if (!h || h === "localhost" || h.endsWith(".localhost")) return true;
|
|
68
|
+
if (h.endsWith(".local") || h.endsWith(".internal") || h.endsWith(".localdomain")) return true;
|
|
69
|
+
if (h === "metadata" || h === "metadata.google.internal") return true;
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function hostOf(parsedUrl) {
|
|
74
|
+
return String(parsedUrl.hostname || "").replace(/^\[|\]$/g, "");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Synchronous shape check (used at registration): protocol + literal-host rules. No DNS here.
|
|
78
|
+
function checkEndpointUrlShape(endpointUrl) {
|
|
79
|
+
const policy = endpointPolicyMode();
|
|
80
|
+
let parsed;
|
|
81
|
+
try {
|
|
82
|
+
parsed = new URL(String(endpointUrl || ""));
|
|
83
|
+
} catch (_) {
|
|
84
|
+
return { ok: false, reason: "endpoint_url_invalid", policy };
|
|
85
|
+
}
|
|
86
|
+
if (parsed.username || parsed.password) {
|
|
87
|
+
return { ok: false, reason: "endpoint_url_credentials_not_allowed", policy };
|
|
88
|
+
}
|
|
89
|
+
if (policy !== "strict") return { ok: true, policy };
|
|
90
|
+
if (parsed.protocol !== "https:") {
|
|
91
|
+
return { ok: false, reason: "endpoint_url_insecure", detail: "https:// is required for provider endpoints", policy };
|
|
92
|
+
}
|
|
93
|
+
const host = hostOf(parsed);
|
|
94
|
+
if (isBlockedHostname(host) || (net.isIP(host) && isPrivateIp(host))) {
|
|
95
|
+
return { ok: false, reason: "endpoint_url_not_public", detail: "loopback, private, link-local and metadata hosts are refused", policy };
|
|
96
|
+
}
|
|
97
|
+
return { ok: true, policy };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Dispatch-time check: re-validates the shape AND resolves the host, refusing any address in a
|
|
101
|
+
// private range (defeats DNS rebinding to an internal address after registration). Results are
|
|
102
|
+
// cached briefly so the hot path adds at most one lookup per host per minute.
|
|
103
|
+
const resolveCache = new Map();
|
|
104
|
+
const RESOLVE_CACHE_TTL_MS = 60000;
|
|
105
|
+
|
|
106
|
+
async function checkEndpointBeforeDispatch(endpointUrl) {
|
|
107
|
+
const shape = checkEndpointUrlShape(endpointUrl);
|
|
108
|
+
if (!shape.ok || shape.policy !== "strict") return shape;
|
|
109
|
+
let host;
|
|
110
|
+
try {
|
|
111
|
+
host = hostOf(new URL(String(endpointUrl)));
|
|
112
|
+
} catch (_) {
|
|
113
|
+
return { ok: false, reason: "endpoint_url_invalid", policy: shape.policy };
|
|
114
|
+
}
|
|
115
|
+
if (net.isIP(host)) return shape; // literal already checked above
|
|
116
|
+
const cached = resolveCache.get(host);
|
|
117
|
+
if (cached && cached.expires_at > Date.now()) return cached.result;
|
|
118
|
+
let result;
|
|
119
|
+
try {
|
|
120
|
+
const addresses = await dns.lookup(host, { all: true, verbatim: true });
|
|
121
|
+
const bad = addresses.find((entry) => isPrivateIp(entry.address));
|
|
122
|
+
result = bad
|
|
123
|
+
? { ok: false, reason: "endpoint_url_not_public", detail: "endpoint resolves to a non-public address", policy: shape.policy }
|
|
124
|
+
: { ok: true, policy: shape.policy };
|
|
125
|
+
} catch (_) {
|
|
126
|
+
// Unresolvable host: let the fetch surface ENOTFOUND (a not-processed, failover-safe signal).
|
|
127
|
+
result = { ok: true, policy: shape.policy, unresolved: true };
|
|
128
|
+
}
|
|
129
|
+
resolveCache.set(host, { result, expires_at: Date.now() + RESOLVE_CACHE_TTL_MS });
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = {
|
|
134
|
+
MAX_TARGET_TIMEOUT_MS,
|
|
135
|
+
DEFAULT_TARGET_TIMEOUT_MS,
|
|
136
|
+
DEFAULT_EXECUTION_BUDGET_MS,
|
|
137
|
+
clampTargetTimeoutMs,
|
|
138
|
+
externalExecutionBudgetMs,
|
|
139
|
+
endpointPolicyMode,
|
|
140
|
+
isPrivateIp,
|
|
141
|
+
checkEndpointUrlShape,
|
|
142
|
+
checkEndpointBeforeDispatch
|
|
143
|
+
};
|