@agentspend/sdk 0.3.7 → 0.3.9

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/index.ts DELETED
@@ -1,662 +0,0 @@
1
- // ---------------------------------------------------------------------------
2
- // Types (inlined from @agentspend/types to avoid cross-repo publish)
3
- // ---------------------------------------------------------------------------
4
-
5
- export interface ChargeRequest {
6
- card_id: string;
7
- amount_cents: number;
8
- currency?: string;
9
- description?: string;
10
- metadata?: Record<string, string>;
11
- idempotency_key?: string;
12
- }
13
-
14
- export interface ChargeResponse {
15
- charged: true;
16
- card_id: string;
17
- amount_cents: number;
18
- currency: string;
19
- remaining_limit_cents: number;
20
- stripe_payment_intent_id: string;
21
- stripe_charge_id: string;
22
- charge_attempt_id: string;
23
- }
24
-
25
- export interface ErrorResponse {
26
- error: string;
27
- }
28
-
29
- export type PaymentMethod = "card" | "crypto";
30
-
31
- export interface PaywallPaymentContext {
32
- method: PaymentMethod;
33
- amount_cents: number;
34
- currency: string;
35
- card_id?: string;
36
- remaining_limit_cents?: number;
37
- transaction_hash?: string;
38
- payer_address?: string;
39
- network?: string;
40
- }
41
-
42
- // ---------------------------------------------------------------------------
43
- // x402 imports – server-side only (HTTP calls to facilitator, no crypto deps)
44
- // ---------------------------------------------------------------------------
45
- import { HTTPFacilitatorClient, x402ResourceServer } from "@x402/core/server";
46
- import { registerExactEvmScheme } from "@x402/evm/exact/server";
47
- import type {
48
- PaymentRequirements,
49
- PaymentPayload,
50
- VerifyResponse,
51
- SettleResponse,
52
- Network
53
- } from "@x402/core/types";
54
-
55
- // ---------------------------------------------------------------------------
56
- // Options
57
- // ---------------------------------------------------------------------------
58
-
59
- export interface AgentSpendOptions {
60
- /**
61
- * Base URL for the AgentSpend Platform API.
62
- *
63
- * If omitted, the SDK will use `process.env.AGENTSPEND_API_URL` when available,
64
- * otherwise it falls back to the hosted default.
65
- */
66
- platformApiBaseUrl?: string;
67
- /** Service API key. Optional — crypto-only services don't need one. */
68
- serviceApiKey?: string;
69
- fetchImpl?: typeof fetch;
70
- /** Crypto / x402 configuration. */
71
- crypto?: {
72
- /** Static payTo address for crypto-only services. */
73
- receiverAddress?: string;
74
- /** Chain identifier. Default: "eip155:8453" (Base). */
75
- network?: string;
76
- /** x402 facilitator URL. Default: "https://x402.org/facilitator". */
77
- facilitatorUrl?: string;
78
- };
79
- }
80
-
81
- export interface ChargeOptions {
82
- amount_cents: number;
83
- currency?: string;
84
- description?: string;
85
- metadata?: Record<string, string>;
86
- idempotency_key?: string;
87
- }
88
-
89
- export class AgentSpendChargeError extends Error {
90
- statusCode: number;
91
- details: unknown;
92
-
93
- constructor(message: string, statusCode: number, details?: unknown) {
94
- super(message);
95
- this.statusCode = statusCode;
96
- this.details = details;
97
- }
98
- }
99
-
100
- // ---------------------------------------------------------------------------
101
- // Context abstraction (Hono-compatible)
102
- // ---------------------------------------------------------------------------
103
-
104
- export interface HonoContextLike {
105
- req: {
106
- header(name: string): string | undefined;
107
- json(): Promise<unknown>;
108
- url: string;
109
- method: string;
110
- };
111
- json(body: unknown, status?: number): Response;
112
- header(name: string, value: string): void;
113
- set(key: string, value: unknown): void;
114
- get(key: string): unknown;
115
- }
116
-
117
- // ---------------------------------------------------------------------------
118
- // Paywall options
119
- // ---------------------------------------------------------------------------
120
-
121
- export interface PaywallOptions {
122
- /**
123
- * Amount in cents.
124
- * - number: fixed price (e.g. 500 = $5.00)
125
- * - string: body field name to read amount from (e.g. "amount_cents")
126
- * - function: custom dynamic pricing (body: unknown) => number
127
- */
128
- amount: number | string | ((body: unknown) => number);
129
- currency?: string;
130
- description?: string;
131
- metadata?: (body: unknown) => Record<string, unknown>;
132
- }
133
-
134
- // ---------------------------------------------------------------------------
135
- // Payment context helper
136
- // ---------------------------------------------------------------------------
137
-
138
- const PAYMENT_CONTEXT_KEY = "payment";
139
-
140
- export function getPaymentContext(c: HonoContextLike): PaywallPaymentContext | null {
141
- const ctx = c.get(PAYMENT_CONTEXT_KEY);
142
- return (ctx as PaywallPaymentContext) ?? null;
143
- }
144
-
145
- // ---------------------------------------------------------------------------
146
- // Public interface
147
- // ---------------------------------------------------------------------------
148
-
149
- export interface AgentSpend {
150
- charge(cardId: string, opts: ChargeOptions): Promise<ChargeResponse>;
151
- paywall(opts: PaywallOptions): (c: HonoContextLike, next: () => Promise<void>) => Promise<Response | void>;
152
- }
153
-
154
- // ---------------------------------------------------------------------------
155
- // Factory
156
- // ---------------------------------------------------------------------------
157
-
158
- export function createAgentSpend(options: AgentSpendOptions): AgentSpend {
159
- // Validate: at least one of serviceApiKey or crypto must be provided
160
- if (!options.serviceApiKey && !options.crypto) {
161
- throw new AgentSpendChargeError(
162
- "At least one of serviceApiKey or crypto config must be provided",
163
- 500
164
- );
165
- }
166
-
167
- const fetchImpl = options.fetchImpl ?? globalThis.fetch;
168
- if (!fetchImpl) {
169
- throw new AgentSpendChargeError("No fetch implementation available", 500);
170
- }
171
-
172
- const platformApiBaseUrl = resolvePlatformApiBaseUrl(options.platformApiBaseUrl);
173
-
174
- // -------------------------------------------------------------------
175
- // Lazy service_id fetch + cache
176
- // -------------------------------------------------------------------
177
- let cachedServiceId: string | null = null;
178
-
179
- async function getServiceId(): Promise<string | null> {
180
- if (cachedServiceId) return cachedServiceId;
181
- if (!options.serviceApiKey) return null;
182
- try {
183
- const res = await fetchImpl(joinUrl(platformApiBaseUrl, "/v1/service/me"), {
184
- headers: { authorization: `Bearer ${options.serviceApiKey}` }
185
- });
186
- if (res.ok) {
187
- const data = (await res.json()) as { id?: string };
188
- cachedServiceId = data.id ?? null;
189
- }
190
- } catch { /* graceful fallback */ }
191
- return cachedServiceId;
192
- }
193
-
194
- // -------------------------------------------------------------------
195
- // x402 singleton setup (Decision 9)
196
- // Server-side: facilitator handles verify + settle over HTTP.
197
- // No client-side EVM scheme needed — we delegate to the facilitator.
198
- // -------------------------------------------------------------------
199
- let facilitator: HTTPFacilitatorClient | null = null;
200
- let resourceServer: x402ResourceServer | null = null;
201
- const cryptoNetwork: Network = (options.crypto?.network ?? "eip155:8453") as Network;
202
-
203
- if (options.crypto || options.serviceApiKey) {
204
- const facilitatorUrl =
205
- options.crypto?.facilitatorUrl ?? "https://x402.org/facilitator";
206
- facilitator = new HTTPFacilitatorClient({ url: facilitatorUrl });
207
- resourceServer = new x402ResourceServer(facilitator);
208
- registerExactEvmScheme(resourceServer);
209
- }
210
-
211
- // -------------------------------------------------------------------
212
- // charge() — card-only, unchanged
213
- // -------------------------------------------------------------------
214
-
215
- async function charge(cardIdInput: string, opts: ChargeOptions): Promise<ChargeResponse> {
216
- if (!options.serviceApiKey) {
217
- throw new AgentSpendChargeError("charge() requires serviceApiKey", 500);
218
- }
219
-
220
- const cardId = toCardId(cardIdInput);
221
- if (!cardId) {
222
- throw new AgentSpendChargeError("card_id must start with card_", 400);
223
- }
224
- if (!Number.isInteger(opts.amount_cents) || opts.amount_cents <= 0) {
225
- throw new AgentSpendChargeError("amount_cents must be a positive integer", 400);
226
- }
227
-
228
- const payload: ChargeRequest = {
229
- card_id: cardId,
230
- amount_cents: opts.amount_cents,
231
- currency: opts.currency ?? "usd",
232
- ...(opts.description ? { description: opts.description } : {}),
233
- ...(opts.metadata ? { metadata: opts.metadata } : {}),
234
- idempotency_key: opts.idempotency_key ?? bestEffortIdempotencyKey()
235
- };
236
-
237
- const response = await fetchImpl(joinUrl(platformApiBaseUrl, "/v1/charge"), {
238
- method: "POST",
239
- headers: {
240
- authorization: `Bearer ${options.serviceApiKey}`,
241
- "content-type": "application/json"
242
- },
243
- body: JSON.stringify(payload)
244
- });
245
-
246
- const responseBody = (await response.json().catch(() => ({}))) as Partial<ChargeResponse> &
247
- Partial<ErrorResponse> &
248
- Record<string, unknown>;
249
- if (!response.ok) {
250
- throw new AgentSpendChargeError(
251
- typeof responseBody.error === "string" ? responseBody.error : "AgentSpend charge failed",
252
- response.status,
253
- responseBody
254
- );
255
- }
256
-
257
- return responseBody as ChargeResponse;
258
- }
259
-
260
- // -------------------------------------------------------------------
261
- // paywall() — unified card + crypto middleware
262
- // -------------------------------------------------------------------
263
-
264
- function paywall(opts: PaywallOptions) {
265
- const { amount } = opts;
266
-
267
- // Validate fixed-price amount at creation time
268
- if (typeof amount === "number") {
269
- if (!Number.isInteger(amount) || amount <= 0) {
270
- throw new AgentSpendChargeError("amount must be a positive integer", 500);
271
- }
272
- }
273
-
274
- return async function paywallMiddleware(
275
- c: HonoContextLike,
276
- next: () => Promise<void>
277
- ): Promise<Response | void> {
278
- // Step 1: Parse body once (Decision 11)
279
- const body: unknown = await c.req.json().catch(() => ({}));
280
-
281
- // Step 2: Determine effective amount
282
- let effectiveAmount: number;
283
- if (typeof amount === "number") {
284
- effectiveAmount = amount;
285
- } else if (typeof amount === "string") {
286
- const raw = (body as Record<string, unknown>)?.[amount];
287
- effectiveAmount = typeof raw === "number" ? raw : 0;
288
- } else {
289
- effectiveAmount = amount(body);
290
- }
291
-
292
- if (!Number.isInteger(effectiveAmount) || effectiveAmount <= 0) {
293
- return c.json({ error: "Could not determine payment amount from request" }, 400);
294
- }
295
-
296
- const currency = opts.currency ?? "usd";
297
-
298
- // Step 3: Check for payment header → crypto payment
299
- // x402 v2 uses "Payment-Signature", v1 uses "X-Payment"
300
- const paymentHeader = c.req.header("payment-signature") ?? c.req.header("x-payment");
301
- if (paymentHeader) {
302
- return handleCryptoPayment(c, next, paymentHeader, effectiveAmount, currency, body, opts);
303
- }
304
-
305
- // Step 4: Check for x-card-id header or body.card_id → card payment
306
- const cardIdFromHeader = c.req.header("x-card-id");
307
- let cardId = cardIdFromHeader ? toCardId(cardIdFromHeader) : null;
308
- if (!cardId) {
309
- const bodyCardId =
310
- typeof (body as { card_id?: unknown })?.card_id === "string"
311
- ? (body as { card_id: string }).card_id
312
- : null;
313
- cardId = toCardId(bodyCardId);
314
- }
315
-
316
- if (cardId) {
317
- return handleCardPayment(c, next, cardId, effectiveAmount, currency, body, opts);
318
- }
319
-
320
- // Step 5: Neither → return 402 with Payment-Required header (Decision 8)
321
- return return402Response(c, effectiveAmount, currency);
322
- };
323
- }
324
-
325
- // -------------------------------------------------------------------
326
- // handleCardPayment — existing charge() flow
327
- // -------------------------------------------------------------------
328
-
329
- async function handleCardPayment(
330
- c: HonoContextLike,
331
- next: () => Promise<void>,
332
- cardId: string,
333
- amountCents: number,
334
- currency: string,
335
- body: unknown,
336
- opts: PaywallOptions
337
- ): Promise<Response | void> {
338
- if (!options.serviceApiKey) {
339
- return c.json({ error: "Card payments require serviceApiKey" }, 500);
340
- }
341
-
342
- try {
343
- const chargeResult = await charge(cardId, {
344
- amount_cents: amountCents,
345
- currency,
346
- description: opts.description,
347
- metadata: opts.metadata ? toStringMetadata(opts.metadata(body)) : undefined,
348
- idempotency_key:
349
- c.req.header("x-request-id") ?? c.req.header("idempotency-key") ?? undefined
350
- });
351
-
352
- const paymentContext: PaywallPaymentContext = {
353
- method: "card",
354
- amount_cents: amountCents,
355
- currency,
356
- card_id: cardId,
357
- remaining_limit_cents: chargeResult.remaining_limit_cents
358
- };
359
- c.set(PAYMENT_CONTEXT_KEY, paymentContext);
360
- } catch (error) {
361
- if (error instanceof AgentSpendChargeError) {
362
- if (error.statusCode === 403) {
363
- // No binding — return 402 so agent can discover service_id and bind
364
- return return402Response(c, amountCents, currency);
365
- }
366
- if (error.statusCode === 402) {
367
- return c.json({ error: "Payment required", details: error.details }, 402);
368
- }
369
- return c.json({ error: error.message, details: error.details }, error.statusCode);
370
- }
371
- return c.json({ error: "Unexpected paywall failure" }, 500);
372
- }
373
-
374
- await next();
375
- }
376
-
377
- // -------------------------------------------------------------------
378
- // handleCryptoPayment — x402 verify + settle via facilitator
379
- // -------------------------------------------------------------------
380
-
381
- async function handleCryptoPayment(
382
- c: HonoContextLike,
383
- next: () => Promise<void>,
384
- paymentHeader: string,
385
- amountCents: number,
386
- currency: string,
387
- _body: unknown,
388
- _opts: PaywallOptions
389
- ): Promise<Response | void> {
390
- if (!resourceServer) {
391
- return c.json({ error: "Crypto payments not configured" }, 500);
392
- }
393
-
394
- try {
395
- // Decode the x-payment header (base64 JSON payment payload)
396
- let paymentPayload: PaymentPayload;
397
- try {
398
- paymentPayload = JSON.parse(
399
- Buffer.from(paymentHeader, "base64").toString("utf-8")
400
- ) as PaymentPayload;
401
- } catch {
402
- return c.json({ error: "Invalid payment payload encoding" }, 400);
403
- }
404
-
405
- // Extract payTo from the payment payload's accepted requirements
406
- // rather than generating a new deposit address.
407
- // resolvePayToAddress() creates a fresh Stripe PaymentIntent each
408
- // time, which would return a *different* address than the one in the
409
- // 402 response the client signed against → facilitator verification
410
- // would fail.
411
- const acceptedPayTo = (paymentPayload as unknown as { accepted?: { payTo?: string } })
412
- .accepted?.payTo;
413
- const payTo: string = acceptedPayTo ?? await resolvePayToAddress();
414
-
415
- // Build the payment requirements that the payment should satisfy
416
- const paymentRequirements: PaymentRequirements = {
417
- scheme: "exact",
418
- network: cryptoNetwork,
419
- amount: String(amountCents),
420
- asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", // USDC on Base
421
- payTo,
422
- maxTimeoutSeconds: 300,
423
- extra: { name: "USD Coin", version: "2" }
424
- };
425
-
426
- // Verify payment via resource server (delegates to facilitator)
427
- const verifyResult: VerifyResponse = await resourceServer!.verifyPayment(
428
- paymentPayload,
429
- paymentRequirements
430
- );
431
-
432
- if (!verifyResult.isValid) {
433
- return c.json(
434
- { error: "Payment verification failed", details: verifyResult.invalidReason },
435
- 402
436
- );
437
- }
438
-
439
- // Settle payment via resource server (delegates to facilitator)
440
- const settleResult: SettleResponse = await resourceServer!.settlePayment(
441
- paymentPayload,
442
- paymentRequirements
443
- );
444
-
445
- if (!settleResult.success) {
446
- return c.json(
447
- { error: "Payment settlement failed", details: settleResult.errorReason },
448
- 402
449
- );
450
- }
451
-
452
- const paymentContext: PaywallPaymentContext = {
453
- method: "crypto",
454
- amount_cents: amountCents,
455
- currency,
456
- transaction_hash: settleResult.transaction,
457
- payer_address: verifyResult.payer ?? undefined,
458
- network: cryptoNetwork
459
- };
460
- c.set(PAYMENT_CONTEXT_KEY, paymentContext);
461
-
462
- await next();
463
- } catch (error) {
464
- if (error instanceof AgentSpendChargeError) {
465
- return c.json({ error: error.message, details: error.details }, error.statusCode);
466
- }
467
- return c.json(
468
- { error: "Crypto payment processing failed", details: (error as Error).message },
469
- 500
470
- );
471
- }
472
- }
473
-
474
- // -------------------------------------------------------------------
475
- // return402Response — x402 Payment-Required format (Decision 8)
476
- // -------------------------------------------------------------------
477
-
478
- async function return402Response(
479
- c: HonoContextLike,
480
- amountCents: number,
481
- currency: string
482
- ): Promise<Response> {
483
- const serviceId = await getServiceId();
484
-
485
- try {
486
- const payTo = await resolvePayToAddress();
487
-
488
- const paymentRequirements: PaymentRequirements = {
489
- scheme: "exact",
490
- network: cryptoNetwork,
491
- amount: String(amountCents),
492
- asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", // USDC on Base
493
- payTo,
494
- maxTimeoutSeconds: 300,
495
- extra: { name: "USD Coin", version: "2" }
496
- };
497
-
498
- // Build x402 v2 PaymentRequired response
499
- const paymentRequired = {
500
- x402Version: 2,
501
- error: "Payment required",
502
- resource: {
503
- url: c.req.url,
504
- description: `Payment of ${amountCents} cents`,
505
- mimeType: "application/json"
506
- },
507
- accepts: [paymentRequirements]
508
- };
509
-
510
- // Set Payment-Required header (base64 encoded)
511
- const headerValue = Buffer.from(
512
- JSON.stringify(paymentRequired)
513
- ).toString("base64");
514
- c.header("Payment-Required", headerValue);
515
-
516
- return c.json({
517
- error: "Payment required",
518
- amount_cents: amountCents,
519
- currency,
520
- ...(serviceId ? {
521
- agentspend: {
522
- service_id: serviceId,
523
- amount_cents: amountCents,
524
- }
525
- } : {})
526
- }, 402);
527
- } catch (error) {
528
- // Log clearly so service developers can diagnose why crypto is unavailable
529
- console.error(
530
- "[agentspend] Failed to resolve crypto payTo address — returning card-only 402:",
531
- error instanceof Error ? error.message : error
532
- );
533
- // Return card-only 402 (no Payment-Required header → crypto clients
534
- // will see "Invalid payment required response")
535
- return c.json(
536
- {
537
- error: "Payment required",
538
- amount_cents: amountCents,
539
- currency,
540
- ...(serviceId ? {
541
- agentspend: {
542
- service_id: serviceId,
543
- amount_cents: amountCents,
544
- }
545
- } : {})
546
- },
547
- 402
548
- );
549
- }
550
- }
551
-
552
- // -------------------------------------------------------------------
553
- // resolvePayToAddress — static address or Stripe Machine Payments
554
- // -------------------------------------------------------------------
555
-
556
- async function resolvePayToAddress(): Promise<string> {
557
- // Static address for crypto-only services
558
- if (options.crypto?.receiverAddress) {
559
- return options.crypto.receiverAddress;
560
- }
561
-
562
- // Stripe Connect service → get deposit address from platform
563
- if (options.serviceApiKey) {
564
- const response = await fetchImpl(
565
- joinUrl(platformApiBaseUrl, "/v1/crypto/deposit-address"),
566
- {
567
- method: "POST",
568
- headers: {
569
- authorization: `Bearer ${options.serviceApiKey}`,
570
- "content-type": "application/json"
571
- },
572
- body: JSON.stringify({ amount_cents: 0, currency: "usd" })
573
- }
574
- );
575
-
576
- if (!response.ok) {
577
- throw new AgentSpendChargeError("Failed to resolve crypto deposit address", 502);
578
- }
579
-
580
- const data = (await response.json()) as { deposit_address?: string };
581
- if (!data.deposit_address) {
582
- throw new AgentSpendChargeError("No deposit address returned", 502);
583
- }
584
- return data.deposit_address;
585
- }
586
-
587
- throw new AgentSpendChargeError("No crypto payTo address available", 500);
588
- }
589
-
590
- // -------------------------------------------------------------------
591
- // Return public interface
592
- // -------------------------------------------------------------------
593
-
594
- return {
595
- charge,
596
- paywall
597
- };
598
- }
599
-
600
- // ---------------------------------------------------------------------------
601
- // Helpers (unchanged from original)
602
- // ---------------------------------------------------------------------------
603
-
604
- function toCardId(input: unknown): string | null {
605
- if (typeof input !== "string") {
606
- return null;
607
- }
608
- const trimmed = input.trim();
609
- if (!trimmed.startsWith("card_")) {
610
- return null;
611
- }
612
- return trimmed;
613
- }
614
-
615
- function joinUrl(base: string, path: string): string {
616
- const normalizedBase = base.endsWith("/") ? base.slice(0, -1) : base;
617
- const normalizedPath = path.startsWith("/") ? path : `/${path}`;
618
- return `${normalizedBase}${normalizedPath}`;
619
- }
620
-
621
- function bestEffortIdempotencyKey(): string {
622
- const uuid = globalThis.crypto?.randomUUID?.();
623
- if (uuid) {
624
- return uuid;
625
- }
626
- return `auto_${Date.now()}_${Math.random().toString(16).slice(2)}`;
627
- }
628
-
629
- function toStringMetadata(input: unknown): Record<string, string> {
630
- if (!input || typeof input !== "object" || Array.isArray(input)) {
631
- return {};
632
- }
633
-
634
- const result: Record<string, string> = {};
635
- for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
636
- if (typeof value === "string") {
637
- result[key] = value;
638
- } else if (typeof value === "number" && Number.isFinite(value)) {
639
- result[key] = String(value);
640
- } else if (typeof value === "boolean") {
641
- result[key] = value ? "true" : "false";
642
- }
643
- }
644
- return result;
645
- }
646
-
647
- const DEFAULT_PLATFORM_API_BASE_URL = "https://api.agentspend.co";
648
-
649
- function resolvePlatformApiBaseUrl(explicitBaseUrl: string | undefined): string {
650
- if (explicitBaseUrl && explicitBaseUrl.trim().length > 0) {
651
- return explicitBaseUrl.trim();
652
- }
653
-
654
- const envValue =
655
- typeof process !== "undefined" && process.env ? process.env.AGENTSPEND_API_URL : undefined;
656
-
657
- if (typeof envValue === "string" && envValue.trim().length > 0) {
658
- return envValue.trim();
659
- }
660
-
661
- return DEFAULT_PLATFORM_API_BASE_URL;
662
- }