aixyz 0.34.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.
- package/app/index.ts +31 -1
- package/app/payment/payment.ts +119 -3
- package/app/plugin.ts +24 -1
- package/app/plugins/a2a.ts +2 -1
- package/app/plugins/mcp.ts +52 -8
- package/app/plugins/session/index.ts +193 -0
- package/app/plugins/session/memory.ts +206 -0
- package/docs/api-reference/aixyz-server.mdx +3 -0
- package/docs/api-reference/session-plugin.mdx +192 -0
- package/docs/getting-started/payments.mdx +16 -0
- package/docs/getting-started/project-structure.mdx +8 -1
- package/docs/protocols/mcp.mdx +17 -0
- package/docs/templates/advanced/x402-sessions.mdx +99 -0
- package/docs/templates/overview.mdx +3 -0
- package/package.json +5 -4
package/app/index.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { type HttpMethod, type RouteHandler, type Middleware, type RouteEntry, t
|
|
|
5
5
|
import { PaymentGateway } from "./payment/payment";
|
|
6
6
|
import { getAixyzConfig } from "@aixyz/config";
|
|
7
7
|
import { loadEnvConfig } from "@next/env";
|
|
8
|
-
import { BasePlugin, type RegisterContext, type InitializeContext } from "./plugin";
|
|
8
|
+
import { BasePlugin, type RegisterContext, type InitializeContext, type PaymentHookContext } from "./plugin";
|
|
9
9
|
|
|
10
10
|
// Load .env and .env.production files at runtime (local files are excluded at build time).
|
|
11
11
|
loadEnvConfig(process.cwd());
|
|
@@ -68,6 +68,35 @@ export class AixyzApp {
|
|
|
68
68
|
// clear registered routes for this plugin before registration, in case of hot reload or multiple registrations
|
|
69
69
|
plugin.registeredRoutes.clear();
|
|
70
70
|
|
|
71
|
+
const paymentHooks: PaymentHookContext | undefined = this.payment
|
|
72
|
+
? {
|
|
73
|
+
onBeforeVerify: (h) => {
|
|
74
|
+
this.payment!.onBeforeVerify(h);
|
|
75
|
+
return paymentHooks!;
|
|
76
|
+
},
|
|
77
|
+
onAfterVerify: (h) => {
|
|
78
|
+
this.payment!.onAfterVerify(h);
|
|
79
|
+
return paymentHooks!;
|
|
80
|
+
},
|
|
81
|
+
onVerifyFailure: (h) => {
|
|
82
|
+
this.payment!.onVerifyFailure(h);
|
|
83
|
+
return paymentHooks!;
|
|
84
|
+
},
|
|
85
|
+
onBeforeSettle: (h) => {
|
|
86
|
+
this.payment!.onBeforeSettle(h);
|
|
87
|
+
return paymentHooks!;
|
|
88
|
+
},
|
|
89
|
+
onAfterSettle: (h) => {
|
|
90
|
+
this.payment!.onAfterSettle(h);
|
|
91
|
+
return paymentHooks!;
|
|
92
|
+
},
|
|
93
|
+
onSettleFailure: (h) => {
|
|
94
|
+
this.payment!.onSettleFailure(h);
|
|
95
|
+
return paymentHooks!;
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
: undefined;
|
|
99
|
+
|
|
71
100
|
const ctx: RegisterContext = {
|
|
72
101
|
route: (method, path, handler, options) => {
|
|
73
102
|
this.route(method, path, handler, options);
|
|
@@ -76,6 +105,7 @@ export class AixyzApp {
|
|
|
76
105
|
if (entry) plugin.registeredRoutes.set(key, entry);
|
|
77
106
|
},
|
|
78
107
|
use: (mw) => this.use(mw),
|
|
108
|
+
paymentHooks,
|
|
79
109
|
};
|
|
80
110
|
await plugin.register?.(ctx);
|
|
81
111
|
return this;
|
package/app/payment/payment.ts
CHANGED
|
@@ -10,9 +10,54 @@ import {
|
|
|
10
10
|
import { ExactEvmScheme } from "@x402/evm/exact/server";
|
|
11
11
|
import type { AcceptsX402, AcceptsX402Multi } from "../../accepts";
|
|
12
12
|
import { normalizeAcceptsX402 } from "../../accepts";
|
|
13
|
-
import { Network, PaymentPayload, PaymentRequirements } from "@x402/core/types";
|
|
13
|
+
import { Network, PaymentPayload, PaymentRequirements, VerifyResponse, SettleResponse } from "@x402/core/types";
|
|
14
14
|
import { AixyzConfig } from "@aixyz/config";
|
|
15
15
|
|
|
16
|
+
// ── x402 Lifecycle Hook Types ──────────────────────────────────────────
|
|
17
|
+
// These mirror x402ResourceServer's hook signatures but are defined here
|
|
18
|
+
// because x402/core does not export them publicly.
|
|
19
|
+
|
|
20
|
+
export interface VerifyContext {
|
|
21
|
+
paymentPayload: PaymentPayload;
|
|
22
|
+
requirements: PaymentRequirements;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface VerifyResultContext extends VerifyContext {
|
|
26
|
+
result: VerifyResponse;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface VerifyFailureContext extends VerifyContext {
|
|
30
|
+
error: Error;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SettleContext {
|
|
34
|
+
paymentPayload: PaymentPayload;
|
|
35
|
+
requirements: PaymentRequirements;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SettleResultContext extends SettleContext {
|
|
39
|
+
result: SettleResponse;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface SettleFailureContext extends SettleContext {
|
|
43
|
+
error: Error;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type BeforeVerifyHook = (
|
|
47
|
+
context: VerifyContext,
|
|
48
|
+
) => Promise<void | { abort: true; reason: string; message?: string }>;
|
|
49
|
+
export type AfterVerifyHook = (context: VerifyResultContext) => Promise<void>;
|
|
50
|
+
export type OnVerifyFailureHook = (
|
|
51
|
+
context: VerifyFailureContext,
|
|
52
|
+
) => Promise<void | { recovered: true; result: VerifyResponse }>;
|
|
53
|
+
export type BeforeSettleHook = (
|
|
54
|
+
context: SettleContext,
|
|
55
|
+
) => Promise<void | { abort: true; reason: string; message?: string }>;
|
|
56
|
+
export type AfterSettleHook = (context: SettleResultContext) => Promise<void>;
|
|
57
|
+
export type OnSettleFailureHook = (
|
|
58
|
+
context: SettleFailureContext,
|
|
59
|
+
) => Promise<void | { recovered: true; result: SettleResponse }>;
|
|
60
|
+
|
|
16
61
|
/**
|
|
17
62
|
* Converts a web-standard Request into the x402 HTTPAdapter interface.
|
|
18
63
|
*/
|
|
@@ -45,10 +90,12 @@ function toResponse(instructions: HTTPResponseInstructions): Response {
|
|
|
45
90
|
return new Response(body, { status: instructions.status, headers });
|
|
46
91
|
}
|
|
47
92
|
|
|
48
|
-
interface PaymentContext {
|
|
93
|
+
export interface PaymentContext {
|
|
49
94
|
paymentPayload: PaymentPayload;
|
|
50
95
|
paymentRequirements: PaymentRequirements;
|
|
51
96
|
declaredExtensions?: Record<string, unknown>;
|
|
97
|
+
/** The payer's address, captured from x402 verification. */
|
|
98
|
+
payer?: string;
|
|
52
99
|
}
|
|
53
100
|
|
|
54
101
|
/**
|
|
@@ -60,10 +107,22 @@ export class PaymentGateway {
|
|
|
60
107
|
private readonly config: AixyzConfig;
|
|
61
108
|
private readonly pendingRoutes = new Map<string, RouteConfig>();
|
|
62
109
|
private readonly verifiedPayments = new WeakMap<Request, PaymentContext>();
|
|
110
|
+
/**
|
|
111
|
+
* Temporary map to capture payer address from the afterVerify hook.
|
|
112
|
+
* Keyed by PaymentPayload reference (same object flows through the verify pipeline).
|
|
113
|
+
*/
|
|
114
|
+
private readonly pendingPayers = new WeakMap<PaymentPayload, string>();
|
|
63
115
|
|
|
64
116
|
constructor(facilitators: FacilitatorClient | FacilitatorClient[], config: AixyzConfig) {
|
|
65
117
|
this.resourceServer = new x402ResourceServer(facilitators);
|
|
66
118
|
this.config = config;
|
|
119
|
+
|
|
120
|
+
// Capture payer address from verification so it can be exposed to handlers.
|
|
121
|
+
this.onAfterVerify(async (context) => {
|
|
122
|
+
if (context.result.payer) {
|
|
123
|
+
this.pendingPayers.set(context.paymentPayload, context.result.payer);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
67
126
|
}
|
|
68
127
|
|
|
69
128
|
/** Register an EVM payment scheme for the given network (e.g. Base mainnet). */
|
|
@@ -145,13 +204,16 @@ export class PaymentGateway {
|
|
|
145
204
|
case "no-payment-required":
|
|
146
205
|
return null;
|
|
147
206
|
|
|
148
|
-
case "payment-verified":
|
|
207
|
+
case "payment-verified": {
|
|
208
|
+
const payer = this.pendingPayers.get(result.paymentPayload);
|
|
149
209
|
this.verifiedPayments.set(request, {
|
|
150
210
|
paymentPayload: result.paymentPayload,
|
|
151
211
|
paymentRequirements: result.paymentRequirements,
|
|
152
212
|
declaredExtensions: result.declaredExtensions,
|
|
213
|
+
payer,
|
|
153
214
|
});
|
|
154
215
|
return null;
|
|
216
|
+
}
|
|
155
217
|
|
|
156
218
|
case "payment-error":
|
|
157
219
|
return toResponse(result.response);
|
|
@@ -175,4 +237,58 @@ export class PaymentGateway {
|
|
|
175
237
|
|
|
176
238
|
return result;
|
|
177
239
|
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Get the payer's address for a verified payment request.
|
|
243
|
+
* Available after `verify()` succeeds and before `settle()` is called.
|
|
244
|
+
*/
|
|
245
|
+
getPayer(request: Request): string | undefined {
|
|
246
|
+
return this.verifiedPayments.get(request)?.payer;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Get the full payment context for a verified payment request.
|
|
251
|
+
* Available after `verify()` succeeds and before `settle()` is called.
|
|
252
|
+
*/
|
|
253
|
+
getPaymentContext(request: Request): Readonly<PaymentContext> | undefined {
|
|
254
|
+
return this.verifiedPayments.get(request);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ── x402 Lifecycle Hooks ───────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
/** Register a hook to run before payment verification. Return `{ abort: true, reason }` to reject. */
|
|
260
|
+
onBeforeVerify(hook: BeforeVerifyHook): this {
|
|
261
|
+
this.resourceServer.onBeforeVerify(hook);
|
|
262
|
+
return this;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Register a hook to run after successful payment verification. */
|
|
266
|
+
onAfterVerify(hook: AfterVerifyHook): this {
|
|
267
|
+
this.resourceServer.onAfterVerify(hook);
|
|
268
|
+
return this;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Register a hook to run when payment verification fails. Return `{ recovered: true, result }` to recover. */
|
|
272
|
+
onVerifyFailure(hook: OnVerifyFailureHook): this {
|
|
273
|
+
this.resourceServer.onVerifyFailure(hook);
|
|
274
|
+
return this;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Register a hook to run before payment settlement. Return `{ abort: true, reason }` to reject. */
|
|
278
|
+
onBeforeSettle(hook: BeforeSettleHook): this {
|
|
279
|
+
this.resourceServer.onBeforeSettle(hook);
|
|
280
|
+
return this;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Register a hook to run after successful payment settlement. */
|
|
284
|
+
onAfterSettle(hook: AfterSettleHook): this {
|
|
285
|
+
this.resourceServer.onAfterSettle(hook);
|
|
286
|
+
return this;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Register a hook to run when payment settlement fails. Return `{ recovered: true, result }` to recover. */
|
|
290
|
+
onSettleFailure(hook: OnSettleFailureHook): this {
|
|
291
|
+
this.resourceServer.onSettleFailure(hook);
|
|
292
|
+
return this;
|
|
293
|
+
}
|
|
178
294
|
}
|
package/app/plugin.ts
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
import type { HttpMethod, RouteHandler, RouteEntry, RouteOptions, Middleware } from "./types";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
PaymentGateway,
|
|
4
|
+
BeforeVerifyHook,
|
|
5
|
+
AfterVerifyHook,
|
|
6
|
+
OnVerifyFailureHook,
|
|
7
|
+
BeforeSettleHook,
|
|
8
|
+
AfterSettleHook,
|
|
9
|
+
OnSettleFailureHook,
|
|
10
|
+
} from "./payment/payment";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Scoped interface for registering x402 payment lifecycle hooks.
|
|
14
|
+
* Exposes only hook registration — not the full PaymentGateway.
|
|
15
|
+
*/
|
|
16
|
+
export interface PaymentHookContext {
|
|
17
|
+
onBeforeVerify(hook: BeforeVerifyHook): PaymentHookContext;
|
|
18
|
+
onAfterVerify(hook: AfterVerifyHook): PaymentHookContext;
|
|
19
|
+
onVerifyFailure(hook: OnVerifyFailureHook): PaymentHookContext;
|
|
20
|
+
onBeforeSettle(hook: BeforeSettleHook): PaymentHookContext;
|
|
21
|
+
onAfterSettle(hook: AfterSettleHook): PaymentHookContext;
|
|
22
|
+
onSettleFailure(hook: OnSettleFailureHook): PaymentHookContext;
|
|
23
|
+
}
|
|
3
24
|
|
|
4
25
|
/**
|
|
5
26
|
* Scoped context passed to {@link BasePlugin.register}.
|
|
@@ -16,6 +37,8 @@ export interface RegisterContext {
|
|
|
16
37
|
route(method: HttpMethod, path: string, handler: RouteHandler, options?: RouteOptions): void;
|
|
17
38
|
/** Append a middleware to the application's middleware chain. */
|
|
18
39
|
use(middleware: Middleware): void;
|
|
40
|
+
/** Register hooks on the x402 payment lifecycle. Undefined when no facilitators configured. */
|
|
41
|
+
readonly paymentHooks?: PaymentHookContext;
|
|
19
42
|
}
|
|
20
43
|
|
|
21
44
|
/**
|
package/app/plugins/a2a.ts
CHANGED
|
@@ -25,7 +25,8 @@ export type Capabilities = z.infer<typeof CapabilitiesSchema>;
|
|
|
25
25
|
|
|
26
26
|
export interface A2AAgentEntry {
|
|
27
27
|
name?: string;
|
|
28
|
-
|
|
28
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
29
|
+
exports: { default: ToolLoopAgent<never, any>; accepts?: Accepts; capabilities?: Capabilities };
|
|
29
30
|
/** Optional task store override. Defaults to a per-agent InMemoryTaskStore for isolation. */
|
|
30
31
|
taskStore?: TaskStore;
|
|
31
32
|
}
|
package/app/plugins/mcp.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
1
2
|
import { type Tool } from "ai";
|
|
2
3
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
4
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
@@ -8,6 +9,13 @@ import { AcceptsScheme, isAcceptsPaid, normalizeAcceptsX402 } from "../../accept
|
|
|
8
9
|
import { getAixyzConfig, getAixyzConfigRuntime } from "../../config";
|
|
9
10
|
import { Network } from "@x402/core/types";
|
|
10
11
|
import { ExactEvmScheme } from "@x402/evm/exact/server";
|
|
12
|
+
import type { SessionPlugin } from "./session/index";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* AsyncLocalStorage to pass the MCP-level payer address from the
|
|
16
|
+
* onAfterVerify hook to the tool handler within the same async context.
|
|
17
|
+
*/
|
|
18
|
+
const mcpPayerStorage = new AsyncLocalStorage<{ payer?: string }>();
|
|
11
19
|
|
|
12
20
|
/**
|
|
13
21
|
* MCP (Model Context Protocol) plugin. Collects tools and exposes them
|
|
@@ -21,6 +29,7 @@ export class MCPPlugin extends BasePlugin {
|
|
|
21
29
|
readonly name = "mcp";
|
|
22
30
|
readonly registeredTools: Array<{ name: string; tool: Tool; accepts?: Accepts }> = [];
|
|
23
31
|
private paymentWrappers = new Map<string, (handler: any) => any>();
|
|
32
|
+
private sessionPlugin?: SessionPlugin;
|
|
24
33
|
|
|
25
34
|
constructor(private tools: Array<{ name: string; exports: { default: Tool; accepts?: Accepts } }>) {
|
|
26
35
|
super();
|
|
@@ -31,22 +40,44 @@ export class MCPPlugin extends BasePlugin {
|
|
|
31
40
|
const mcpServer = new McpServer({ name: config.name, version: config.version }, { capabilities: { tools: {} } });
|
|
32
41
|
|
|
33
42
|
for (const { name, tool } of this.registeredTools) {
|
|
43
|
+
const sessionPlugin = this.sessionPlugin;
|
|
34
44
|
const handler = async (args: Record<string, unknown>) => {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
45
|
+
const execute = async () => {
|
|
46
|
+
try {
|
|
47
|
+
const result = await tool.execute!(args, { toolCallId: name, messages: [] });
|
|
48
|
+
const text = typeof result === "string" ? result : JSON.stringify(result, null, 2);
|
|
49
|
+
return { content: [{ type: "text" as const, text }] };
|
|
50
|
+
} catch (error) {
|
|
51
|
+
console.error(`[mcp] Tool "${name}" failed:`, error);
|
|
52
|
+
const text = error instanceof Error ? error.message : "Unknown error";
|
|
53
|
+
return { content: [{ type: "text" as const, text: `Error: ${text}` }], isError: true };
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// If a payer was captured from MCP-level payment, run the tool
|
|
58
|
+
// within a session context so getSession() works.
|
|
59
|
+
const payer = mcpPayerStorage.getStore()?.payer;
|
|
60
|
+
if (payer && sessionPlugin) {
|
|
61
|
+
return sessionPlugin.runWithPayer(payer, execute);
|
|
42
62
|
}
|
|
63
|
+
return execute();
|
|
43
64
|
};
|
|
44
65
|
|
|
45
66
|
const wrapper = this.paymentWrappers.get(name);
|
|
67
|
+
let registeredHandler: (args: any, extra?: any) => any;
|
|
68
|
+
if (wrapper) {
|
|
69
|
+
const wrapped = wrapper(handler);
|
|
70
|
+
// Wrap the payment wrapper call in mcpPayerStorage.run() so the
|
|
71
|
+
// onAfterVerify hook and handler share the same async context.
|
|
72
|
+
registeredHandler = (args: any, extra: any) =>
|
|
73
|
+
mcpPayerStorage.run({ payer: undefined }, () => wrapped(args, extra));
|
|
74
|
+
} else {
|
|
75
|
+
registeredHandler = handler;
|
|
76
|
+
}
|
|
46
77
|
mcpServer.registerTool(
|
|
47
78
|
name,
|
|
48
79
|
{ description: tool.description, inputSchema: tool.inputSchema as any },
|
|
49
|
-
|
|
80
|
+
registeredHandler,
|
|
50
81
|
);
|
|
51
82
|
}
|
|
52
83
|
|
|
@@ -80,6 +111,8 @@ export class MCPPlugin extends BasePlugin {
|
|
|
80
111
|
}
|
|
81
112
|
|
|
82
113
|
async initialize(ctx: InitializeContext): Promise<void> {
|
|
114
|
+
this.sessionPlugin = ctx.getPlugin<SessionPlugin>("session");
|
|
115
|
+
|
|
83
116
|
if (!ctx.payment) return;
|
|
84
117
|
|
|
85
118
|
const config = getAixyzConfig();
|
|
@@ -102,6 +135,17 @@ export class MCPPlugin extends BasePlugin {
|
|
|
102
135
|
}
|
|
103
136
|
}
|
|
104
137
|
|
|
138
|
+
// Capture payer from MCP-level payment verification for session integration.
|
|
139
|
+
// This hook is global (fires for all verifications, including HTTP-level), but
|
|
140
|
+
// only writes when mcpPayerStorage has an active context — which only happens
|
|
141
|
+
// inside MCP tool calls wrapped by mcpPayerStorage.run() below.
|
|
142
|
+
ctx.payment.onAfterVerify(async (context) => {
|
|
143
|
+
const store = mcpPayerStorage.getStore();
|
|
144
|
+
if (store && context.result.payer) {
|
|
145
|
+
store.payer = context.result.payer;
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
105
149
|
for (const { name, accepts } of this.registeredTools) {
|
|
106
150
|
if (!accepts || !isAcceptsPaid(accepts)) continue;
|
|
107
151
|
|
|
@@ -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
|
+
}
|
|
@@ -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
|
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "SessionPlugin"
|
|
3
|
+
description: "Payer-scoped key-value storage gated by x402 payment identity"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Overview
|
|
7
|
+
|
|
8
|
+
`SessionPlugin` provides per-payer key-value storage for aixyz agents. Each x402 signer gets an isolated session -- two different payers never see each other's data. Sessions are accessed via `getSession()` using `AsyncLocalStorage`, so tools don't need to thread payer identity through function parameters.
|
|
9
|
+
|
|
10
|
+
SessionPlugin is **always registered** by the build pipeline. To use a custom storage backend, create an `app/session.ts` file.
|
|
11
|
+
|
|
12
|
+
```typescript
|
|
13
|
+
import { getSession, getPayer } from "aixyz/app/plugins/session";
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Setup
|
|
17
|
+
|
|
18
|
+
SessionPlugin is auto-registered when `aixyz build` or `aixyz dev` generates the server entrypoint. No manual setup is needed for the default in-memory store.
|
|
19
|
+
|
|
20
|
+
To use a custom store (Redis, database, etc.), create `app/session.ts`:
|
|
21
|
+
|
|
22
|
+
```typescript title="app/session.ts"
|
|
23
|
+
import { defineSessionStore, InMemorySessionStore } from "aixyz/app/plugins/session";
|
|
24
|
+
|
|
25
|
+
export default defineSessionStore(new InMemorySessionStore());
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The build pipeline detects `app/session.ts` and passes it to `SessionPlugin({ store: sessionStore })`. If no `app/session.ts` exists, the default `InMemorySessionStore` is used.
|
|
29
|
+
|
|
30
|
+
## API
|
|
31
|
+
|
|
32
|
+
### `getSession()`
|
|
33
|
+
|
|
34
|
+
Returns the current payer-scoped `Session`, or `undefined` if no x402 payment was made for this request.
|
|
35
|
+
|
|
36
|
+
```typescript
|
|
37
|
+
import { getSession } from "aixyz/app/plugins/session";
|
|
38
|
+
|
|
39
|
+
const session = getSession();
|
|
40
|
+
if (!session) {
|
|
41
|
+
return { error: "No authenticated signer" };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
await session.set("key", "value");
|
|
45
|
+
const value = await session.get("key");
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### `getPayer()`
|
|
49
|
+
|
|
50
|
+
Shorthand for `getSession()?.payer`. Returns the x402 signer address or `undefined`.
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
import { getPayer } from "aixyz/app/plugins/session";
|
|
54
|
+
|
|
55
|
+
const payer = getPayer(); // "0x1234..."
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Session Interface
|
|
59
|
+
|
|
60
|
+
All operations are scoped to the current x402 payer automatically.
|
|
61
|
+
|
|
62
|
+
| Method | Signature | Description |
|
|
63
|
+
| -------- | --------------------------------------------------------------------- | --------------------------------------------- |
|
|
64
|
+
| `payer` | `readonly string` | The x402 signer address |
|
|
65
|
+
| `get` | `(key: string) => Promise<string \| undefined>` | Get a value by key |
|
|
66
|
+
| `set` | `(key: string, value: string, options?: SetOptions) => Promise<void>` | Store a key-value pair |
|
|
67
|
+
| `delete` | `(key: string) => Promise<boolean>` | Delete a key, returns whether it existed |
|
|
68
|
+
| `list` | `(options?: ListOptions) => Promise<ListResult>` | List key-value pairs with optional pagination |
|
|
69
|
+
|
|
70
|
+
### `SetOptions`
|
|
71
|
+
|
|
72
|
+
| Field | Type | Description |
|
|
73
|
+
| ------- | -------- | -------------------------------------------------------------------- |
|
|
74
|
+
| `ttlMs` | `number` | Per-key TTL in milliseconds. Overrides store default when supported. |
|
|
75
|
+
|
|
76
|
+
### `ListOptions`
|
|
77
|
+
|
|
78
|
+
| Field | Type | Description |
|
|
79
|
+
| ---------- | --------- | ------------------------------------------------- |
|
|
80
|
+
| `prefix` | `string` | Only return keys starting with this prefix |
|
|
81
|
+
| `cursor` | `string` | Opaque cursor from a previous `list()` call |
|
|
82
|
+
| `limit` | `number` | Maximum number of entries to return |
|
|
83
|
+
| `keysOnly` | `boolean` | If true, values are omitted (all values are `""`) |
|
|
84
|
+
|
|
85
|
+
### `ListResult`
|
|
86
|
+
|
|
87
|
+
| Field | Type | Description |
|
|
88
|
+
| --------- | ------------------------ | ------------------------------------------------------------------- |
|
|
89
|
+
| `entries` | `Record<string, string>` | The key-value pairs |
|
|
90
|
+
| `cursor` | `string \| undefined` | If present, more results are available. Pass to next `list()` call. |
|
|
91
|
+
|
|
92
|
+
## InMemorySessionStore
|
|
93
|
+
|
|
94
|
+
The default store. Uses LRU eviction and optional sliding-window TTL.
|
|
95
|
+
|
|
96
|
+
- **LRU eviction** -- when `maxEntries` is reached, the least-recently-used entry is evicted
|
|
97
|
+
- **Sliding-window TTL** -- `get()` refreshes the expiry timer. Expired entries are lazily removed on read.
|
|
98
|
+
- **OOM-safe** -- `maxEntries` is a hard cap across all payers
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
import { InMemorySessionStore } from "aixyz/app/plugins/session";
|
|
102
|
+
|
|
103
|
+
const store = new InMemorySessionStore({
|
|
104
|
+
maxEntries: 10_000, // default
|
|
105
|
+
ttlMs: 3_600_000, // 1 hour default, 0 to disable
|
|
106
|
+
});
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
| Option | Type | Default | Description |
|
|
110
|
+
| ------------ | -------- | --------- | ------------------------------------------------ |
|
|
111
|
+
| `maxEntries` | `number` | `10000` | Maximum entries across all payers. Must be >= 1. |
|
|
112
|
+
| `ttlMs` | `number` | `3600000` | Sliding-window TTL in ms. 0 disables expiry. |
|
|
113
|
+
|
|
114
|
+
## Custom Storage Backend
|
|
115
|
+
|
|
116
|
+
Implement the `SessionStore` interface for Redis, a database, or any KV store:
|
|
117
|
+
|
|
118
|
+
```typescript title="app/session.ts"
|
|
119
|
+
import { defineSessionStore } from "aixyz/app/plugins/session";
|
|
120
|
+
import type { SessionStore, ListOptions, SetOptions } from "aixyz/app/plugins/session";
|
|
121
|
+
|
|
122
|
+
class RedisSessionStore implements SessionStore {
|
|
123
|
+
async get(payer: string, key: string) {
|
|
124
|
+
return (await redis.get(`${payer}:${key}`)) ?? undefined;
|
|
125
|
+
}
|
|
126
|
+
async set(payer: string, key: string, value: string, options?: SetOptions) {
|
|
127
|
+
if (options?.ttlMs) {
|
|
128
|
+
await redis.set(`${payer}:${key}`, value, "PX", options.ttlMs);
|
|
129
|
+
} else {
|
|
130
|
+
await redis.set(`${payer}:${key}`, value);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
async delete(payer: string, key: string) {
|
|
134
|
+
return (await redis.del(`${payer}:${key}`)) > 0;
|
|
135
|
+
}
|
|
136
|
+
async list(payer: string, options?: ListOptions) {
|
|
137
|
+
// scan for keys matching payer prefix, apply options.prefix/limit/cursor
|
|
138
|
+
return {
|
|
139
|
+
entries: {
|
|
140
|
+
/* ... */
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export default defineSessionStore(new RedisSessionStore());
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### `SessionStore` Interface
|
|
150
|
+
|
|
151
|
+
**Required methods:**
|
|
152
|
+
|
|
153
|
+
| Method | Signature | Description |
|
|
154
|
+
| -------- | ------------------------------------------------------------------------------------ | ----------------------- |
|
|
155
|
+
| `get` | `(payer: string, key: string) => Promise<string \| undefined>` | Get a value |
|
|
156
|
+
| `set` | `(payer: string, key: string, value: string, options?: SetOptions) => Promise<void>` | Store a value |
|
|
157
|
+
| `delete` | `(payer: string, key: string) => Promise<boolean>` | Delete a value |
|
|
158
|
+
| `list` | `(payer: string, options?: ListOptions) => Promise<ListResult>` | List values for a payer |
|
|
159
|
+
|
|
160
|
+
**Optional methods:**
|
|
161
|
+
|
|
162
|
+
| Method | Signature | Description |
|
|
163
|
+
| ------------ | ----------------------------------------------------------------------------------------- | --------------------------- |
|
|
164
|
+
| `getMany` | `(payer: string, keys: string[]) => Promise<Record<string, string \| undefined>>` | Batch get |
|
|
165
|
+
| `setMany` | `(payer: string, entries: Record<string, string>, options?: SetOptions) => Promise<void>` | Batch set |
|
|
166
|
+
| `deleteMany` | `(payer: string, keys: string[]) => Promise<number>` | Batch delete, returns count |
|
|
167
|
+
| `close` | `() => Promise<void>` | Release connections/timers |
|
|
168
|
+
|
|
169
|
+
## MCP Integration
|
|
170
|
+
|
|
171
|
+
Sessions work automatically inside MCP tool handlers. The MCP plugin detects the session plugin and wraps tool execution with the payer context from x402 payment verification.
|
|
172
|
+
|
|
173
|
+
No additional configuration is needed.
|
|
174
|
+
|
|
175
|
+
## Constructor Options
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
new SessionPlugin(options?: SessionPluginOptions)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
| Option | Type | Default | Description |
|
|
182
|
+
| ------- | -------------- | ---------------------- | ---------------------- |
|
|
183
|
+
| `store` | `SessionStore` | `InMemorySessionStore` | Custom storage backend |
|
|
184
|
+
|
|
185
|
+
<CardGroup cols={2}>
|
|
186
|
+
<Card title="x402 Payments" icon="credit-card" href="/protocols/x402">
|
|
187
|
+
Payment protocol that provides payer identity for sessions.
|
|
188
|
+
</Card>
|
|
189
|
+
<Card title="x402 Sessions Template" icon="database" href="/templates/advanced/x402-sessions">
|
|
190
|
+
Working example with session-backed content storage.
|
|
191
|
+
</Card>
|
|
192
|
+
</CardGroup>
|
|
@@ -137,3 +137,19 @@ export const facilitator = new HTTPFacilitatorClient({
|
|
|
137
137
|
```
|
|
138
138
|
|
|
139
139
|
See the [BYO Facilitator template](/templates/advanced/with-custom-facilitator) for a working example.
|
|
140
|
+
|
|
141
|
+
## Payer Identity and Sessions
|
|
142
|
+
|
|
143
|
+
Every verified x402 payment identifies the payer by their wallet address. `SessionPlugin` (auto-registered by the build pipeline) gives each payer isolated key-value storage accessible via `getSession()`:
|
|
144
|
+
|
|
145
|
+
```typescript
|
|
146
|
+
import { getSession } from "aixyz/app/plugins/session";
|
|
147
|
+
|
|
148
|
+
const session = getSession();
|
|
149
|
+
if (session) {
|
|
150
|
+
await session.set("preference", "dark-mode");
|
|
151
|
+
const payer = session.payer; // "0x1234..."
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
This works in both A2A agent handlers and MCP tool handlers. See [SessionPlugin](/api-reference/session-plugin) for the full API and custom store configuration.
|
|
@@ -35,6 +35,11 @@ An aixyz agent is defined by a small set of files in a standard layout. Run `aix
|
|
|
35
35
|
description: "Custom x402 facilitator",
|
|
36
36
|
badge: { text: "optional override", color: "purple" },
|
|
37
37
|
},
|
|
38
|
+
{
|
|
39
|
+
name: "session.ts",
|
|
40
|
+
description: "Custom session store",
|
|
41
|
+
badge: { text: "optional override", color: "purple" },
|
|
42
|
+
},
|
|
38
43
|
{
|
|
39
44
|
name: "erc-8004.ts",
|
|
40
45
|
description: "ERC-8004 identity registration",
|
|
@@ -126,6 +131,7 @@ An aixyz agent is defined by a small set of files in a standard layout. Run `aix
|
|
|
126
131
|
| `app/tools/*.ts` | No | Tool implementations, auto-discovered by the build |
|
|
127
132
|
| `app/server.ts` | No | [Custom server](/api-reference/aixyz-server) — overrides auto-generation entirely |
|
|
128
133
|
| `app/accepts.ts` | No | [Custom x402 facilitator](/api-reference/accepts) for payment verification |
|
|
134
|
+
| `app/session.ts` | No | [Custom session store](/api-reference/session-plugin) override for SessionPlugin |
|
|
129
135
|
| `app/erc-8004.ts` | No | ERC-8004 identity registration and trust config |
|
|
130
136
|
| `app/agent.test.ts` | No | Agent tests using `bun:test` |
|
|
131
137
|
| `app/icon.svg` | No | Agent icon served as a static asset |
|
|
@@ -141,7 +147,8 @@ The build pipeline scans the `app/` directory to auto-generate a server:
|
|
|
141
147
|
2. Imports `app/agent.ts` for the main agent definition (if present)
|
|
142
148
|
3. Discovers all `.ts` files in `app/agents/` for sub-agents (each gets its own A2A endpoint)
|
|
143
149
|
4. Discovers all `.ts` files in `app/tools/` (excluding `_` prefixed files)
|
|
144
|
-
5.
|
|
150
|
+
5. Registers `SessionPlugin` (with custom store from `app/session.ts` if present)
|
|
151
|
+
6. Wires up A2A (when agents exist), MCP (when tools exist), and x402 endpoints automatically
|
|
145
152
|
|
|
146
153
|
The result is a server exposing:
|
|
147
154
|
|
package/docs/protocols/mcp.mdx
CHANGED
|
@@ -58,6 +58,20 @@ await server.withPlugin(
|
|
|
58
58
|
);
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
+
## Session Integration
|
|
62
|
+
|
|
63
|
+
Paid MCP tools automatically get session context. When a tool is invoked with x402 payment, `getSession()` returns the payer-scoped session inside the tool handler — no additional configuration needed.
|
|
64
|
+
|
|
65
|
+
```typescript
|
|
66
|
+
import { getSession } from "aixyz/app/plugins/session";
|
|
67
|
+
|
|
68
|
+
// Inside an MCP tool handler:
|
|
69
|
+
const session = getSession();
|
|
70
|
+
await session?.set("key", "value"); // stored per-payer
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
See [SessionPlugin](/api-reference/session-plugin) for the full API.
|
|
74
|
+
|
|
61
75
|
## Payment-Gated Tools
|
|
62
76
|
|
|
63
77
|
Tools with `accepts.scheme === "exact"` require [x402 payment](/protocols/x402) via `@x402/mcp`. The payment wrapper is applied automatically when you provide an `accepts` configuration during registration.
|
|
@@ -81,6 +95,9 @@ await server.withPlugin(
|
|
|
81
95
|
<Card title="Tools" icon="folder-tree" href="/api-reference/tools">
|
|
82
96
|
How tools are defined in the app/ directory.
|
|
83
97
|
</Card>
|
|
98
|
+
<Card title="SessionPlugin" icon="database" href="/api-reference/session-plugin">
|
|
99
|
+
Payer-scoped storage for MCP tools.
|
|
100
|
+
</Card>
|
|
84
101
|
<Card title="A2A Protocol" icon="diagram-project" href="/protocols/a2a">
|
|
85
102
|
Agent discovery and communication via A2A.
|
|
86
103
|
</Card>
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "x402 Sessions"
|
|
3
|
+
description: "Template demonstrating payer-scoped session storage with x402 payment identity"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
<Info>**Source:** [`examples/x402-sessions`](https://github.com/AgentlyHQ/aixyz/tree/main/examples/x402-sessions)</Info>
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
This template demonstrates `SessionPlugin` -- payer-scoped key-value storage gated by x402 payment identity. Each x402 signer gets isolated storage that persists across requests. Two different payers never see each other's data.
|
|
11
|
+
|
|
12
|
+
## Project Structure
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
x402-sessions/
|
|
16
|
+
├── aixyz.config.ts # Agent metadata and skills
|
|
17
|
+
├── app/
|
|
18
|
+
│ ├── session.ts # Custom session store (optional)
|
|
19
|
+
│ ├── agent.ts # Agent definition
|
|
20
|
+
│ ├── accepts.ts # Custom x402 facilitator
|
|
21
|
+
│ └── tools/
|
|
22
|
+
│ ├── put-content.ts # Store content in session
|
|
23
|
+
│ └── get-content.ts # Retrieve content from session
|
|
24
|
+
├── package.json
|
|
25
|
+
└── vercel.json
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Session Store
|
|
29
|
+
|
|
30
|
+
SessionPlugin is auto-registered by the build pipeline. To customize the store, create `app/session.ts`:
|
|
31
|
+
|
|
32
|
+
```typescript title="app/session.ts"
|
|
33
|
+
import { defineSessionStore, InMemorySessionStore } from "aixyz/app/plugins/session";
|
|
34
|
+
|
|
35
|
+
// Use the built-in in-memory store. Replace with Redis, DB, etc. for production.
|
|
36
|
+
export default defineSessionStore(new InMemorySessionStore());
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
If `app/session.ts` is not present, the default `InMemorySessionStore` is used automatically.
|
|
40
|
+
|
|
41
|
+
## Using Sessions in Tools
|
|
42
|
+
|
|
43
|
+
Tools access the session via `getSession()` -- no need to pass payer identity manually:
|
|
44
|
+
|
|
45
|
+
```typescript title="app/tools/put-content.ts"
|
|
46
|
+
import { tool } from "ai";
|
|
47
|
+
import { z } from "zod";
|
|
48
|
+
import { getSession } from "aixyz/app/plugins/session";
|
|
49
|
+
import type { Accepts } from "aixyz/accepts";
|
|
50
|
+
|
|
51
|
+
export default tool({
|
|
52
|
+
description: "Store a key-value pair in the current user's session",
|
|
53
|
+
inputSchema: z.object({
|
|
54
|
+
key: z.string(),
|
|
55
|
+
value: z.string().nullable(),
|
|
56
|
+
}),
|
|
57
|
+
execute: async ({ key, value }) => {
|
|
58
|
+
const session = getSession();
|
|
59
|
+
if (!session) {
|
|
60
|
+
return { success: false, error: "No authenticated signer in context" };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (value === null) {
|
|
64
|
+
await session.delete(key);
|
|
65
|
+
return { success: true, key, deleted: true };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
await session.set(key, value);
|
|
69
|
+
return { success: true, key };
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
export const accepts: Accepts = { scheme: "exact", price: "$0.01" };
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Skills
|
|
77
|
+
|
|
78
|
+
| Skill | Description | Price |
|
|
79
|
+
| ------------- | ----------------------------------------------- | ------ |
|
|
80
|
+
| `put-content` | Store key-value content in payer-scoped session | $0.01 |
|
|
81
|
+
| `get-content` | Retrieve stored content from session | $0.001 |
|
|
82
|
+
|
|
83
|
+
## Running
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
cd examples/x402-sessions
|
|
87
|
+
# create a .env file
|
|
88
|
+
bun install
|
|
89
|
+
bun run dev
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
<CardGroup cols={2}>
|
|
93
|
+
<Card title="SessionPlugin API" icon="plug" href="/api-reference/session-plugin">
|
|
94
|
+
Full API reference for SessionPlugin, Session, and SessionStore.
|
|
95
|
+
</Card>
|
|
96
|
+
<Card title="x402 Payments" icon="credit-card" href="/protocols/x402">
|
|
97
|
+
Payment protocol that provides payer identity for sessions.
|
|
98
|
+
</Card>
|
|
99
|
+
</CardGroup>
|
|
@@ -54,4 +54,7 @@ Custom server wiring, bring-your-own payment facilitator, and testing patterns.
|
|
|
54
54
|
<Card title="Express Integration" icon="plug" href="/templates/advanced/with-express">
|
|
55
55
|
Mount AixyzApp as Express middleware alongside your own routes.
|
|
56
56
|
</Card>
|
|
57
|
+
<Card title="x402 Sessions" icon="database" href="/templates/advanced/x402-sessions">
|
|
58
|
+
Payer-scoped session storage with x402 payment identity.
|
|
59
|
+
</Card>
|
|
57
60
|
</CardGroup>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aixyz",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.0",
|
|
4
4
|
"description": "Payment-native SDK for AI Agent",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"./app/*": "./app/*.ts",
|
|
25
25
|
"./app/adapters/*": "./app/adapters/*.ts",
|
|
26
26
|
"./app/plugins/index-page": "./app/plugins/index-page/index.ts",
|
|
27
|
+
"./app/plugins/session": "./app/plugins/session/index.ts",
|
|
27
28
|
"./app/plugins/*": "./app/plugins/*.ts"
|
|
28
29
|
},
|
|
29
30
|
"bin": "bin.js",
|
|
@@ -40,9 +41,9 @@
|
|
|
40
41
|
},
|
|
41
42
|
"dependencies": {
|
|
42
43
|
"@a2a-js/sdk": "^0.3.10",
|
|
43
|
-
"@aixyz/cli": "0.
|
|
44
|
-
"@aixyz/config": "0.
|
|
45
|
-
"@aixyz/erc-8004": "0.
|
|
44
|
+
"@aixyz/cli": "0.35.0",
|
|
45
|
+
"@aixyz/config": "0.35.0",
|
|
46
|
+
"@aixyz/erc-8004": "0.35.0",
|
|
46
47
|
"@kitajs/html": "^4.2.13",
|
|
47
48
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
48
49
|
"@next/env": "^16.1.6",
|