@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.
- package/.env.example +53 -0
- package/db/migrations/001_init.sql +117 -0
- package/db/migrations/002_agents.sql +20 -0
- package/db/migrations/003_a2a_results.sql +23 -0
- package/deploy/.env.deploy.example +23 -0
- package/deploy/add_swap_build.py +45 -0
- package/deploy/build_and_deploy.py +110 -0
- package/deploy/build_deploy_a2a.py +190 -0
- package/deploy/build_no_lint.py +52 -0
- package/deploy/build_on_fe.py +86 -0
- package/deploy/build_on_gw.py +98 -0
- package/deploy/build_prod.py +54 -0
- package/deploy/check_bal.py +31 -0
- package/deploy/check_code.py +47 -0
- package/deploy/check_deploy.sh +20 -0
- package/deploy/check_docs.py +16 -0
- package/deploy/check_frontend.py +42 -0
- package/deploy/check_frontend2.py +35 -0
- package/deploy/check_pks.py +34 -0
- package/deploy/check_status.py +32 -0
- package/deploy/check_v2.py +8 -0
- package/deploy/clean_install.py +28 -0
- package/deploy/deploy_a2a.py +58 -0
- package/deploy/deploy_a2a.sh +33 -0
- package/deploy/deploy_a2a_bg.sh +40 -0
- package/deploy/deploy_a2a_v2.py +151 -0
- package/deploy/deploy_a2a_v2.sh +36 -0
- package/deploy/deploy_bin.py +64 -0
- package/deploy/deploy_config.py +34 -0
- package/deploy/deploy_final.py +42 -0
- package/deploy/deploy_frontend.py +108 -0
- package/deploy/deploy_mcp.py +108 -0
- package/deploy/deploy_now.py +49 -0
- package/deploy/deploy_push_all.py +128 -0
- package/deploy/deploy_script.py +64 -0
- package/deploy/deploy_sep_a2a.sh +18 -0
- package/deploy/dev_mode.py +51 -0
- package/deploy/diag.py +12 -0
- package/deploy/diff_test.py +22 -0
- package/deploy/final_check.py +33 -0
- package/deploy/final_deploy.py +50 -0
- package/deploy/final_fix.py +54 -0
- package/deploy/final_push.py +114 -0
- package/deploy/find_frontend.py +38 -0
- package/deploy/fix_a2a.py +43 -0
- package/deploy/fix_and_build.py +77 -0
- package/deploy/fix_and_verify.py +56 -0
- package/deploy/fix_build.py +51 -0
- package/deploy/forge_deploy.py +67 -0
- package/deploy/full_push.py +101 -0
- package/deploy/full_push2.py +106 -0
- package/deploy/go_prod.py +59 -0
- package/deploy/gw_deploy.py +133 -0
- package/deploy/gw_final.py +206 -0
- package/deploy/gw_restart.py +28 -0
- package/deploy/gw_status.py +18 -0
- package/deploy/inline_build.py +99 -0
- package/deploy/one_shot.py +84 -0
- package/deploy/ox_deploy.py +32 -0
- package/deploy/pg_setup.py +36 -0
- package/deploy/prod_deploy.py +126 -0
- package/deploy/prod_final.py +140 -0
- package/deploy/prod_go.py +61 -0
- package/deploy/prod_one_shot.py +139 -0
- package/deploy/prod_setup.sh +62 -0
- package/deploy/prod_v2.py +91 -0
- package/deploy/prod_v3.py +103 -0
- package/deploy/push_docs.py +93 -0
- package/deploy/push_mcp.py +66 -0
- package/deploy/push_mcp_doc.py +28 -0
- package/deploy/push_v063.py +80 -0
- package/deploy/push_v2.py +71 -0
- package/deploy/qcheck.py +13 -0
- package/deploy/qcheck2.py +18 -0
- package/deploy/quick_check.py +8 -0
- package/deploy/rebuild.py +47 -0
- package/deploy/rebuild_frontend.py +44 -0
- package/deploy/restart.py +28 -0
- package/deploy/restore_frontend.py +42 -0
- package/deploy/restore_frontend2.py +41 -0
- package/deploy/restore_gateway.py +34 -0
- package/deploy/restore_quick.py +33 -0
- package/deploy/run_bash.py +19 -0
- package/deploy/run_sep.py +20 -0
- package/deploy/sdk_upgrade.py +50 -0
- package/deploy/server_prepare.py +41 -0
- package/deploy/server_setup.py +41 -0
- package/deploy/ssh_check.py +14 -0
- package/deploy/ssh_push.py +24 -0
- package/deploy/start_deploy.py +35 -0
- package/deploy/start_frontend.py +12 -0
- package/deploy/start_v2.py +29 -0
- package/deploy/stat.py +18 -0
- package/deploy/tail_log.py +11 -0
- package/deploy/update_env.py +32 -0
- package/deploy/upload_and_start.py +107 -0
- package/deploy/verify_a2a.py +13 -0
- package/deploy/verify_mcp.py +37 -0
- package/deploy/verify_sdk.py +21 -0
- package/dist/config.d.ts +26 -0
- package/dist/config.js +41 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +149 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/crypto.d.ts +2 -0
- package/dist/lib/crypto.js +43 -0
- package/dist/lib/crypto.js.map +1 -0
- package/dist/lib/db.d.ts +3 -0
- package/dist/lib/db.js +26 -0
- package/dist/lib/db.js.map +1 -0
- package/dist/lib/ethers-utils.d.ts +60 -0
- package/dist/lib/ethers-utils.js +186 -0
- package/dist/lib/ethers-utils.js.map +1 -0
- package/dist/middleware/adminAuth.d.ts +2 -0
- package/dist/middleware/adminAuth.js +31 -0
- package/dist/middleware/adminAuth.js.map +1 -0
- package/dist/middleware/auth.d.ts +22 -0
- package/dist/middleware/auth.js +192 -0
- package/dist/middleware/auth.js.map +1 -0
- package/dist/middleware/error-handler.d.ts +36 -0
- package/dist/middleware/error-handler.js +100 -0
- package/dist/middleware/error-handler.js.map +1 -0
- package/dist/middleware/rate-limiter.d.ts +5 -0
- package/dist/middleware/rate-limiter.js +107 -0
- package/dist/middleware/rate-limiter.js.map +1 -0
- package/dist/routes/a2a.d.ts +2 -0
- package/dist/routes/a2a.js +84 -0
- package/dist/routes/a2a.js.map +1 -0
- package/dist/routes/admin.d.ts +2 -0
- package/dist/routes/admin.js +180 -0
- package/dist/routes/admin.js.map +1 -0
- package/dist/routes/agents.d.ts +2 -0
- package/dist/routes/agents.js +43 -0
- package/dist/routes/agents.js.map +1 -0
- package/dist/routes/chat.d.ts +2 -0
- package/dist/routes/chat.js +183 -0
- package/dist/routes/chat.js.map +1 -0
- package/dist/routes/history.d.ts +2 -0
- package/dist/routes/history.js +92 -0
- package/dist/routes/history.js.map +1 -0
- package/dist/routes/mcp.d.ts +13 -0
- package/dist/routes/mcp.js +513 -0
- package/dist/routes/mcp.js.map +1 -0
- package/dist/routes/tenant.d.ts +2 -0
- package/dist/routes/tenant.js +154 -0
- package/dist/routes/tenant.js.map +1 -0
- package/dist/services/a2a-worker.d.ts +7 -0
- package/dist/services/a2a-worker.js +406 -0
- package/dist/services/a2a-worker.js.map +1 -0
- package/dist/services/agent-indexer.d.ts +4 -0
- package/dist/services/agent-indexer.js +124 -0
- package/dist/services/agent-indexer.js.map +1 -0
- package/e2e_wallet.js +175 -0
- package/ecosystem.config.js +17 -0
- package/package.json +33 -0
- package/src/config.ts +47 -0
- package/src/index.ts +143 -0
- package/src/lib/crypto.ts +44 -0
- package/src/lib/db.ts +25 -0
- package/src/lib/ethers-utils.ts +229 -0
- package/src/middleware/adminAuth.ts +34 -0
- package/src/middleware/auth.ts +244 -0
- package/src/middleware/error-handler.ts +109 -0
- package/src/middleware/rate-limiter.ts +115 -0
- package/src/routes/a2a.ts +97 -0
- package/src/routes/admin.ts +219 -0
- package/src/routes/agents.ts +52 -0
- package/src/routes/chat.ts +219 -0
- package/src/routes/history.ts +114 -0
- package/src/routes/mcp.ts +567 -0
- package/src/routes/tenant.ts +196 -0
- package/src/services/a2a-worker.ts +486 -0
- package/src/services/agent-indexer.ts +127 -0
- 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
|
+
}
|