xbird-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of xbird-mcp might be problematic. Click here for more details.
- package/docs/listing.md +42 -0
- package/docs/mcp-setup.md +146 -0
- package/package.json +50 -0
- package/src/cli/context.ts +80 -0
- package/src/cli/output.ts +33 -0
- package/src/cli/program.ts +63 -0
- package/src/cli.ts +8 -0
- package/src/commands/about.ts +30 -0
- package/src/commands/bookmark.ts +40 -0
- package/src/commands/bookmarks-list.ts +108 -0
- package/src/commands/check.ts +27 -0
- package/src/commands/engagement.ts +31 -0
- package/src/commands/follow.ts +40 -0
- package/src/commands/followers.ts +132 -0
- package/src/commands/home.ts +59 -0
- package/src/commands/likes.ts +73 -0
- package/src/commands/lists.ts +101 -0
- package/src/commands/mentions.ts +32 -0
- package/src/commands/news.ts +38 -0
- package/src/commands/read.ts +36 -0
- package/src/commands/replies.ts +59 -0
- package/src/commands/search.ts +60 -0
- package/src/commands/tweet.ts +128 -0
- package/src/commands/user-tweets.ts +73 -0
- package/src/commands/user.ts +20 -0
- package/src/commands/whoami.ts +26 -0
- package/src/formatters/common.ts +49 -0
- package/src/formatters/list.ts +22 -0
- package/src/formatters/news.ts +23 -0
- package/src/formatters/tweet.ts +38 -0
- package/src/formatters/user.ts +20 -0
- package/src/index.ts +26 -0
- package/src/lib/auth.ts +105 -0
- package/src/lib/client-bookmarks.ts +62 -0
- package/src/lib/client-engagement.ts +152 -0
- package/src/lib/client-follow.ts +90 -0
- package/src/lib/client-full.ts +48 -0
- package/src/lib/client-likes.ts +62 -0
- package/src/lib/client-lists.ts +227 -0
- package/src/lib/client-media.ts +162 -0
- package/src/lib/client-news.ts +185 -0
- package/src/lib/client-posting.ts +163 -0
- package/src/lib/client-reading.ts +452 -0
- package/src/lib/client-search.ts +156 -0
- package/src/lib/client-timeline.ts +98 -0
- package/src/lib/client-user.ts +518 -0
- package/src/lib/client.ts +134 -0
- package/src/lib/config.ts +22 -0
- package/src/lib/constants.ts +55 -0
- package/src/lib/cookies.ts +132 -0
- package/src/lib/features.ts +175 -0
- package/src/lib/headers.ts +28 -0
- package/src/lib/paginate.ts +39 -0
- package/src/lib/query-ids.ts +190 -0
- package/src/lib/types.ts +147 -0
- package/src/lib/utils.ts +176 -0
- package/src/mcp/executor.ts +178 -0
- package/src/mcp/payment.ts +38 -0
- package/src/mcp/server.ts +53 -0
- package/src/mcp/tools.ts +389 -0
- package/src/server/app.ts +117 -0
- package/src/server/config/accounts.ts +137 -0
- package/src/server/config/pricing.ts +217 -0
- package/src/server/erc8004/register.ts +77 -0
- package/src/server/middleware/account-pool.ts +101 -0
- package/src/server/middleware/error-handler.ts +27 -0
- package/src/server/middleware/payer-extract.ts +43 -0
- package/src/server/middleware/x402.ts +61 -0
- package/src/server/routes/accounts.ts +93 -0
- package/src/server/routes/authorize.ts +19 -0
- package/src/server/routes/bookmarks.ts +21 -0
- package/src/server/routes/engagement.ts +84 -0
- package/src/server/routes/follow.ts +32 -0
- package/src/server/routes/health.ts +14 -0
- package/src/server/routes/lists.ts +38 -0
- package/src/server/routes/media.ts +44 -0
- package/src/server/routes/mentions.ts +20 -0
- package/src/server/routes/news.ts +23 -0
- package/src/server/routes/search.ts +26 -0
- package/src/server/routes/timeline.ts +21 -0
- package/src/server/routes/tweets.ts +82 -0
- package/src/server/routes/users.ts +92 -0
- package/src/server/storage/accounts-db.ts +84 -0
- package/src/server.ts +34 -0
- package/tsconfig.json +29 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import type { Network } from "@x402/core/types";
|
|
2
|
+
import { declareDiscoveryExtension } from "@x402/extensions/bazaar";
|
|
3
|
+
|
|
4
|
+
const isMainnet = process.env.XBIRD_NETWORK === "mainnet";
|
|
5
|
+
|
|
6
|
+
export const NETWORK: Network = isMainnet ? "eip155:8453" : "eip155:84532";
|
|
7
|
+
|
|
8
|
+
export const FACILITATOR_URL = process.env.XBIRD_FACILITATOR_URL ??
|
|
9
|
+
(isMainnet
|
|
10
|
+
? "https://api.cdp.coinbase.com/platform/v2/x402"
|
|
11
|
+
: "https://www.x402.org/facilitator");
|
|
12
|
+
|
|
13
|
+
/** Prices in USD per request */
|
|
14
|
+
export const PRICES = {
|
|
15
|
+
read: "$0.001",
|
|
16
|
+
search: "$0.005",
|
|
17
|
+
bulk: "$0.01",
|
|
18
|
+
write: "$0.01",
|
|
19
|
+
media: "$0.05",
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
export type PriceTier = keyof typeof PRICES;
|
|
23
|
+
|
|
24
|
+
/** Map route patterns to price tiers (x402 uses [param] not :param) */
|
|
25
|
+
export const ROUTE_PRICES: Record<string, PriceTier> = {
|
|
26
|
+
// Read — $0.001
|
|
27
|
+
"GET /api/tweets/[id]": "read",
|
|
28
|
+
"GET /api/tweets/[id]/thread": "read",
|
|
29
|
+
"GET /api/tweets/[id]/replies": "read",
|
|
30
|
+
"GET /api/users/[handle]": "read",
|
|
31
|
+
"GET /api/users/[handle]/about": "read",
|
|
32
|
+
"GET /api/timeline/home": "read",
|
|
33
|
+
"GET /api/news": "read",
|
|
34
|
+
"GET /api/lists": "read",
|
|
35
|
+
"GET /api/lists/[id]/tweets": "read",
|
|
36
|
+
|
|
37
|
+
// Search — $0.005
|
|
38
|
+
"GET /api/search": "search",
|
|
39
|
+
"GET /api/mentions/[handle]": "search",
|
|
40
|
+
|
|
41
|
+
// Bulk — $0.01
|
|
42
|
+
"GET /api/users/[id]/tweets": "bulk",
|
|
43
|
+
"GET /api/users/[id]/followers": "bulk",
|
|
44
|
+
"GET /api/users/[id]/following": "bulk",
|
|
45
|
+
"GET /api/users/[id]/likes": "bulk",
|
|
46
|
+
"GET /api/bookmarks": "bulk",
|
|
47
|
+
|
|
48
|
+
// Accounts — registration management (cheap to encourage onboarding)
|
|
49
|
+
"POST /api/accounts": "read",
|
|
50
|
+
"GET /api/accounts": "read",
|
|
51
|
+
"DELETE /api/accounts": "read",
|
|
52
|
+
|
|
53
|
+
// Write — $0.01
|
|
54
|
+
"POST /api/tweets": "write",
|
|
55
|
+
"POST /api/tweets/[id]/reply": "write",
|
|
56
|
+
"POST /api/tweets/[id]/like": "write",
|
|
57
|
+
"DELETE /api/tweets/[id]/like": "write",
|
|
58
|
+
"POST /api/tweets/[id]/retweet": "write",
|
|
59
|
+
"DELETE /api/tweets/[id]/retweet": "write",
|
|
60
|
+
"POST /api/tweets/[id]/bookmark": "write",
|
|
61
|
+
"DELETE /api/tweets/[id]/bookmark": "write",
|
|
62
|
+
"POST /api/users/[handle]/follow": "write",
|
|
63
|
+
"DELETE /api/users/[handle]/follow": "write",
|
|
64
|
+
|
|
65
|
+
// Media — $0.05
|
|
66
|
+
"POST /api/media": "media",
|
|
67
|
+
|
|
68
|
+
// Authorize — payment-only endpoints for MCP local execution
|
|
69
|
+
"POST /api/authorize/read": "read",
|
|
70
|
+
"POST /api/authorize/search": "search",
|
|
71
|
+
"POST /api/authorize/bulk": "bulk",
|
|
72
|
+
"POST /api/authorize/write": "write",
|
|
73
|
+
"POST /api/authorize/media": "media",
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** Bazaar discovery extensions per route — describe inputs/outputs for AI agent discoverability */
|
|
77
|
+
const DISCOVERY: Record<string, ReturnType<typeof declareDiscoveryExtension>> = {
|
|
78
|
+
"GET /api/tweets/[id]": declareDiscoveryExtension({
|
|
79
|
+
input: { id: "1234567890" },
|
|
80
|
+
inputSchema: { properties: { id: { type: "string", description: "Tweet ID" } }, required: ["id"] },
|
|
81
|
+
output: { example: { id: "1234567890", text: "Hello world", author: { handle: "user", name: "User" }, createdAt: "2024-01-01T00:00:00Z" } },
|
|
82
|
+
}),
|
|
83
|
+
"GET /api/tweets/[id]/thread": declareDiscoveryExtension({
|
|
84
|
+
input: { id: "1234567890" },
|
|
85
|
+
inputSchema: { properties: { id: { type: "string", description: "Tweet ID" } }, required: ["id"] },
|
|
86
|
+
output: { example: { tweets: [{ id: "1234567890", text: "Thread tweet", author: { handle: "user" } }] } },
|
|
87
|
+
}),
|
|
88
|
+
"GET /api/tweets/[id]/replies": declareDiscoveryExtension({
|
|
89
|
+
input: { id: "1234567890", count: 20 },
|
|
90
|
+
inputSchema: { properties: { id: { type: "string" }, count: { type: "number" } }, required: ["id"] },
|
|
91
|
+
output: { example: { replies: [{ id: "9876543210", text: "A reply", author: { handle: "replier" } }] } },
|
|
92
|
+
}),
|
|
93
|
+
"GET /api/users/[handle]": declareDiscoveryExtension({
|
|
94
|
+
input: { handle: "elonmusk" },
|
|
95
|
+
inputSchema: { properties: { handle: { type: "string", description: "Twitter handle (without @)" } }, required: ["handle"] },
|
|
96
|
+
output: { example: { id: "44196397", handle: "elonmusk", name: "Elon Musk", followers: 200000000, following: 800 } },
|
|
97
|
+
}),
|
|
98
|
+
"GET /api/users/[handle]/about": declareDiscoveryExtension({
|
|
99
|
+
input: { handle: "elonmusk" },
|
|
100
|
+
inputSchema: { properties: { handle: { type: "string" } }, required: ["handle"] },
|
|
101
|
+
output: { example: { handle: "elonmusk", bio: "Mars & Cars", location: "Austin, TX", joined: "2009-06-02" } },
|
|
102
|
+
}),
|
|
103
|
+
"GET /api/search": declareDiscoveryExtension({
|
|
104
|
+
input: { q: "bitcoin", count: 20 },
|
|
105
|
+
inputSchema: { properties: { q: { type: "string", description: "Search query" }, count: { type: "number" } }, required: ["q"] },
|
|
106
|
+
output: { example: { tweets: [{ id: "123", text: "Bitcoin hits new ATH", author: { handle: "crypto" } }] } },
|
|
107
|
+
}),
|
|
108
|
+
"GET /api/mentions/[handle]": declareDiscoveryExtension({
|
|
109
|
+
input: { handle: "user" },
|
|
110
|
+
inputSchema: { properties: { handle: { type: "string" } }, required: ["handle"] },
|
|
111
|
+
output: { example: { tweets: [{ id: "123", text: "@user great post!", author: { handle: "fan" } }] } },
|
|
112
|
+
}),
|
|
113
|
+
"GET /api/timeline/home": declareDiscoveryExtension({
|
|
114
|
+
input: { count: 20 },
|
|
115
|
+
inputSchema: { properties: { count: { type: "number" } } },
|
|
116
|
+
output: { example: { tweets: [{ id: "123", text: "Latest from timeline", author: { handle: "friend" } }] } },
|
|
117
|
+
}),
|
|
118
|
+
"GET /api/news": declareDiscoveryExtension({
|
|
119
|
+
output: { example: { topics: [{ name: "Trending", tweets: [{ id: "123", text: "Breaking news" }] }] } },
|
|
120
|
+
}),
|
|
121
|
+
"GET /api/lists": declareDiscoveryExtension({
|
|
122
|
+
output: { example: { lists: [{ id: "123", name: "Tech", memberCount: 50 }] } },
|
|
123
|
+
}),
|
|
124
|
+
"GET /api/lists/[id]/tweets": declareDiscoveryExtension({
|
|
125
|
+
input: { id: "123" },
|
|
126
|
+
inputSchema: { properties: { id: { type: "string", description: "List ID" } }, required: ["id"] },
|
|
127
|
+
output: { example: { tweets: [{ id: "456", text: "List tweet", author: { handle: "member" } }] } },
|
|
128
|
+
}),
|
|
129
|
+
"GET /api/users/[id]/tweets": declareDiscoveryExtension({
|
|
130
|
+
input: { id: "44196397", count: 20 },
|
|
131
|
+
inputSchema: { properties: { id: { type: "string", description: "User ID" }, count: { type: "number" } }, required: ["id"] },
|
|
132
|
+
output: { example: { tweets: [{ id: "123", text: "User's tweet" }] } },
|
|
133
|
+
}),
|
|
134
|
+
"GET /api/users/[id]/followers": declareDiscoveryExtension({
|
|
135
|
+
input: { id: "44196397", count: 20 },
|
|
136
|
+
inputSchema: { properties: { id: { type: "string" }, count: { type: "number" } }, required: ["id"] },
|
|
137
|
+
output: { example: { users: [{ id: "789", handle: "follower", name: "Follower" }] } },
|
|
138
|
+
}),
|
|
139
|
+
"GET /api/users/[id]/following": declareDiscoveryExtension({
|
|
140
|
+
input: { id: "44196397", count: 20 },
|
|
141
|
+
inputSchema: { properties: { id: { type: "string" }, count: { type: "number" } }, required: ["id"] },
|
|
142
|
+
output: { example: { users: [{ id: "789", handle: "following", name: "Following" }] } },
|
|
143
|
+
}),
|
|
144
|
+
"GET /api/users/[id]/likes": declareDiscoveryExtension({
|
|
145
|
+
input: { id: "44196397", count: 20 },
|
|
146
|
+
inputSchema: { properties: { id: { type: "string" }, count: { type: "number" } }, required: ["id"] },
|
|
147
|
+
output: { example: { tweets: [{ id: "123", text: "Liked tweet" }] } },
|
|
148
|
+
}),
|
|
149
|
+
"GET /api/bookmarks": declareDiscoveryExtension({
|
|
150
|
+
input: { count: 20 },
|
|
151
|
+
inputSchema: { properties: { count: { type: "number" } } },
|
|
152
|
+
output: { example: { tweets: [{ id: "123", text: "Bookmarked tweet" }] } },
|
|
153
|
+
}),
|
|
154
|
+
"POST /api/accounts": declareDiscoveryExtension({
|
|
155
|
+
bodyType: "json",
|
|
156
|
+
input: { authToken: "your_auth_token", ct0: "your_ct0_token" },
|
|
157
|
+
inputSchema: { properties: { authToken: { type: "string" }, ct0: { type: "string" } }, required: ["authToken", "ct0"] },
|
|
158
|
+
output: { example: { registered: true, username: "yourhandle" } },
|
|
159
|
+
}),
|
|
160
|
+
"POST /api/tweets": declareDiscoveryExtension({
|
|
161
|
+
bodyType: "json",
|
|
162
|
+
input: { text: "Hello from xbird!" },
|
|
163
|
+
inputSchema: { properties: { text: { type: "string" }, mediaIds: { type: "array", items: { type: "string" } } }, required: ["text"] },
|
|
164
|
+
output: { example: { id: "1234567890", text: "Hello from xbird!" } },
|
|
165
|
+
}),
|
|
166
|
+
"POST /api/tweets/[id]/reply": declareDiscoveryExtension({
|
|
167
|
+
bodyType: "json",
|
|
168
|
+
input: { text: "Great tweet!" },
|
|
169
|
+
inputSchema: { properties: { text: { type: "string" } }, required: ["text"] },
|
|
170
|
+
output: { example: { id: "9876543210", text: "Great tweet!" } },
|
|
171
|
+
}),
|
|
172
|
+
"POST /api/tweets/[id]/like": declareDiscoveryExtension({
|
|
173
|
+
bodyType: "json",
|
|
174
|
+
input: {},
|
|
175
|
+
output: { example: { success: true } },
|
|
176
|
+
}),
|
|
177
|
+
"POST /api/tweets/[id]/retweet": declareDiscoveryExtension({
|
|
178
|
+
bodyType: "json",
|
|
179
|
+
input: {},
|
|
180
|
+
output: { example: { success: true } },
|
|
181
|
+
}),
|
|
182
|
+
"POST /api/tweets/[id]/bookmark": declareDiscoveryExtension({
|
|
183
|
+
bodyType: "json",
|
|
184
|
+
input: {},
|
|
185
|
+
output: { example: { success: true } },
|
|
186
|
+
}),
|
|
187
|
+
"POST /api/users/[handle]/follow": declareDiscoveryExtension({
|
|
188
|
+
bodyType: "json",
|
|
189
|
+
input: {},
|
|
190
|
+
output: { example: { success: true } },
|
|
191
|
+
}),
|
|
192
|
+
"POST /api/media": declareDiscoveryExtension({
|
|
193
|
+
bodyType: "form-data",
|
|
194
|
+
input: { file: "(binary image/video data)" },
|
|
195
|
+
inputSchema: { properties: { file: { type: "string", format: "binary" } }, required: ["file"] },
|
|
196
|
+
output: { example: { mediaId: "1234567890" } },
|
|
197
|
+
}),
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/** Build x402 RoutesConfig from our pricing map with Bazaar discovery extensions */
|
|
201
|
+
export function buildRoutesConfig(payTo: string): Record<string, { accepts: { scheme: string; network: Network; payTo: string; price: string }; extensions?: Record<string, unknown> }> {
|
|
202
|
+
const routes: Record<string, { accepts: { scheme: string; network: Network; payTo: string; price: string }; extensions?: Record<string, unknown> }> = {};
|
|
203
|
+
|
|
204
|
+
for (const [route, tier] of Object.entries(ROUTE_PRICES)) {
|
|
205
|
+
routes[route] = {
|
|
206
|
+
accepts: {
|
|
207
|
+
scheme: "exact",
|
|
208
|
+
network: NETWORK,
|
|
209
|
+
payTo,
|
|
210
|
+
price: PRICES[tier],
|
|
211
|
+
},
|
|
212
|
+
...(DISCOVERY[route] ? { extensions: DISCOVERY[route] } : {}),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return routes;
|
|
217
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-time script to register xbird as an ERC-8004 agent on-chain.
|
|
3
|
+
*
|
|
4
|
+
* Usage: PRIVATE_KEY=0x... XBIRD_BASE_URL=https://... bun run src/server/erc8004/register.ts
|
|
5
|
+
*
|
|
6
|
+
* This registers the agent's metadata URL in the Identity Registry,
|
|
7
|
+
* allowing other AI agents to discover xbird via the Reputation Registry.
|
|
8
|
+
*/
|
|
9
|
+
import { createWalletClient, createPublicClient, http, parseAbi } from "viem";
|
|
10
|
+
import { base } from "viem/chains";
|
|
11
|
+
import { privateKeyToAccount } from "viem/accounts";
|
|
12
|
+
|
|
13
|
+
const PRIVATE_KEY = process.env.PRIVATE_KEY as `0x${string}`;
|
|
14
|
+
const BASE_URL = process.env.XBIRD_BASE_URL;
|
|
15
|
+
|
|
16
|
+
if (!PRIVATE_KEY || !BASE_URL) {
|
|
17
|
+
console.error("Required env vars: PRIVATE_KEY, XBIRD_BASE_URL");
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ERC-8004 Identity Registry (same address on Base mainnet and Ethereum)
|
|
22
|
+
const IDENTITY_REGISTRY = "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432" as const;
|
|
23
|
+
|
|
24
|
+
const abi = parseAbi([
|
|
25
|
+
"function register(string calldata agentURI) external returns (uint256 agentId)",
|
|
26
|
+
"event AgentRegistered(uint256 indexed agentId, address indexed owner, string agentURI)",
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
const account = privateKeyToAccount(PRIVATE_KEY);
|
|
30
|
+
|
|
31
|
+
const walletClient = createWalletClient({
|
|
32
|
+
account,
|
|
33
|
+
chain: base,
|
|
34
|
+
transport: http(),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const publicClient = createPublicClient({
|
|
38
|
+
chain: base,
|
|
39
|
+
transport: http(),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const agentURI = `${BASE_URL}/.well-known/agent.json`;
|
|
43
|
+
console.log(`Registering agent at: ${agentURI}`);
|
|
44
|
+
console.log(`Registry: ${IDENTITY_REGISTRY}`);
|
|
45
|
+
console.log(`Chain: Base mainnet (${base.id})`);
|
|
46
|
+
console.log(`Account: ${account.address}`);
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const hash = await walletClient.writeContract({
|
|
50
|
+
address: IDENTITY_REGISTRY,
|
|
51
|
+
abi,
|
|
52
|
+
functionName: "register",
|
|
53
|
+
args: [agentURI],
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
console.log(`Transaction submitted: ${hash}`);
|
|
57
|
+
console.log("Waiting for confirmation...");
|
|
58
|
+
|
|
59
|
+
const receipt = await publicClient.waitForTransactionReceipt({ hash });
|
|
60
|
+
console.log(`Confirmed in block ${receipt.blockNumber}`);
|
|
61
|
+
|
|
62
|
+
// Extract agentId from AgentRegistered event logs
|
|
63
|
+
const registeredLog = receipt.logs.find(
|
|
64
|
+
(log) => log.address.toLowerCase() === IDENTITY_REGISTRY.toLowerCase()
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
if (registeredLog && registeredLog.topics[1]) {
|
|
68
|
+
const agentId = BigInt(registeredLog.topics[1]);
|
|
69
|
+
console.log(`Agent ID: ${agentId}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
console.log(`Transaction: https://basescan.org/tx/${hash}`);
|
|
73
|
+
console.log("Done! Your agent is now discoverable via ERC-8004.");
|
|
74
|
+
} catch (err) {
|
|
75
|
+
console.error("Registration failed:", err);
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { Context, Next } from "hono";
|
|
2
|
+
import { XClient } from "../../lib/client-full.ts";
|
|
3
|
+
import type { AccountsDB } from "../storage/accounts-db.ts";
|
|
4
|
+
|
|
5
|
+
/** Hono env type with injected XClient */
|
|
6
|
+
export interface XbirdEnv {
|
|
7
|
+
Variables: {
|
|
8
|
+
xClient: XClient;
|
|
9
|
+
accountName: string;
|
|
10
|
+
payerAddress: string;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* BYOA-first middleware — resolves an XClient from user's own credentials:
|
|
16
|
+
* 1. Per-request headers (X-Twitter-Auth-Token + X-Twitter-CT0)
|
|
17
|
+
* 2. Registered account (wallet → credentials from DB)
|
|
18
|
+
*
|
|
19
|
+
* No server pool fallback — users must bring their own Twitter account.
|
|
20
|
+
*/
|
|
21
|
+
export function accountPoolMiddleware(accountsDb: AccountsDB) {
|
|
22
|
+
return async (c: Context<XbirdEnv>, next: Next): Promise<Response> => {
|
|
23
|
+
// Skip for routes that don't need Twitter credentials
|
|
24
|
+
const path = new URL(c.req.url).pathname;
|
|
25
|
+
if (
|
|
26
|
+
path === "/api/accounts" ||
|
|
27
|
+
path === "/api/accounts/" ||
|
|
28
|
+
path.startsWith("/api/authorize/")
|
|
29
|
+
) {
|
|
30
|
+
await next();
|
|
31
|
+
return c.res;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 1. Per-request credentials via headers
|
|
35
|
+
const headerAuth = c.req.header("X-Twitter-Auth-Token");
|
|
36
|
+
const headerCt0 = c.req.header("X-Twitter-CT0");
|
|
37
|
+
|
|
38
|
+
// Reject partial BYOA headers — user clearly intended BYOA but missing one
|
|
39
|
+
if ((headerAuth && !headerCt0) || (!headerAuth && headerCt0)) {
|
|
40
|
+
return c.json(
|
|
41
|
+
{ error: "Both X-Twitter-Auth-Token and X-Twitter-CT0 headers are required" },
|
|
42
|
+
400,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (headerAuth && headerCt0) {
|
|
47
|
+
// Validate header credential length (same limit as registration)
|
|
48
|
+
if (headerAuth.length > 256 || headerCt0.length > 256) {
|
|
49
|
+
return c.json({ error: "Credential headers exceed max length (256)" }, 400);
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const client = new XClient({ authToken: headerAuth, ct0: headerCt0, source: "env" });
|
|
53
|
+
c.set("xClient", client);
|
|
54
|
+
c.set("accountName", "byoa-header");
|
|
55
|
+
await next();
|
|
56
|
+
return c.res;
|
|
57
|
+
} catch {
|
|
58
|
+
return c.json({ error: "Invalid Twitter credentials in headers" }, 400);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 2. Registered account lookup by payer wallet
|
|
63
|
+
const payerAddress = c.get("payerAddress");
|
|
64
|
+
if (payerAddress) {
|
|
65
|
+
const registered = accountsDb.lookup(payerAddress);
|
|
66
|
+
if (registered) {
|
|
67
|
+
try {
|
|
68
|
+
const client = new XClient({
|
|
69
|
+
authToken: registered.auth_token,
|
|
70
|
+
ct0: registered.ct0,
|
|
71
|
+
source: "env",
|
|
72
|
+
});
|
|
73
|
+
c.set("xClient", client);
|
|
74
|
+
c.set("accountName", "byoa-registered");
|
|
75
|
+
accountsDb.updateLastUsed(payerAddress);
|
|
76
|
+
await next();
|
|
77
|
+
return c.res;
|
|
78
|
+
} catch {
|
|
79
|
+
return c.json(
|
|
80
|
+
{
|
|
81
|
+
error: "Registered account credentials expired or invalid. Please re-register via POST /api/accounts.",
|
|
82
|
+
},
|
|
83
|
+
401,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// No account found — require BYOA
|
|
90
|
+
return c.json(
|
|
91
|
+
{
|
|
92
|
+
error: "No Twitter account linked to your wallet. Register your account first.",
|
|
93
|
+
help: {
|
|
94
|
+
register: "POST /api/accounts with { authToken, ct0 } to link your Twitter account",
|
|
95
|
+
adhoc: "Or pass X-Twitter-Auth-Token and X-Twitter-CT0 headers per-request",
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
403,
|
|
99
|
+
);
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Context, Next } from "hono";
|
|
2
|
+
|
|
3
|
+
/** Map known XClient error patterns to HTTP status codes */
|
|
4
|
+
export async function errorHandler(c: Context, next: Next): Promise<Response> {
|
|
5
|
+
try {
|
|
6
|
+
await next();
|
|
7
|
+
return c.res;
|
|
8
|
+
} catch (err) {
|
|
9
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
10
|
+
|
|
11
|
+
if (message.includes("Rate limit") || message.includes("429")) {
|
|
12
|
+
return c.json({ error: "Rate limited — try again later" }, 429);
|
|
13
|
+
}
|
|
14
|
+
if (message.includes("not found") || message.includes("404")) {
|
|
15
|
+
return c.json({ error: "Not found" }, 404);
|
|
16
|
+
}
|
|
17
|
+
if (message.includes("unauthorized") || message.includes("401") || message.includes("auth")) {
|
|
18
|
+
return c.json({ error: "Upstream auth error" }, 502);
|
|
19
|
+
}
|
|
20
|
+
if (message.includes("suspended") || message.includes("locked")) {
|
|
21
|
+
return c.json({ error: "Account suspended or locked" }, 503);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
console.error("[xbird] Unhandled error:", message);
|
|
25
|
+
return c.json({ error: "Internal server error" }, 500);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Context, Next } from "hono";
|
|
2
|
+
import { decodePaymentSignatureHeader } from "@x402/core/http";
|
|
3
|
+
import type { XbirdEnv } from "./account-pool.ts";
|
|
4
|
+
|
|
5
|
+
const EVM_ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
|
|
6
|
+
const MAX_HEADER_LENGTH = 8192;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Middleware that extracts the payer wallet address from the payment-signature
|
|
10
|
+
* header — the same header that x402 middleware cryptographically verifies.
|
|
11
|
+
*
|
|
12
|
+
* SECURITY: We read `payment-signature` (verified by x402), NOT `X-PAYMENT`
|
|
13
|
+
* (unverified, client-controlled). This prevents wallet impersonation.
|
|
14
|
+
*/
|
|
15
|
+
export function payerExtractMiddleware() {
|
|
16
|
+
return async (c: Context<XbirdEnv>, next: Next): Promise<void> => {
|
|
17
|
+
const header = c.req.header("payment-signature");
|
|
18
|
+
|
|
19
|
+
if (!header || header.length > MAX_HEADER_LENGTH) {
|
|
20
|
+
await next();
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const decoded = decodePaymentSignatureHeader(header);
|
|
26
|
+
const payload = decoded?.payload as Record<string, unknown> | undefined;
|
|
27
|
+
|
|
28
|
+
// EIP-3009: payload.authorization.from
|
|
29
|
+
// Permit2: payload.permit2Authorization.from
|
|
30
|
+
const auth = payload?.authorization as Record<string, unknown> | undefined;
|
|
31
|
+
const permit2 = payload?.permit2Authorization as Record<string, unknown> | undefined;
|
|
32
|
+
const from = (auth?.from ?? permit2?.from) as string | undefined;
|
|
33
|
+
|
|
34
|
+
if (from && EVM_ADDRESS_RE.test(from)) {
|
|
35
|
+
c.set("payerAddress", from.toLowerCase());
|
|
36
|
+
}
|
|
37
|
+
} catch {
|
|
38
|
+
// Malformed header — skip extraction, don't block the request
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
await next();
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { paymentMiddlewareFromConfig } from "@x402/hono";
|
|
2
|
+
import { HTTPFacilitatorClient } from "@x402/core/server";
|
|
3
|
+
import { ExactEvmScheme } from "@x402/evm/exact/server";
|
|
4
|
+
import { bazaarResourceServerExtension } from "@x402/extensions/bazaar";
|
|
5
|
+
import type { MiddlewareHandler } from "hono";
|
|
6
|
+
import { buildRoutesConfig, NETWORK, FACILITATOR_URL } from "../config/pricing.ts";
|
|
7
|
+
|
|
8
|
+
function createFacilitatorClient(): HTTPFacilitatorClient {
|
|
9
|
+
const cdpKeyId = process.env.CDP_API_KEY_ID;
|
|
10
|
+
const cdpKeySecret = process.env.CDP_API_KEY_SECRET;
|
|
11
|
+
|
|
12
|
+
if (cdpKeyId && cdpKeySecret) {
|
|
13
|
+
return new HTTPFacilitatorClient({
|
|
14
|
+
url: FACILITATOR_URL,
|
|
15
|
+
createAuthHeaders: async () => {
|
|
16
|
+
const { generateJwt } = await import("@coinbase/cdp-sdk/auth");
|
|
17
|
+
const facilitatorHost = new URL(FACILITATOR_URL).host;
|
|
18
|
+
|
|
19
|
+
const mkJwt = (method: string, path: string) =>
|
|
20
|
+
generateJwt({
|
|
21
|
+
apiKeyId: cdpKeyId,
|
|
22
|
+
apiKeySecret: cdpKeySecret,
|
|
23
|
+
requestMethod: method,
|
|
24
|
+
requestHost: facilitatorHost,
|
|
25
|
+
requestPath: path,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const basePath = new URL(FACILITATOR_URL).pathname;
|
|
29
|
+
const [verifyToken, settleToken, supportedToken] = await Promise.all([
|
|
30
|
+
mkJwt("POST", `${basePath}/verify`),
|
|
31
|
+
mkJwt("POST", `${basePath}/settle`),
|
|
32
|
+
mkJwt("GET", `${basePath}/supported`),
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
verify: { Authorization: `Bearer ${verifyToken}` },
|
|
37
|
+
settle: { Authorization: `Bearer ${settleToken}` },
|
|
38
|
+
supported: { Authorization: `Bearer ${supportedToken}` },
|
|
39
|
+
};
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Testnet facilitator — no auth needed
|
|
45
|
+
return new HTTPFacilitatorClient({ url: FACILITATOR_URL });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Create x402 payment middleware configured for our routes */
|
|
49
|
+
export function createPaymentMiddleware(payTo: string): MiddlewareHandler {
|
|
50
|
+
const routes = buildRoutesConfig(payTo);
|
|
51
|
+
const facilitator = createFacilitatorClient();
|
|
52
|
+
|
|
53
|
+
return paymentMiddlewareFromConfig(
|
|
54
|
+
routes,
|
|
55
|
+
facilitator,
|
|
56
|
+
[{ network: NETWORK, server: new ExactEvmScheme() }],
|
|
57
|
+
{ extensions: [bazaarResourceServerExtension] },
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export { FACILITATOR_URL, NETWORK };
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { XbirdEnv } from "../middleware/account-pool.ts";
|
|
3
|
+
import { XClient } from "../../lib/client-full.ts";
|
|
4
|
+
import type { AccountsDB } from "../storage/accounts-db.ts";
|
|
5
|
+
|
|
6
|
+
/** Max length for auth token / ct0 values */
|
|
7
|
+
const MAX_CREDENTIAL_LENGTH = 256;
|
|
8
|
+
|
|
9
|
+
function isValidCredential(value: unknown): value is string {
|
|
10
|
+
return typeof value === "string" && value.length > 0 && value.length <= MAX_CREDENTIAL_LENGTH;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createAccountsRoutes(accountsDb: AccountsDB): Hono<XbirdEnv> {
|
|
14
|
+
const accounts = new Hono<XbirdEnv>();
|
|
15
|
+
|
|
16
|
+
/** POST /api/accounts — register Twitter credentials for the payer wallet */
|
|
17
|
+
accounts.post("/", async (c) => {
|
|
18
|
+
const payerAddress = c.get("payerAddress");
|
|
19
|
+
if (!payerAddress) {
|
|
20
|
+
return c.json({ error: "Could not determine payer wallet from payment" }, 400);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let body: Record<string, unknown>;
|
|
24
|
+
try {
|
|
25
|
+
body = await c.req.json();
|
|
26
|
+
} catch {
|
|
27
|
+
return c.json({ error: "Invalid JSON body" }, 400);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const { authToken, ct0 } = body;
|
|
31
|
+
|
|
32
|
+
if (!isValidCredential(authToken) || !isValidCredential(ct0)) {
|
|
33
|
+
return c.json(
|
|
34
|
+
{ error: `authToken and ct0 are required (string, max ${MAX_CREDENTIAL_LENGTH} chars)` },
|
|
35
|
+
400,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Try to validate credentials (may fail from datacenter IPs — not a blocker)
|
|
40
|
+
const testClient = new XClient({ authToken, ct0, source: "env" });
|
|
41
|
+
let username: string | undefined;
|
|
42
|
+
try {
|
|
43
|
+
const result = await testClient.getCurrentUser();
|
|
44
|
+
if (result.success) {
|
|
45
|
+
username = result.user?.screenName;
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
// Validation failed (likely IP-blocked by Twitter) — store anyway
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
accountsDb.register(payerAddress, authToken, ct0, username);
|
|
52
|
+
|
|
53
|
+
return c.json({
|
|
54
|
+
registered: true,
|
|
55
|
+
username: username ?? null,
|
|
56
|
+
}, 201);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/** GET /api/accounts — check registration status for the payer wallet */
|
|
60
|
+
accounts.get("/", async (c) => {
|
|
61
|
+
const payerAddress = c.get("payerAddress");
|
|
62
|
+
if (!payerAddress) {
|
|
63
|
+
return c.json({ error: "Could not determine payer wallet from payment" }, 400);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const record = accountsDb.lookup(payerAddress);
|
|
67
|
+
if (!record) {
|
|
68
|
+
return c.json({ registered: false });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return c.json({
|
|
72
|
+
registered: true,
|
|
73
|
+
username: record.twitter_username,
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/** DELETE /api/accounts — remove registration for the payer wallet */
|
|
78
|
+
accounts.delete("/", async (c) => {
|
|
79
|
+
const payerAddress = c.get("payerAddress");
|
|
80
|
+
if (!payerAddress) {
|
|
81
|
+
return c.json({ error: "Could not determine payer wallet from payment" }, 400);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const removed = accountsDb.remove(payerAddress);
|
|
85
|
+
if (!removed) {
|
|
86
|
+
return c.json({ error: "No registered account found" }, 404);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return c.json({ removed: true });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return accounts;
|
|
93
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { PRICES, type PriceTier } from "../config/pricing.ts";
|
|
3
|
+
|
|
4
|
+
type AuthorizeEnv = {};
|
|
5
|
+
|
|
6
|
+
const authorize = new Hono<AuthorizeEnv>();
|
|
7
|
+
|
|
8
|
+
/** POST /api/authorize/:tier — verify x402 payment, return authorization */
|
|
9
|
+
for (const tier of Object.keys(PRICES) as PriceTier[]) {
|
|
10
|
+
authorize.post(`/${tier}`, (c) => {
|
|
11
|
+
return c.json({
|
|
12
|
+
authorized: true,
|
|
13
|
+
tier,
|
|
14
|
+
price: PRICES[tier],
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export { authorize };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { XbirdEnv } from "../middleware/account-pool.ts";
|
|
3
|
+
|
|
4
|
+
const bookmarks = new Hono<XbirdEnv>();
|
|
5
|
+
|
|
6
|
+
/** GET /api/bookmarks?count=...&cursor=...&folderId=... */
|
|
7
|
+
bookmarks.get("/", async (c) => {
|
|
8
|
+
const client = c.get("xClient");
|
|
9
|
+
const count = Number(c.req.query("count") ?? 20);
|
|
10
|
+
const cursor = c.req.query("cursor");
|
|
11
|
+
const folderId = c.req.query("folderId");
|
|
12
|
+
|
|
13
|
+
const result = await client.getBookmarks(count, cursor, folderId);
|
|
14
|
+
if (!result.success) {
|
|
15
|
+
return c.json({ error: result.error ?? "Failed to fetch bookmarks" }, 400);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return c.json({ data: result.tweets, cursor: result.cursor });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export { bookmarks };
|