xytara 3.1.13 → 3.1.15

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.
Files changed (68) hide show
  1. package/.env.example +38 -4
  2. package/BSV_TERANODE_PLATFORM_DISCONTINUITY_RUNBOOK.md +126 -126
  3. package/REAL_PAYMENT_SETUP.md +14 -7
  4. package/RELEASE_NOTES.md +9 -4
  5. package/fixtures/catalog.js +22 -0
  6. package/lib/a2a_rpc.js +287 -280
  7. package/lib/account_auth.js +585 -585
  8. package/lib/agent_identity.js +162 -162
  9. package/lib/asyncapi_contract.js +3 -3
  10. package/lib/bolt_adapter.js +187 -187
  11. package/lib/bolt_payment_front.js +224 -224
  12. package/lib/buyer_event_log.js +215 -0
  13. package/lib/buyer_safety_policy.js +132 -0
  14. package/lib/checkout_processor_adapter.js +217 -217
  15. package/lib/command_flow.js +911 -722
  16. package/lib/commerce_artifacts.js +591 -591
  17. package/lib/commerce_bridge.js +274 -274
  18. package/lib/commerce_cases.js +399 -399
  19. package/lib/commerce_client.js +7694 -7694
  20. package/lib/commerce_quotes.js +29 -0
  21. package/lib/commerce_runtime.js +322 -322
  22. package/lib/execution_dedupe.js +86 -86
  23. package/lib/external_credit_bridge.js +142 -142
  24. package/lib/http_transport.js +99 -81
  25. package/lib/l402_adapter.js +241 -241
  26. package/lib/l402_payment_front.js +265 -265
  27. package/lib/mpp_adapter.js +237 -237
  28. package/lib/openapi_contract.js +4 -0
  29. package/lib/payment_config.js +257 -253
  30. package/lib/payment_verification.js +357 -357
  31. package/lib/provider_payout_execution.js +155 -155
  32. package/lib/provider_registry.js +852 -842
  33. package/lib/rate_limiter.js +81 -81
  34. package/lib/runtime_state_store.js +495 -369
  35. package/lib/settlement_bsv_live.js +600 -600
  36. package/lib/settlement_bsv_teranode.js +117 -117
  37. package/lib/stripe_mpp_payment_front.js +258 -258
  38. package/lib/x402_facilitator.js +198 -191
  39. package/lib/x402_lane_contract.js +5 -3
  40. package/lib/x402_settlement_recovery.js +175 -0
  41. package/package.json +9 -3
  42. package/scripts/buyer_events_daily_report.js +79 -0
  43. package/scripts/state_backup_offsite.js +196 -0
  44. package/scripts/verify_a2a_rpc.js +131 -105
  45. package/scripts/verify_agent_identity.js +153 -153
  46. package/scripts/verify_all.js +1 -1
  47. package/scripts/verify_bolt_adapter.js +128 -128
  48. package/scripts/verify_buyer_event_log.js +123 -0
  49. package/scripts/verify_buyer_safety.js +141 -0
  50. package/scripts/verify_cdp_facilitator.js +110 -110
  51. package/scripts/verify_checkout_processor.js +109 -109
  52. package/scripts/verify_commerce_bridge.js +177 -177
  53. package/scripts/verify_execution_dedupe.js +189 -189
  54. package/scripts/verify_execution_surfacing.js +76 -76
  55. package/scripts/verify_final_activation.js +191 -191
  56. package/scripts/verify_l402_adapter.js +86 -86
  57. package/scripts/verify_lifecycle_spend_binding.js +88 -0
  58. package/scripts/verify_mpp_adapter.js +128 -128
  59. package/scripts/verify_production_readiness.js +268 -268
  60. package/scripts/verify_rate_limiter.js +52 -52
  61. package/scripts/verify_refund_credit.js +106 -106
  62. package/scripts/verify_release_candidate.js +81 -81
  63. package/scripts/verify_runtime_state_guard.js +117 -0
  64. package/scripts/verify_service.js +15421 -15421
  65. package/scripts/verify_verify_execute_settle.js +209 -208
  66. package/scripts/verify_x402_facilitator.js +307 -286
  67. package/scripts/verify_x402_settlement_recovery.js +153 -0
  68. package/server.js +12242 -11974
@@ -1,585 +1,585 @@
1
- "use strict";
2
-
3
- const crypto = require("crypto");
4
- const { normalizeAgentPublicKey, verifySpendAuthProof } = require("./agent_identity");
5
-
6
- function normalizeString(value, fallback = null) {
7
- const trimmed = typeof value === "string" ? value.trim() : "";
8
- return trimmed || fallback;
9
- }
10
-
11
- function ensureObject(value) {
12
- return value && typeof value === "object" && !Array.isArray(value) ? value : {};
13
- }
14
-
15
- function ensureArray(value) {
16
- return Array.isArray(value) ? value : [];
17
- }
18
-
19
- function nowIso() {
20
- return new Date().toISOString();
21
- }
22
-
23
- function buildCredentialPublicView(credential, includeToken = false) {
24
- if (!credential) return null;
25
- const view = {
26
- credential_id: credential.credential_id,
27
- account_id: credential.account_id,
28
- status: credential.status,
29
- spend_posture: credential.spend_posture,
30
- effective_pool_binding_mode: credential.entitlement_id ? "entitlement_bound" : credential.pack_id ? "pack_bound" : "unbound",
31
- scope: credential.scope,
32
- agent_id: credential.agent_id,
33
- budget_id: credential.budget_id,
34
- pack_id: credential.pack_id || null,
35
- entitlement_id: credential.entitlement_id || null,
36
- expires_at_iso: credential.expires_at_iso,
37
- created_at_iso: credential.created_at_iso,
38
- updated_at_iso: credential.updated_at_iso,
39
- issued_by: credential.issued_by || null,
40
- labels: ensureArray(credential.labels),
41
- // Cryptographic identity: when a credential is bound to an agent ed25519 key,
42
- // using it requires a fresh signed proof (not just the bearer token).
43
- identity_bound: Boolean(credential.agent_public_key),
44
- agent_key_ref: credential.agent_key_ref || null
45
- };
46
- if (includeToken) {
47
- view.bearer_token = credential.bearer_token;
48
- }
49
- return view;
50
- }
51
-
52
- function buildAuthorityBindingPublicView(binding, credential, includeToken = false) {
53
- if (!binding) return null;
54
- const view = {
55
- binding_id: binding.binding_id,
56
- account_id: binding.account_id,
57
- status: binding.status,
58
- authority_kind: binding.authority_kind,
59
- spend_posture: binding.spend_posture,
60
- scope: binding.scope,
61
- authority_scope: ensureArray(binding.authority_scope),
62
- allowed_consequence_families: ensureArray(binding.allowed_consequence_families),
63
- consequence_bounds: binding.consequence_bounds || {
64
- max_commit_units: null,
65
- max_reserve_units: null,
66
- max_reversal_units: null
67
- },
68
- agent_id: binding.agent_id,
69
- budget_id: binding.budget_id,
70
- pack_id: binding.pack_id || null,
71
- entitlement_id: binding.entitlement_id || null,
72
- credential_id: binding.credential_id || null,
73
- issued_by: binding.issued_by || null,
74
- labels: ensureArray(binding.labels),
75
- expires_at_iso: binding.expires_at_iso || null,
76
- rotated_from_binding_id: binding.rotated_from_binding_id || null,
77
- replaced_by_binding_id: binding.replaced_by_binding_id || null,
78
- created_at_iso: binding.created_at_iso,
79
- updated_at_iso: binding.updated_at_iso,
80
- revoked_at_iso: binding.revoked_at_iso || null,
81
- revoke_reason: binding.revoke_reason || null,
82
- spend_credential: buildCredentialPublicView(credential, includeToken)
83
- };
84
- return view;
85
- }
86
-
87
- function buildAuthorityBindingContractSummary(binding, credential) {
88
- if (!binding) return null;
89
- const publicView = buildAuthorityBindingPublicView(binding, credential, false);
90
- return {
91
- summary_version: "xytara-authority-binding-contract-summary-v1",
92
- binding_id: binding.binding_id,
93
- account_id: binding.account_id,
94
- authority_kind: binding.authority_kind,
95
- spend_posture: binding.spend_posture,
96
- authority_scope: ensureArray(binding.authority_scope),
97
- allowed_consequence_families: ensureArray(binding.allowed_consequence_families),
98
- consequence_bounds: publicView.consequence_bounds,
99
- linked_credential_id: binding.credential_id || null,
100
- contract_state: binding.status === "active" ? "active_contract" : "inactive_contract",
101
- linked_surfaces: {
102
- binding_ref: `/v1/account-auth/authority-bindings/${encodeURIComponent(binding.binding_id)}`,
103
- spend_credential_ref: binding.credential_id ? `/v1/account-auth/spend-credentials/${encodeURIComponent(binding.credential_id)}` : null
104
- }
105
- };
106
- }
107
-
108
- function buildAuthorityBindingRotationSummary(binding, credential) {
109
- if (!binding) return null;
110
- const active = binding.status === "active";
111
- return {
112
- summary_version: "xytara-authority-binding-rotation-summary-v1",
113
- binding_id: binding.binding_id,
114
- account_id: binding.account_id,
115
- status: binding.status,
116
- renewable_state: active ? "renewable" : "inactive",
117
- rotatable_state: active ? "rotatable" : "inactive",
118
- expires_at_iso: binding.expires_at_iso || null,
119
- rotated_from_binding_id: binding.rotated_from_binding_id || null,
120
- replaced_by_binding_id: binding.replaced_by_binding_id || null,
121
- linked_credential_id: binding.credential_id || null,
122
- linked_surfaces: {
123
- binding_ref: `/v1/account-auth/authority-bindings/${encodeURIComponent(binding.binding_id)}`,
124
- contract_summary_ref: `/v1/account-auth/authority-bindings/${encodeURIComponent(binding.binding_id)}/contract-summary`,
125
- spend_credential_ref: binding.credential_id ? `/v1/account-auth/spend-credentials/${encodeURIComponent(binding.credential_id)}` : null
126
- }
127
- };
128
- }
129
-
130
- function isCredentialExpired(credential) {
131
- if (!credential || !credential.expires_at_iso) return false;
132
- const expiresAt = Date.parse(credential.expires_at_iso);
133
- return Number.isFinite(expiresAt) ? Date.now() >= expiresAt : false;
134
- }
135
-
136
- function createSpendCredential(state, body) {
137
- const payload = ensureObject(body);
138
- const credentialId = normalizeString(payload.credential_id, `spend_credential_${state.accountSpendCredentials.size + 1}`);
139
- const accountId = normalizeString(payload.account_id, "acct_demo");
140
- const agentId = normalizeString(payload.agent_id, null);
141
- const budgetId = normalizeString(payload.budget_id, null);
142
- const packId = normalizeString(payload.pack_id, null);
143
- const entitlementId = normalizeString(payload.entitlement_id, null);
144
- const createdAtIso = nowIso();
145
- const expiresAtIso = normalizeString(payload.expires_at_iso, null);
146
- // Optional cryptographic identity binding: an ed25519 agent public key. When
147
- // set, the credential can only be USED with a fresh signature from that key.
148
- // Only a VALID key is bound — an unparseable key is ignored (no brick / no
149
- // inconsistent identity_bound=true with a null ref); routes should reject an
150
- // invalid key up front so the operator sees the error.
151
- const agentPublicKeyRaw = normalizeString(payload.agent_public_key, null);
152
- const agentKeyMaterial = agentPublicKeyRaw ? normalizeAgentPublicKey(agentPublicKeyRaw) : null;
153
- const agentPublicKey = agentKeyMaterial ? agentPublicKeyRaw : null;
154
- const agentKeyRef = agentKeyMaterial ? agentKeyMaterial.key_ref : null;
155
- const credential = {
156
- credential_id: credentialId,
157
- bearer_token: normalizeString(payload.bearer_token, `xytara_spend_${crypto.randomBytes(18).toString("hex")}`),
158
- agent_public_key: agentPublicKey,
159
- agent_key_ref: agentKeyRef,
160
- account_id: accountId,
161
- agent_id: agentId,
162
- budget_id: budgetId,
163
- pack_id: packId,
164
- entitlement_id: entitlementId,
165
- spend_posture: agentId || budgetId ? "bounded_spend" : "open_spend",
166
- scope: normalizeString(payload.scope, "runtime_spend"),
167
- labels: ensureArray(payload.labels).map((value) => String(value || "").trim()).filter(Boolean),
168
- status: normalizeString(payload.status, "active"),
169
- issued_by: normalizeString(payload.issued_by, "operator"),
170
- expires_at_iso: expiresAtIso,
171
- created_at_iso: createdAtIso,
172
- updated_at_iso: createdAtIso,
173
- revoked_at_iso: null,
174
- revoke_reason: null
175
- };
176
- state.accountSpendCredentials.set(credentialId, credential);
177
- state.accountSpendCredentialsByToken.set(credential.bearer_token, credentialId);
178
- return buildCredentialPublicView(credential, true);
179
- }
180
-
181
- function createAuthorityBinding(state, body) {
182
- const payload = ensureObject(body);
183
- const bindingId = normalizeString(payload.binding_id, `authority_binding_${state.accountAuthorityBindings.size + 1}`);
184
- const credential = createSpendCredential(state, {
185
- credential_id: normalizeString(payload.credential_id, `spend_for_${bindingId}`),
186
- bearer_token: normalizeString(payload.bearer_token, null),
187
- agent_public_key: normalizeString(payload.agent_public_key, null),
188
- account_id: normalizeString(payload.account_id, "acct_demo"),
189
- agent_id: normalizeString(payload.agent_id, null),
190
- budget_id: normalizeString(payload.budget_id, null),
191
- pack_id: normalizeString(payload.pack_id, null),
192
- entitlement_id: normalizeString(payload.entitlement_id, null),
193
- scope: normalizeString(payload.scope, "runtime_spend"),
194
- labels: ensureArray(payload.labels),
195
- status: normalizeString(payload.status, "active"),
196
- issued_by: normalizeString(payload.issued_by, "operator"),
197
- expires_at_iso: normalizeString(payload.expires_at_iso, null)
198
- });
199
- const createdAtIso = nowIso();
200
- const binding = {
201
- binding_id: bindingId,
202
- account_id: credential.account_id,
203
- credential_id: credential.credential_id,
204
- authority_kind: normalizeString(payload.authority_kind, "delegated_runtime_spend"),
205
- spend_posture: credential.spend_posture,
206
- scope: credential.scope,
207
- authority_scope: ensureArray(payload.authority_scope).map((value) => String(value || "").trim()).filter(Boolean),
208
- allowed_consequence_families: ensureArray(payload.allowed_consequence_families).map((value) => String(value || "").trim()).filter(Boolean),
209
- consequence_bounds: {
210
- max_commit_units: Number.isFinite(Number(payload.max_commit_units)) ? Number(payload.max_commit_units) : null,
211
- max_reserve_units: Number.isFinite(Number(payload.max_reserve_units)) ? Number(payload.max_reserve_units) : null,
212
- max_reversal_units: Number.isFinite(Number(payload.max_reversal_units)) ? Number(payload.max_reversal_units) : null
213
- },
214
- agent_id: credential.agent_id,
215
- budget_id: credential.budget_id,
216
- pack_id: credential.pack_id || null,
217
- entitlement_id: credential.entitlement_id || null,
218
- status: normalizeString(payload.status, "active"),
219
- issued_by: normalizeString(payload.issued_by, "operator"),
220
- labels: ensureArray(payload.labels),
221
- expires_at_iso: credential.expires_at_iso,
222
- rotated_from_binding_id: normalizeString(payload.rotated_from_binding_id, null),
223
- replaced_by_binding_id: normalizeString(payload.replaced_by_binding_id, null),
224
- created_at_iso: createdAtIso,
225
- updated_at_iso: createdAtIso,
226
- revoked_at_iso: null,
227
- revoke_reason: null
228
- };
229
- state.accountAuthorityBindings.set(bindingId, binding);
230
- return buildAuthorityBindingPublicView(binding, state.accountSpendCredentials.get(credential.credential_id), true);
231
- }
232
-
233
- function listSpendCredentials(state, filters) {
234
- const payload = ensureObject(filters);
235
- const accountId = normalizeString(payload.account_id, null);
236
- const status = normalizeString(payload.status, null);
237
- return Array.from(state.accountSpendCredentials.values())
238
- .filter((credential) => {
239
- if (accountId && credential.account_id !== accountId) return false;
240
- if (status && credential.status !== status) return false;
241
- return true;
242
- })
243
- .map((credential) => buildCredentialPublicView(credential, false));
244
- }
245
-
246
- function getSpendCredential(state, credentialId) {
247
- if (!credentialId || !state.accountSpendCredentials.has(credentialId)) return null;
248
- return buildCredentialPublicView(state.accountSpendCredentials.get(credentialId), false);
249
- }
250
-
251
- function listAuthorityBindings(state, filters) {
252
- const payload = ensureObject(filters);
253
- const accountId = normalizeString(payload.account_id, null);
254
- const status = normalizeString(payload.status, null);
255
- return Array.from(state.accountAuthorityBindings.values())
256
- .filter((binding) => {
257
- if (accountId && binding.account_id !== accountId) return false;
258
- if (status && binding.status !== status) return false;
259
- return true;
260
- })
261
- .map((binding) => buildAuthorityBindingPublicView(binding, state.accountSpendCredentials.get(binding.credential_id), false));
262
- }
263
-
264
- function getAuthorityBinding(state, bindingId) {
265
- if (!bindingId || !state.accountAuthorityBindings.has(bindingId)) return null;
266
- const binding = state.accountAuthorityBindings.get(bindingId);
267
- return buildAuthorityBindingPublicView(binding, state.accountSpendCredentials.get(binding.credential_id), false);
268
- }
269
-
270
- function getAuthorityBindingContractSummary(state, bindingId) {
271
- if (!bindingId || !state.accountAuthorityBindings.has(bindingId)) return null;
272
- const binding = state.accountAuthorityBindings.get(bindingId);
273
- return buildAuthorityBindingContractSummary(binding, state.accountSpendCredentials.get(binding.credential_id));
274
- }
275
-
276
- function getAuthorityBindingRotationSummary(state, bindingId) {
277
- if (!bindingId || !state.accountAuthorityBindings.has(bindingId)) return null;
278
- const binding = state.accountAuthorityBindings.get(bindingId);
279
- return buildAuthorityBindingRotationSummary(binding, state.accountSpendCredentials.get(binding.credential_id));
280
- }
281
-
282
- function evaluateAuthorityBindingAction(binding, action, units) {
283
- const target = binding && typeof binding === "object" ? binding : null;
284
- const numericUnits = Number.isFinite(Number(units)) ? Number(units) : 0;
285
- const allowedScopes = target ? ensureArray(target.authority_scope) : [];
286
- const allowedFamilies = target ? ensureArray(target.allowed_consequence_families) : [];
287
- const bounds = target && target.consequence_bounds ? target.consequence_bounds : {};
288
-
289
- if (!target) {
290
- return { ok: true };
291
- }
292
- if (target.status !== "active") {
293
- return { ok: false, reason: "authority_binding_inactive", binding_id: target.binding_id };
294
- }
295
-
296
- const scopeMap = {
297
- delegated_credit_spend: "runtime.execute",
298
- reserve: "economics.reserve",
299
- release: "economics.release",
300
- reverse: "economics.reverse"
301
- };
302
- const familyMap = {
303
- delegated_credit_spend: "commit",
304
- reserve: "reserve",
305
- release: "release",
306
- reverse: "reverse"
307
- };
308
- const boundKeyMap = {
309
- delegated_credit_spend: "max_commit_units",
310
- reserve: "max_reserve_units",
311
- reverse: "max_reversal_units"
312
- };
313
-
314
- const requiredScope = scopeMap[action] || null;
315
- if (requiredScope && allowedScopes.length > 0 && !allowedScopes.includes(requiredScope)) {
316
- return {
317
- ok: false,
318
- reason: "authority_scope_not_allowed",
319
- binding_id: target.binding_id,
320
- required_scope: requiredScope,
321
- authority_scope: allowedScopes
322
- };
323
- }
324
-
325
- const requiredFamily = familyMap[action] || null;
326
- if (requiredFamily && allowedFamilies.length > 0 && !allowedFamilies.includes(requiredFamily)) {
327
- return {
328
- ok: false,
329
- reason: "authority_consequence_family_not_allowed",
330
- binding_id: target.binding_id,
331
- required_consequence_family: requiredFamily,
332
- allowed_consequence_families: allowedFamilies
333
- };
334
- }
335
-
336
- const boundKey = boundKeyMap[action] || null;
337
- if (boundKey && Number.isFinite(Number(bounds[boundKey])) && numericUnits > Number(bounds[boundKey])) {
338
- return {
339
- ok: false,
340
- reason: "authority_consequence_bound_exceeded",
341
- binding_id: target.binding_id,
342
- bound_key: boundKey,
343
- max_units: Number(bounds[boundKey]),
344
- attempted_units: numericUnits
345
- };
346
- }
347
-
348
- return {
349
- ok: true,
350
- binding_id: target.binding_id,
351
- authority_scope: allowedScopes,
352
- allowed_consequence_families: allowedFamilies,
353
- consequence_bounds: {
354
- max_commit_units: Number.isFinite(Number(bounds.max_commit_units)) ? Number(bounds.max_commit_units) : null,
355
- max_reserve_units: Number.isFinite(Number(bounds.max_reserve_units)) ? Number(bounds.max_reserve_units) : null,
356
- max_reversal_units: Number.isFinite(Number(bounds.max_reversal_units)) ? Number(bounds.max_reversal_units) : null
357
- }
358
- };
359
- }
360
-
361
- function revokeSpendCredential(state, credentialId, body) {
362
- if (!credentialId || !state.accountSpendCredentials.has(credentialId)) return null;
363
- const payload = ensureObject(body);
364
- const current = state.accountSpendCredentials.get(credentialId);
365
- const next = {
366
- ...current,
367
- status: "revoked",
368
- revoked_at_iso: nowIso(),
369
- revoke_reason: normalizeString(payload.reason, "operator_revoked"),
370
- updated_at_iso: nowIso()
371
- };
372
- state.accountSpendCredentials.set(credentialId, next);
373
- return buildCredentialPublicView(next, false);
374
- }
375
-
376
- function revokeAuthorityBinding(state, bindingId, body) {
377
- if (!bindingId || !state.accountAuthorityBindings.has(bindingId)) return null;
378
- const payload = ensureObject(body);
379
- const current = state.accountAuthorityBindings.get(bindingId);
380
- const next = {
381
- ...current,
382
- status: "revoked",
383
- revoked_at_iso: nowIso(),
384
- revoke_reason: normalizeString(payload.reason, "operator_revoked"),
385
- updated_at_iso: nowIso()
386
- };
387
- state.accountAuthorityBindings.set(bindingId, next);
388
- if (current.credential_id) {
389
- revokeSpendCredential(state, current.credential_id, payload);
390
- }
391
- return buildAuthorityBindingPublicView(next, current.credential_id ? state.accountSpendCredentials.get(current.credential_id) : null, false);
392
- }
393
-
394
- function renewAuthorityBinding(state, bindingId, body) {
395
- if (!bindingId || !state.accountAuthorityBindings.has(bindingId)) return null;
396
- const payload = ensureObject(body);
397
- const current = state.accountAuthorityBindings.get(bindingId);
398
- const nextExpiresAtIso = normalizeString(payload.expires_at_iso, current.expires_at_iso || null);
399
- const updatedAtIso = nowIso();
400
- const next = {
401
- ...current,
402
- expires_at_iso: nextExpiresAtIso,
403
- updated_at_iso: updatedAtIso
404
- };
405
- state.accountAuthorityBindings.set(bindingId, next);
406
- if (current.credential_id && state.accountSpendCredentials.has(current.credential_id)) {
407
- const credential = state.accountSpendCredentials.get(current.credential_id);
408
- state.accountSpendCredentials.set(current.credential_id, {
409
- ...credential,
410
- expires_at_iso: nextExpiresAtIso,
411
- updated_at_iso: updatedAtIso
412
- });
413
- }
414
- return buildAuthorityBindingPublicView(next, current.credential_id ? state.accountSpendCredentials.get(current.credential_id) : null, false);
415
- }
416
-
417
- function rotateAuthorityBinding(state, bindingId, body) {
418
- if (!bindingId || !state.accountAuthorityBindings.has(bindingId)) return null;
419
- const payload = ensureObject(body);
420
- const current = state.accountAuthorityBindings.get(bindingId);
421
- const currentCredential = current.credential_id ? state.accountSpendCredentials.get(current.credential_id) : null;
422
- const successorBindingId = normalizeString(payload.successor_binding_id, `${bindingId}_rotated`);
423
- const successorCredentialId = normalizeString(payload.successor_credential_id, current.credential_id ? `${current.credential_id}_rotated` : `spend_for_${successorBindingId}`);
424
- const successor = createAuthorityBinding(state, {
425
- binding_id: successorBindingId,
426
- credential_id: successorCredentialId,
427
- bearer_token: normalizeString(payload.bearer_token, null),
428
- // Carry the cryptographic identity binding forward on rotation — otherwise a
429
- // key-bound credential would silently downgrade to bearer-only.
430
- agent_public_key: normalizeString(payload.agent_public_key, currentCredential ? currentCredential.agent_public_key : null),
431
- account_id: current.account_id,
432
- agent_id: normalizeString(payload.agent_id, current.agent_id),
433
- budget_id: normalizeString(payload.budget_id, current.budget_id),
434
- pack_id: normalizeString(payload.pack_id, current.pack_id),
435
- entitlement_id: normalizeString(payload.entitlement_id, current.entitlement_id),
436
- scope: normalizeString(payload.scope, current.scope),
437
- authority_kind: normalizeString(payload.authority_kind, current.authority_kind),
438
- authority_scope: ensureArray(payload.authority_scope).length > 0 ? ensureArray(payload.authority_scope) : ensureArray(current.authority_scope),
439
- allowed_consequence_families: ensureArray(payload.allowed_consequence_families).length > 0 ? ensureArray(payload.allowed_consequence_families) : ensureArray(current.allowed_consequence_families),
440
- max_commit_units: Number.isFinite(Number(payload.max_commit_units)) ? Number(payload.max_commit_units) : current.consequence_bounds.max_commit_units,
441
- max_reserve_units: Number.isFinite(Number(payload.max_reserve_units)) ? Number(payload.max_reserve_units) : current.consequence_bounds.max_reserve_units,
442
- max_reversal_units: Number.isFinite(Number(payload.max_reversal_units)) ? Number(payload.max_reversal_units) : current.consequence_bounds.max_reversal_units,
443
- labels: ensureArray(payload.labels).length > 0 ? ensureArray(payload.labels) : ensureArray(current.labels),
444
- issued_by: normalizeString(payload.issued_by, current.issued_by || "operator"),
445
- expires_at_iso: normalizeString(payload.expires_at_iso, current.expires_at_iso || (currentCredential ? currentCredential.expires_at_iso : null)),
446
- rotated_from_binding_id: current.binding_id
447
- });
448
- const revoked = revokeAuthorityBinding(state, bindingId, {
449
- reason: normalizeString(payload.reason, "rotated_to_successor")
450
- });
451
- const successorRecord = state.accountAuthorityBindings.get(successor.binding_id);
452
- state.accountAuthorityBindings.set(successor.binding_id, {
453
- ...successorRecord,
454
- rotated_from_binding_id: current.binding_id
455
- });
456
- state.accountAuthorityBindings.set(bindingId, {
457
- ...state.accountAuthorityBindings.get(bindingId),
458
- replaced_by_binding_id: successor.binding_id
459
- });
460
- return {
461
- rotated_binding: buildAuthorityBindingPublicView(state.accountAuthorityBindings.get(bindingId), current.credential_id ? state.accountSpendCredentials.get(current.credential_id) : null, false),
462
- successor_authority_binding: buildAuthorityBindingPublicView(state.accountAuthorityBindings.get(successor.binding_id), state.accountSpendCredentials.get(successor.credential_id), true),
463
- previous_view: revoked
464
- };
465
- }
466
-
467
- function resolveSpendCredential(state, bearerToken, proof, requestBinding) {
468
- const token = normalizeString(bearerToken, null);
469
- if (!token) {
470
- return {
471
- ok: false,
472
- reason: "missing_spend_credential"
473
- };
474
- }
475
- const credentialId = state.accountSpendCredentialsByToken.get(token);
476
- if (!credentialId || !state.accountSpendCredentials.has(credentialId)) {
477
- return {
478
- ok: false,
479
- reason: "spend_credential_not_found"
480
- };
481
- }
482
- const credential = state.accountSpendCredentials.get(credentialId);
483
- if (credential.status !== "active") {
484
- return {
485
- ok: false,
486
- reason: "spend_credential_inactive",
487
- credential: buildCredentialPublicView(credential, false)
488
- };
489
- }
490
- if (isCredentialExpired(credential)) {
491
- return {
492
- ok: false,
493
- reason: "spend_credential_expired",
494
- credential: buildCredentialPublicView(credential, false)
495
- };
496
- }
497
- // Cryptographic identity gate: a key-bound credential requires a fresh signed
498
- // proof of control of the agent key — the bearer token alone is not enough.
499
- if (credential.agent_public_key) {
500
- // Bind the proof to the actual request (account/amount/task) when the caller supplies a
501
- // request binding. `require` is set BY THE CALLER (requestBinding.require) so strict mode
502
- // applies only where a binding is actually supplied — the execute path. The reserve/
503
- // release/reverse lifecycle paths pass no binding, so they are unaffected by strict mode
504
- // (and remain capped by the authority-binding bounds). Default keeps back-compat: an
505
- // unbound proof still authorizes.
506
- const requireBinding = Boolean(requestBinding && requestBinding.require);
507
- const verdict = verifySpendAuthProof(credential.agent_public_key, credential.credential_id, proof || {}, {
508
- requestBinding: requestBinding || null,
509
- requireBinding
510
- });
511
- if (!verdict.ok) {
512
- return {
513
- ok: false,
514
- reason: verdict.reason,
515
- credential: buildCredentialPublicView(credential, false)
516
- };
517
- }
518
- // Replay protection is MANDATORY for a key-bound credential — fail closed if
519
- // the replay store is unavailable rather than silently skipping it.
520
- const replayStore = state && state.acceptedAgentSignatures instanceof Map ? state.acceptedAgentSignatures : null;
521
- if (!replayStore) {
522
- return {
523
- ok: false,
524
- reason: "agent_replay_store_unavailable",
525
- credential: buildCredentialPublicView(credential, false)
526
- };
527
- }
528
- const now = Date.now();
529
- // Opportunistic prune: a signature past its freshness window can never be a
530
- // valid replay again, so forget it (bounds memory + snapshot size). The key is
531
- // the canonical decoded-signature fingerprint (re-encoding cannot evade it).
532
- for (const [key, expiresAt] of replayStore) {
533
- if (!Number.isFinite(expiresAt) || expiresAt <= now) replayStore.delete(key);
534
- }
535
- if (replayStore.has(verdict.canonical)) {
536
- return {
537
- ok: false,
538
- reason: "agent_signature_replayed",
539
- credential: buildCredentialPublicView(credential, false)
540
- };
541
- }
542
- replayStore.set(verdict.canonical, Number.isFinite(verdict.expires_at_ms) ? verdict.expires_at_ms : now + 120000);
543
- }
544
- const binding = Array.from(state.accountAuthorityBindings.values()).find((entry) => entry && entry.credential_id === credentialId && entry.status === "active") || null;
545
- return {
546
- ok: true,
547
- credential,
548
- binding,
549
- authorization_context: {
550
- verification_mode: "delegated_spend_credential",
551
- wallet_id: credential.credential_id,
552
- payment_payload: {
553
- binding_id: binding ? binding.binding_id : null,
554
- credential_id: credential.credential_id,
555
- spend_posture: credential.spend_posture,
556
- pack_id: credential.pack_id || null,
557
- entitlement_id: credential.entitlement_id || null,
558
- authority_scope: binding ? ensureArray(binding.authority_scope) : [],
559
- allowed_consequence_families: binding ? ensureArray(binding.allowed_consequence_families) : [],
560
- consequence_bounds: binding && binding.consequence_bounds ? binding.consequence_bounds : {
561
- max_commit_units: null,
562
- max_reserve_units: null,
563
- max_reversal_units: null
564
- }
565
- }
566
- }
567
- };
568
- }
569
-
570
- module.exports = {
571
- createAuthorityBinding,
572
- createSpendCredential,
573
- evaluateAuthorityBindingAction,
574
- getAuthorityBinding,
575
- getAuthorityBindingContractSummary,
576
- getAuthorityBindingRotationSummary,
577
- getSpendCredential,
578
- listAuthorityBindings,
579
- listSpendCredentials,
580
- renewAuthorityBinding,
581
- resolveSpendCredential,
582
- rotateAuthorityBinding,
583
- revokeAuthorityBinding,
584
- revokeSpendCredential
585
- };
1
+ "use strict";
2
+
3
+ const crypto = require("crypto");
4
+ const { normalizeAgentPublicKey, verifySpendAuthProof } = require("./agent_identity");
5
+
6
+ function normalizeString(value, fallback = null) {
7
+ const trimmed = typeof value === "string" ? value.trim() : "";
8
+ return trimmed || fallback;
9
+ }
10
+
11
+ function ensureObject(value) {
12
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
13
+ }
14
+
15
+ function ensureArray(value) {
16
+ return Array.isArray(value) ? value : [];
17
+ }
18
+
19
+ function nowIso() {
20
+ return new Date().toISOString();
21
+ }
22
+
23
+ function buildCredentialPublicView(credential, includeToken = false) {
24
+ if (!credential) return null;
25
+ const view = {
26
+ credential_id: credential.credential_id,
27
+ account_id: credential.account_id,
28
+ status: credential.status,
29
+ spend_posture: credential.spend_posture,
30
+ effective_pool_binding_mode: credential.entitlement_id ? "entitlement_bound" : credential.pack_id ? "pack_bound" : "unbound",
31
+ scope: credential.scope,
32
+ agent_id: credential.agent_id,
33
+ budget_id: credential.budget_id,
34
+ pack_id: credential.pack_id || null,
35
+ entitlement_id: credential.entitlement_id || null,
36
+ expires_at_iso: credential.expires_at_iso,
37
+ created_at_iso: credential.created_at_iso,
38
+ updated_at_iso: credential.updated_at_iso,
39
+ issued_by: credential.issued_by || null,
40
+ labels: ensureArray(credential.labels),
41
+ // Cryptographic identity: when a credential is bound to an agent ed25519 key,
42
+ // using it requires a fresh signed proof (not just the bearer token).
43
+ identity_bound: Boolean(credential.agent_public_key),
44
+ agent_key_ref: credential.agent_key_ref || null
45
+ };
46
+ if (includeToken) {
47
+ view.bearer_token = credential.bearer_token;
48
+ }
49
+ return view;
50
+ }
51
+
52
+ function buildAuthorityBindingPublicView(binding, credential, includeToken = false) {
53
+ if (!binding) return null;
54
+ const view = {
55
+ binding_id: binding.binding_id,
56
+ account_id: binding.account_id,
57
+ status: binding.status,
58
+ authority_kind: binding.authority_kind,
59
+ spend_posture: binding.spend_posture,
60
+ scope: binding.scope,
61
+ authority_scope: ensureArray(binding.authority_scope),
62
+ allowed_consequence_families: ensureArray(binding.allowed_consequence_families),
63
+ consequence_bounds: binding.consequence_bounds || {
64
+ max_commit_units: null,
65
+ max_reserve_units: null,
66
+ max_reversal_units: null
67
+ },
68
+ agent_id: binding.agent_id,
69
+ budget_id: binding.budget_id,
70
+ pack_id: binding.pack_id || null,
71
+ entitlement_id: binding.entitlement_id || null,
72
+ credential_id: binding.credential_id || null,
73
+ issued_by: binding.issued_by || null,
74
+ labels: ensureArray(binding.labels),
75
+ expires_at_iso: binding.expires_at_iso || null,
76
+ rotated_from_binding_id: binding.rotated_from_binding_id || null,
77
+ replaced_by_binding_id: binding.replaced_by_binding_id || null,
78
+ created_at_iso: binding.created_at_iso,
79
+ updated_at_iso: binding.updated_at_iso,
80
+ revoked_at_iso: binding.revoked_at_iso || null,
81
+ revoke_reason: binding.revoke_reason || null,
82
+ spend_credential: buildCredentialPublicView(credential, includeToken)
83
+ };
84
+ return view;
85
+ }
86
+
87
+ function buildAuthorityBindingContractSummary(binding, credential) {
88
+ if (!binding) return null;
89
+ const publicView = buildAuthorityBindingPublicView(binding, credential, false);
90
+ return {
91
+ summary_version: "xytara-authority-binding-contract-summary-v1",
92
+ binding_id: binding.binding_id,
93
+ account_id: binding.account_id,
94
+ authority_kind: binding.authority_kind,
95
+ spend_posture: binding.spend_posture,
96
+ authority_scope: ensureArray(binding.authority_scope),
97
+ allowed_consequence_families: ensureArray(binding.allowed_consequence_families),
98
+ consequence_bounds: publicView.consequence_bounds,
99
+ linked_credential_id: binding.credential_id || null,
100
+ contract_state: binding.status === "active" ? "active_contract" : "inactive_contract",
101
+ linked_surfaces: {
102
+ binding_ref: `/v1/account-auth/authority-bindings/${encodeURIComponent(binding.binding_id)}`,
103
+ spend_credential_ref: binding.credential_id ? `/v1/account-auth/spend-credentials/${encodeURIComponent(binding.credential_id)}` : null
104
+ }
105
+ };
106
+ }
107
+
108
+ function buildAuthorityBindingRotationSummary(binding, credential) {
109
+ if (!binding) return null;
110
+ const active = binding.status === "active";
111
+ return {
112
+ summary_version: "xytara-authority-binding-rotation-summary-v1",
113
+ binding_id: binding.binding_id,
114
+ account_id: binding.account_id,
115
+ status: binding.status,
116
+ renewable_state: active ? "renewable" : "inactive",
117
+ rotatable_state: active ? "rotatable" : "inactive",
118
+ expires_at_iso: binding.expires_at_iso || null,
119
+ rotated_from_binding_id: binding.rotated_from_binding_id || null,
120
+ replaced_by_binding_id: binding.replaced_by_binding_id || null,
121
+ linked_credential_id: binding.credential_id || null,
122
+ linked_surfaces: {
123
+ binding_ref: `/v1/account-auth/authority-bindings/${encodeURIComponent(binding.binding_id)}`,
124
+ contract_summary_ref: `/v1/account-auth/authority-bindings/${encodeURIComponent(binding.binding_id)}/contract-summary`,
125
+ spend_credential_ref: binding.credential_id ? `/v1/account-auth/spend-credentials/${encodeURIComponent(binding.credential_id)}` : null
126
+ }
127
+ };
128
+ }
129
+
130
+ function isCredentialExpired(credential) {
131
+ if (!credential || !credential.expires_at_iso) return false;
132
+ const expiresAt = Date.parse(credential.expires_at_iso);
133
+ return Number.isFinite(expiresAt) ? Date.now() >= expiresAt : false;
134
+ }
135
+
136
+ function createSpendCredential(state, body) {
137
+ const payload = ensureObject(body);
138
+ const credentialId = normalizeString(payload.credential_id, `spend_credential_${state.accountSpendCredentials.size + 1}`);
139
+ const accountId = normalizeString(payload.account_id, "acct_demo");
140
+ const agentId = normalizeString(payload.agent_id, null);
141
+ const budgetId = normalizeString(payload.budget_id, null);
142
+ const packId = normalizeString(payload.pack_id, null);
143
+ const entitlementId = normalizeString(payload.entitlement_id, null);
144
+ const createdAtIso = nowIso();
145
+ const expiresAtIso = normalizeString(payload.expires_at_iso, null);
146
+ // Optional cryptographic identity binding: an ed25519 agent public key. When
147
+ // set, the credential can only be USED with a fresh signature from that key.
148
+ // Only a VALID key is bound — an unparseable key is ignored (no brick / no
149
+ // inconsistent identity_bound=true with a null ref); routes should reject an
150
+ // invalid key up front so the operator sees the error.
151
+ const agentPublicKeyRaw = normalizeString(payload.agent_public_key, null);
152
+ const agentKeyMaterial = agentPublicKeyRaw ? normalizeAgentPublicKey(agentPublicKeyRaw) : null;
153
+ const agentPublicKey = agentKeyMaterial ? agentPublicKeyRaw : null;
154
+ const agentKeyRef = agentKeyMaterial ? agentKeyMaterial.key_ref : null;
155
+ const credential = {
156
+ credential_id: credentialId,
157
+ bearer_token: normalizeString(payload.bearer_token, `xytara_spend_${crypto.randomBytes(18).toString("hex")}`),
158
+ agent_public_key: agentPublicKey,
159
+ agent_key_ref: agentKeyRef,
160
+ account_id: accountId,
161
+ agent_id: agentId,
162
+ budget_id: budgetId,
163
+ pack_id: packId,
164
+ entitlement_id: entitlementId,
165
+ spend_posture: agentId || budgetId ? "bounded_spend" : "open_spend",
166
+ scope: normalizeString(payload.scope, "runtime_spend"),
167
+ labels: ensureArray(payload.labels).map((value) => String(value || "").trim()).filter(Boolean),
168
+ status: normalizeString(payload.status, "active"),
169
+ issued_by: normalizeString(payload.issued_by, "operator"),
170
+ expires_at_iso: expiresAtIso,
171
+ created_at_iso: createdAtIso,
172
+ updated_at_iso: createdAtIso,
173
+ revoked_at_iso: null,
174
+ revoke_reason: null
175
+ };
176
+ state.accountSpendCredentials.set(credentialId, credential);
177
+ state.accountSpendCredentialsByToken.set(credential.bearer_token, credentialId);
178
+ return buildCredentialPublicView(credential, true);
179
+ }
180
+
181
+ function createAuthorityBinding(state, body) {
182
+ const payload = ensureObject(body);
183
+ const bindingId = normalizeString(payload.binding_id, `authority_binding_${state.accountAuthorityBindings.size + 1}`);
184
+ const credential = createSpendCredential(state, {
185
+ credential_id: normalizeString(payload.credential_id, `spend_for_${bindingId}`),
186
+ bearer_token: normalizeString(payload.bearer_token, null),
187
+ agent_public_key: normalizeString(payload.agent_public_key, null),
188
+ account_id: normalizeString(payload.account_id, "acct_demo"),
189
+ agent_id: normalizeString(payload.agent_id, null),
190
+ budget_id: normalizeString(payload.budget_id, null),
191
+ pack_id: normalizeString(payload.pack_id, null),
192
+ entitlement_id: normalizeString(payload.entitlement_id, null),
193
+ scope: normalizeString(payload.scope, "runtime_spend"),
194
+ labels: ensureArray(payload.labels),
195
+ status: normalizeString(payload.status, "active"),
196
+ issued_by: normalizeString(payload.issued_by, "operator"),
197
+ expires_at_iso: normalizeString(payload.expires_at_iso, null)
198
+ });
199
+ const createdAtIso = nowIso();
200
+ const binding = {
201
+ binding_id: bindingId,
202
+ account_id: credential.account_id,
203
+ credential_id: credential.credential_id,
204
+ authority_kind: normalizeString(payload.authority_kind, "delegated_runtime_spend"),
205
+ spend_posture: credential.spend_posture,
206
+ scope: credential.scope,
207
+ authority_scope: ensureArray(payload.authority_scope).map((value) => String(value || "").trim()).filter(Boolean),
208
+ allowed_consequence_families: ensureArray(payload.allowed_consequence_families).map((value) => String(value || "").trim()).filter(Boolean),
209
+ consequence_bounds: {
210
+ max_commit_units: Number.isFinite(Number(payload.max_commit_units)) ? Number(payload.max_commit_units) : null,
211
+ max_reserve_units: Number.isFinite(Number(payload.max_reserve_units)) ? Number(payload.max_reserve_units) : null,
212
+ max_reversal_units: Number.isFinite(Number(payload.max_reversal_units)) ? Number(payload.max_reversal_units) : null
213
+ },
214
+ agent_id: credential.agent_id,
215
+ budget_id: credential.budget_id,
216
+ pack_id: credential.pack_id || null,
217
+ entitlement_id: credential.entitlement_id || null,
218
+ status: normalizeString(payload.status, "active"),
219
+ issued_by: normalizeString(payload.issued_by, "operator"),
220
+ labels: ensureArray(payload.labels),
221
+ expires_at_iso: credential.expires_at_iso,
222
+ rotated_from_binding_id: normalizeString(payload.rotated_from_binding_id, null),
223
+ replaced_by_binding_id: normalizeString(payload.replaced_by_binding_id, null),
224
+ created_at_iso: createdAtIso,
225
+ updated_at_iso: createdAtIso,
226
+ revoked_at_iso: null,
227
+ revoke_reason: null
228
+ };
229
+ state.accountAuthorityBindings.set(bindingId, binding);
230
+ return buildAuthorityBindingPublicView(binding, state.accountSpendCredentials.get(credential.credential_id), true);
231
+ }
232
+
233
+ function listSpendCredentials(state, filters) {
234
+ const payload = ensureObject(filters);
235
+ const accountId = normalizeString(payload.account_id, null);
236
+ const status = normalizeString(payload.status, null);
237
+ return Array.from(state.accountSpendCredentials.values())
238
+ .filter((credential) => {
239
+ if (accountId && credential.account_id !== accountId) return false;
240
+ if (status && credential.status !== status) return false;
241
+ return true;
242
+ })
243
+ .map((credential) => buildCredentialPublicView(credential, false));
244
+ }
245
+
246
+ function getSpendCredential(state, credentialId) {
247
+ if (!credentialId || !state.accountSpendCredentials.has(credentialId)) return null;
248
+ return buildCredentialPublicView(state.accountSpendCredentials.get(credentialId), false);
249
+ }
250
+
251
+ function listAuthorityBindings(state, filters) {
252
+ const payload = ensureObject(filters);
253
+ const accountId = normalizeString(payload.account_id, null);
254
+ const status = normalizeString(payload.status, null);
255
+ return Array.from(state.accountAuthorityBindings.values())
256
+ .filter((binding) => {
257
+ if (accountId && binding.account_id !== accountId) return false;
258
+ if (status && binding.status !== status) return false;
259
+ return true;
260
+ })
261
+ .map((binding) => buildAuthorityBindingPublicView(binding, state.accountSpendCredentials.get(binding.credential_id), false));
262
+ }
263
+
264
+ function getAuthorityBinding(state, bindingId) {
265
+ if (!bindingId || !state.accountAuthorityBindings.has(bindingId)) return null;
266
+ const binding = state.accountAuthorityBindings.get(bindingId);
267
+ return buildAuthorityBindingPublicView(binding, state.accountSpendCredentials.get(binding.credential_id), false);
268
+ }
269
+
270
+ function getAuthorityBindingContractSummary(state, bindingId) {
271
+ if (!bindingId || !state.accountAuthorityBindings.has(bindingId)) return null;
272
+ const binding = state.accountAuthorityBindings.get(bindingId);
273
+ return buildAuthorityBindingContractSummary(binding, state.accountSpendCredentials.get(binding.credential_id));
274
+ }
275
+
276
+ function getAuthorityBindingRotationSummary(state, bindingId) {
277
+ if (!bindingId || !state.accountAuthorityBindings.has(bindingId)) return null;
278
+ const binding = state.accountAuthorityBindings.get(bindingId);
279
+ return buildAuthorityBindingRotationSummary(binding, state.accountSpendCredentials.get(binding.credential_id));
280
+ }
281
+
282
+ function evaluateAuthorityBindingAction(binding, action, units) {
283
+ const target = binding && typeof binding === "object" ? binding : null;
284
+ const numericUnits = Number.isFinite(Number(units)) ? Number(units) : 0;
285
+ const allowedScopes = target ? ensureArray(target.authority_scope) : [];
286
+ const allowedFamilies = target ? ensureArray(target.allowed_consequence_families) : [];
287
+ const bounds = target && target.consequence_bounds ? target.consequence_bounds : {};
288
+
289
+ if (!target) {
290
+ return { ok: true };
291
+ }
292
+ if (target.status !== "active") {
293
+ return { ok: false, reason: "authority_binding_inactive", binding_id: target.binding_id };
294
+ }
295
+
296
+ const scopeMap = {
297
+ delegated_credit_spend: "runtime.execute",
298
+ reserve: "economics.reserve",
299
+ release: "economics.release",
300
+ reverse: "economics.reverse"
301
+ };
302
+ const familyMap = {
303
+ delegated_credit_spend: "commit",
304
+ reserve: "reserve",
305
+ release: "release",
306
+ reverse: "reverse"
307
+ };
308
+ const boundKeyMap = {
309
+ delegated_credit_spend: "max_commit_units",
310
+ reserve: "max_reserve_units",
311
+ reverse: "max_reversal_units"
312
+ };
313
+
314
+ const requiredScope = scopeMap[action] || null;
315
+ if (requiredScope && allowedScopes.length > 0 && !allowedScopes.includes(requiredScope)) {
316
+ return {
317
+ ok: false,
318
+ reason: "authority_scope_not_allowed",
319
+ binding_id: target.binding_id,
320
+ required_scope: requiredScope,
321
+ authority_scope: allowedScopes
322
+ };
323
+ }
324
+
325
+ const requiredFamily = familyMap[action] || null;
326
+ if (requiredFamily && allowedFamilies.length > 0 && !allowedFamilies.includes(requiredFamily)) {
327
+ return {
328
+ ok: false,
329
+ reason: "authority_consequence_family_not_allowed",
330
+ binding_id: target.binding_id,
331
+ required_consequence_family: requiredFamily,
332
+ allowed_consequence_families: allowedFamilies
333
+ };
334
+ }
335
+
336
+ const boundKey = boundKeyMap[action] || null;
337
+ if (boundKey && Number.isFinite(Number(bounds[boundKey])) && numericUnits > Number(bounds[boundKey])) {
338
+ return {
339
+ ok: false,
340
+ reason: "authority_consequence_bound_exceeded",
341
+ binding_id: target.binding_id,
342
+ bound_key: boundKey,
343
+ max_units: Number(bounds[boundKey]),
344
+ attempted_units: numericUnits
345
+ };
346
+ }
347
+
348
+ return {
349
+ ok: true,
350
+ binding_id: target.binding_id,
351
+ authority_scope: allowedScopes,
352
+ allowed_consequence_families: allowedFamilies,
353
+ consequence_bounds: {
354
+ max_commit_units: Number.isFinite(Number(bounds.max_commit_units)) ? Number(bounds.max_commit_units) : null,
355
+ max_reserve_units: Number.isFinite(Number(bounds.max_reserve_units)) ? Number(bounds.max_reserve_units) : null,
356
+ max_reversal_units: Number.isFinite(Number(bounds.max_reversal_units)) ? Number(bounds.max_reversal_units) : null
357
+ }
358
+ };
359
+ }
360
+
361
+ function revokeSpendCredential(state, credentialId, body) {
362
+ if (!credentialId || !state.accountSpendCredentials.has(credentialId)) return null;
363
+ const payload = ensureObject(body);
364
+ const current = state.accountSpendCredentials.get(credentialId);
365
+ const next = {
366
+ ...current,
367
+ status: "revoked",
368
+ revoked_at_iso: nowIso(),
369
+ revoke_reason: normalizeString(payload.reason, "operator_revoked"),
370
+ updated_at_iso: nowIso()
371
+ };
372
+ state.accountSpendCredentials.set(credentialId, next);
373
+ return buildCredentialPublicView(next, false);
374
+ }
375
+
376
+ function revokeAuthorityBinding(state, bindingId, body) {
377
+ if (!bindingId || !state.accountAuthorityBindings.has(bindingId)) return null;
378
+ const payload = ensureObject(body);
379
+ const current = state.accountAuthorityBindings.get(bindingId);
380
+ const next = {
381
+ ...current,
382
+ status: "revoked",
383
+ revoked_at_iso: nowIso(),
384
+ revoke_reason: normalizeString(payload.reason, "operator_revoked"),
385
+ updated_at_iso: nowIso()
386
+ };
387
+ state.accountAuthorityBindings.set(bindingId, next);
388
+ if (current.credential_id) {
389
+ revokeSpendCredential(state, current.credential_id, payload);
390
+ }
391
+ return buildAuthorityBindingPublicView(next, current.credential_id ? state.accountSpendCredentials.get(current.credential_id) : null, false);
392
+ }
393
+
394
+ function renewAuthorityBinding(state, bindingId, body) {
395
+ if (!bindingId || !state.accountAuthorityBindings.has(bindingId)) return null;
396
+ const payload = ensureObject(body);
397
+ const current = state.accountAuthorityBindings.get(bindingId);
398
+ const nextExpiresAtIso = normalizeString(payload.expires_at_iso, current.expires_at_iso || null);
399
+ const updatedAtIso = nowIso();
400
+ const next = {
401
+ ...current,
402
+ expires_at_iso: nextExpiresAtIso,
403
+ updated_at_iso: updatedAtIso
404
+ };
405
+ state.accountAuthorityBindings.set(bindingId, next);
406
+ if (current.credential_id && state.accountSpendCredentials.has(current.credential_id)) {
407
+ const credential = state.accountSpendCredentials.get(current.credential_id);
408
+ state.accountSpendCredentials.set(current.credential_id, {
409
+ ...credential,
410
+ expires_at_iso: nextExpiresAtIso,
411
+ updated_at_iso: updatedAtIso
412
+ });
413
+ }
414
+ return buildAuthorityBindingPublicView(next, current.credential_id ? state.accountSpendCredentials.get(current.credential_id) : null, false);
415
+ }
416
+
417
+ function rotateAuthorityBinding(state, bindingId, body) {
418
+ if (!bindingId || !state.accountAuthorityBindings.has(bindingId)) return null;
419
+ const payload = ensureObject(body);
420
+ const current = state.accountAuthorityBindings.get(bindingId);
421
+ const currentCredential = current.credential_id ? state.accountSpendCredentials.get(current.credential_id) : null;
422
+ const successorBindingId = normalizeString(payload.successor_binding_id, `${bindingId}_rotated`);
423
+ const successorCredentialId = normalizeString(payload.successor_credential_id, current.credential_id ? `${current.credential_id}_rotated` : `spend_for_${successorBindingId}`);
424
+ const successor = createAuthorityBinding(state, {
425
+ binding_id: successorBindingId,
426
+ credential_id: successorCredentialId,
427
+ bearer_token: normalizeString(payload.bearer_token, null),
428
+ // Carry the cryptographic identity binding forward on rotation — otherwise a
429
+ // key-bound credential would silently downgrade to bearer-only.
430
+ agent_public_key: normalizeString(payload.agent_public_key, currentCredential ? currentCredential.agent_public_key : null),
431
+ account_id: current.account_id,
432
+ agent_id: normalizeString(payload.agent_id, current.agent_id),
433
+ budget_id: normalizeString(payload.budget_id, current.budget_id),
434
+ pack_id: normalizeString(payload.pack_id, current.pack_id),
435
+ entitlement_id: normalizeString(payload.entitlement_id, current.entitlement_id),
436
+ scope: normalizeString(payload.scope, current.scope),
437
+ authority_kind: normalizeString(payload.authority_kind, current.authority_kind),
438
+ authority_scope: ensureArray(payload.authority_scope).length > 0 ? ensureArray(payload.authority_scope) : ensureArray(current.authority_scope),
439
+ allowed_consequence_families: ensureArray(payload.allowed_consequence_families).length > 0 ? ensureArray(payload.allowed_consequence_families) : ensureArray(current.allowed_consequence_families),
440
+ max_commit_units: Number.isFinite(Number(payload.max_commit_units)) ? Number(payload.max_commit_units) : current.consequence_bounds.max_commit_units,
441
+ max_reserve_units: Number.isFinite(Number(payload.max_reserve_units)) ? Number(payload.max_reserve_units) : current.consequence_bounds.max_reserve_units,
442
+ max_reversal_units: Number.isFinite(Number(payload.max_reversal_units)) ? Number(payload.max_reversal_units) : current.consequence_bounds.max_reversal_units,
443
+ labels: ensureArray(payload.labels).length > 0 ? ensureArray(payload.labels) : ensureArray(current.labels),
444
+ issued_by: normalizeString(payload.issued_by, current.issued_by || "operator"),
445
+ expires_at_iso: normalizeString(payload.expires_at_iso, current.expires_at_iso || (currentCredential ? currentCredential.expires_at_iso : null)),
446
+ rotated_from_binding_id: current.binding_id
447
+ });
448
+ const revoked = revokeAuthorityBinding(state, bindingId, {
449
+ reason: normalizeString(payload.reason, "rotated_to_successor")
450
+ });
451
+ const successorRecord = state.accountAuthorityBindings.get(successor.binding_id);
452
+ state.accountAuthorityBindings.set(successor.binding_id, {
453
+ ...successorRecord,
454
+ rotated_from_binding_id: current.binding_id
455
+ });
456
+ state.accountAuthorityBindings.set(bindingId, {
457
+ ...state.accountAuthorityBindings.get(bindingId),
458
+ replaced_by_binding_id: successor.binding_id
459
+ });
460
+ return {
461
+ rotated_binding: buildAuthorityBindingPublicView(state.accountAuthorityBindings.get(bindingId), current.credential_id ? state.accountSpendCredentials.get(current.credential_id) : null, false),
462
+ successor_authority_binding: buildAuthorityBindingPublicView(state.accountAuthorityBindings.get(successor.binding_id), state.accountSpendCredentials.get(successor.credential_id), true),
463
+ previous_view: revoked
464
+ };
465
+ }
466
+
467
+ function resolveSpendCredential(state, bearerToken, proof, requestBinding) {
468
+ const token = normalizeString(bearerToken, null);
469
+ if (!token) {
470
+ return {
471
+ ok: false,
472
+ reason: "missing_spend_credential"
473
+ };
474
+ }
475
+ const credentialId = state.accountSpendCredentialsByToken.get(token);
476
+ if (!credentialId || !state.accountSpendCredentials.has(credentialId)) {
477
+ return {
478
+ ok: false,
479
+ reason: "spend_credential_not_found"
480
+ };
481
+ }
482
+ const credential = state.accountSpendCredentials.get(credentialId);
483
+ if (credential.status !== "active") {
484
+ return {
485
+ ok: false,
486
+ reason: "spend_credential_inactive",
487
+ credential: buildCredentialPublicView(credential, false)
488
+ };
489
+ }
490
+ if (isCredentialExpired(credential)) {
491
+ return {
492
+ ok: false,
493
+ reason: "spend_credential_expired",
494
+ credential: buildCredentialPublicView(credential, false)
495
+ };
496
+ }
497
+ // Cryptographic identity gate: a key-bound credential requires a fresh signed
498
+ // proof of control of the agent key — the bearer token alone is not enough.
499
+ if (credential.agent_public_key) {
500
+ // Bind the proof to the actual request (account/amount/task) when the caller supplies a
501
+ // request binding: a request-bound signature then verifies only for that exact request.
502
+ // `require` is set BY THE CALLER (requestBinding.require), so strict mode applies only to the
503
+ // execute path. The reserve/release/reverse lifecycle paths supply a binding WITHOUT
504
+ // `require`: bound signatures are checked against the request, unbound ones still authorize
505
+ // (back-compat), and both remain capped by the authority-binding bounds.
506
+ const requireBinding = Boolean(requestBinding && requestBinding.require);
507
+ const verdict = verifySpendAuthProof(credential.agent_public_key, credential.credential_id, proof || {}, {
508
+ requestBinding: requestBinding || null,
509
+ requireBinding
510
+ });
511
+ if (!verdict.ok) {
512
+ return {
513
+ ok: false,
514
+ reason: verdict.reason,
515
+ credential: buildCredentialPublicView(credential, false)
516
+ };
517
+ }
518
+ // Replay protection is MANDATORY for a key-bound credential — fail closed if
519
+ // the replay store is unavailable rather than silently skipping it.
520
+ const replayStore = state && state.acceptedAgentSignatures instanceof Map ? state.acceptedAgentSignatures : null;
521
+ if (!replayStore) {
522
+ return {
523
+ ok: false,
524
+ reason: "agent_replay_store_unavailable",
525
+ credential: buildCredentialPublicView(credential, false)
526
+ };
527
+ }
528
+ const now = Date.now();
529
+ // Opportunistic prune: a signature past its freshness window can never be a
530
+ // valid replay again, so forget it (bounds memory + snapshot size). The key is
531
+ // the canonical decoded-signature fingerprint (re-encoding cannot evade it).
532
+ for (const [key, expiresAt] of replayStore) {
533
+ if (!Number.isFinite(expiresAt) || expiresAt <= now) replayStore.delete(key);
534
+ }
535
+ if (replayStore.has(verdict.canonical)) {
536
+ return {
537
+ ok: false,
538
+ reason: "agent_signature_replayed",
539
+ credential: buildCredentialPublicView(credential, false)
540
+ };
541
+ }
542
+ replayStore.set(verdict.canonical, Number.isFinite(verdict.expires_at_ms) ? verdict.expires_at_ms : now + 120000);
543
+ }
544
+ const binding = Array.from(state.accountAuthorityBindings.values()).find((entry) => entry && entry.credential_id === credentialId && entry.status === "active") || null;
545
+ return {
546
+ ok: true,
547
+ credential,
548
+ binding,
549
+ authorization_context: {
550
+ verification_mode: "delegated_spend_credential",
551
+ wallet_id: credential.credential_id,
552
+ payment_payload: {
553
+ binding_id: binding ? binding.binding_id : null,
554
+ credential_id: credential.credential_id,
555
+ spend_posture: credential.spend_posture,
556
+ pack_id: credential.pack_id || null,
557
+ entitlement_id: credential.entitlement_id || null,
558
+ authority_scope: binding ? ensureArray(binding.authority_scope) : [],
559
+ allowed_consequence_families: binding ? ensureArray(binding.allowed_consequence_families) : [],
560
+ consequence_bounds: binding && binding.consequence_bounds ? binding.consequence_bounds : {
561
+ max_commit_units: null,
562
+ max_reserve_units: null,
563
+ max_reversal_units: null
564
+ }
565
+ }
566
+ }
567
+ };
568
+ }
569
+
570
+ module.exports = {
571
+ createAuthorityBinding,
572
+ createSpendCredential,
573
+ evaluateAuthorityBindingAction,
574
+ getAuthorityBinding,
575
+ getAuthorityBindingContractSummary,
576
+ getAuthorityBindingRotationSummary,
577
+ getSpendCredential,
578
+ listAuthorityBindings,
579
+ listSpendCredentials,
580
+ renewAuthorityBinding,
581
+ resolveSpendCredential,
582
+ rotateAuthorityBinding,
583
+ revokeAuthorityBinding,
584
+ revokeSpendCredential
585
+ };