@agentxv2/gateway 0.1.1

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.
Files changed (175) hide show
  1. package/.env.example +53 -0
  2. package/db/migrations/001_init.sql +117 -0
  3. package/db/migrations/002_agents.sql +20 -0
  4. package/db/migrations/003_a2a_results.sql +23 -0
  5. package/deploy/.env.deploy.example +23 -0
  6. package/deploy/add_swap_build.py +45 -0
  7. package/deploy/build_and_deploy.py +110 -0
  8. package/deploy/build_deploy_a2a.py +190 -0
  9. package/deploy/build_no_lint.py +52 -0
  10. package/deploy/build_on_fe.py +86 -0
  11. package/deploy/build_on_gw.py +98 -0
  12. package/deploy/build_prod.py +54 -0
  13. package/deploy/check_bal.py +31 -0
  14. package/deploy/check_code.py +47 -0
  15. package/deploy/check_deploy.sh +20 -0
  16. package/deploy/check_docs.py +16 -0
  17. package/deploy/check_frontend.py +42 -0
  18. package/deploy/check_frontend2.py +35 -0
  19. package/deploy/check_pks.py +34 -0
  20. package/deploy/check_status.py +32 -0
  21. package/deploy/check_v2.py +8 -0
  22. package/deploy/clean_install.py +28 -0
  23. package/deploy/deploy_a2a.py +58 -0
  24. package/deploy/deploy_a2a.sh +33 -0
  25. package/deploy/deploy_a2a_bg.sh +40 -0
  26. package/deploy/deploy_a2a_v2.py +151 -0
  27. package/deploy/deploy_a2a_v2.sh +36 -0
  28. package/deploy/deploy_bin.py +64 -0
  29. package/deploy/deploy_config.py +34 -0
  30. package/deploy/deploy_final.py +42 -0
  31. package/deploy/deploy_frontend.py +108 -0
  32. package/deploy/deploy_mcp.py +108 -0
  33. package/deploy/deploy_now.py +49 -0
  34. package/deploy/deploy_push_all.py +128 -0
  35. package/deploy/deploy_script.py +64 -0
  36. package/deploy/deploy_sep_a2a.sh +18 -0
  37. package/deploy/dev_mode.py +51 -0
  38. package/deploy/diag.py +12 -0
  39. package/deploy/diff_test.py +22 -0
  40. package/deploy/final_check.py +33 -0
  41. package/deploy/final_deploy.py +50 -0
  42. package/deploy/final_fix.py +54 -0
  43. package/deploy/final_push.py +114 -0
  44. package/deploy/find_frontend.py +38 -0
  45. package/deploy/fix_a2a.py +43 -0
  46. package/deploy/fix_and_build.py +77 -0
  47. package/deploy/fix_and_verify.py +56 -0
  48. package/deploy/fix_build.py +51 -0
  49. package/deploy/forge_deploy.py +67 -0
  50. package/deploy/full_push.py +101 -0
  51. package/deploy/full_push2.py +106 -0
  52. package/deploy/go_prod.py +59 -0
  53. package/deploy/gw_deploy.py +133 -0
  54. package/deploy/gw_final.py +206 -0
  55. package/deploy/gw_restart.py +28 -0
  56. package/deploy/gw_status.py +18 -0
  57. package/deploy/inline_build.py +99 -0
  58. package/deploy/one_shot.py +84 -0
  59. package/deploy/ox_deploy.py +32 -0
  60. package/deploy/pg_setup.py +36 -0
  61. package/deploy/prod_deploy.py +126 -0
  62. package/deploy/prod_final.py +140 -0
  63. package/deploy/prod_go.py +61 -0
  64. package/deploy/prod_one_shot.py +139 -0
  65. package/deploy/prod_setup.sh +62 -0
  66. package/deploy/prod_v2.py +91 -0
  67. package/deploy/prod_v3.py +103 -0
  68. package/deploy/push_docs.py +93 -0
  69. package/deploy/push_mcp.py +66 -0
  70. package/deploy/push_mcp_doc.py +28 -0
  71. package/deploy/push_v063.py +80 -0
  72. package/deploy/push_v2.py +71 -0
  73. package/deploy/qcheck.py +13 -0
  74. package/deploy/qcheck2.py +18 -0
  75. package/deploy/quick_check.py +8 -0
  76. package/deploy/rebuild.py +47 -0
  77. package/deploy/rebuild_frontend.py +44 -0
  78. package/deploy/restart.py +28 -0
  79. package/deploy/restore_frontend.py +42 -0
  80. package/deploy/restore_frontend2.py +41 -0
  81. package/deploy/restore_gateway.py +34 -0
  82. package/deploy/restore_quick.py +33 -0
  83. package/deploy/run_bash.py +19 -0
  84. package/deploy/run_sep.py +20 -0
  85. package/deploy/sdk_upgrade.py +50 -0
  86. package/deploy/server_prepare.py +41 -0
  87. package/deploy/server_setup.py +41 -0
  88. package/deploy/ssh_check.py +14 -0
  89. package/deploy/ssh_push.py +24 -0
  90. package/deploy/start_deploy.py +35 -0
  91. package/deploy/start_frontend.py +12 -0
  92. package/deploy/start_v2.py +29 -0
  93. package/deploy/stat.py +18 -0
  94. package/deploy/tail_log.py +11 -0
  95. package/deploy/update_env.py +32 -0
  96. package/deploy/upload_and_start.py +107 -0
  97. package/deploy/verify_a2a.py +13 -0
  98. package/deploy/verify_mcp.py +37 -0
  99. package/deploy/verify_sdk.py +21 -0
  100. package/dist/config.d.ts +26 -0
  101. package/dist/config.js +41 -0
  102. package/dist/config.js.map +1 -0
  103. package/dist/index.d.ts +1 -0
  104. package/dist/index.js +149 -0
  105. package/dist/index.js.map +1 -0
  106. package/dist/lib/crypto.d.ts +2 -0
  107. package/dist/lib/crypto.js +43 -0
  108. package/dist/lib/crypto.js.map +1 -0
  109. package/dist/lib/db.d.ts +3 -0
  110. package/dist/lib/db.js +26 -0
  111. package/dist/lib/db.js.map +1 -0
  112. package/dist/lib/ethers-utils.d.ts +60 -0
  113. package/dist/lib/ethers-utils.js +186 -0
  114. package/dist/lib/ethers-utils.js.map +1 -0
  115. package/dist/middleware/adminAuth.d.ts +2 -0
  116. package/dist/middleware/adminAuth.js +31 -0
  117. package/dist/middleware/adminAuth.js.map +1 -0
  118. package/dist/middleware/auth.d.ts +22 -0
  119. package/dist/middleware/auth.js +192 -0
  120. package/dist/middleware/auth.js.map +1 -0
  121. package/dist/middleware/error-handler.d.ts +36 -0
  122. package/dist/middleware/error-handler.js +100 -0
  123. package/dist/middleware/error-handler.js.map +1 -0
  124. package/dist/middleware/rate-limiter.d.ts +5 -0
  125. package/dist/middleware/rate-limiter.js +107 -0
  126. package/dist/middleware/rate-limiter.js.map +1 -0
  127. package/dist/routes/a2a.d.ts +2 -0
  128. package/dist/routes/a2a.js +84 -0
  129. package/dist/routes/a2a.js.map +1 -0
  130. package/dist/routes/admin.d.ts +2 -0
  131. package/dist/routes/admin.js +180 -0
  132. package/dist/routes/admin.js.map +1 -0
  133. package/dist/routes/agents.d.ts +2 -0
  134. package/dist/routes/agents.js +43 -0
  135. package/dist/routes/agents.js.map +1 -0
  136. package/dist/routes/chat.d.ts +2 -0
  137. package/dist/routes/chat.js +183 -0
  138. package/dist/routes/chat.js.map +1 -0
  139. package/dist/routes/history.d.ts +2 -0
  140. package/dist/routes/history.js +92 -0
  141. package/dist/routes/history.js.map +1 -0
  142. package/dist/routes/mcp.d.ts +13 -0
  143. package/dist/routes/mcp.js +513 -0
  144. package/dist/routes/mcp.js.map +1 -0
  145. package/dist/routes/tenant.d.ts +2 -0
  146. package/dist/routes/tenant.js +154 -0
  147. package/dist/routes/tenant.js.map +1 -0
  148. package/dist/services/a2a-worker.d.ts +7 -0
  149. package/dist/services/a2a-worker.js +406 -0
  150. package/dist/services/a2a-worker.js.map +1 -0
  151. package/dist/services/agent-indexer.d.ts +4 -0
  152. package/dist/services/agent-indexer.js +124 -0
  153. package/dist/services/agent-indexer.js.map +1 -0
  154. package/e2e_wallet.js +175 -0
  155. package/ecosystem.config.js +17 -0
  156. package/package.json +33 -0
  157. package/src/config.ts +47 -0
  158. package/src/index.ts +143 -0
  159. package/src/lib/crypto.ts +44 -0
  160. package/src/lib/db.ts +25 -0
  161. package/src/lib/ethers-utils.ts +229 -0
  162. package/src/middleware/adminAuth.ts +34 -0
  163. package/src/middleware/auth.ts +244 -0
  164. package/src/middleware/error-handler.ts +109 -0
  165. package/src/middleware/rate-limiter.ts +115 -0
  166. package/src/routes/a2a.ts +97 -0
  167. package/src/routes/admin.ts +219 -0
  168. package/src/routes/agents.ts +52 -0
  169. package/src/routes/chat.ts +219 -0
  170. package/src/routes/history.ts +114 -0
  171. package/src/routes/mcp.ts +567 -0
  172. package/src/routes/tenant.ts +196 -0
  173. package/src/services/a2a-worker.ts +486 -0
  174. package/src/services/agent-indexer.ts +127 -0
  175. package/tsconfig.json +18 -0
@@ -0,0 +1,229 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Gateway — ethers.js v6 Utilities
3
+ // ---------------------------------------------------------------------------
4
+ // Workarounds for known ethers.js v6 bugs when interacting with Solidity
5
+ // contracts that use complex tuple types (structs with mixed static/dynamic
6
+ // fields, nested tuple arrays, etc.).
7
+ //
8
+ // Issues tracked:
9
+ // P4 — ethers.js v6 cannot encode `(string,bytes)[]` tuple arrays
10
+ // via Interface.encodeFunctionData when values contain Uint8Array
11
+ // or hex-encoded strings.
12
+ // Workaround: manual AbiCoder.encode()
13
+ // P6 — estimateGas reverts on Geth for functions returning complex structs
14
+ // (getPlan, completeTask). Workaround: skip estimateGas, use fixed
15
+ // gasLimit.
16
+ // ---------------------------------------------------------------------------
17
+
18
+ import { ethers } from 'ethers'
19
+
20
+ const coder = ethers.AbiCoder.defaultAbiCoder()
21
+
22
+ /**
23
+ * Encode registerWithMetadata(string, (string,bytes)[]) call data.
24
+ *
25
+ * ethers.js v6 Interface.encodeFunctionData fails on the inner tuple array
26
+ * when values are Uint8Array or hex strings (misidentified as string type).
27
+ * This bypasses Interface entirely and uses raw AbiCoder.
28
+ *
29
+ * Usage:
30
+ * const data = encodeRegisterWithMetadata(tokenURI, [
31
+ * ['name', 'SecurityAuditor'],
32
+ * ['description', 'AI auditor'],
33
+ * ])
34
+ * await wallet.sendTransaction({ to: identityRegistry, data, value: fee })
35
+ */
36
+ export function encodeRegisterWithMetadata(
37
+ tokenURI: string,
38
+ meta: [string, string][]
39
+ ): string {
40
+ // keccak256("registerWithMetadata(string,(string,bytes)[])") = 0x5ac4d1c3
41
+ const encoded = coder.encode(
42
+ ['string', 'tuple(string,bytes)[]'],
43
+ [
44
+ tokenURI,
45
+ meta.map(([k, v]) => [k, '0x' + Buffer.from(v).toString('hex')]),
46
+ ]
47
+ )
48
+ return '0x5ac4d1c3' + encoded.slice(2)
49
+ }
50
+
51
+ /**
52
+ * Encode hasActiveSubscription(address,uint256) call data.
53
+ *
54
+ * Works around ethers.js v6 address validation edge case where string
55
+ * addresses passed through JSON parsing may be rejected.
56
+ * Uses ethers.getAddress() for strict checksum validation.
57
+ */
58
+ export function encodeHasActiveSubscription(
59
+ subscriber: string,
60
+ agentId: number
61
+ ): string {
62
+ // keccak256("hasActiveSubscription(address,uint256)") = 0x9b2f1234
63
+ const addr = ethers.getAddress(subscriber)
64
+ const encoded = coder.encode(['address', 'uint256'], [addr, agentId])
65
+ return '0x9b2f1234' + encoded.slice(2)
66
+ }
67
+
68
+ /**
69
+ * Safely call getPlan(uint256) and decode the SubscriptionPlan struct.
70
+ *
71
+ * Geth RPC on OxaChain L1 hangs indefinitely on struct-returning eth_call
72
+ * (known issue P1). This wrapper has a 5-second timeout to prevent the
73
+ * event loop from blocking.
74
+ *
75
+ * Returns null if the call fails, times out, or returns empty data.
76
+ */
77
+ export async function safeGetPlan(
78
+ provider: ethers.Provider,
79
+ contractAddress: string,
80
+ planId: number,
81
+ timeoutMs = 5000
82
+ ): Promise<Record<string, unknown> | null> {
83
+ const selector = '0x26cd5274' // keccak256("getPlan(uint256)")
84
+ const encPid = coder.encode(['uint256'], [planId]).slice(2)
85
+ try {
86
+ const raw = await Promise.race([
87
+ provider.call({ to: contractAddress, data: selector + encPid }),
88
+ new Promise<string>((_, reject) =>
89
+ setTimeout(() => reject(new Error('getPlan timeout')), timeoutMs)
90
+ ),
91
+ ])
92
+ if (!raw || raw === '0x') return null
93
+ const decoded = coder.decode(
94
+ ['uint256', 'uint256', 'address', 'uint256', 'string', 'bool', 'address', 'uint256'],
95
+ raw
96
+ )
97
+ return {
98
+ planId: Number(decoded[0]),
99
+ agentId: Number(decoded[1]),
100
+ creator: decoded[2],
101
+ price: Number(decoded[3]),
102
+ period: decoded[4],
103
+ active: decoded[5],
104
+ payToken: decoded[6],
105
+ trialDays: Number(decoded[7]),
106
+ }
107
+ } catch {
108
+ return null
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Encode giveFeedback(uint256,uint8,bytes32,bytes32,string,bytes32,bytes)
114
+ *
115
+ * This uses named parameters in the ABI to ensure proper type matching.
116
+ * The bytes feedbackAuth parameter is the EIP-191 signed authorization.
117
+ */
118
+ export function encodeGiveFeedback(
119
+ agentId: number,
120
+ score: number,
121
+ tag1: string,
122
+ tag2: string,
123
+ fileuri: string,
124
+ filehash: string,
125
+ feedbackAuth: string
126
+ ): string {
127
+ // keccak256("giveFeedback(uint256,uint8,bytes32,bytes32,string,bytes32,bytes)")
128
+ const encoded = coder.encode(
129
+ ['uint256', 'uint8', 'bytes32', 'bytes32', 'string', 'bytes32', 'bytes'],
130
+ [agentId, score, tag1, tag2, fileuri, filehash, feedbackAuth]
131
+ )
132
+ return '0x155e5bbd' + encoded.slice(2)
133
+ }
134
+
135
+ /**
136
+ * Safely call getSubscriptionDetail(uint256) via raw eth_call.
137
+ *
138
+ * ethers.js v6 fails to decode the 12-field struct (mixed static/dynamic types
139
+ * with string fields). This bypasses ethers.js entirely and uses raw eth_call
140
+ * + manual AbiCoder.decode, same pattern as safeGetPlan.
141
+ *
142
+ * Returns null on failure.
143
+ */
144
+ export async function safeGetSubscriptionDetail(
145
+ provider: ethers.Provider,
146
+ contractAddress: string,
147
+ subscriptionId: number,
148
+ timeoutMs = 5000
149
+ ): Promise<Record<string, unknown> | null> {
150
+ // keccak256("getSubscriptionDetail(uint256)") = 0xae823f57
151
+ const selector = '0xae823f57'
152
+ const encId = coder.encode(['uint256'], [subscriptionId]).slice(2)
153
+ try {
154
+ const raw = await Promise.race([
155
+ provider.call({ to: contractAddress, data: selector + encId }),
156
+ new Promise<string>((_, reject) =>
157
+ setTimeout(() => reject(new Error('getSubscriptionDetail timeout')), timeoutMs)
158
+ ),
159
+ ])
160
+ if (!raw || raw === '0x') return null
161
+ // Struct: (uint256,address,uint256,uint8,uint256,uint256,string,address,uint256,bool,uint256,bool)
162
+ const decoded = coder.decode(
163
+ ['uint256', 'address', 'uint256', 'uint8', 'uint256', 'uint256', 'string', 'address', 'uint256', 'bool', 'uint256', 'bool'],
164
+ raw
165
+ )
166
+ return {
167
+ subscriptionId: Number(decoded[0]),
168
+ subscriber: decoded[1],
169
+ planId: Number(decoded[2]),
170
+ status: decoded[3],
171
+ startedAt: Number(decoded[4]),
172
+ expiresAt: Number(decoded[5]),
173
+ period: decoded[6],
174
+ payToken: decoded[7],
175
+ amountPaid: Number(decoded[8]),
176
+ trialActive: decoded[9],
177
+ trialEndsAt: Number(decoded[10]),
178
+ fundsReleased: decoded[11],
179
+ }
180
+ } catch {
181
+ return null
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Safely call getTask(uint256) via raw eth_call.
187
+ *
188
+ * ethers.js v6 fails to decode the 9-field struct (3 string fields cause
189
+ * issues with the ABI decoder). This bypasses ethers.js entirely.
190
+ *
191
+ * Returns null on failure.
192
+ */
193
+ export async function safeGetTask(
194
+ provider: ethers.Provider,
195
+ contractAddress: string,
196
+ taskId: number,
197
+ timeoutMs = 5000
198
+ ): Promise<Record<string, unknown> | null> {
199
+ // keccak256("getTask(uint256)") = 0x1d65e77e (verify with ethers.id)
200
+ const selector = '0x1d65e77e'
201
+ const encId = coder.encode(['uint256'], [taskId]).slice(2)
202
+ try {
203
+ const raw = await Promise.race([
204
+ provider.call({ to: contractAddress, data: selector + encId }),
205
+ new Promise<string>((_, reject) =>
206
+ setTimeout(() => reject(new Error('getTask timeout')), timeoutMs)
207
+ ),
208
+ ])
209
+ if (!raw || raw === '0x') return null
210
+ // Struct: (uint256,uint256,string,string,string,uint256,address,uint256,uint256)
211
+ const decoded = coder.decode(
212
+ ['uint256', 'uint256', 'string', 'string', 'string', 'uint256', 'address', 'uint256', 'uint256'],
213
+ raw
214
+ )
215
+ return {
216
+ taskId: Number(decoded[0]),
217
+ agentId: Number(decoded[1]),
218
+ taskType: decoded[2],
219
+ inputData: decoded[3],
220
+ outputData: decoded[4],
221
+ status: decoded[5],
222
+ clientAddress: decoded[6],
223
+ createdAt: Number(decoded[7]),
224
+ completedAt: Number(decoded[8]),
225
+ }
226
+ } catch {
227
+ return null
228
+ }
229
+ }
@@ -0,0 +1,34 @@
1
+ // ---------------------------------------------------------------------------
2
+ // AgentX Gateway — Admin Auth Middleware
3
+ // ---------------------------------------------------------------------------
4
+ // Simple shared-secret auth for admin routes.
5
+ // Pass via Authorization: Bearer <ADMIN_KEY> or X-Admin-Key header.
6
+ // ---------------------------------------------------------------------------
7
+
8
+ import { Request, Response, NextFunction } from 'express'
9
+
10
+ export function adminAuth(req: Request, res: Response, next: NextFunction): void {
11
+ const adminKey = process.env.ADMIN_KEY
12
+
13
+ if (!adminKey) {
14
+ res.status(500).json({ error: 'Admin key not configured on server' })
15
+ return
16
+ }
17
+
18
+ const authHeader = req.headers.authorization
19
+ const xAdminKey = req.headers['x-admin-key'] as string | undefined
20
+
21
+ let token: string | undefined
22
+ if (authHeader && authHeader.startsWith('Bearer ')) {
23
+ token = authHeader.slice(7)
24
+ } else if (xAdminKey) {
25
+ token = xAdminKey
26
+ }
27
+
28
+ if (!token || token !== adminKey) {
29
+ res.status(401).json({ error: 'Unauthorized: invalid admin key' })
30
+ return
31
+ }
32
+
33
+ next()
34
+ }
@@ -0,0 +1,244 @@
1
+ // ---------------------------------------------------------------------------
2
+ // AgentX Gateway — Auth Middleware
3
+ // ---------------------------------------------------------------------------
4
+ // EIP-191 wallet signature auth + JWT + auto tenant creation.
5
+ // ---------------------------------------------------------------------------
6
+
7
+ import crypto from 'crypto'
8
+ import { Request, Response, NextFunction } from 'express'
9
+ import { ethers } from 'ethers'
10
+ import jwt from 'jsonwebtoken'
11
+ import { config } from '../config'
12
+ import { getPool } from '../lib/db'
13
+ import { getRedis } from './rate-limiter'
14
+
15
+ export interface TenantContext {
16
+ id: string
17
+ walletAddress: string
18
+ planId: string
19
+ planSlug: string
20
+ quotaDaily: number
21
+ quotaUsed: number
22
+ rateLimitRpm: number
23
+ maxConcurrent: number
24
+ status: string
25
+ }
26
+
27
+ declare global {
28
+ namespace Express {
29
+ interface Request {
30
+ tenant?: TenantContext
31
+ }
32
+ }
33
+ }
34
+
35
+ interface Challenge {
36
+ address: string
37
+ timestamp: number
38
+ nonce: string
39
+ }
40
+
41
+ // Challenge TTL in seconds (matches the 5-minute window)
42
+ const CHALLENGE_TTL_SEC = 5 * 60
43
+
44
+ /**
45
+ * Store a challenge in Redis so it works across PM2 cluster workers.
46
+ * Falls back to an in-memory Map when Redis is unavailable (single-process dev).
47
+ */
48
+ const fallbackMap = new Map<string, Challenge>()
49
+
50
+ function challengeKey(address: string): string {
51
+ return `auth:challenge:${address}`
52
+ }
53
+
54
+ async function saveChallenge(address: string, challenge: Challenge): Promise<void> {
55
+ const r = getRedis()
56
+ if (r) {
57
+ await r.setex(challengeKey(address), CHALLENGE_TTL_SEC, JSON.stringify(challenge))
58
+ } else {
59
+ fallbackMap.set(address, challenge)
60
+ }
61
+ }
62
+
63
+ async function loadChallenge(address: string): Promise<Challenge | undefined> {
64
+ const r = getRedis()
65
+ if (r) {
66
+ const raw = await r.get(challengeKey(address))
67
+ if (raw) return JSON.parse(raw) as Challenge
68
+ return undefined
69
+ }
70
+ return fallbackMap.get(address)
71
+ }
72
+
73
+ async function deleteChallenge(address: string): Promise<void> {
74
+ const r = getRedis()
75
+ if (r) {
76
+ await r.del(challengeKey(address))
77
+ } else {
78
+ fallbackMap.delete(address)
79
+ }
80
+ }
81
+
82
+ export async function getChallenge(req: Request, res: Response): Promise<void> {
83
+ const address = (req.query.address as string || '').toLowerCase()
84
+ if (!address) {
85
+ res.status(400).json({ error: 'Missing wallet address' })
86
+ return
87
+ }
88
+
89
+ const timestamp = Math.floor(Date.now() / 1000)
90
+ const nonce = crypto.randomBytes(16).toString('hex')
91
+ const message = `agentx:auth:${timestamp}:${nonce}`
92
+
93
+ await saveChallenge(address, { address, timestamp, nonce })
94
+
95
+ res.json({ challenge: message, timestamp, nonce })
96
+ }
97
+
98
+ export async function verifyChallenge(req: Request, res: Response): Promise<void> {
99
+ const { wallet_address, signature, timestamp, nonce } = req.body
100
+
101
+ if (!wallet_address || !signature) {
102
+ res.status(400).json({ error: 'Missing wallet_address or signature' })
103
+ return
104
+ }
105
+
106
+ const address = wallet_address.toLowerCase()
107
+ const challenge = await loadChallenge(address)
108
+
109
+ if (!challenge || challenge.nonce !== nonce) {
110
+ res.status(401).json({ error: 'Challenge expired or not found. Please request a new challenge.' })
111
+ return
112
+ }
113
+
114
+ const expectedMessage = `agentx:auth:${timestamp}:${nonce}`
115
+ let recovered: string
116
+ try {
117
+ recovered = ethers.verifyMessage(expectedMessage, signature).toLowerCase()
118
+ } catch {
119
+ res.status(401).json({ error: 'Invalid signature' })
120
+ return
121
+ }
122
+
123
+ if (recovered !== address) {
124
+ res.status(401).json({ error: 'Signature does not match wallet address' })
125
+ return
126
+ }
127
+
128
+ await deleteChallenge(address)
129
+
130
+ const pool = getPool()
131
+ let tenant: TenantContext | null = null
132
+
133
+ const existing = await pool.query(
134
+ `SELECT t.id, t.wallet_address, t.status,
135
+ t.quota_daily, t.quota_used, t.rate_limit_rpm, t.max_concurrent,
136
+ p.id as plan_id, p.slug as plan_slug
137
+ FROM tenants t
138
+ LEFT JOIN plans p ON t.plan_id = p.id
139
+ WHERE LOWER(t.wallet_address) = $1`,
140
+ [address]
141
+ )
142
+
143
+ if (existing.rows.length > 0) {
144
+ const row = existing.rows[0]
145
+ if (row.status === 'suspended') {
146
+ res.status(403).json({ error: 'Account suspended' })
147
+ return
148
+ }
149
+ tenant = {
150
+ id: row.id,
151
+ walletAddress: row.wallet_address,
152
+ planId: row.plan_id || '',
153
+ planSlug: row.plan_slug || 'free',
154
+ quotaDaily: row.quota_daily,
155
+ quotaUsed: row.quota_used,
156
+ rateLimitRpm: row.rate_limit_rpm,
157
+ maxConcurrent: row.max_concurrent,
158
+ status: row.status,
159
+ }
160
+ } else {
161
+ const freePlan = await pool.query(`SELECT id FROM plans WHERE slug = 'free' LIMIT 1`)
162
+ const planId = freePlan.rows[0]?.id || null
163
+
164
+ const inserted = await pool.query(
165
+ `INSERT INTO tenants (wallet_address, plan_id, quota_daily, rate_limit_rpm, max_concurrent)
166
+ VALUES ($1, $2, $3, $4, $5)
167
+ RETURNING id`,
168
+ [address, planId, 0, 5, 1]
169
+ )
170
+ tenant = {
171
+ id: inserted.rows[0].id,
172
+ walletAddress: address,
173
+ planId: planId || '',
174
+ planSlug: 'free',
175
+ quotaDaily: 0,
176
+ quotaUsed: 0,
177
+ rateLimitRpm: 5,
178
+ maxConcurrent: 1,
179
+ status: 'active',
180
+ }
181
+ }
182
+
183
+ const token = jwt.sign(
184
+ { tenantId: tenant.id, walletAddress: tenant.walletAddress },
185
+ config.jwtSecret,
186
+ { expiresIn: config.sessionTtlSec }
187
+ )
188
+
189
+ res.json({ access_token: token, expires_in: config.sessionTtlSec, tenant })
190
+ }
191
+
192
+ export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
193
+ const authHeader = req.headers.authorization
194
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
195
+ res.status(401).json({ error: 'Missing or invalid Authorization header' })
196
+ return
197
+ }
198
+
199
+ const token = authHeader.slice(7)
200
+ let decoded: { tenantId: string; walletAddress: string }
201
+ try {
202
+ decoded = jwt.verify(token, config.jwtSecret) as typeof decoded
203
+ } catch {
204
+ res.status(401).json({ error: 'Invalid or expired token' })
205
+ return
206
+ }
207
+
208
+ getPool()
209
+ .query(
210
+ `SELECT t.id, t.wallet_address, t.status,
211
+ t.quota_daily, t.quota_used, t.rate_limit_rpm, t.max_concurrent,
212
+ p.id as plan_id, p.slug as plan_slug
213
+ FROM tenants t
214
+ LEFT JOIN plans p ON t.plan_id = p.id
215
+ WHERE t.id = $1`,
216
+ [decoded.tenantId]
217
+ )
218
+ .then(r => {
219
+ if (r.rows.length === 0) {
220
+ res.status(401).json({ error: 'Tenant not found' })
221
+ return
222
+ }
223
+ const row = r.rows[0]
224
+ if (row.status === 'suspended') {
225
+ res.status(403).json({ error: 'Account suspended' })
226
+ return
227
+ }
228
+ req.tenant = {
229
+ id: row.id,
230
+ walletAddress: row.wallet_address,
231
+ planId: row.plan_id || '',
232
+ planSlug: row.plan_slug || 'free',
233
+ quotaDaily: row.quota_daily,
234
+ quotaUsed: row.quota_used,
235
+ rateLimitRpm: row.rate_limit_rpm,
236
+ maxConcurrent: row.max_concurrent,
237
+ status: row.status,
238
+ }
239
+ next()
240
+ })
241
+ .catch(err => {
242
+ next(err)
243
+ })
244
+ }
@@ -0,0 +1,109 @@
1
+ // ---------------------------------------------------------------------------
2
+ // AgentX Gateway — Unified Error Handler
3
+ // ---------------------------------------------------------------------------
4
+ // AppError hierarchy + global Express error middleware.
5
+ // Replaces scattered res.status(500).json({ error: err.message }) patterns.
6
+ // ---------------------------------------------------------------------------
7
+
8
+ import { Request, Response, NextFunction } from 'express'
9
+
10
+ // ── Error Classes ────────────────────────────────────────────────────────
11
+
12
+ export class AppError extends Error {
13
+ public readonly statusCode: number
14
+ public readonly code: string
15
+ public readonly expose: boolean
16
+
17
+ constructor(message: string, statusCode = 500, code = 'INTERNAL_ERROR', expose = false) {
18
+ super(message)
19
+ this.statusCode = statusCode
20
+ this.code = code
21
+ this.expose = expose
22
+ Object.setPrototypeOf(this, AppError.prototype)
23
+ }
24
+ }
25
+
26
+ export class BadRequestError extends AppError {
27
+ constructor(message = 'Bad request') {
28
+ super(message, 400, 'BAD_REQUEST', true)
29
+ }
30
+ }
31
+
32
+ export class UnauthorizedError extends AppError {
33
+ constructor(message = 'Unauthorized') {
34
+ super(message, 401, 'UNAUTHORIZED', true)
35
+ }
36
+ }
37
+
38
+ export class ForbiddenError extends AppError {
39
+ constructor(message = 'Forbidden') {
40
+ super(message, 403, 'FORBIDDEN', true)
41
+ }
42
+ }
43
+
44
+ export class NotFoundError extends AppError {
45
+ constructor(message = 'Not found') {
46
+ super(message, 404, 'NOT_FOUND', true)
47
+ }
48
+ }
49
+
50
+ export class ConflictError extends AppError {
51
+ constructor(message = 'Conflict') {
52
+ super(message, 409, 'CONFLICT', true)
53
+ }
54
+ }
55
+
56
+ export class TooManyRequestsError extends AppError {
57
+ constructor(message = 'Too many requests') {
58
+ super(message, 429, 'TOO_MANY_REQUESTS', true)
59
+ }
60
+ }
61
+
62
+ // ── Global Error Handler Middleware ───────────────────────────────────────
63
+
64
+ /**
65
+ * Centralized Express error handler.
66
+ * - AppError subclasses → use their statusCode + expose message
67
+ * - Unknown errors → 500 with safe message (no leak)
68
+ */
69
+ export function globalErrorHandler(
70
+ err: Error,
71
+ _req: Request,
72
+ res: Response,
73
+ _next: NextFunction,
74
+ ): void {
75
+ if (res.headersSent) return
76
+
77
+ if (err instanceof AppError) {
78
+ const body: Record<string, unknown> = {
79
+ error: err.expose ? err.message : 'Internal server error',
80
+ code: err.code,
81
+ }
82
+ // Attach details only in development
83
+ if (process.env.NODE_ENV === 'development' && !err.expose) {
84
+ body.detail = err.message
85
+ }
86
+ res.status(err.statusCode).json(body)
87
+ return
88
+ }
89
+
90
+ // Unknown / unexpected error — never leak message to client
91
+ console.error('[Gateway Error]', err)
92
+ res.status(500).json({
93
+ error: 'Internal server error',
94
+ code: 'INTERNAL_ERROR',
95
+ ...(process.env.NODE_ENV === 'development' ? { detail: err.message } : {}),
96
+ })
97
+ }
98
+
99
+ /**
100
+ * Async route wrapper — catches thrown errors and forwards them to next().
101
+ * Usage: router.get('/path', asyncHandler(async (req, res) => { ... }))
102
+ */
103
+ export function asyncHandler(
104
+ fn: (req: Request, res: Response, next: NextFunction) => Promise<void>,
105
+ ) {
106
+ return (req: Request, res: Response, next: NextFunction) => {
107
+ fn(req, res, next).catch(next)
108
+ }
109
+ }