@brainai/satp-client 2.0.1 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +113 -19
- package/examples/integration-patterns.js +495 -0
- package/examples/runtime-policy-adapter.js +75 -0
- package/examples/x402-discovery-evidence-lookup.js +66 -0
- package/package.json +20 -3
- package/src/index.d.ts +241 -0
- package/src/index.js +30 -0
- package/src/runtime-policy-adapter.js +206 -0
- package/src/wallet-control-challenge.js +351 -0
- package/src/x402-discovery.js +180 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const { PublicKey } = require('@solana/web3.js');
|
|
5
|
+
const bs58Module = require('bs58');
|
|
6
|
+
const {
|
|
7
|
+
hashAgentId,
|
|
8
|
+
getGenesisPDA,
|
|
9
|
+
getLinkedWalletPDA,
|
|
10
|
+
} = require('./v3-pda');
|
|
11
|
+
|
|
12
|
+
const bs58 = bs58Module.default || bs58Module;
|
|
13
|
+
|
|
14
|
+
const WALLET_CONTROL_CHALLENGE_SCHEMA_VERSION = 'satp.walletControlChallenge.v1';
|
|
15
|
+
const WALLET_CONTROL_CHALLENGE_TYPE = 'wallet-control';
|
|
16
|
+
const DEFAULT_WALLET_CONTROL_DOMAIN = 'satp.brainai.wallet-control';
|
|
17
|
+
const DEFAULT_WALLET_CONTROL_AUDIENCE = 'satp-client';
|
|
18
|
+
const DEFAULT_TTL_SECONDS = 300;
|
|
19
|
+
const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
|
|
20
|
+
|
|
21
|
+
function canonicalStringify(value) {
|
|
22
|
+
if (Array.isArray(value)) {
|
|
23
|
+
return '[' + value.map(canonicalStringify).join(',') + ']';
|
|
24
|
+
}
|
|
25
|
+
if (value && typeof value === 'object') {
|
|
26
|
+
return '{' + Object.keys(value)
|
|
27
|
+
.sort()
|
|
28
|
+
.map((key) => JSON.stringify(key) + ':' + canonicalStringify(value[key]))
|
|
29
|
+
.join(',') + '}';
|
|
30
|
+
}
|
|
31
|
+
return JSON.stringify(value);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeNetwork(network = 'devnet') {
|
|
35
|
+
if (network !== 'devnet' && network !== 'mainnet') {
|
|
36
|
+
throw new Error('Invalid network: expected devnet or mainnet');
|
|
37
|
+
}
|
|
38
|
+
return network;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizePublicKey(value, field) {
|
|
42
|
+
try {
|
|
43
|
+
return new PublicKey(value).toBase58();
|
|
44
|
+
} catch (err) {
|
|
45
|
+
throw new Error('Invalid ' + field + ': expected a Solana public key');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizeString(value, field, { maxBytes } = {}) {
|
|
50
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
51
|
+
throw new Error('Invalid ' + field + ': expected a non-empty string');
|
|
52
|
+
}
|
|
53
|
+
const normalized = value.trim();
|
|
54
|
+
if (maxBytes && Buffer.byteLength(normalized, 'utf8') > maxBytes) {
|
|
55
|
+
throw new Error('Invalid ' + field + ': expected at most ' + maxBytes + ' UTF-8 bytes');
|
|
56
|
+
}
|
|
57
|
+
return normalized;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function normalizeTimestamp(value, field) {
|
|
61
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
62
|
+
throw new Error('Invalid ' + field + ': expected a non-negative safe integer Unix timestamp');
|
|
63
|
+
}
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function hasReplayNonce(container, nonce) {
|
|
68
|
+
if (!container) return false;
|
|
69
|
+
if (typeof container.has === 'function') return !!container.has(nonce);
|
|
70
|
+
if (Array.isArray(container)) return container.includes(nonce);
|
|
71
|
+
if (typeof container === 'object') return !!container[nonce];
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function decodeSignature(signature) {
|
|
76
|
+
if (Buffer.isBuffer(signature)) return signature;
|
|
77
|
+
if (signature instanceof Uint8Array || Array.isArray(signature)) return Buffer.from(signature);
|
|
78
|
+
if (typeof signature !== 'string' || signature.trim() === '') {
|
|
79
|
+
throw new Error('Invalid signature: expected base58, base64, hex, Buffer, or Uint8Array');
|
|
80
|
+
}
|
|
81
|
+
const value = signature.trim();
|
|
82
|
+
try {
|
|
83
|
+
return Buffer.from(bs58.decode(value));
|
|
84
|
+
} catch (err) {
|
|
85
|
+
// Fall through to base64/hex decoders.
|
|
86
|
+
}
|
|
87
|
+
if (/^[a-fA-F0-9]{128}$/.test(value)) {
|
|
88
|
+
return Buffer.from(value, 'hex');
|
|
89
|
+
}
|
|
90
|
+
const base64 = Buffer.from(value, 'base64');
|
|
91
|
+
if (base64.length === 64) return base64;
|
|
92
|
+
throw new Error('Invalid signature: expected 64-byte Ed25519 signature');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function ed25519PublicKeyFromSolanaPublicKey(wallet) {
|
|
96
|
+
const walletKey = new PublicKey(wallet);
|
|
97
|
+
return crypto.createPublicKey({
|
|
98
|
+
key: Buffer.concat([ED25519_SPKI_PREFIX, walletKey.toBuffer()]),
|
|
99
|
+
format: 'der',
|
|
100
|
+
type: 'spki',
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function deriveWalletControlChallengePdas({ agentId, wallet, network = 'devnet' } = {}) {
|
|
105
|
+
const normalizedNetwork = normalizeNetwork(network);
|
|
106
|
+
const normalizedAgentId = normalizeString(agentId, 'agentId');
|
|
107
|
+
const normalizedWallet = normalizePublicKey(wallet, 'wallet');
|
|
108
|
+
const agentIdHash = hashAgentId(normalizedAgentId);
|
|
109
|
+
const [genesisPda, genesisBump] = getGenesisPDA(agentIdHash, normalizedNetwork);
|
|
110
|
+
const [linkedWalletPda, linkedWalletBump] = getLinkedWalletPDA(genesisPda, normalizedWallet, normalizedNetwork);
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
agentIdHash: agentIdHash.toString('hex'),
|
|
114
|
+
genesisPda: genesisPda.toBase58(),
|
|
115
|
+
genesisBump,
|
|
116
|
+
linkedWalletPda: linkedWalletPda.toBase58(),
|
|
117
|
+
linkedWalletBump,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function normalizeWalletControlChallenge(challenge) {
|
|
122
|
+
if (!challenge || typeof challenge !== 'object') {
|
|
123
|
+
throw new Error('Invalid challenge: expected an object');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const network = normalizeNetwork(challenge.network);
|
|
127
|
+
const agentId = normalizeString(challenge.agentId, 'agentId');
|
|
128
|
+
const wallet = normalizePublicKey(challenge.wallet, 'wallet');
|
|
129
|
+
const domain = normalizeString(challenge.domain, 'domain', { maxBytes: 128 });
|
|
130
|
+
const audience = normalizeString(challenge.audience, 'audience', { maxBytes: 128 });
|
|
131
|
+
const nonce = normalizeString(challenge.nonce, 'nonce', { maxBytes: 128 });
|
|
132
|
+
const issuedAt = normalizeTimestamp(challenge.issuedAt, 'issuedAt');
|
|
133
|
+
const expiresAt = normalizeTimestamp(challenge.expiresAt, 'expiresAt');
|
|
134
|
+
const derived = deriveWalletControlChallengePdas({ agentId, wallet, network });
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
schemaVersion: challenge.schemaVersion,
|
|
138
|
+
challengeType: challenge.challengeType,
|
|
139
|
+
domain,
|
|
140
|
+
audience,
|
|
141
|
+
network,
|
|
142
|
+
agentId,
|
|
143
|
+
wallet,
|
|
144
|
+
nonce,
|
|
145
|
+
issuedAt,
|
|
146
|
+
expiresAt,
|
|
147
|
+
agentIdHash: challenge.agentIdHash,
|
|
148
|
+
genesisPda: challenge.genesisPda,
|
|
149
|
+
genesisBump: challenge.genesisBump,
|
|
150
|
+
linkedWalletPda: challenge.linkedWalletPda,
|
|
151
|
+
linkedWalletBump: challenge.linkedWalletBump,
|
|
152
|
+
derived,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function buildWalletControlChallenge(opts = {}) {
|
|
157
|
+
if (!opts || typeof opts !== 'object') {
|
|
158
|
+
throw new Error('Invalid opts: expected an options object');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const network = normalizeNetwork(opts.network);
|
|
162
|
+
const agentId = normalizeString(opts.agentId, 'agentId');
|
|
163
|
+
const wallet = normalizePublicKey(opts.wallet, 'wallet');
|
|
164
|
+
const domain = normalizeString(opts.domain || DEFAULT_WALLET_CONTROL_DOMAIN, 'domain', { maxBytes: 128 });
|
|
165
|
+
const audience = normalizeString(opts.audience || DEFAULT_WALLET_CONTROL_AUDIENCE, 'audience', { maxBytes: 128 });
|
|
166
|
+
const nonce = normalizeString(opts.nonce || crypto.randomBytes(16).toString('hex'), 'nonce', { maxBytes: 128 });
|
|
167
|
+
const issuedAt = normalizeTimestamp(
|
|
168
|
+
opts.issuedAt === undefined ? Math.floor(Date.now() / 1000) : opts.issuedAt,
|
|
169
|
+
'issuedAt'
|
|
170
|
+
);
|
|
171
|
+
const expiresAt = normalizeTimestamp(
|
|
172
|
+
opts.expiresAt === undefined ? issuedAt + DEFAULT_TTL_SECONDS : opts.expiresAt,
|
|
173
|
+
'expiresAt'
|
|
174
|
+
);
|
|
175
|
+
const derived = deriveWalletControlChallengePdas({ agentId, wallet, network });
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
schemaVersion: WALLET_CONTROL_CHALLENGE_SCHEMA_VERSION,
|
|
179
|
+
challengeType: WALLET_CONTROL_CHALLENGE_TYPE,
|
|
180
|
+
domain,
|
|
181
|
+
audience,
|
|
182
|
+
network,
|
|
183
|
+
agentId,
|
|
184
|
+
wallet,
|
|
185
|
+
nonce,
|
|
186
|
+
issuedAt,
|
|
187
|
+
expiresAt,
|
|
188
|
+
agentIdHash: derived.agentIdHash,
|
|
189
|
+
genesisPda: derived.genesisPda,
|
|
190
|
+
genesisBump: derived.genesisBump,
|
|
191
|
+
linkedWalletPda: derived.linkedWalletPda,
|
|
192
|
+
linkedWalletBump: derived.linkedWalletBump,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function canonicalWalletControlChallenge(challenge) {
|
|
197
|
+
const normalized = normalizeWalletControlChallenge(challenge);
|
|
198
|
+
return canonicalStringify({
|
|
199
|
+
schemaVersion: normalized.schemaVersion,
|
|
200
|
+
challengeType: normalized.challengeType,
|
|
201
|
+
domain: normalized.domain,
|
|
202
|
+
audience: normalized.audience,
|
|
203
|
+
network: normalized.network,
|
|
204
|
+
agentId: normalized.agentId,
|
|
205
|
+
wallet: normalized.wallet,
|
|
206
|
+
nonce: normalized.nonce,
|
|
207
|
+
issuedAt: normalized.issuedAt,
|
|
208
|
+
expiresAt: normalized.expiresAt,
|
|
209
|
+
agentIdHash: normalized.agentIdHash,
|
|
210
|
+
genesisPda: normalized.genesisPda,
|
|
211
|
+
genesisBump: normalized.genesisBump,
|
|
212
|
+
linkedWalletPda: normalized.linkedWalletPda,
|
|
213
|
+
linkedWalletBump: normalized.linkedWalletBump,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function hashWalletControlChallenge(challenge) {
|
|
218
|
+
return crypto
|
|
219
|
+
.createHash('sha256')
|
|
220
|
+
.update(canonicalWalletControlChallenge(challenge), 'utf8')
|
|
221
|
+
.digest('hex');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function verifyWalletControlChallengeSignature({
|
|
225
|
+
challenge,
|
|
226
|
+
signature,
|
|
227
|
+
expectedWallet,
|
|
228
|
+
expectedAgentId,
|
|
229
|
+
expectedDomain = DEFAULT_WALLET_CONTROL_DOMAIN,
|
|
230
|
+
expectedAudience = DEFAULT_WALLET_CONTROL_AUDIENCE,
|
|
231
|
+
now = Math.floor(Date.now() / 1000),
|
|
232
|
+
usedNonces,
|
|
233
|
+
replayCache,
|
|
234
|
+
isNonceUsed,
|
|
235
|
+
} = {}) {
|
|
236
|
+
const errors = [];
|
|
237
|
+
let normalized;
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
normalized = normalizeWalletControlChallenge(challenge);
|
|
241
|
+
} catch (err) {
|
|
242
|
+
return { ok: false, errors: [err.message] };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (normalized.schemaVersion !== WALLET_CONTROL_CHALLENGE_SCHEMA_VERSION) {
|
|
246
|
+
errors.push('schemaVersion must be ' + WALLET_CONTROL_CHALLENGE_SCHEMA_VERSION);
|
|
247
|
+
}
|
|
248
|
+
if (normalized.challengeType !== WALLET_CONTROL_CHALLENGE_TYPE) {
|
|
249
|
+
errors.push('challengeType must be ' + WALLET_CONTROL_CHALLENGE_TYPE);
|
|
250
|
+
}
|
|
251
|
+
if (normalized.domain !== expectedDomain) {
|
|
252
|
+
errors.push('domain does not match expected domain');
|
|
253
|
+
}
|
|
254
|
+
if (normalized.audience !== expectedAudience) {
|
|
255
|
+
errors.push('audience does not match expected audience');
|
|
256
|
+
}
|
|
257
|
+
if (expectedAgentId !== undefined && normalized.agentId !== expectedAgentId) {
|
|
258
|
+
errors.push('agentId does not match expected agentId');
|
|
259
|
+
}
|
|
260
|
+
if (expectedWallet !== undefined) {
|
|
261
|
+
let normalizedExpectedWallet;
|
|
262
|
+
try {
|
|
263
|
+
normalizedExpectedWallet = normalizePublicKey(expectedWallet, 'expectedWallet');
|
|
264
|
+
} catch (err) {
|
|
265
|
+
errors.push(err.message);
|
|
266
|
+
}
|
|
267
|
+
if (normalizedExpectedWallet && normalized.wallet !== normalizedExpectedWallet) {
|
|
268
|
+
errors.push('wallet does not match expected wallet');
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (normalized.expiresAt <= normalized.issuedAt) {
|
|
273
|
+
errors.push('expiresAt must be after issuedAt');
|
|
274
|
+
}
|
|
275
|
+
if (!Number.isSafeInteger(now) || now < 0) {
|
|
276
|
+
errors.push('now must be a non-negative safe integer Unix timestamp');
|
|
277
|
+
} else {
|
|
278
|
+
if (normalized.issuedAt > now) {
|
|
279
|
+
errors.push('challenge issuedAt is in the future');
|
|
280
|
+
}
|
|
281
|
+
if (normalized.expiresAt <= now) {
|
|
282
|
+
errors.push('challenge is expired');
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (normalized.agentIdHash !== normalized.derived.agentIdHash) {
|
|
287
|
+
errors.push('agentIdHash does not match derived agentId hash');
|
|
288
|
+
}
|
|
289
|
+
if (normalized.genesisPda !== normalized.derived.genesisPda || normalized.genesisBump !== normalized.derived.genesisBump) {
|
|
290
|
+
errors.push('genesis PDA does not match derived wallet-control challenge');
|
|
291
|
+
}
|
|
292
|
+
if (normalized.linkedWalletPda !== normalized.derived.linkedWalletPda || normalized.linkedWalletBump !== normalized.derived.linkedWalletBump) {
|
|
293
|
+
errors.push('linked wallet PDA does not match derived wallet-control challenge');
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
try {
|
|
297
|
+
if (hasReplayNonce(usedNonces, normalized.nonce) || hasReplayNonce(replayCache, normalized.nonce)) {
|
|
298
|
+
errors.push('nonce has already been used');
|
|
299
|
+
}
|
|
300
|
+
if (typeof isNonceUsed === 'function' && isNonceUsed(normalized.nonce, normalized) === true) {
|
|
301
|
+
errors.push('nonce has already been used');
|
|
302
|
+
}
|
|
303
|
+
} catch (err) {
|
|
304
|
+
errors.push('replay check failed: ' + err.message);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
let signatureBytes;
|
|
308
|
+
try {
|
|
309
|
+
signatureBytes = decodeSignature(signature);
|
|
310
|
+
if (signatureBytes.length !== 64) {
|
|
311
|
+
errors.push('signature must be 64 bytes');
|
|
312
|
+
}
|
|
313
|
+
} catch (err) {
|
|
314
|
+
errors.push(err.message);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (signatureBytes && signatureBytes.length === 64) {
|
|
318
|
+
try {
|
|
319
|
+
const signedMessage = Buffer.from(canonicalWalletControlChallenge(challenge), 'utf8');
|
|
320
|
+
const verified = crypto.verify(
|
|
321
|
+
null,
|
|
322
|
+
signedMessage,
|
|
323
|
+
ed25519PublicKeyFromSolanaPublicKey(normalized.wallet),
|
|
324
|
+
signatureBytes
|
|
325
|
+
);
|
|
326
|
+
if (!verified) {
|
|
327
|
+
errors.push('signature does not verify for wallet');
|
|
328
|
+
}
|
|
329
|
+
} catch (err) {
|
|
330
|
+
errors.push('signature verification failed: ' + err.message);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
ok: errors.length === 0,
|
|
336
|
+
errors,
|
|
337
|
+
challengeHash: hashWalletControlChallenge(challenge),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
module.exports = {
|
|
342
|
+
WALLET_CONTROL_CHALLENGE_SCHEMA_VERSION,
|
|
343
|
+
WALLET_CONTROL_CHALLENGE_TYPE,
|
|
344
|
+
DEFAULT_WALLET_CONTROL_DOMAIN,
|
|
345
|
+
DEFAULT_WALLET_CONTROL_AUDIENCE,
|
|
346
|
+
buildWalletControlChallenge,
|
|
347
|
+
canonicalWalletControlChallenge,
|
|
348
|
+
hashWalletControlChallenge,
|
|
349
|
+
deriveWalletControlChallengePdas,
|
|
350
|
+
verifyWalletControlChallengeSignature,
|
|
351
|
+
};
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const X402_PAYMENT_IS_NOT_ACTION_AUTHORIZATION = 'X402_PAYMENT_IS_NOT_ACTION_AUTHORIZATION';
|
|
4
|
+
const X402_DISCOVERY_SCHEMA_VERSION = 'satp.x402DiscoveryMetadata.v1';
|
|
5
|
+
const RUNTIME_POLICY_ACTION_DESCRIPTOR_SCHEMA_VERSION = 'satp.runtimePolicyActionDescriptor.v1';
|
|
6
|
+
|
|
7
|
+
function isRecord(value) {
|
|
8
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function parseJsonObject(value, label) {
|
|
12
|
+
if (typeof value !== 'string') return value;
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(value);
|
|
15
|
+
} catch (err) {
|
|
16
|
+
throw new Error(label + ' must be an object or JSON object string');
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function optionalString(value) {
|
|
21
|
+
if (value === undefined || value === null || value === '') return null;
|
|
22
|
+
return String(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function optionalNumber(value, label) {
|
|
26
|
+
if (value === undefined || value === null || value === '') return null;
|
|
27
|
+
const number = Number(value);
|
|
28
|
+
if (!Number.isFinite(number) || number < 0) {
|
|
29
|
+
throw new Error(label + ' must be a finite non-negative number');
|
|
30
|
+
}
|
|
31
|
+
return number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function copyPaymentRequirement(requirement) {
|
|
35
|
+
if (!isRecord(requirement)) {
|
|
36
|
+
throw new Error('x402 payment requirement must be an object');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const out = {};
|
|
40
|
+
for (const key of [
|
|
41
|
+
'scheme',
|
|
42
|
+
'network',
|
|
43
|
+
'asset',
|
|
44
|
+
'payTo',
|
|
45
|
+
'maxAmountRequired',
|
|
46
|
+
'amountRequired',
|
|
47
|
+
'resource',
|
|
48
|
+
'description',
|
|
49
|
+
'mimeType',
|
|
50
|
+
'maxTimeoutSeconds',
|
|
51
|
+
'extra',
|
|
52
|
+
]) {
|
|
53
|
+
if (requirement[key] !== undefined) out[key] = requirement[key];
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function firstPresent(values) {
|
|
59
|
+
return values.find((value) => value !== undefined && value !== null);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function normalizePaymentRequirements(x402) {
|
|
63
|
+
const value = firstPresent([
|
|
64
|
+
x402.accepts,
|
|
65
|
+
x402.paymentRequirements,
|
|
66
|
+
isRecord(x402.payment) ? x402.payment.accepts : undefined,
|
|
67
|
+
isRecord(x402.payment) ? x402.payment.requirements : undefined,
|
|
68
|
+
isRecord(x402.x402) ? x402.x402.accepts : undefined,
|
|
69
|
+
isRecord(x402.x402) ? x402.x402.paymentRequirements : undefined,
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
if (value === undefined || value === null) return [];
|
|
73
|
+
return (Array.isArray(value) ? value : [value]).map(copyPaymentRequirement);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function firstPaymentRequirementResource(paymentRequirements) {
|
|
77
|
+
for (const requirement of paymentRequirements) {
|
|
78
|
+
const resource = optionalString(requirement.resource);
|
|
79
|
+
if (resource) return resource;
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function resolveDiscoveryEnvelope(input) {
|
|
85
|
+
const metadata = parseJsonObject(input, 'x402 discovery metadata');
|
|
86
|
+
if (!isRecord(metadata)) {
|
|
87
|
+
throw new Error('x402 discovery metadata must be an object');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (
|
|
91
|
+
isRecord(metadata.x402)
|
|
92
|
+
&& metadata.accepts === undefined
|
|
93
|
+
&& metadata.paymentRequirements === undefined
|
|
94
|
+
&& metadata.payment === undefined
|
|
95
|
+
) {
|
|
96
|
+
return { envelope: metadata, x402: metadata.x402 };
|
|
97
|
+
}
|
|
98
|
+
return { envelope: metadata, x402: metadata };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function parseX402DiscoveryMetadata(input) {
|
|
102
|
+
const { envelope, x402 } = resolveDiscoveryEnvelope(input);
|
|
103
|
+
const paymentRequirements = normalizePaymentRequirements(x402);
|
|
104
|
+
const endpoint = optionalString(firstPresent([
|
|
105
|
+
x402.endpoint,
|
|
106
|
+
x402.discoveryEndpoint,
|
|
107
|
+
x402.url,
|
|
108
|
+
envelope.endpoint,
|
|
109
|
+
envelope.discoveryEndpoint,
|
|
110
|
+
envelope.url,
|
|
111
|
+
]));
|
|
112
|
+
const resource = optionalString(firstPresent([
|
|
113
|
+
x402.resource,
|
|
114
|
+
x402.resourceUrl,
|
|
115
|
+
envelope.resource,
|
|
116
|
+
envelope.resourceUrl,
|
|
117
|
+
])) || firstPaymentRequirementResource(paymentRequirements);
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
schemaVersion: X402_DISCOVERY_SCHEMA_VERSION,
|
|
121
|
+
protocol: 'x402',
|
|
122
|
+
resource,
|
|
123
|
+
endpoint,
|
|
124
|
+
action: optionalString(firstPresent([envelope.action, x402.action, envelope.operation, x402.operation])),
|
|
125
|
+
paymentRequired: paymentRequirements.length > 0 || x402.paymentRequired === true || envelope.paymentRequired === true,
|
|
126
|
+
paymentRequirements,
|
|
127
|
+
guardrail: X402_PAYMENT_IS_NOT_ACTION_AUTHORIZATION,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function buildX402EvidenceLookup(input, opts = {}) {
|
|
132
|
+
const discovery = parseX402DiscoveryMetadata(input);
|
|
133
|
+
return {
|
|
134
|
+
type: 'x402',
|
|
135
|
+
endpoint: opts.endpoint || discovery.endpoint,
|
|
136
|
+
maxCostUsd: optionalNumber(opts.maxCostUsd, 'maxCostUsd'),
|
|
137
|
+
protocol: 'x402',
|
|
138
|
+
source: {
|
|
139
|
+
kind: opts.sourceKind || 'x402-discovery-metadata',
|
|
140
|
+
url: opts.sourceUrl || discovery.endpoint || discovery.resource,
|
|
141
|
+
},
|
|
142
|
+
resource: discovery.resource,
|
|
143
|
+
paymentRequired: discovery.paymentRequired,
|
|
144
|
+
paymentRequirements: discovery.paymentRequirements,
|
|
145
|
+
discovery,
|
|
146
|
+
guardrail: X402_PAYMENT_IS_NOT_ACTION_AUTHORIZATION,
|
|
147
|
+
paymentAuthorization: false,
|
|
148
|
+
actionAuthorization: false,
|
|
149
|
+
spendAuthorized: false,
|
|
150
|
+
livePaymentRequired: false,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function buildRuntimePolicyActionDescriptorFromX402Discovery(input, opts = {}) {
|
|
155
|
+
const discovery = parseX402DiscoveryMetadata(input);
|
|
156
|
+
return {
|
|
157
|
+
schemaVersion: RUNTIME_POLICY_ACTION_DESCRIPTOR_SCHEMA_VERSION,
|
|
158
|
+
type: opts.type || 'x402_evidence_lookup',
|
|
159
|
+
resource: opts.resource || discovery.resource || discovery.endpoint,
|
|
160
|
+
operation: opts.operation || discovery.action || 'lookup',
|
|
161
|
+
requiresFreshEvidence: opts.requiresFreshEvidence !== false,
|
|
162
|
+
evidenceLookup: buildX402EvidenceLookup(input, opts),
|
|
163
|
+
costUsd: 0,
|
|
164
|
+
paymentAuthorization: false,
|
|
165
|
+
actionAuthorization: false,
|
|
166
|
+
spendAuthorized: false,
|
|
167
|
+
livePaymentRequired: false,
|
|
168
|
+
guardrail: X402_PAYMENT_IS_NOT_ACTION_AUTHORIZATION,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
module.exports = {
|
|
173
|
+
X402_PAYMENT_IS_NOT_ACTION_AUTHORIZATION,
|
|
174
|
+
X402_DISCOVERY_SCHEMA_VERSION,
|
|
175
|
+
RUNTIME_POLICY_ACTION_DESCRIPTOR_SCHEMA_VERSION,
|
|
176
|
+
parseX402DiscoveryMetadata,
|
|
177
|
+
buildX402EvidenceLookup,
|
|
178
|
+
buildRuntimePolicyActionDescriptorFromX402Discovery,
|
|
179
|
+
buildRuntimePolicyActionDescriptorFromX402: buildRuntimePolicyActionDescriptorFromX402Discovery,
|
|
180
|
+
};
|