aixyz 0.33.0 → 0.35.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.
@@ -0,0 +1,193 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { BasePlugin, type RegisterContext, type InitializeContext } from "../../plugin";
3
+ import type { PaymentGateway } from "../../payment/payment";
4
+ import { InMemorySessionStore } from "./memory";
5
+
6
+ export { InMemorySessionStore, type InMemorySessionStoreOptions } from "./memory";
7
+
8
+ // ── Storage Interface ────────────────────────────────────────────────
9
+
10
+ /** Options for {@link SessionStore.set} and {@link SessionStore.setMany}. */
11
+ export interface SetOptions {
12
+ /** Per-key TTL in milliseconds. Overrides the store-level default when supported. */
13
+ ttlMs?: number;
14
+ }
15
+
16
+ /** Options for {@link SessionStore.list}. */
17
+ export interface ListOptions {
18
+ /** Only return keys starting with this prefix. */
19
+ prefix?: string;
20
+ /** Opaque cursor from a previous `list()` call for pagination. */
21
+ cursor?: string;
22
+ /** Maximum number of entries to return. The store decides its own default when omitted. */
23
+ limit?: number;
24
+ /** If true, values are omitted (all values will be empty strings). */
25
+ keysOnly?: boolean;
26
+ }
27
+
28
+ /** Return type for {@link SessionStore.list}. */
29
+ export interface ListResult {
30
+ entries: Record<string, string>;
31
+ /** If present, more results are available — pass to the next `list()` call. */
32
+ cursor?: string;
33
+ }
34
+
35
+ /**
36
+ * Pluggable session storage backend.
37
+ * All methods are async to support external stores (Redis, DB, KV, etc.).
38
+ * Operations are scoped by payer address — the x402 signer for the request.
39
+ */
40
+ export interface SessionStore {
41
+ get(payer: string, key: string): Promise<string | undefined>;
42
+ set(payer: string, key: string, value: string, options?: SetOptions): Promise<void>;
43
+ delete(payer: string, key: string): Promise<boolean>;
44
+ list(payer: string, options?: ListOptions): Promise<ListResult>;
45
+
46
+ /** Retrieve multiple keys in a single call. */
47
+ getMany?(payer: string, keys: string[]): Promise<Record<string, string | undefined>>;
48
+ /** Set multiple keys in a single call. */
49
+ setMany?(payer: string, entries: Record<string, string>, options?: SetOptions): Promise<void>;
50
+ /** Delete multiple keys in a single call. Returns the number of keys actually removed. */
51
+ deleteMany?(payer: string, keys: string[]): Promise<number>;
52
+
53
+ /** Release resources held by the store (connections, timers, etc.). */
54
+ close?(): Promise<void>;
55
+ }
56
+
57
+ // ── Session Handle ───────────────────────────────────────────────────
58
+
59
+ /**
60
+ * Payer-scoped session handle. All operations target the current
61
+ * x402 payer without requiring the caller to pass an address.
62
+ */
63
+ export interface Session {
64
+ readonly payer: string;
65
+ get(key: string): Promise<string | undefined>;
66
+ set(key: string, value: string, options?: SetOptions): Promise<void>;
67
+ delete(key: string): Promise<boolean>;
68
+ list(options?: ListOptions): Promise<ListResult>;
69
+ }
70
+
71
+ function createSession(payer: string, store: SessionStore): Session {
72
+ // Normalize the payer address to lowercase so sessions are keyed consistently
73
+ // regardless of whether the x402 verifier returns a checksummed (EIP-55) or
74
+ // lowercase address. The original (possibly checksummed) address is still
75
+ // exposed as `session.payer` for display purposes.
76
+ const storedPayer = payer.toLowerCase();
77
+ return {
78
+ payer,
79
+ get: (key) => store.get(storedPayer, key),
80
+ set: (key, value, options) => store.set(storedPayer, key, value, options),
81
+ delete: (key) => store.delete(storedPayer, key),
82
+ list: (options) => store.list(storedPayer, options),
83
+ };
84
+ }
85
+
86
+ // ── AsyncLocalStorage API ────────────────────────────────────────────
87
+
88
+ const sessionStorage = new AsyncLocalStorage<Session | undefined>();
89
+
90
+ /**
91
+ * Get the current payer-scoped session.
92
+ * Returns `undefined` if no x402 payment was made for this request.
93
+ */
94
+ export function getSession(): Session | undefined {
95
+ return sessionStorage.getStore();
96
+ }
97
+
98
+ /**
99
+ * Get the current x402 payer address.
100
+ * Shorthand for `getSession()?.payer`.
101
+ */
102
+ export function getPayer(): string | undefined {
103
+ return sessionStorage.getStore()?.payer;
104
+ }
105
+
106
+ // ── Plugin ───────────────────────────────────────────────────────────
107
+
108
+ export interface SessionPluginOptions {
109
+ /** Custom session store. Defaults to {@link InMemorySessionStore}. */
110
+ store?: SessionStore;
111
+ }
112
+
113
+ /**
114
+ * Session plugin for aixyz. Provides payer-scoped key-value storage
115
+ * gated by x402 payment identity.
116
+ *
117
+ * Register **before** other plugins so the session middleware runs first:
118
+ *
119
+ * ```ts
120
+ * await app.withPlugin(new SessionPlugin());
121
+ * await app.withPlugin(new A2APlugin([...]));
122
+ * ```
123
+ *
124
+ * Tools access the session via {@link getSession}:
125
+ *
126
+ * ```ts
127
+ * import { getSession } from "aixyz/app/plugins/session";
128
+ *
129
+ * const session = getSession();
130
+ * await session?.set("key", "value");
131
+ * ```
132
+ */
133
+ export class SessionPlugin extends BasePlugin {
134
+ readonly name = "session";
135
+ readonly store: SessionStore;
136
+ private payment?: PaymentGateway;
137
+
138
+ constructor(options?: SessionPluginOptions) {
139
+ super();
140
+ this.store = options?.store ?? new InMemorySessionStore();
141
+ }
142
+
143
+ register(ctx: RegisterContext): void {
144
+ ctx.use(async (request, next) => {
145
+ const payer = this.payment?.getPayer(request);
146
+ if (payer) {
147
+ const session = createSession(payer, this.store);
148
+ return sessionStorage.run(session, next);
149
+ }
150
+ // Explicitly clear any inherited ALS context so that an unpaid route
151
+ // nested inside a paid handler's app.fetch() doesn't leak the outer session.
152
+ return sessionStorage.run(undefined, next);
153
+ });
154
+ }
155
+
156
+ initialize(ctx: InitializeContext): void {
157
+ this.payment = ctx.payment;
158
+ }
159
+
160
+ /**
161
+ * Run a function within a session context for the given payer.
162
+ * Used by other plugins (e.g., MCPPlugin) to set session context
163
+ * for tool execution when payment is handled at the protocol level.
164
+ *
165
+ * **Security note:** This method bypasses HTTP-level payment verification.
166
+ * Only call it with a payer address that has already been authenticated by a
167
+ * trusted payment mechanism (e.g., the `@x402/mcp` payment wrapper).
168
+ * @internal
169
+ */
170
+ runWithPayer<T>(payer: string, fn: () => T): T {
171
+ const session = createSession(payer, this.store);
172
+ return sessionStorage.run(session, fn);
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Identity helper that provides full type inference for a {@link SessionStore}.
178
+ * Use in `app/session.ts` to get type-safe autocompletion:
179
+ *
180
+ * ```ts
181
+ * import { defineSessionStore } from "aixyz/app/plugins/session";
182
+ *
183
+ * export default defineSessionStore({
184
+ * async get(payer, key) { ... },
185
+ * async set(payer, key, value) { ... },
186
+ * async delete(payer, key) { ... },
187
+ * async list(payer) { ... },
188
+ * });
189
+ * ```
190
+ */
191
+ export function defineSessionStore(store: SessionStore): SessionStore {
192
+ return store;
193
+ }
@@ -0,0 +1,206 @@
1
+ import type { SessionStore, SetOptions, ListOptions, ListResult } from "./index";
2
+
3
+ // ── Types ─────────────────────────────────────────────────────────────
4
+
5
+ export interface InMemorySessionStoreOptions {
6
+ /** Maximum number of entries across all payers. Default: 10 000. */
7
+ maxEntries?: number;
8
+ /** Time-to-live in milliseconds (sliding window). 0 = no expiry. Default: 3 600 000 (1 h). */
9
+ ttlMs?: number;
10
+ }
11
+
12
+ interface Entry {
13
+ value: string;
14
+ payer: string;
15
+ key: string;
16
+ expiresAt: number; // 0 = never expires
17
+ ttlMs: number; // effective TTL used when refreshing on get(); 0 = no expiry
18
+ }
19
+
20
+ // ── Implementation ────────────────────────────────────────────────────
21
+
22
+ /**
23
+ * Production-ready in-memory {@link SessionStore} with LRU eviction and
24
+ * optional TTL.
25
+ *
26
+ * - **LRU eviction** — backed by `Map` insertion-order. When `maxEntries`
27
+ * is reached, the least-recently-used entry is evicted.
28
+ * - **Sliding-window TTL** — `get()` refreshes the expiry timer.
29
+ * Expired entries are lazily removed on read; no background timers.
30
+ * - **OOM-safe** — `maxEntries` is a hard cap. Memory usage is bounded
31
+ * regardless of TTL or access patterns.
32
+ */
33
+ export class InMemorySessionStore implements SessionStore {
34
+ private readonly maxEntries: number;
35
+ private readonly ttlMs: number;
36
+
37
+ /** Flat LRU map. Key = `${payer}:${key}`. Insertion order = access order. */
38
+ private readonly entries = new Map<string, Entry>();
39
+ /** Secondary index: payer → set of composite keys (for efficient `list`). */
40
+ private readonly payerIndex = new Map<string, Set<string>>();
41
+
42
+ constructor(options?: InMemorySessionStoreOptions) {
43
+ this.maxEntries = options?.maxEntries ?? 10_000;
44
+ this.ttlMs = options?.ttlMs ?? 3_600_000;
45
+ if (this.maxEntries < 1) throw new Error("maxEntries must be >= 1");
46
+ if (this.ttlMs < 0) throw new Error("ttlMs must be >= 0");
47
+ }
48
+
49
+ async get(payer: string, key: string): Promise<string | undefined> {
50
+ const ck = compositeKey(payer, key);
51
+ const entry = this.entries.get(ck);
52
+ if (!entry) return undefined;
53
+
54
+ if (this.isExpired(entry)) {
55
+ this.removeEntry(ck, entry);
56
+ return undefined;
57
+ }
58
+
59
+ // LRU touch: move to end + refresh TTL using the entry's own TTL
60
+ this.entries.delete(ck);
61
+ entry.expiresAt = this.expiryFor(entry.ttlMs);
62
+ this.entries.set(ck, entry);
63
+
64
+ return entry.value;
65
+ }
66
+
67
+ async set(payer: string, key: string, value: string, options?: SetOptions): Promise<void> {
68
+ const ck = compositeKey(payer, key);
69
+
70
+ // If overwriting, remove first so re-insert lands at the end.
71
+ const existing = this.entries.get(ck);
72
+ if (existing) {
73
+ this.entries.delete(ck);
74
+ }
75
+
76
+ // Evict LRU entry if at capacity.
77
+ if (this.entries.size >= this.maxEntries) {
78
+ const oldest = this.entries.keys().next();
79
+ if (!oldest.done) {
80
+ const oldestEntry = this.entries.get(oldest.value)!;
81
+ this.removeEntry(oldest.value, oldestEntry);
82
+ }
83
+ }
84
+
85
+ const effectiveTtl = options?.ttlMs ?? this.ttlMs;
86
+ const entry: Entry = { value, payer, key, expiresAt: this.expiryFor(effectiveTtl), ttlMs: effectiveTtl };
87
+ this.entries.set(ck, entry);
88
+
89
+ // Update payer index.
90
+ let idx = this.payerIndex.get(payer);
91
+ if (!idx) {
92
+ idx = new Set();
93
+ this.payerIndex.set(payer, idx);
94
+ }
95
+ idx.add(ck);
96
+ }
97
+
98
+ async delete(payer: string, key: string): Promise<boolean> {
99
+ const ck = compositeKey(payer, key);
100
+ const entry = this.entries.get(ck);
101
+ if (!entry) return false;
102
+ this.removeEntry(ck, entry);
103
+ return true;
104
+ }
105
+
106
+ async list(payer: string, options?: ListOptions): Promise<ListResult> {
107
+ const idx = this.payerIndex.get(payer);
108
+ if (!idx) return { entries: {} };
109
+
110
+ const prefix = options?.prefix;
111
+ const limit = options?.limit && options.limit > 0 ? options.limit : Infinity;
112
+ const keysOnly = options?.keysOnly ?? false;
113
+ const cursor = options?.cursor;
114
+
115
+ const result: Record<string, string> = {};
116
+ let count = 0;
117
+ let pastCursor = !cursor;
118
+ let lastCk: string | undefined;
119
+ let hasMore = false;
120
+
121
+ for (const ck of idx) {
122
+ const entry = this.entries.get(ck);
123
+ if (!entry) {
124
+ idx.delete(ck);
125
+ continue;
126
+ }
127
+ if (this.isExpired(entry)) {
128
+ this.removeEntry(ck, entry);
129
+ continue;
130
+ }
131
+
132
+ // Skip until we pass the cursor position.
133
+ if (!pastCursor) {
134
+ if (ck === cursor) pastCursor = true;
135
+ continue;
136
+ }
137
+
138
+ // Apply prefix filter.
139
+ if (prefix && !entry.key.startsWith(prefix)) continue;
140
+
141
+ if (count >= limit) {
142
+ hasMore = true;
143
+ break;
144
+ }
145
+
146
+ result[entry.key] = keysOnly ? "" : entry.value;
147
+ lastCk = ck;
148
+ count++;
149
+ }
150
+
151
+ if (idx.size === 0) this.payerIndex.delete(payer);
152
+ return { entries: result, cursor: hasMore ? lastCk : undefined };
153
+ }
154
+
155
+ async getMany(payer: string, keys: string[]): Promise<Record<string, string | undefined>> {
156
+ const result: Record<string, string | undefined> = {};
157
+ for (const key of keys) {
158
+ result[key] = await this.get(payer, key);
159
+ }
160
+ return result;
161
+ }
162
+
163
+ async setMany(payer: string, entries: Record<string, string>, options?: SetOptions): Promise<void> {
164
+ for (const [key, value] of Object.entries(entries)) {
165
+ await this.set(payer, key, value, options);
166
+ }
167
+ }
168
+
169
+ async deleteMany(payer: string, keys: string[]): Promise<number> {
170
+ let count = 0;
171
+ for (const key of keys) {
172
+ if (await this.delete(payer, key)) count++;
173
+ }
174
+ return count;
175
+ }
176
+
177
+ async close(): Promise<void> {
178
+ this.entries.clear();
179
+ this.payerIndex.clear();
180
+ }
181
+
182
+ // ── Internals ───────────────────────────────────────────────────────
183
+
184
+ private removeEntry(ck: string, entry: Entry): void {
185
+ this.entries.delete(ck);
186
+ const idx = this.payerIndex.get(entry.payer);
187
+ if (idx) {
188
+ idx.delete(ck);
189
+ if (idx.size === 0) this.payerIndex.delete(entry.payer);
190
+ }
191
+ }
192
+
193
+ private isExpired(entry: Entry): boolean {
194
+ return entry.expiresAt > 0 && Date.now() > entry.expiresAt;
195
+ }
196
+
197
+ private expiryFor(ttlMs: number): number {
198
+ return ttlMs > 0 ? Date.now() + ttlMs : 0;
199
+ }
200
+ }
201
+
202
+ // ── Helpers ───────────────────────────────────────────────────────────
203
+
204
+ function compositeKey(payer: string, key: string): string {
205
+ return `${payer}:${key}`;
206
+ }
package/app/types.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AcceptsX402 } from "../accepts";
1
+ import type { Accepts, AcceptsX402, AcceptsX402Multi } from "../accepts";
2
2
 
3
3
  export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD" | "OPTIONS";
4
4
 
@@ -6,9 +6,13 @@ export type RouteHandler = (request: Request) => Response | Promise<Response>;
6
6
 
7
7
  export type Middleware = (request: Request, next: () => Promise<Response>) => Response | Promise<Response>;
8
8
 
9
+ export interface RouteOptions {
10
+ payment?: Accepts;
11
+ }
12
+
9
13
  export interface RouteEntry {
10
14
  method: HttpMethod;
11
15
  path: string;
12
16
  handler: RouteHandler;
13
- payment?: AcceptsX402;
17
+ payment?: AcceptsX402 | AcceptsX402Multi;
14
18
  }
@@ -6,18 +6,18 @@ description: "Payment configuration types and facilitator client for x402 gating
6
6
  Types and utilities for configuring x402 payment gating on agent and tool endpoints.
7
7
 
8
8
  ```typescript
9
- import type { Accepts, AcceptsX402, AcceptsFree } from "aixyz/accepts";
10
- import { HTTPFacilitatorClient, facilitator } from "aixyz/accepts";
9
+ import type { Accepts, AcceptsX402, AcceptsX402Entry, AcceptsX402Multi, AcceptsFree } from "aixyz/accepts";
10
+ import { HTTPFacilitatorClient, facilitator, normalizeAcceptsX402, isAcceptsPaid } from "aixyz/accepts";
11
11
  ```
12
12
 
13
13
  ## Types
14
14
 
15
15
  ### `Accepts`
16
16
 
17
- Union type for payment configuration:
17
+ Union type for payment configuration. Supports a single payment option, free access, or an array of payment options for multi-network support:
18
18
 
19
19
  ```typescript
20
- type Accepts = AcceptsX402 | AcceptsFree;
20
+ type Accepts = AcceptsX402 | AcceptsFree | AcceptsX402Multi;
21
21
  ```
22
22
 
23
23
  ### `AcceptsX402`
@@ -40,6 +40,34 @@ type AcceptsX402 = {
40
40
  | `network` | `string` | No | CAIP-2 chain ID, overrides `x402.network` from config |
41
41
  | `payTo` | `string` | No | EVM address to receive payment, overrides `x402.payTo` config |
42
42
 
43
+ ### `AcceptsX402Entry`
44
+
45
+ A single payment option within a multi-accepts array. Same as `AcceptsX402` but `network` is **required** — the server needs an explicit network to register each payment scheme:
46
+
47
+ ```typescript
48
+ type AcceptsX402Entry = {
49
+ scheme: "exact";
50
+ price: string;
51
+ network: string; // required
52
+ payTo?: string;
53
+ };
54
+ ```
55
+
56
+ | Field | Type | Required | Description |
57
+ | --------- | -------- | -------- | ------------------------------------------------------------- |
58
+ | `scheme` | `string` | Yes | Must be `"exact"` |
59
+ | `price` | `string` | Yes | USD price string (e.g. `"$0.005"`) |
60
+ | `network` | `string` | Yes | CAIP-2 chain ID — required for multi-accepts |
61
+ | `payTo` | `string` | No | EVM address to receive payment, overrides `x402.payTo` config |
62
+
63
+ ### `AcceptsX402Multi`
64
+
65
+ An array of payment entries, enabling multi-network support. Must contain at least one entry:
66
+
67
+ ```typescript
68
+ type AcceptsX402Multi = AcceptsX402Entry[];
69
+ ```
70
+
43
71
  ### `AcceptsFree`
44
72
 
45
73
  No payment required:
@@ -52,6 +80,22 @@ type AcceptsFree = {
52
80
 
53
81
  ## Exports
54
82
 
83
+ ### `normalizeAcceptsX402`
84
+
85
+ Converts a single or multi accepts value into a uniform array:
86
+
87
+ ```typescript
88
+ function normalizeAcceptsX402(accepts: AcceptsX402 | AcceptsX402Multi): AcceptsX402[];
89
+ ```
90
+
91
+ ### `isAcceptsPaid`
92
+
93
+ Type guard that returns `true` if the accepts config requires payment (i.e., is not `{ scheme: "free" }`):
94
+
95
+ ```typescript
96
+ function isAcceptsPaid(accepts: Accepts): accepts is AcceptsX402 | AcceptsX402Multi;
97
+ ```
98
+
55
99
  ### `HTTPFacilitatorClient`
56
100
 
57
101
  Client for communicating with an x402 facilitator service. Re-exported from `@x402/core/server`.
@@ -74,12 +118,19 @@ import { facilitator } from "aixyz/accepts";
74
118
 
75
119
  ### `AcceptsScheme`
76
120
 
77
- Zod schema for validating `Accepts` objects at runtime:
121
+ Zod schema for validating `Accepts` objects at runtime. Accepts a single object or an array of payment entries:
78
122
 
79
123
  ```typescript
80
124
  import { AcceptsScheme } from "aixyz/accepts";
81
125
 
126
+ // Single accepts
82
127
  AcceptsScheme.parse({ scheme: "exact", price: "$0.005" });
128
+
129
+ // Multiple accepts
130
+ AcceptsScheme.parse([
131
+ { scheme: "exact", price: "$0.005", network: "eip155:8453" },
132
+ { scheme: "exact", price: "$0.005", network: "eip155:84532" },
133
+ ]);
83
134
  ```
84
135
 
85
136
  ## Usage
@@ -103,6 +154,21 @@ export const accepts: Accepts = {
103
154
  };
104
155
  ```
105
156
 
157
+ ### Multiple payment options
158
+
159
+ Accept payment across multiple networks by passing an array. Each entry requires an explicit `network`:
160
+
161
+ ```typescript title="app/agent.ts"
162
+ import type { Accepts } from "aixyz/accepts";
163
+
164
+ export const accepts: Accepts = [
165
+ { scheme: "exact", price: "$0.005", network: "eip155:8453" },
166
+ { scheme: "exact", price: "$0.005", network: "eip155:84532" },
167
+ ];
168
+ ```
169
+
170
+ All referenced networks are automatically registered with the payment gateway during initialization — no manual setup required.
171
+
106
172
  ### Custom facilitator
107
173
 
108
174
  Create `app/accepts.ts` to override the default facilitator:
@@ -38,7 +38,7 @@ export default new ToolLoopAgent({
38
38
  | `accepts` | `Accepts` | No | Payment config — gates the A2A `/agent` route |
39
39
  | `capabilities` | `Capabilities` | No | A2A capabilities — controls streaming and push notification support |
40
40
 
41
- When `accepts` is exported, the `/agent` endpoint requires x402 payment. Without it, the agent is not registered on the A2A endpoint.
41
+ When `accepts` is exported, the `/agent` endpoint requires x402 payment. Without it, the agent is not registered on the A2A endpoint. `accepts` can also be an array of payment entries for [multi-network support](/getting-started/payments#multiple-payment-options).
42
42
 
43
43
  ## Capabilities
44
44
 
@@ -91,12 +91,15 @@ await server.initialize();
91
91
 
92
92
  ```typescript title="app/server.ts"
93
93
  import { AixyzApp } from "aixyz/app";
94
+ import { SessionPlugin } from "aixyz/app/plugins/session";
94
95
  import { IndexPagePlugin } from "aixyz/app/plugins/index-page";
95
96
  import { A2APlugin } from "aixyz/app/plugins/a2a";
96
97
  import * as agent from "./agent";
97
98
 
98
99
  const server = new AixyzApp();
99
100
 
101
+ // SessionPlugin must be registered first so its middleware runs before other plugins
102
+ await server.withPlugin(new SessionPlugin());
100
103
  await server.withPlugin(new IndexPagePlugin());
101
104
  await server.withPlugin(new A2APlugin([{ exports: agent }]));
102
105