openpay-x402-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/signer.mjs ADDED
@@ -0,0 +1,295 @@
1
+ import { getAddress, isHex, verifyTypedData } from 'viem';
2
+ import { privateKeyToAccount } from 'viem/accounts';
3
+
4
+ export const SIGNER_MODES = {
5
+ envKey: 'env-key',
6
+ steward: 'steward',
7
+ };
8
+
9
+ function nonEmpty(raw) {
10
+ return typeof raw === 'string' && raw.length > 0 ? raw : undefined;
11
+ }
12
+
13
+ export function readSignerMode(env = process.env) {
14
+ const mode = nonEmpty(env.SIGNER_MODE) ?? SIGNER_MODES.envKey;
15
+ if (mode !== SIGNER_MODES.envKey && mode !== SIGNER_MODES.steward) {
16
+ throw new Error('SIGNER_MODE must be "env-key" or "steward"');
17
+ }
18
+ return mode;
19
+ }
20
+
21
+ function requireEnv(env, name) {
22
+ const value = nonEmpty(env[name]);
23
+ if (value === undefined) throw new Error(`${name} is required when SIGNER_MODE=steward`);
24
+ return value;
25
+ }
26
+
27
+ function requireHttpUrl(raw, label) {
28
+ if (typeof raw !== 'string') throw new Error(`${label} must be an http(s) URL`);
29
+ try {
30
+ const url = new URL(raw);
31
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
32
+ throw new Error(`${label} must be an http(s) URL`);
33
+ }
34
+ return url.toString().replace(/\/+$/, '');
35
+ } catch (error) {
36
+ if (error instanceof Error && error.message.startsWith(`${label} must be`)) {
37
+ throw error;
38
+ }
39
+ throw new Error(`${label} must be an http(s) URL`);
40
+ }
41
+ }
42
+
43
+ function requirePrivateKey(env) {
44
+ const key = nonEmpty(env.BUYER_PRIVATE_KEY);
45
+ if (key === undefined) {
46
+ throw new Error('BUYER_PRIVATE_KEY is required when SIGNER_MODE=env-key');
47
+ }
48
+ if (!isHex(key) || key.length !== 66) {
49
+ throw new Error('BUYER_PRIVATE_KEY must be a 32-byte 0x-prefixed hex string');
50
+ }
51
+ return key;
52
+ }
53
+
54
+ function toJsonValue(value) {
55
+ if (typeof value === 'bigint') return value.toString();
56
+ if (Array.isArray(value)) return value.map(toJsonValue);
57
+ if (typeof value !== 'object' || value === null) return value;
58
+ return Object.fromEntries(
59
+ Object.entries(value).map(([key, item]) => [key, toJsonValue(item)]),
60
+ );
61
+ }
62
+
63
+ function stewardRequestBody(typedData) {
64
+ return {
65
+ domain: toJsonValue(typedData.domain),
66
+ types: toJsonValue(typedData.types),
67
+ primaryType: typedData.primaryType,
68
+ value: toJsonValue(typedData.message),
69
+ };
70
+ }
71
+
72
+ function redactStewardPreview(text, config) {
73
+ let redacted = String(text);
74
+ for (const secret of [config.apiKey, config.signerSecret]) {
75
+ if (typeof secret === 'string' && secret.length > 0) {
76
+ redacted = redacted.split(secret).join('[redacted_secret]');
77
+ }
78
+ }
79
+ return redacted
80
+ .replace(/\b0x[0-9a-fA-F]{64}\b/g, '[redacted_private_key]')
81
+ .replace(/\b0x[0-9a-fA-F]{130}\b/g, '[redacted_signature]');
82
+ }
83
+
84
+ async function readStewardJson(res, config) {
85
+ const text = await res.text();
86
+ if (text.length === 0) return { body: null, preview: '' };
87
+ const preview = redactStewardPreview(text, config).slice(0, 200);
88
+ try {
89
+ return { body: JSON.parse(text), preview };
90
+ } catch {
91
+ return { body: null, preview };
92
+ }
93
+ }
94
+
95
+ function isObject(value) {
96
+ return typeof value === 'object' && value !== null;
97
+ }
98
+
99
+ function stewardFailure(status, preview) {
100
+ const suffix = preview.length > 0 ? preview : 'empty response body';
101
+ return new Error(`steward sign-typed-data failed (${status}): ${suffix}`);
102
+ }
103
+
104
+ async function requestStewardSignature(config, fetchImpl, typedData) {
105
+ const res = await fetchImpl(
106
+ `${config.url}/vault/${encodeURIComponent(config.agentId)}/sign-typed-data`,
107
+ {
108
+ method: 'POST',
109
+ headers: {
110
+ 'content-type': 'application/json',
111
+ 'X-Steward-Key': config.apiKey,
112
+ 'X-Steward-Tenant': config.tenant,
113
+ 'x-steward-signer-id': config.signerId,
114
+ 'x-steward-signer-secret': config.signerSecret,
115
+ },
116
+ body: JSON.stringify(stewardRequestBody(typedData)),
117
+ },
118
+ );
119
+ const { body, preview } = await readStewardJson(res, config);
120
+ if (
121
+ !res.ok ||
122
+ !isObject(body) ||
123
+ body.ok !== true ||
124
+ !isObject(body.data) ||
125
+ typeof body.data.signature !== 'string'
126
+ ) {
127
+ throw stewardFailure(res.status, preview);
128
+ }
129
+ return body.data.signature;
130
+ }
131
+
132
+ function createEnvKeySigner(env) {
133
+ const account = privateKeyToAccount(requirePrivateKey(env));
134
+ return {
135
+ mode: SIGNER_MODES.envKey,
136
+ address: account.address,
137
+ signTypedData: (typedData) => account.signTypedData(typedData),
138
+ };
139
+ }
140
+
141
+ function createStewardSignerFromConfig(config, fetchImpl) {
142
+ let firstSignatureVerified = false;
143
+
144
+ return {
145
+ mode: SIGNER_MODES.steward,
146
+ address: config.agentAddress,
147
+ async signTypedData(typedData) {
148
+ const signature = await requestStewardSignature(config, fetchImpl, typedData);
149
+ if (!firstSignatureVerified) {
150
+ const verified = await verifyTypedData({
151
+ ...typedData,
152
+ address: config.agentAddress,
153
+ signature,
154
+ });
155
+ // This contains agent/signer credential mix-ups before they propagate as invalid payment signatures sent to x402 resources.
156
+ if (!verified) {
157
+ throw new Error(
158
+ 'steward signature self-verification failed for STEWARD_AGENT_ADDRESS',
159
+ );
160
+ }
161
+ firstSignatureVerified = true;
162
+ }
163
+ return signature;
164
+ },
165
+ };
166
+ }
167
+
168
+ function createStewardSigner(env, fetchImpl) {
169
+ return createStewardSignerFromConfig(
170
+ {
171
+ url: requireHttpUrl(requireEnv(env, 'STEWARD_URL'), 'STEWARD_URL'),
172
+ tenant: requireEnv(env, 'STEWARD_TENANT'),
173
+ apiKey: requireEnv(env, 'STEWARD_API_KEY'),
174
+ agentId: requireEnv(env, 'STEWARD_AGENT_ID'),
175
+ agentAddress: getAddress(requireEnv(env, 'STEWARD_AGENT_ADDRESS')),
176
+ signerId: requireEnv(env, 'STEWARD_SIGNER_ID'),
177
+ signerSecret: requireEnv(env, 'STEWARD_SIGNER_SECRET'),
178
+ },
179
+ fetchImpl,
180
+ );
181
+ }
182
+
183
+ export function createSigner(env = process.env, { fetchImpl = fetch } = {}) {
184
+ const mode = readSignerMode(env);
185
+ if (mode === SIGNER_MODES.envKey) return createEnvKeySigner(env);
186
+ return createStewardSigner(env, fetchImpl);
187
+ }
188
+
189
+ function requireOptionsObject(options) {
190
+ if (typeof options !== 'object' || options === null || Array.isArray(options)) {
191
+ throw new Error('client options must be an object');
192
+ }
193
+ return options;
194
+ }
195
+
196
+ function requireStewardOption(steward, key, label) {
197
+ const value = nonEmpty(steward[key]);
198
+ if (value === undefined) {
199
+ throw new Error(`${label} is required when SIGNER_MODE=steward`);
200
+ }
201
+ return value;
202
+ }
203
+
204
+ function parseStewardOptions(steward) {
205
+ if (typeof steward !== 'object' || steward === null || Array.isArray(steward)) {
206
+ throw new Error('steward must be an object');
207
+ }
208
+ return {
209
+ url: requireHttpUrl(
210
+ requireStewardOption(steward, 'url', 'STEWARD_URL'),
211
+ 'STEWARD_URL',
212
+ ),
213
+ tenant: requireStewardOption(steward, 'tenant', 'STEWARD_TENANT'),
214
+ apiKey: requireStewardOption(steward, 'apiKey', 'STEWARD_API_KEY'),
215
+ agentId: requireStewardOption(steward, 'agentId', 'STEWARD_AGENT_ID'),
216
+ agentAddress: getAddress(
217
+ requireStewardOption(steward, 'agentAddress', 'STEWARD_AGENT_ADDRESS'),
218
+ ),
219
+ signerId: requireStewardOption(steward, 'signerId', 'STEWARD_SIGNER_ID'),
220
+ signerSecret: requireStewardOption(
221
+ steward,
222
+ 'signerSecret',
223
+ 'STEWARD_SIGNER_SECRET',
224
+ ),
225
+ };
226
+ }
227
+
228
+ function parseCustomSigner(signer) {
229
+ if (typeof signer !== 'object' || signer === null || Array.isArray(signer)) {
230
+ throw new Error('signer must be an object');
231
+ }
232
+ if (typeof signer.address !== 'string') {
233
+ throw new Error('signer.address must be an EVM address');
234
+ }
235
+ let address;
236
+ try {
237
+ address = getAddress(signer.address);
238
+ } catch {
239
+ throw new Error('signer.address must be an EVM address');
240
+ }
241
+ if (typeof signer.signTypedData !== 'function') {
242
+ throw new Error('signer.signTypedData must be a function');
243
+ }
244
+ return { signer, address };
245
+ }
246
+
247
+ export function parseSignerOptions(options = {}) {
248
+ const input = requireOptionsObject(options);
249
+ const selections = ['privateKey', 'steward', 'signer'].filter(
250
+ (key) => input[key] !== undefined && input[key] !== null,
251
+ );
252
+ if (selections.length > 1) {
253
+ throw new Error('privateKey, steward, and signer are mutually exclusive');
254
+ }
255
+
256
+ if (input.privateKey !== undefined && input.privateKey !== null) {
257
+ const privateKey = nonEmpty(input.privateKey);
258
+ if (privateKey === undefined) return { kind: 'none' };
259
+ if (!isHex(privateKey) || privateKey.length !== 66) {
260
+ throw new Error('BUYER_PRIVATE_KEY must be a 32-byte 0x-prefixed hex string');
261
+ }
262
+ return { kind: 'private-key', privateKey };
263
+ }
264
+ if (input.steward !== undefined && input.steward !== null) {
265
+ return { kind: 'steward', config: parseStewardOptions(input.steward) };
266
+ }
267
+ if (input.signer !== undefined && input.signer !== null) {
268
+ return { kind: 'custom', ...parseCustomSigner(input.signer) };
269
+ }
270
+ return { kind: 'none' };
271
+ }
272
+
273
+ export function createSignerFromOptions(
274
+ options = {},
275
+ { fetchImpl = fetch } = {},
276
+ ) {
277
+ const parsed = parseSignerOptions(options);
278
+ if (parsed.kind === 'none') return null;
279
+ if (parsed.kind === 'private-key') {
280
+ const account = privateKeyToAccount(parsed.privateKey);
281
+ return {
282
+ mode: SIGNER_MODES.envKey,
283
+ address: account.address,
284
+ signTypedData: (typedData) => account.signTypedData(typedData),
285
+ };
286
+ }
287
+ if (parsed.kind === 'steward') {
288
+ return createStewardSignerFromConfig(parsed.config, fetchImpl);
289
+ }
290
+ return {
291
+ mode: 'custom',
292
+ address: parsed.address,
293
+ signTypedData: (typedData) => parsed.signer.signTypedData(typedData),
294
+ };
295
+ }