@waiaas/daemon 2.10.0 → 2.11.0-rc

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 (95) hide show
  1. package/dist/api/helpers/resolve-chain-id.d.ts +2 -0
  2. package/dist/api/helpers/resolve-chain-id.d.ts.map +1 -0
  3. package/dist/api/helpers/resolve-chain-id.js +21 -0
  4. package/dist/api/helpers/resolve-chain-id.js.map +1 -0
  5. package/dist/api/routes/actions.d.ts.map +1 -1
  6. package/dist/api/routes/actions.js +1 -20
  7. package/dist/api/routes/actions.js.map +1 -1
  8. package/dist/api/routes/admin-actions.d.ts.map +1 -1
  9. package/dist/api/routes/admin-actions.js +1 -19
  10. package/dist/api/routes/admin-actions.js.map +1 -1
  11. package/dist/api/routes/admin-auth.d.ts +9 -0
  12. package/dist/api/routes/admin-auth.d.ts.map +1 -0
  13. package/dist/api/routes/admin-auth.js +453 -0
  14. package/dist/api/routes/admin-auth.js.map +1 -0
  15. package/dist/api/routes/admin-monitoring.d.ts +10 -0
  16. package/dist/api/routes/admin-monitoring.d.ts.map +1 -0
  17. package/dist/api/routes/admin-monitoring.js +788 -0
  18. package/dist/api/routes/admin-monitoring.js.map +1 -0
  19. package/dist/api/routes/admin-notifications.d.ts +9 -0
  20. package/dist/api/routes/admin-notifications.d.ts.map +1 -0
  21. package/dist/api/routes/admin-notifications.js +210 -0
  22. package/dist/api/routes/admin-notifications.js.map +1 -0
  23. package/dist/api/routes/admin-settings.d.ts +9 -0
  24. package/dist/api/routes/admin-settings.d.ts.map +1 -0
  25. package/dist/api/routes/admin-settings.js +433 -0
  26. package/dist/api/routes/admin-settings.js.map +1 -0
  27. package/dist/api/routes/admin-wallets.d.ts +16 -0
  28. package/dist/api/routes/admin-wallets.d.ts.map +1 -0
  29. package/dist/api/routes/admin-wallets.js +582 -0
  30. package/dist/api/routes/admin-wallets.js.map +1 -0
  31. package/dist/api/routes/admin.d.ts +8 -26
  32. package/dist/api/routes/admin.d.ts.map +1 -1
  33. package/dist/api/routes/admin.js +20 -2536
  34. package/dist/api/routes/admin.js.map +1 -1
  35. package/dist/api/routes/erc8004.d.ts.map +1 -1
  36. package/dist/api/routes/erc8004.js +0 -1
  37. package/dist/api/routes/erc8004.js.map +1 -1
  38. package/dist/api/routes/mcp.d.ts +2 -0
  39. package/dist/api/routes/mcp.d.ts.map +1 -1
  40. package/dist/api/routes/mcp.js +2 -1
  41. package/dist/api/routes/mcp.js.map +1 -1
  42. package/dist/api/routes/nft-approvals.js +4 -4
  43. package/dist/api/routes/nft-approvals.js.map +1 -1
  44. package/dist/api/routes/sessions.d.ts +2 -0
  45. package/dist/api/routes/sessions.d.ts.map +1 -1
  46. package/dist/api/routes/sessions.js +5 -3
  47. package/dist/api/routes/sessions.js.map +1 -1
  48. package/dist/api/routes/wallets.d.ts.map +1 -1
  49. package/dist/api/routes/wallets.js +3 -3
  50. package/dist/api/routes/wallets.js.map +1 -1
  51. package/dist/api/server.d.ts.map +1 -1
  52. package/dist/api/server.js +2 -0
  53. package/dist/api/server.js.map +1 -1
  54. package/dist/constants.d.ts +17 -0
  55. package/dist/constants.d.ts.map +1 -0
  56. package/dist/constants.js +17 -0
  57. package/dist/constants.js.map +1 -0
  58. package/dist/infrastructure/nft/nft-indexer-client.d.ts.map +1 -1
  59. package/dist/infrastructure/nft/nft-indexer-client.js +3 -3
  60. package/dist/infrastructure/nft/nft-indexer-client.js.map +1 -1
  61. package/dist/infrastructure/oracle/coingecko-forex.d.ts.map +1 -1
  62. package/dist/infrastructure/oracle/coingecko-forex.js +2 -3
  63. package/dist/infrastructure/oracle/coingecko-forex.js.map +1 -1
  64. package/dist/infrastructure/oracle/coingecko-oracle.d.ts.map +1 -1
  65. package/dist/infrastructure/oracle/coingecko-oracle.js +2 -3
  66. package/dist/infrastructure/oracle/coingecko-oracle.js.map +1 -1
  67. package/dist/infrastructure/oracle/pyth-oracle.d.ts +0 -1
  68. package/dist/infrastructure/oracle/pyth-oracle.d.ts.map +1 -1
  69. package/dist/infrastructure/oracle/pyth-oracle.js +3 -3
  70. package/dist/infrastructure/oracle/pyth-oracle.js.map +1 -1
  71. package/dist/lifecycle/workers.d.ts.map +1 -1
  72. package/dist/lifecycle/workers.js +2 -1
  73. package/dist/lifecycle/workers.js.map +1 -1
  74. package/dist/pipeline/dry-run.d.ts.map +1 -1
  75. package/dist/pipeline/dry-run.js +4 -3
  76. package/dist/pipeline/dry-run.js.map +1 -1
  77. package/dist/pipeline/stages.d.ts.map +1 -1
  78. package/dist/pipeline/stages.js +4 -3
  79. package/dist/pipeline/stages.js.map +1 -1
  80. package/dist/services/signing-sdk/channels/ntfy-signing-channel.js +2 -2
  81. package/dist/services/signing-sdk/channels/ntfy-signing-channel.js.map +1 -1
  82. package/dist/services/signing-sdk/channels/telegram-signing-channel.js +1 -1
  83. package/dist/services/signing-sdk/channels/telegram-signing-channel.js.map +1 -1
  84. package/dist/services/signing-sdk/channels/wallet-notification-channel.js +2 -2
  85. package/dist/services/signing-sdk/channels/wallet-notification-channel.js.map +1 -1
  86. package/dist/services/signing-sdk/sign-request-builder.d.ts +2 -0
  87. package/dist/services/signing-sdk/sign-request-builder.d.ts.map +1 -1
  88. package/dist/services/signing-sdk/sign-request-builder.js +20 -3
  89. package/dist/services/signing-sdk/sign-request-builder.js.map +1 -1
  90. package/dist/services/signing-sdk/sign-response-handler.js +4 -4
  91. package/dist/services/signing-sdk/sign-response-handler.js.map +1 -1
  92. package/package.json +5 -5
  93. package/public/admin/assets/index-By5VUJ-B.js +3 -0
  94. package/public/admin/index.html +1 -1
  95. package/public/admin/assets/index--wQVT9Dz.js +0 -3
@@ -1,2550 +1,34 @@
1
1
  /**
2
- * Admin route handlers: 27 daemon administration endpoints.
2
+ * Admin route aggregator: delegates to domain-specific modules.
3
3
  *
4
- * GET /admin/status - Daemon health/uptime/version (masterAuth)
5
- * POST /admin/kill-switch - Activate kill switch (masterAuth)
6
- * GET /admin/kill-switch - Get kill switch state (public)
7
- * POST /admin/recover - Deactivate kill switch (masterAuth)
8
- * POST /admin/shutdown - Graceful daemon shutdown (masterAuth)
9
- * POST /admin/rotate-secret - Rotate JWT secret (masterAuth)
10
- * GET /admin/notifications/status - Notification channel status (masterAuth)
11
- * POST /admin/notifications/test - Send test notification (masterAuth)
12
- * GET /admin/notifications/log - Query notification logs (masterAuth)
13
- * GET /admin/settings - Get all settings (masterAuth)
14
- * PUT /admin/settings - Update settings (masterAuth)
15
- * POST /admin/settings/test-rpc - Test RPC connectivity (masterAuth)
16
- * GET /admin/oracle-status - Oracle cache/source/cross-validation status (masterAuth)
17
- * GET /admin/api-keys - List Action Provider API key status (masterAuth)
18
- * PUT /admin/api-keys/:provider - Set or update API key (masterAuth)
19
- * DELETE /admin/api-keys/:provider - Delete API key (masterAuth)
20
- * GET /admin/forex/rates - Forex exchange rates for display currency (masterAuth)
21
- * GET /admin/telegram-users - List Telegram bot users (masterAuth)
22
- * PUT /admin/telegram-users/:chatId - Update Telegram user role (masterAuth)
23
- * DELETE /admin/telegram-users/:chatId - Delete Telegram user (masterAuth)
24
- * GET /admin/transactions - Cross-wallet transaction list with filters (masterAuth)
25
- * GET /admin/incoming - Cross-wallet incoming transaction list with filters (masterAuth)
26
- * GET /admin/rpc-status - Per-network RPC pool endpoint status (masterAuth)
27
- * POST /admin/transactions/:id/cancel - Cancel a QUEUED (DELAY) transaction (masterAuth)
28
- * POST /admin/transactions/:id/reject - Reject a pending approval transaction (masterAuth)
4
+ * Domain modules:
5
+ * - admin-auth.ts: status, kill-switch, recover, shutdown, password, rotate-secret
6
+ * - admin-notifications.ts: notifications status/test/log
7
+ * - admin-settings.ts: settings CRUD, test-rpc, oracle, API keys, forex, RPC status
8
+ * - admin-wallets.ts: wallet transactions/balance/staking, telegram users, DeFi positions
9
+ * - admin-monitoring.ts: cross-wallet transactions, incoming, agent-prompt, session-reissue,
10
+ * tx cancel/reject, backup, stats, autostop
29
11
  *
30
12
  * @see docs/37-rest-api-complete-spec.md
31
13
  * @see docs/36-killswitch-evm-freeze.md
32
14
  */
33
- import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi';
34
- import { sql, desc, eq, and, isNull, gt, gte, lte, count as drizzleCount } from 'drizzle-orm';
35
- import { createHash } from 'node:crypto';
36
- import { WAIaaSError, getNetworksForEnvironment, formatAmount, BUILT_IN_RPC_DEFAULTS } from '@waiaas/core';
37
- import { CurrencyCodeSchema, formatRatePreview } from '@waiaas/core';
38
- import { wallets, sessions, sessionWallets, notificationLogs, policies, transactions, incomingTransactions, tokenRegistry, walletApps } from '../../infrastructure/database/schema.js';
39
- import { generateId } from '../../infrastructure/database/id.js';
40
- import { buildConnectInfoPrompt } from './connect-info.js';
41
- import { getSettingDefinition, ActionTierOverrideSchema } from '../../infrastructure/settings/index.js';
42
- import { resolveRpcUrl } from '../../infrastructure/adapter-pool.js';
43
- import { AdminStatusResponseSchema, KillSwitchResponseSchema, KillSwitchActivateResponseSchema, KillSwitchEscalateResponseSchema, MasterPasswordChangeRequestSchema, MasterPasswordChangeResponseSchema, RecoverResponseSchema, KillSwitchRecoverRequestSchema, ShutdownResponseSchema, RotateSecretResponseSchema, NotificationStatusResponseSchema, NotificationTestRequestSchema, NotificationTestResponseSchema, NotificationLogResponseSchema, SettingsResponseSchema, SettingsUpdateRequestSchema, SettingsUpdateResponseSchema, TestRpcRequestSchema, TestRpcResponseSchema, OracleStatusResponseSchema, AgentPromptRequestSchema, AgentPromptResponseSchema, SessionReissueResponseSchema, StakingPositionsResponseSchema, RpcStatusResponseSchema, BackupInfoResponseSchema, BackupListResponseSchema, ErrorResponseSchema, buildErrorResponses, openApiValidationHook, } from './openapi-schemas.js';
44
- // ---------------------------------------------------------------------------
45
- // Route definitions
46
- // ---------------------------------------------------------------------------
47
- const statusRoute = createRoute({
48
- method: 'get',
49
- path: '/admin/status',
50
- tags: ['Admin'],
51
- summary: 'Get daemon status',
52
- responses: {
53
- 200: {
54
- description: 'Daemon status',
55
- content: { 'application/json': { schema: AdminStatusResponseSchema } },
56
- },
57
- },
58
- });
59
- const activateKillSwitchRoute = createRoute({
60
- method: 'post',
61
- path: '/admin/kill-switch',
62
- tags: ['Admin'],
63
- summary: 'Activate kill switch',
64
- responses: {
65
- 200: {
66
- description: 'Kill switch activated',
67
- content: { 'application/json': { schema: KillSwitchActivateResponseSchema } },
68
- },
69
- ...buildErrorResponses(['KILL_SWITCH_ACTIVE']),
70
- },
71
- });
72
- const getKillSwitchRoute = createRoute({
73
- method: 'get',
74
- path: '/admin/kill-switch',
75
- tags: ['Admin'],
76
- summary: 'Get kill switch state',
77
- responses: {
78
- 200: {
79
- description: 'Kill switch state',
80
- content: { 'application/json': { schema: KillSwitchResponseSchema } },
81
- },
82
- },
83
- });
84
- const escalateKillSwitchRoute = createRoute({
85
- method: 'post',
86
- path: '/admin/kill-switch/escalate',
87
- tags: ['Admin'],
88
- summary: 'Escalate kill switch to LOCKED',
89
- responses: {
90
- 200: {
91
- description: 'Kill switch escalated to LOCKED',
92
- content: { 'application/json': { schema: KillSwitchEscalateResponseSchema } },
93
- },
94
- ...buildErrorResponses(['INVALID_STATE_TRANSITION']),
95
- },
96
- });
97
- const recoverRoute = createRoute({
98
- method: 'post',
99
- path: '/admin/recover',
100
- tags: ['Admin'],
101
- summary: 'Recover from kill switch (dual-auth)',
102
- request: {
103
- body: {
104
- content: { 'application/json': { schema: KillSwitchRecoverRequestSchema } },
105
- required: false,
106
- },
107
- },
108
- responses: {
109
- 200: {
110
- description: 'Kill switch deactivated',
111
- content: { 'application/json': { schema: RecoverResponseSchema } },
112
- },
113
- ...buildErrorResponses(['KILL_SWITCH_NOT_ACTIVE', 'INVALID_STATE_TRANSITION', 'INVALID_SIGNATURE']),
114
- },
115
- });
116
- const shutdownRoute = createRoute({
117
- method: 'post',
118
- path: '/admin/shutdown',
119
- tags: ['Admin'],
120
- summary: 'Initiate graceful shutdown',
121
- responses: {
122
- 200: {
123
- description: 'Shutdown initiated',
124
- content: { 'application/json': { schema: ShutdownResponseSchema } },
125
- },
126
- },
127
- });
128
- const masterPasswordChangeRoute = createRoute({
129
- method: 'put',
130
- path: '/admin/master-password',
131
- tags: ['Admin'],
132
- summary: 'Change master password',
133
- request: {
134
- body: {
135
- content: { 'application/json': { schema: MasterPasswordChangeRequestSchema } },
136
- },
137
- },
138
- responses: {
139
- 200: {
140
- description: 'Master password changed successfully',
141
- content: { 'application/json': { schema: MasterPasswordChangeResponseSchema } },
142
- },
143
- ...buildErrorResponses(['INVALID_MASTER_PASSWORD', 'ACTION_VALIDATION_FAILED']),
144
- },
145
- });
146
- const rotateSecretRoute = createRoute({
147
- method: 'post',
148
- path: '/admin/rotate-secret',
149
- tags: ['Admin'],
150
- summary: 'Rotate JWT secret',
151
- responses: {
152
- 200: {
153
- description: 'JWT secret rotated',
154
- content: { 'application/json': { schema: RotateSecretResponseSchema } },
155
- },
156
- ...buildErrorResponses(['ROTATION_TOO_RECENT']),
157
- },
158
- });
159
- const notificationsStatusRoute = createRoute({
160
- method: 'get',
161
- path: '/admin/notifications/status',
162
- tags: ['Admin'],
163
- summary: 'Get notification channel status',
164
- responses: {
165
- 200: {
166
- description: 'Notification channel status (no credentials)',
167
- content: { 'application/json': { schema: NotificationStatusResponseSchema } },
168
- },
169
- },
170
- });
171
- const notificationsTestRoute = createRoute({
172
- method: 'post',
173
- path: '/admin/notifications/test',
174
- tags: ['Admin'],
175
- summary: 'Send test notification',
176
- request: {
177
- body: {
178
- content: { 'application/json': { schema: NotificationTestRequestSchema } },
179
- required: false,
180
- },
181
- },
182
- responses: {
183
- 200: {
184
- description: 'Test notification results',
185
- content: { 'application/json': { schema: NotificationTestResponseSchema } },
186
- },
187
- },
188
- });
189
- const notificationLogQuerySchema = z.object({
190
- page: z.string().optional().default('1'),
191
- pageSize: z.string().optional().default('20'),
192
- channel: z.string().optional(),
193
- status: z.string().optional(),
194
- eventType: z.string().optional(),
195
- since: z.string().optional(),
196
- until: z.string().optional(),
197
- });
198
- const notificationsLogRoute = createRoute({
199
- method: 'get',
200
- path: '/admin/notifications/log',
201
- tags: ['Admin'],
202
- summary: 'Query notification delivery logs',
203
- request: {
204
- query: notificationLogQuerySchema,
205
- },
206
- responses: {
207
- 200: {
208
- description: 'Paginated notification logs',
209
- content: { 'application/json': { schema: NotificationLogResponseSchema } },
210
- },
211
- },
212
- });
213
- // ---------------------------------------------------------------------------
214
- // Settings route definitions
215
- // ---------------------------------------------------------------------------
216
- const settingsGetRoute = createRoute({
217
- method: 'get',
218
- path: '/admin/settings',
219
- tags: ['Admin'],
220
- summary: 'Get all settings grouped by category',
221
- responses: {
222
- 200: {
223
- description: 'All settings with credentials masked as boolean',
224
- content: { 'application/json': { schema: SettingsResponseSchema } },
225
- },
226
- },
227
- });
228
- const settingsPutRoute = createRoute({
229
- method: 'put',
230
- path: '/admin/settings',
231
- tags: ['Admin'],
232
- summary: 'Update settings',
233
- request: {
234
- body: {
235
- content: { 'application/json': { schema: SettingsUpdateRequestSchema } },
236
- required: true,
237
- },
238
- },
239
- responses: {
240
- 200: {
241
- description: 'Updated settings',
242
- content: { 'application/json': { schema: SettingsUpdateResponseSchema } },
243
- },
244
- ...buildErrorResponses(['ACTION_VALIDATION_FAILED']),
245
- },
246
- });
247
- const testRpcRoute = createRoute({
248
- method: 'post',
249
- path: '/admin/settings/test-rpc',
250
- tags: ['Admin'],
251
- summary: 'Test RPC endpoint connectivity',
252
- request: {
253
- body: {
254
- content: { 'application/json': { schema: TestRpcRequestSchema } },
255
- required: true,
256
- },
257
- },
258
- responses: {
259
- 200: {
260
- description: 'RPC connectivity test result',
261
- content: { 'application/json': { schema: TestRpcResponseSchema } },
262
- },
263
- },
264
- });
265
- // ---------------------------------------------------------------------------
266
- // RPC Pool status route definition
267
- // ---------------------------------------------------------------------------
268
- const rpcStatusRoute = createRoute({
269
- method: 'get',
270
- path: '/admin/rpc-status',
271
- tags: ['Admin'],
272
- summary: 'Get per-network RPC pool endpoint status',
273
- responses: {
274
- 200: {
275
- description: 'RPC pool status per network',
276
- content: { 'application/json': { schema: RpcStatusResponseSchema } },
277
- },
278
- },
279
- });
280
- // ---------------------------------------------------------------------------
281
- // Oracle status route definition
282
- // ---------------------------------------------------------------------------
283
- const oracleStatusRoute = createRoute({
284
- method: 'get',
285
- path: '/admin/oracle-status',
286
- tags: ['Admin'],
287
- summary: 'Get oracle cache statistics and source status',
288
- responses: {
289
- 200: {
290
- description: 'Oracle status',
291
- content: { 'application/json': { schema: OracleStatusResponseSchema } },
292
- },
293
- },
294
- });
295
- // ---------------------------------------------------------------------------
296
- // API Keys route definitions
297
- // ---------------------------------------------------------------------------
298
- const apiKeysListResponseSchema = z.object({
299
- keys: z.array(z.object({
300
- providerName: z.string(),
301
- hasKey: z.boolean(),
302
- maskedKey: z.string().nullable(),
303
- requiresApiKey: z.boolean(),
304
- updatedAt: z.string().nullable(),
305
- })),
306
- });
307
- const apiKeysListRoute = createRoute({
308
- method: 'get',
309
- path: '/admin/api-keys',
310
- tags: ['Admin'],
311
- summary: 'List Action Provider API key status',
312
- responses: {
313
- 200: {
314
- description: 'API key status per provider',
315
- content: { 'application/json': { schema: apiKeysListResponseSchema } },
316
- },
317
- },
318
- });
319
- const apiKeyPutRoute = createRoute({
320
- method: 'put',
321
- path: '/admin/api-keys/{provider}',
322
- tags: ['Admin'],
323
- summary: 'Set or update Action Provider API key',
324
- request: {
325
- params: z.object({ provider: z.string() }),
326
- body: {
327
- content: {
328
- 'application/json': {
329
- schema: z.object({ apiKey: z.string().min(1) }),
330
- },
331
- },
332
- required: true,
333
- },
334
- },
335
- responses: {
336
- 200: {
337
- description: 'API key saved',
338
- content: {
339
- 'application/json': {
340
- schema: z.object({
341
- success: z.boolean(),
342
- providerName: z.string(),
343
- }),
344
- },
345
- },
346
- },
347
- },
348
- });
349
- const apiKeyDeleteRoute = createRoute({
350
- method: 'delete',
351
- path: '/admin/api-keys/{provider}',
352
- tags: ['Admin'],
353
- summary: 'Delete Action Provider API key',
354
- request: {
355
- params: z.object({ provider: z.string() }),
356
- },
357
- responses: {
358
- 200: {
359
- description: 'API key deleted',
360
- content: {
361
- 'application/json': {
362
- schema: z.object({ success: z.boolean() }),
363
- },
364
- },
365
- },
366
- ...buildErrorResponses(['ACTION_NOT_FOUND']),
367
- },
368
- });
369
- // ---------------------------------------------------------------------------
370
- // Forex rates route definitions
371
- // ---------------------------------------------------------------------------
372
- const forexRatesQuerySchema = z.object({
373
- currencies: z.string().optional().openapi({
374
- description: 'Comma-separated currency codes (e.g. KRW,JPY,EUR). If omitted, returns empty.',
375
- }),
376
- });
377
- const forexRatesRoute = createRoute({
378
- method: 'get',
379
- path: '/admin/forex/rates',
380
- tags: ['Admin'],
381
- summary: 'Get forex exchange rates for display currencies',
382
- request: {
383
- query: forexRatesQuerySchema,
384
- },
385
- responses: {
386
- 200: {
387
- description: 'Forex rates with preview strings',
388
- content: {
389
- 'application/json': {
390
- schema: z.object({
391
- rates: z.record(z.string(), z.object({
392
- rate: z.number(),
393
- preview: z.string(),
394
- })),
395
- }),
396
- },
397
- },
398
- },
399
- },
400
- });
401
- // ---------------------------------------------------------------------------
402
- // Admin wallet route definitions
403
- // ---------------------------------------------------------------------------
404
- const adminWalletTransactionsRoute = createRoute({
405
- method: 'get',
406
- path: '/admin/wallets/{id}/transactions',
407
- tags: ['Admin'],
408
- summary: 'Get wallet transactions',
409
- request: {
410
- params: z.object({ id: z.string().uuid() }),
411
- query: z.object({
412
- limit: z.coerce.number().int().min(1).max(100).default(20).optional(),
413
- offset: z.coerce.number().int().min(0).default(0).optional(),
414
- }),
415
- },
416
- responses: {
417
- 200: {
418
- description: 'Wallet transaction list',
419
- content: {
420
- 'application/json': {
421
- schema: z.object({
422
- items: z.array(z.object({
423
- id: z.string(),
424
- type: z.string(),
425
- status: z.string(),
426
- toAddress: z.string().nullable(),
427
- amount: z.string().nullable(),
428
- formattedAmount: z.string().nullable(),
429
- amountUsd: z.number().nullable(),
430
- network: z.string().nullable(),
431
- txHash: z.string().nullable(),
432
- createdAt: z.number().nullable(),
433
- })),
434
- total: z.number().int(),
435
- }),
436
- },
437
- },
438
- },
439
- ...buildErrorResponses(['WALLET_NOT_FOUND']),
440
- },
441
- });
442
- const adminWalletBalanceRoute = createRoute({
443
- method: 'get',
444
- path: '/admin/wallets/{id}/balance',
445
- tags: ['Admin'],
446
- summary: 'Get wallet balance across all available networks',
447
- request: {
448
- params: z.object({ id: z.string().uuid() }),
449
- },
450
- responses: {
451
- 200: {
452
- description: 'Wallet balances per network',
453
- content: {
454
- 'application/json': {
455
- schema: z.object({
456
- balances: z.array(z.object({
457
- network: z.string(),
458
- native: z
459
- .object({
460
- balance: z.string(),
461
- symbol: z.string(),
462
- usd: z.number().nullable().optional(),
463
- })
464
- .nullable(),
465
- tokens: z.array(z.object({
466
- symbol: z.string(),
467
- balance: z.string(),
468
- address: z.string(),
469
- })),
470
- error: z.string().optional(),
471
- })),
472
- }),
473
- },
474
- },
475
- },
476
- ...buildErrorResponses(['WALLET_NOT_FOUND']),
477
- },
478
- });
479
- const adminWalletStakingRoute = createRoute({
480
- method: 'get',
481
- path: '/admin/wallets/{id}/staking',
482
- tags: ['Admin'],
483
- summary: 'Get wallet staking positions',
484
- description: 'Returns staking positions (Lido stETH, Jito JitoSOL) for a specific wallet with balance, APY, pending unstake status.',
485
- request: {
486
- params: z.object({ id: z.string().uuid() }),
487
- },
488
- responses: {
489
- 200: {
490
- description: 'Staking positions for the wallet',
491
- content: { 'application/json': { schema: StakingPositionsResponseSchema } },
492
- },
493
- ...buildErrorResponses(['WALLET_NOT_FOUND']),
494
- },
495
- });
496
- // ---------------------------------------------------------------------------
497
- // DeFi Positions route definition
498
- // ---------------------------------------------------------------------------
499
- const adminDefiPositionsRoute = createRoute({
500
- method: 'get',
501
- path: '/admin/defi/positions',
502
- tags: ['Admin'],
503
- summary: 'Get all DeFi positions across wallets',
504
- description: 'Returns all active DeFi positions across all wallets with aggregated totals. ' +
505
- 'Optionally filter by wallet_id. masterAuth required.',
506
- request: {
507
- query: z.object({
508
- wallet_id: z.string().uuid().optional(),
509
- }),
510
- },
511
- responses: {
512
- 200: {
513
- description: 'Active DeFi positions with aggregates',
514
- content: {
515
- 'application/json': {
516
- schema: z.object({
517
- positions: z.array(z.object({
518
- id: z.string(),
519
- walletId: z.string(),
520
- category: z.string(),
521
- provider: z.string(),
522
- chain: z.string(),
523
- network: z.string().nullable(),
524
- assetId: z.string().nullable(),
525
- amount: z.string(),
526
- amountUsd: z.number().nullable(),
527
- status: z.string(),
528
- openedAt: z.number(),
529
- lastSyncedAt: z.number(),
530
- })),
531
- totalValueUsd: z.number().nullable(),
532
- worstHealthFactor: z.number().nullable(),
533
- activeCount: z.number(),
534
- }),
535
- },
536
- },
537
- },
538
- },
539
- });
540
- // ---------------------------------------------------------------------------
541
- // Telegram Users route definitions
542
- // ---------------------------------------------------------------------------
543
- const TelegramUserSchema = z.object({
544
- chat_id: z.number(),
545
- username: z.string().nullable(),
546
- role: z.enum(['PENDING', 'ADMIN', 'READONLY']),
547
- registered_at: z.number(),
548
- approved_at: z.number().nullable(),
549
- });
550
- const telegramUsersListRoute = createRoute({
551
- method: 'get',
552
- path: '/admin/telegram-users',
553
- tags: ['Admin'],
554
- summary: 'List Telegram bot users',
555
- responses: {
556
- 200: {
557
- description: 'Telegram users list',
558
- content: {
559
- 'application/json': {
560
- schema: z.object({
561
- users: z.array(TelegramUserSchema),
562
- total: z.number(),
563
- }),
564
- },
565
- },
566
- },
567
- },
568
- });
569
- const telegramUserUpdateRoute = createRoute({
570
- method: 'put',
571
- path: '/admin/telegram-users/{chatId}',
572
- tags: ['Admin'],
573
- summary: 'Update Telegram user role',
574
- request: {
575
- params: z.object({ chatId: z.coerce.number() }),
576
- body: {
577
- content: {
578
- 'application/json': {
579
- schema: z.object({
580
- role: z.enum(['ADMIN', 'READONLY']),
581
- }),
582
- },
583
- },
584
- required: true,
585
- },
586
- },
587
- responses: {
588
- 200: {
589
- description: 'User role updated',
590
- content: {
591
- 'application/json': {
592
- schema: z.object({
593
- success: z.boolean(),
594
- chat_id: z.number(),
595
- role: z.enum(['ADMIN', 'READONLY']),
596
- }),
597
- },
598
- },
599
- },
600
- ...buildErrorResponses(['WALLET_NOT_FOUND']),
601
- },
602
- });
603
- const telegramUserDeleteRoute = createRoute({
604
- method: 'delete',
605
- path: '/admin/telegram-users/{chatId}',
606
- tags: ['Admin'],
607
- summary: 'Delete Telegram user',
608
- request: {
609
- params: z.object({ chatId: z.coerce.number() }),
610
- },
611
- responses: {
612
- 200: {
613
- description: 'User deleted',
614
- content: {
615
- 'application/json': {
616
- schema: z.object({ success: z.boolean() }),
617
- },
618
- },
619
- },
620
- ...buildErrorResponses(['WALLET_NOT_FOUND']),
621
- },
622
- });
623
- // ---------------------------------------------------------------------------
624
- // Cross-wallet admin transaction route definitions
625
- // ---------------------------------------------------------------------------
626
- const adminTransactionsQuerySchema = z.object({
627
- offset: z.coerce.number().int().min(0).default(0).optional(),
628
- limit: z.coerce.number().int().min(1).max(100).default(20).optional(),
629
- wallet_id: z.string().uuid().optional(),
630
- type: z.string().optional(),
631
- status: z.string().optional(),
632
- network: z.string().optional(),
633
- since: z.coerce.number().optional(),
634
- until: z.coerce.number().optional(),
635
- search: z.string().optional(),
636
- });
637
- const adminTransactionsRoute = createRoute({
638
- method: 'get',
639
- path: '/admin/transactions',
640
- tags: ['Admin'],
641
- summary: 'List cross-wallet transactions with filters and pagination',
642
- request: {
643
- query: adminTransactionsQuerySchema,
644
- },
645
- responses: {
646
- 200: {
647
- description: 'Paginated cross-wallet transaction list',
648
- content: {
649
- 'application/json': {
650
- schema: z.object({
651
- items: z.array(z.object({
652
- id: z.string(),
653
- walletId: z.string(),
654
- walletName: z.string().nullable(),
655
- type: z.string(),
656
- status: z.string(),
657
- tier: z.string().nullable(),
658
- toAddress: z.string().nullable(),
659
- amount: z.string().nullable(),
660
- amountUsd: z.number().nullable(),
661
- network: z.string().nullable(),
662
- txHash: z.string().nullable(),
663
- chain: z.string(),
664
- createdAt: z.number().nullable(),
665
- })),
666
- total: z.number().int(),
667
- offset: z.number().int(),
668
- limit: z.number().int(),
669
- }),
670
- },
671
- },
672
- },
673
- },
674
- });
675
- const adminIncomingQuerySchema = z.object({
676
- offset: z.coerce.number().int().min(0).default(0).optional(),
677
- limit: z.coerce.number().int().min(1).max(100).default(20).optional(),
678
- wallet_id: z.string().uuid().optional(),
679
- chain: z.string().optional(),
680
- status: z.string().optional(),
681
- suspicious: z.enum(['true', 'false']).optional(),
682
- });
683
- const adminIncomingRoute = createRoute({
684
- method: 'get',
685
- path: '/admin/incoming',
686
- tags: ['Admin'],
687
- summary: 'List cross-wallet incoming transactions with filters and pagination',
688
- request: {
689
- query: adminIncomingQuerySchema,
690
- },
691
- responses: {
692
- 200: {
693
- description: 'Paginated cross-wallet incoming transaction list',
694
- content: {
695
- 'application/json': {
696
- schema: z.object({
697
- items: z.array(z.object({
698
- id: z.string(),
699
- txHash: z.string(),
700
- walletId: z.string(),
701
- walletName: z.string().nullable(),
702
- fromAddress: z.string(),
703
- amount: z.string(),
704
- tokenAddress: z.string().nullable(),
705
- chain: z.string(),
706
- network: z.string(),
707
- status: z.string(),
708
- blockNumber: z.number().nullable(),
709
- detectedAt: z.number().nullable(),
710
- confirmedAt: z.number().nullable(),
711
- suspicious: z.boolean(),
712
- })),
713
- total: z.number().int(),
714
- offset: z.number().int(),
715
- limit: z.number().int(),
716
- }),
717
- },
718
- },
719
- },
720
- },
721
- });
15
+ import { OpenAPIHono } from '@hono/zod-openapi';
16
+ import { openApiValidationHook } from './openapi-schemas.js';
17
+ import { registerAdminAuthRoutes } from './admin-auth.js';
18
+ import { registerAdminNotificationRoutes } from './admin-notifications.js';
19
+ import { registerAdminSettingsRoutes } from './admin-settings.js';
20
+ import { registerAdminWalletRoutes } from './admin-wallets.js';
21
+ import { registerAdminMonitoringRoutes } from './admin-monitoring.js';
722
22
  // ---------------------------------------------------------------------------
723
23
  // Route factory
724
24
  // ---------------------------------------------------------------------------
725
- /**
726
- * Create admin route sub-router.
727
- *
728
- * GET /admin/status - Daemon health info (masterAuth)
729
- * POST /admin/kill-switch - Activate kill switch (masterAuth)
730
- * GET /admin/kill-switch - Get kill switch state (public)
731
- * POST /admin/recover - Deactivate kill switch (masterAuth)
732
- * POST /admin/shutdown - Graceful shutdown (masterAuth)
733
- * POST /admin/rotate-secret - Rotate JWT secret (masterAuth)
734
- * GET /admin/notifications/status - Notification channel status (masterAuth)
735
- * POST /admin/notifications/test - Send test notification (masterAuth)
736
- * GET /admin/notifications/log - Query notification logs (masterAuth)
737
- * GET /admin/settings - Get all settings (masterAuth)
738
- * PUT /admin/settings - Update settings (masterAuth)
739
- * POST /admin/settings/test-rpc - Test RPC connectivity (masterAuth)
740
- * GET /admin/oracle-status - Oracle cache/source/cross-validation status (masterAuth)
741
- * GET /admin/api-keys - List Action Provider API key status (masterAuth)
742
- * PUT /admin/api-keys/:provider - Set or update API key (masterAuth)
743
- * DELETE /admin/api-keys/:provider - Delete API key (masterAuth)
744
- * GET /admin/forex/rates - Forex exchange rates (masterAuth)
745
- * GET /admin/wallets/:id/balance - Wallet native+token balance (masterAuth)
746
- * GET /admin/wallets/:id/transactions - Wallet transaction history (masterAuth)
747
- * GET /admin/telegram-users - List Telegram bot users (masterAuth)
748
- * PUT /admin/telegram-users/:chatId - Update Telegram user role (masterAuth)
749
- * DELETE /admin/telegram-users/:chatId - Delete Telegram user (masterAuth)
750
- * GET /admin/transactions - Cross-wallet transaction list (masterAuth)
751
- * GET /admin/incoming - Cross-wallet incoming tx list (masterAuth)
752
- * POST /admin/transactions/:id/cancel - Cancel delayed transaction (masterAuth)
753
- * POST /admin/transactions/:id/reject - Reject pending approval transaction (masterAuth)
754
- */
755
- // ---------------------------------------------------------------------------
756
- // Amount formatting helpers (#168)
757
- // ---------------------------------------------------------------------------
758
- const NATIVE_DECIMALS = { solana: 9, ethereum: 18 };
759
- const NATIVE_SYMBOLS = { solana: 'SOL', ethereum: 'ETH' };
760
- /**
761
- * Format raw blockchain amount to human-readable string with token symbol.
762
- * Returns null if formatting is not possible (unknown token, null amount, etc).
763
- */
764
- function formatTxAmount(amount, chain, network, tokenAddress, db) {
765
- if (!amount || amount === '0')
766
- return amount;
767
- try {
768
- if (tokenAddress) {
769
- // Token transfer: look up decimals/symbol from token_registry
770
- const token = db
771
- .select({ symbol: tokenRegistry.symbol, decimals: tokenRegistry.decimals })
772
- .from(tokenRegistry)
773
- .where(and(eq(tokenRegistry.address, tokenAddress), network ? eq(tokenRegistry.network, network) : undefined))
774
- .limit(1)
775
- .get();
776
- if (!token)
777
- return null; // unknown token → caller falls back to raw
778
- return `${formatAmount(BigInt(amount), token.decimals)} ${token.symbol}`;
779
- }
780
- // Native transfer
781
- const decimals = NATIVE_DECIMALS[chain] ?? 18;
782
- const symbol = NATIVE_SYMBOLS[chain] ?? chain.toUpperCase();
783
- return `${formatAmount(BigInt(amount), decimals)} ${symbol}`;
784
- }
785
- catch {
786
- return null;
787
- }
788
- }
789
25
  export function adminRoutes(deps) {
790
26
  const router = new OpenAPIHono({ defaultHook: openApiValidationHook });
791
- // ---------------------------------------------------------------------------
792
- // GET /admin/status
793
- // ---------------------------------------------------------------------------
794
- router.openapi(statusRoute, async (c) => {
795
- const nowSec = Math.floor(Date.now() / 1000);
796
- const uptime = nowSec - deps.startTime;
797
- // Count wallets
798
- const walletCountResult = deps.db
799
- .select({ count: sql `count(*)` })
800
- .from(wallets)
801
- .get();
802
- const walletCount = walletCountResult?.count ?? 0;
803
- // Count active sessions (not expired, not revoked)
804
- // Use raw SQL with integer comparison (expiresAt is stored as epoch seconds)
805
- const activeSessionResult = deps.db
806
- .select({ count: sql `count(*)` })
807
- .from(sessions)
808
- .where(sql `${sessions.revokedAt} IS NULL AND (${sessions.expiresAt} = 0 OR ${sessions.expiresAt} > ${nowSec})`)
809
- .get();
810
- const activeSessionCount = activeSessionResult?.count ?? 0;
811
- // Count policies
812
- const policyCountResult = deps.db
813
- .select({ count: sql `count(*)` })
814
- .from(policies)
815
- .get();
816
- const policyCount = policyCountResult?.count ?? 0;
817
- // Count recent transactions (24h) -- created_at stored as epoch seconds in integer column
818
- const cutoffSec = nowSec - 86400;
819
- const recentTxCountResult = deps.db
820
- .select({ count: sql `count(*)` })
821
- .from(transactions)
822
- .where(sql `${transactions.createdAt} > ${cutoffSec}`)
823
- .get();
824
- const recentTxCount = recentTxCountResult?.count ?? 0;
825
- // Count failed transactions (24h)
826
- const failedTxCountResult = deps.db
827
- .select({ count: sql `count(*)` })
828
- .from(transactions)
829
- .where(sql `${transactions.status} = 'FAILED' AND ${transactions.createdAt} > ${cutoffSec}`)
830
- .get();
831
- const failedTxCount = failedTxCountResult?.count ?? 0;
832
- // Recent 5 transactions with wallet name
833
- const recentTxRows = deps.db
834
- .select({
835
- id: transactions.id,
836
- walletId: transactions.walletId,
837
- walletName: wallets.name,
838
- type: transactions.type,
839
- status: transactions.status,
840
- toAddress: transactions.toAddress,
841
- amount: transactions.amount,
842
- amountUsd: transactions.amountUsd,
843
- network: transactions.network,
844
- txHash: transactions.txHash,
845
- chain: transactions.chain,
846
- tokenMint: transactions.tokenMint,
847
- contractAddress: transactions.contractAddress,
848
- createdAt: transactions.createdAt,
849
- })
850
- .from(transactions)
851
- .leftJoin(wallets, eq(transactions.walletId, wallets.id))
852
- .orderBy(desc(transactions.createdAt))
853
- .limit(5)
854
- .all();
855
- const recentTransactions = recentTxRows.map((tx) => {
856
- const tokenAddr = tx.tokenMint ?? tx.contractAddress ?? null;
857
- return {
858
- id: tx.id,
859
- walletId: tx.walletId,
860
- walletName: tx.walletName ?? null,
861
- type: tx.type,
862
- status: tx.status,
863
- toAddress: tx.toAddress ?? null,
864
- amount: tx.amount ?? null,
865
- formattedAmount: formatTxAmount(tx.amount ?? null, tx.chain, tx.network ?? null, tokenAddr, deps.db),
866
- amountUsd: tx.amountUsd ?? null,
867
- network: tx.network ?? null,
868
- txHash: tx.txHash ?? null,
869
- createdAt: tx.createdAt instanceof Date
870
- ? Math.floor(tx.createdAt.getTime() / 1000)
871
- : (typeof tx.createdAt === 'number' ? tx.createdAt : null),
872
- };
873
- });
874
- const ksState = deps.killSwitchService
875
- ? deps.killSwitchService.getState()
876
- : deps.getKillSwitchState();
877
- const latestVersion = deps.versionCheckService?.getLatest() ?? null;
878
- const semverMod = await import('semver');
879
- const updateAvailable = latestVersion !== null
880
- && semverMod.default.valid(latestVersion) !== null
881
- && semverMod.default.gt(latestVersion, deps.version);
882
- // Check for auto-provisioned status (recovery.key exists in data dir)
883
- const { existsSync } = await import('node:fs');
884
- const { join } = await import('node:path');
885
- const autoProvisioned = deps.dataDir
886
- ? existsSync(join(deps.dataDir, 'recovery.key'))
887
- : false;
888
- return c.json({
889
- status: 'running',
890
- version: deps.version,
891
- latestVersion,
892
- updateAvailable,
893
- uptime,
894
- walletCount,
895
- activeSessionCount,
896
- killSwitchState: ksState.state,
897
- adminTimeout: deps.adminTimeout,
898
- timestamp: nowSec,
899
- policyCount,
900
- recentTxCount,
901
- failedTxCount,
902
- autoProvisioned,
903
- recentTransactions,
904
- }, 200);
905
- });
906
- // ---------------------------------------------------------------------------
907
- // POST /admin/kill-switch
908
- // ---------------------------------------------------------------------------
909
- router.openapi(activateKillSwitchRoute, async (c) => {
910
- if (deps.killSwitchService) {
911
- const result = deps.killSwitchService.activateWithCascade('master');
912
- if (!result.success) {
913
- throw new WAIaaSError('KILL_SWITCH_ACTIVE', {
914
- message: result.error ?? 'Kill switch is already active',
915
- });
916
- }
917
- const state = deps.killSwitchService.getState();
918
- return c.json({
919
- state: 'SUSPENDED',
920
- activatedAt: state.activatedAt ?? Math.floor(Date.now() / 1000),
921
- }, 200);
922
- }
923
- // Legacy fallback (no KillSwitchService)
924
- const ksState = deps.getKillSwitchState();
925
- if (ksState.state !== 'ACTIVE' && ksState.state !== 'NORMAL') {
926
- throw new WAIaaSError('KILL_SWITCH_ACTIVE', {
927
- message: 'Kill switch is already activated',
928
- });
929
- }
930
- const nowSec = Math.floor(Date.now() / 1000);
931
- deps.setKillSwitchState('SUSPENDED', 'master');
932
- return c.json({
933
- state: 'SUSPENDED',
934
- activatedAt: nowSec,
935
- }, 200);
936
- });
937
- // ---------------------------------------------------------------------------
938
- // GET /admin/kill-switch
939
- // ---------------------------------------------------------------------------
940
- router.openapi(getKillSwitchRoute, async (c) => {
941
- if (deps.killSwitchService) {
942
- const ksState = deps.killSwitchService.getState();
943
- return c.json({
944
- state: ksState.state,
945
- activatedAt: ksState.activatedAt,
946
- activatedBy: ksState.activatedBy,
947
- }, 200);
948
- }
949
- const ksState = deps.getKillSwitchState();
950
- return c.json({
951
- state: ksState.state,
952
- activatedAt: ksState.activatedAt,
953
- activatedBy: ksState.activatedBy,
954
- }, 200);
955
- });
956
- // ---------------------------------------------------------------------------
957
- // POST /admin/kill-switch/escalate
958
- // ---------------------------------------------------------------------------
959
- router.openapi(escalateKillSwitchRoute, async (c) => {
960
- if (deps.killSwitchService) {
961
- const result = deps.killSwitchService.escalateWithCascade('master');
962
- if (!result.success) {
963
- throw new WAIaaSError('INVALID_STATE_TRANSITION', {
964
- message: result.error ?? 'Cannot escalate kill switch',
965
- });
966
- }
967
- const state = deps.killSwitchService.getState();
968
- return c.json({
969
- state: 'LOCKED',
970
- escalatedAt: state.activatedAt ?? Math.floor(Date.now() / 1000),
971
- }, 200);
972
- }
973
- throw new WAIaaSError('INVALID_STATE_TRANSITION', {
974
- message: 'Kill switch service not available',
975
- });
976
- });
977
- // ---------------------------------------------------------------------------
978
- // POST /admin/recover (dual-auth recovery)
979
- // ---------------------------------------------------------------------------
980
- router.openapi(recoverRoute, async (c) => {
981
- if (deps.killSwitchService) {
982
- const currentState = deps.killSwitchService.getState();
983
- if (currentState.state === 'ACTIVE') {
984
- throw new WAIaaSError('KILL_SWITCH_NOT_ACTIVE', {
985
- message: 'Kill switch is not active, nothing to recover',
986
- });
987
- }
988
- // Master password (masterAuth middleware) is sufficient for recovery.
989
- // Self-hosted daemon: admin with master password = server/DB access.
990
- // Dual-auth adds no real security but blocks emergency recovery.
991
- // LOCKED recovery: additional wait time (5 seconds)
992
- if (currentState.state === 'LOCKED') {
993
- await new Promise((resolve) => setTimeout(resolve, 5000));
994
- const success = deps.killSwitchService.recoverFromLocked();
995
- if (!success) {
996
- throw new WAIaaSError('INVALID_STATE_TRANSITION', {
997
- message: 'Failed to recover from LOCKED state (concurrent state change)',
998
- });
999
- }
1000
- }
1001
- else {
1002
- // SUSPENDED recovery
1003
- const success = deps.killSwitchService.recoverFromSuspended();
1004
- if (!success) {
1005
- throw new WAIaaSError('INVALID_STATE_TRANSITION', {
1006
- message: 'Failed to recover from SUSPENDED state (concurrent state change)',
1007
- });
1008
- }
1009
- }
1010
- const nowSec = Math.floor(Date.now() / 1000);
1011
- // Send recovery notification
1012
- if (deps.notificationService) {
1013
- void deps.notificationService.notify('KILL_SWITCH_RECOVERED', 'system', {});
1014
- }
1015
- return c.json({
1016
- state: 'ACTIVE',
1017
- recoveredAt: nowSec,
1018
- }, 200);
1019
- }
1020
- // Legacy fallback
1021
- const ksState = deps.getKillSwitchState();
1022
- if (ksState.state === 'NORMAL' || ksState.state === 'ACTIVE') {
1023
- throw new WAIaaSError('KILL_SWITCH_NOT_ACTIVE', {
1024
- message: 'Kill switch is not active, nothing to recover',
1025
- });
1026
- }
1027
- deps.setKillSwitchState('ACTIVE');
1028
- const nowSec = Math.floor(Date.now() / 1000);
1029
- return c.json({
1030
- state: 'ACTIVE',
1031
- recoveredAt: nowSec,
1032
- }, 200);
1033
- });
1034
- // ---------------------------------------------------------------------------
1035
- // POST /admin/shutdown
1036
- // ---------------------------------------------------------------------------
1037
- router.openapi(shutdownRoute, async (c) => {
1038
- if (deps.requestShutdown) {
1039
- deps.requestShutdown();
1040
- }
1041
- return c.json({
1042
- message: 'Shutdown initiated',
1043
- }, 200);
1044
- });
1045
- // ---------------------------------------------------------------------------
1046
- // PUT /admin/master-password
1047
- // ---------------------------------------------------------------------------
1048
- router.openapi(masterPasswordChangeRoute, async (c) => {
1049
- const body = c.req.valid('json');
1050
- const newPassword = body.newPassword;
1051
- if (!deps.passwordRef) {
1052
- throw new WAIaaSError('ACTION_VALIDATION_FAILED', {
1053
- message: 'Password change not supported (passwordRef not available)',
1054
- });
1055
- }
1056
- const oldPassword = deps.passwordRef.password;
1057
- if (newPassword === oldPassword) {
1058
- throw new WAIaaSError('ACTION_VALIDATION_FAILED', {
1059
- message: 'New password must be different from the current password',
1060
- });
1061
- }
1062
- // 1. Re-encrypt keystore files
1063
- const { join } = await import('node:path');
1064
- const { reEncryptKeystores, reEncryptSettings } = await import('../../infrastructure/keystore/re-encrypt.js');
1065
- const keystoreDir = deps.dataDir ? join(deps.dataDir, 'keystore') : null;
1066
- let walletsReEncrypted = 0;
1067
- if (keystoreDir) {
1068
- const { existsSync } = await import('node:fs');
1069
- if (existsSync(keystoreDir)) {
1070
- walletsReEncrypted = await reEncryptKeystores(keystoreDir, oldPassword, newPassword);
1071
- }
1072
- }
1073
- // 2. Re-encrypt settings + API keys in DB
1074
- const settingsReEncrypted = reEncryptSettings(deps.db, deps.sqlite, oldPassword, newPassword);
1075
- // 3. Compute new Argon2id hash
1076
- const argon2 = await import('argon2');
1077
- const newHash = await argon2.default.hash(newPassword, {
1078
- type: argon2.default.argon2id,
1079
- memoryCost: 19456,
1080
- timeCost: 2,
1081
- parallelism: 1,
1082
- });
1083
- // 4. Update DB master_password_hash
1084
- const { keyValueStore } = await import('../../infrastructure/database/schema.js');
1085
- deps.db
1086
- .update(keyValueStore)
1087
- .set({ value: newHash, updatedAt: new Date() })
1088
- .where(eq(keyValueStore.key, 'master_password_hash'))
1089
- .run();
1090
- // 5. Update in-memory ref (live swap)
1091
- deps.passwordRef.password = newPassword;
1092
- deps.passwordRef.hash = newHash;
1093
- // 6. Delete recovery.key if it exists (auto-provision → manual)
1094
- if (deps.dataDir) {
1095
- const recoveryPath = join(deps.dataDir, 'recovery.key');
1096
- const { existsSync, unlinkSync } = await import('node:fs');
1097
- if (existsSync(recoveryPath)) {
1098
- try {
1099
- unlinkSync(recoveryPath);
1100
- }
1101
- catch {
1102
- // Non-fatal: recovery.key cleanup failure
1103
- }
1104
- }
1105
- }
1106
- return c.json({
1107
- message: 'Master password changed successfully',
1108
- walletsReEncrypted,
1109
- settingsReEncrypted,
1110
- }, 200);
1111
- });
1112
- // ---------------------------------------------------------------------------
1113
- // POST /admin/rotate-secret
1114
- // ---------------------------------------------------------------------------
1115
- router.openapi(rotateSecretRoute, async (c) => {
1116
- if (!deps.jwtSecretManager) {
1117
- throw new WAIaaSError('ADAPTER_NOT_AVAILABLE', {
1118
- message: 'JWT secret manager not available',
1119
- });
1120
- }
1121
- await deps.jwtSecretManager.rotateSecret();
1122
- const nowSec = Math.floor(Date.now() / 1000);
1123
- return c.json({
1124
- rotatedAt: nowSec,
1125
- message: 'JWT secret rotated. Old tokens valid for 5 minutes.',
1126
- }, 200);
1127
- });
1128
- // ---------------------------------------------------------------------------
1129
- // GET /admin/notifications/status
1130
- // ---------------------------------------------------------------------------
1131
- router.openapi(notificationsStatusRoute, async (c) => {
1132
- const ss = deps.settingsService;
1133
- const svc = deps.notificationService;
1134
- const channelNames = svc ? svc.getChannelNames() : [];
1135
- // Read from SettingsService (dynamic) instead of static config snapshot
1136
- const enabled = ss ? ss.get('notifications.enabled') === 'true' : (deps.notificationConfig?.enabled ?? false);
1137
- const channels = [
1138
- {
1139
- name: 'telegram',
1140
- enabled: !!((ss ? ss.get('notifications.telegram_bot_token') : deps.notificationConfig?.telegram_bot_token) &&
1141
- (ss ? ss.get('notifications.telegram_chat_id') : deps.notificationConfig?.telegram_chat_id) &&
1142
- channelNames.includes('telegram')),
1143
- },
1144
- {
1145
- name: 'discord',
1146
- enabled: !!((ss ? ss.get('notifications.discord_webhook_url') : deps.notificationConfig?.discord_webhook_url) &&
1147
- channelNames.includes('discord')),
1148
- },
1149
- (() => {
1150
- // v29.10+: ntfy topics are per-wallet in wallet_apps table, not global config
1151
- const ntfyWalletCount = deps.db
1152
- .select({ count: sql `count(*)` })
1153
- .from(walletApps)
1154
- .where(sql `${walletApps.signTopic} IS NOT NULL OR ${walletApps.notifyTopic} IS NOT NULL`)
1155
- .get()?.count ?? 0;
1156
- return {
1157
- name: 'ntfy',
1158
- enabled: ntfyWalletCount > 0,
1159
- configuredWallets: ntfyWalletCount,
1160
- };
1161
- })(),
1162
- {
1163
- name: 'slack',
1164
- enabled: !!((ss ? ss.get('notifications.slack_webhook_url') : deps.notificationConfig?.slack_webhook_url) &&
1165
- channelNames.includes('slack')),
1166
- },
1167
- ];
1168
- return c.json({
1169
- enabled,
1170
- channels,
1171
- }, 200);
1172
- });
1173
- // ---------------------------------------------------------------------------
1174
- // POST /admin/notifications/test
1175
- // ---------------------------------------------------------------------------
1176
- router.openapi(notificationsTestRoute, async (c) => {
1177
- const svc = deps.notificationService;
1178
- if (!svc) {
1179
- return c.json({ results: [] }, 200);
1180
- }
1181
- const body = await c.req.json().catch(() => ({}));
1182
- const allChannels = svc.getChannels();
1183
- const targetChannels = body.channel
1184
- ? allChannels.filter((ch) => ch.name === body.channel)
1185
- : allChannels;
1186
- const testPayload = {
1187
- eventType: 'TX_CONFIRMED',
1188
- walletId: 'admin-test',
1189
- title: '[Test] Notification Test',
1190
- body: 'WAIaaS notification test',
1191
- message: '[Test] Notification Test\nWAIaaS notification test',
1192
- timestamp: Math.floor(Date.now() / 1000),
1193
- };
1194
- const results = [];
1195
- for (const ch of targetChannels) {
1196
- try {
1197
- await ch.send(testPayload);
1198
- results.push({ channel: ch.name, success: true });
1199
- }
1200
- catch (err) {
1201
- results.push({
1202
- channel: ch.name,
1203
- success: false,
1204
- error: err instanceof Error ? err.message : String(err),
1205
- });
1206
- }
1207
- }
1208
- return c.json({ results }, 200);
1209
- });
1210
- // ---------------------------------------------------------------------------
1211
- // GET /admin/notifications/log
1212
- // ---------------------------------------------------------------------------
1213
- router.openapi(notificationsLogRoute, async (c) => {
1214
- const query = c.req.valid('query');
1215
- const page = Math.max(1, parseInt(query.page ?? '1', 10) || 1);
1216
- const pageSize = Math.min(100, Math.max(1, parseInt(query.pageSize ?? '20', 10) || 20));
1217
- const offset = (page - 1) * pageSize;
1218
- // Build where conditions
1219
- const conditions = [];
1220
- if (query.channel) {
1221
- conditions.push(eq(notificationLogs.channel, query.channel));
1222
- }
1223
- if (query.status) {
1224
- conditions.push(eq(notificationLogs.status, query.status));
1225
- }
1226
- if (query.eventType) {
1227
- conditions.push(eq(notificationLogs.eventType, query.eventType));
1228
- }
1229
- if (query.since) {
1230
- const sinceTs = parseInt(query.since, 10);
1231
- if (!isNaN(sinceTs)) {
1232
- conditions.push(gte(notificationLogs.createdAt, new Date(sinceTs * 1000)));
1233
- }
1234
- }
1235
- if (query.until) {
1236
- const untilTs = parseInt(query.until, 10);
1237
- if (!isNaN(untilTs)) {
1238
- conditions.push(lte(notificationLogs.createdAt, new Date(untilTs * 1000)));
1239
- }
1240
- }
1241
- const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
1242
- // Query total count
1243
- const totalResult = deps.db
1244
- .select({ count: drizzleCount() })
1245
- .from(notificationLogs)
1246
- .where(whereClause)
1247
- .get();
1248
- const total = totalResult?.count ?? 0;
1249
- // Query logs with pagination
1250
- const rows = deps.db
1251
- .select()
1252
- .from(notificationLogs)
1253
- .where(whereClause)
1254
- .orderBy(desc(notificationLogs.createdAt))
1255
- .limit(pageSize)
1256
- .offset(offset)
1257
- .all();
1258
- const logs = rows.map((row) => ({
1259
- id: row.id,
1260
- eventType: row.eventType,
1261
- walletId: row.walletId ?? null,
1262
- channel: row.channel,
1263
- status: row.status,
1264
- error: row.error ?? null,
1265
- message: row.message ?? null,
1266
- createdAt: row.createdAt instanceof Date
1267
- ? Math.floor(row.createdAt.getTime() / 1000)
1268
- : (typeof row.createdAt === 'number' ? row.createdAt : 0),
1269
- }));
1270
- return c.json({ logs, total, page, pageSize }, 200);
1271
- });
1272
- // ---------------------------------------------------------------------------
1273
- // GET /admin/settings
1274
- // ---------------------------------------------------------------------------
1275
- router.openapi(settingsGetRoute, async (c) => {
1276
- if (!deps.settingsService) {
1277
- const emptyCategory = {};
1278
- return c.json({
1279
- notifications: emptyCategory,
1280
- rpc: emptyCategory,
1281
- security: emptyCategory,
1282
- daemon: emptyCategory,
1283
- walletconnect: emptyCategory,
1284
- oracle: emptyCategory,
1285
- display: emptyCategory,
1286
- autostop: emptyCategory,
1287
- monitoring: emptyCategory,
1288
- telegram: emptyCategory,
1289
- signing_sdk: emptyCategory,
1290
- gas_condition: emptyCategory,
1291
- }, 200);
1292
- }
1293
- const masked = deps.settingsService.getAllMasked();
1294
- return c.json(masked, 200);
1295
- });
1296
- // ---------------------------------------------------------------------------
1297
- // PUT /admin/settings
1298
- // ---------------------------------------------------------------------------
1299
- router.openapi(settingsPutRoute, async (c) => {
1300
- const body = c.req.valid('json');
1301
- const entries = body.settings;
1302
- // Validate all keys exist in SETTING_DEFINITIONS (or dynamic tier pattern)
1303
- for (const entry of entries) {
1304
- const def = getSettingDefinition(entry.key);
1305
- if (!def) {
1306
- throw new WAIaaSError('ACTION_VALIDATION_FAILED', {
1307
- message: `Unknown setting key: ${entry.key}`,
1308
- });
1309
- }
1310
- // [Phase 331] Validate tier override values
1311
- if (entry.key.startsWith('actions.') && entry.key.endsWith('_tier')) {
1312
- const parsed = ActionTierOverrideSchema.safeParse(entry.value);
1313
- if (!parsed.success) {
1314
- throw new WAIaaSError('ACTION_VALIDATION_FAILED', {
1315
- message: `Invalid tier value '${entry.value}' for key '${entry.key}'. Must be one of: INSTANT, NOTIFY, DELAY, APPROVAL, or empty string.`,
1316
- });
1317
- }
1318
- }
1319
- }
1320
- if (!deps.settingsService) {
1321
- throw new WAIaaSError('ADAPTER_NOT_AVAILABLE', {
1322
- message: 'Settings service not available',
1323
- });
1324
- }
1325
- // Persist all values
1326
- deps.settingsService.setMany(entries);
1327
- // Notify hot-reload callback if provided
1328
- if (deps.onSettingsChanged) {
1329
- deps.onSettingsChanged(entries.map((e) => e.key));
1330
- }
1331
- const masked = deps.settingsService.getAllMasked();
1332
- return c.json({
1333
- updated: entries.length,
1334
- settings: masked,
1335
- }, 200);
1336
- });
1337
- // ---------------------------------------------------------------------------
1338
- // POST /admin/settings/test-rpc
1339
- // ---------------------------------------------------------------------------
1340
- router.openapi(testRpcRoute, async (c) => {
1341
- const body = c.req.valid('json');
1342
- const { url, chain } = body;
1343
- const rpcMethod = chain === 'solana' ? 'getBlockHeight' : 'eth_blockNumber';
1344
- const rpcBody = JSON.stringify({
1345
- jsonrpc: '2.0',
1346
- method: rpcMethod,
1347
- params: [],
1348
- id: 1,
1349
- });
1350
- const startMs = performance.now();
1351
- try {
1352
- const response = await fetch(url, {
1353
- method: 'POST',
1354
- headers: { 'Content-Type': 'application/json' },
1355
- body: rpcBody,
1356
- signal: AbortSignal.timeout(5000),
1357
- });
1358
- const latencyMs = Math.round(performance.now() - startMs);
1359
- const result = (await response.json());
1360
- if (result.error) {
1361
- return c.json({
1362
- success: false,
1363
- latencyMs,
1364
- error: result.error.message ?? 'RPC error',
1365
- }, 200);
1366
- }
1367
- // Parse block number from result
1368
- let blockNumber;
1369
- if (chain === 'solana') {
1370
- blockNumber = typeof result.result === 'number' ? result.result : undefined;
1371
- }
1372
- else {
1373
- // eth_blockNumber returns hex string
1374
- blockNumber =
1375
- typeof result.result === 'string'
1376
- ? parseInt(result.result, 16)
1377
- : undefined;
1378
- }
1379
- return c.json({
1380
- success: true,
1381
- latencyMs,
1382
- blockNumber,
1383
- }, 200);
1384
- }
1385
- catch (err) {
1386
- const latencyMs = Math.round(performance.now() - startMs);
1387
- const errorMessage = err instanceof Error ? err.message : String(err);
1388
- return c.json({
1389
- success: false,
1390
- latencyMs,
1391
- error: errorMessage,
1392
- }, 200);
1393
- }
1394
- });
1395
- // ---------------------------------------------------------------------------
1396
- // GET /admin/oracle-status
1397
- // ---------------------------------------------------------------------------
1398
- router.openapi(oracleStatusRoute, async (c) => {
1399
- const stats = deps.priceOracle?.getCacheStats() ?? { hits: 0, misses: 0, staleHits: 0, size: 0, evictions: 0 };
1400
- return c.json({
1401
- cache: stats,
1402
- sources: {
1403
- pyth: {
1404
- available: !!deps.priceOracle,
1405
- baseUrl: 'https://hermes.pyth.network',
1406
- },
1407
- coingecko: {
1408
- available: deps.oracleConfig?.coingeckoApiKeyConfigured ?? false,
1409
- apiKeyConfigured: deps.oracleConfig?.coingeckoApiKeyConfigured ?? false,
1410
- },
1411
- },
1412
- crossValidation: {
1413
- enabled: deps.oracleConfig?.coingeckoApiKeyConfigured ?? false,
1414
- threshold: deps.oracleConfig?.crossValidationThreshold ?? 5,
1415
- },
1416
- }, 200);
1417
- });
1418
- // ---------------------------------------------------------------------------
1419
- // GET /admin/api-keys
1420
- // ---------------------------------------------------------------------------
1421
- router.openapi(apiKeysListRoute, async (c) => {
1422
- const registry = deps.actionProviderRegistry;
1423
- const ss = deps.settingsService;
1424
- if (!registry) {
1425
- return c.json({ keys: [] }, 200);
1426
- }
1427
- const providers = registry.listProviders();
1428
- const keys = providers.map((p) => {
1429
- const hasKey = ss ? ss.hasApiKey(p.name) : false;
1430
- const maskedKey = ss ? ss.getApiKeyMasked(p.name) : null;
1431
- const updatedAt = ss ? ss.getApiKeyUpdatedAt(p.name) : null;
1432
- return {
1433
- providerName: p.name,
1434
- hasKey,
1435
- maskedKey,
1436
- requiresApiKey: p.requiresApiKey ?? false,
1437
- updatedAt: updatedAt instanceof Date ? updatedAt.toISOString() : null,
1438
- };
1439
- });
1440
- return c.json({ keys }, 200);
1441
- });
1442
- // ---------------------------------------------------------------------------
1443
- // PUT /admin/api-keys/:provider
1444
- // ---------------------------------------------------------------------------
1445
- router.openapi(apiKeyPutRoute, async (c) => {
1446
- const { provider } = c.req.valid('param');
1447
- const body = c.req.valid('json');
1448
- if (!deps.settingsService) {
1449
- throw new WAIaaSError('ADAPTER_NOT_AVAILABLE', {
1450
- message: 'Settings service not available',
1451
- });
1452
- }
1453
- const settingKey = `actions.${provider}_api_key`;
1454
- deps.settingsService.setApiKey(provider, body.apiKey);
1455
- // Trigger hot-reload so providers pick up the new key immediately
1456
- if (deps.onSettingsChanged) {
1457
- deps.onSettingsChanged([settingKey]);
1458
- }
1459
- return c.json({ success: true, providerName: provider }, 200);
1460
- });
1461
- // ---------------------------------------------------------------------------
1462
- // DELETE /admin/api-keys/:provider
1463
- // ---------------------------------------------------------------------------
1464
- router.openapi(apiKeyDeleteRoute, async (c) => {
1465
- const { provider } = c.req.valid('param');
1466
- if (!deps.settingsService) {
1467
- throw new WAIaaSError('ADAPTER_NOT_AVAILABLE', {
1468
- message: 'Settings service not available',
1469
- });
1470
- }
1471
- // Check if key exists before "deleting"
1472
- if (!deps.settingsService.hasApiKey(provider)) {
1473
- throw new WAIaaSError('ACTION_NOT_FOUND', {
1474
- message: `No API key found for provider '${provider}'`,
1475
- details: { providerName: provider },
1476
- });
1477
- }
1478
- const settingKey = `actions.${provider}_api_key`;
1479
- deps.settingsService.setApiKey(provider, '');
1480
- // Trigger hot-reload
1481
- if (deps.onSettingsChanged) {
1482
- deps.onSettingsChanged([settingKey]);
1483
- }
1484
- return c.json({ success: true }, 200);
1485
- });
1486
- // ---------------------------------------------------------------------------
1487
- // GET /admin/forex/rates
1488
- // ---------------------------------------------------------------------------
1489
- router.openapi(forexRatesRoute, async (c) => {
1490
- const { currencies: currenciesParam } = c.req.valid('query');
1491
- const rates = {};
1492
- if (!currenciesParam || !deps.forexRateService) {
1493
- return c.json({ rates }, 200);
1494
- }
1495
- // Parse comma-separated currency codes, validate each
1496
- const codes = currenciesParam
1497
- .split(',')
1498
- .map((s) => s.trim().toUpperCase())
1499
- .filter((s) => CurrencyCodeSchema.safeParse(s).success);
1500
- if (codes.length === 0) {
1501
- return c.json({ rates }, 200);
1502
- }
1503
- const rateMap = await deps.forexRateService.getRates(codes);
1504
- for (const [code, forexRate] of rateMap) {
1505
- rates[code] = {
1506
- rate: forexRate.rate,
1507
- preview: formatRatePreview(forexRate.rate, code),
1508
- };
1509
- }
1510
- return c.json({ rates }, 200);
1511
- });
1512
- // ---------------------------------------------------------------------------
1513
- // GET /admin/wallets/:id/transactions
1514
- // ---------------------------------------------------------------------------
1515
- router.openapi(adminWalletTransactionsRoute, async (c) => {
1516
- const { id } = c.req.valid('param');
1517
- const query = c.req.valid('query');
1518
- const limit = query.limit ?? 20;
1519
- const offset = query.offset ?? 0;
1520
- // Verify wallet exists
1521
- const wallet = deps.db.select().from(wallets).where(eq(wallets.id, id)).get();
1522
- if (!wallet) {
1523
- throw new WAIaaSError('WALLET_NOT_FOUND');
1524
- }
1525
- // Query transactions for this wallet
1526
- const rows = deps.db
1527
- .select()
1528
- .from(transactions)
1529
- .where(eq(transactions.walletId, id))
1530
- .orderBy(desc(transactions.createdAt))
1531
- .limit(limit)
1532
- .offset(offset)
1533
- .all();
1534
- // Total count
1535
- const totalResult = deps.db
1536
- .select({ count: sql `count(*)` })
1537
- .from(transactions)
1538
- .where(eq(transactions.walletId, id))
1539
- .get();
1540
- const total = totalResult?.count ?? 0;
1541
- const items = rows.map((tx) => {
1542
- const tokenAddr = tx.tokenMint ?? tx.contractAddress ?? null;
1543
- return {
1544
- id: tx.id,
1545
- type: tx.type,
1546
- status: tx.status,
1547
- toAddress: tx.toAddress ?? null,
1548
- amount: tx.amount ?? null,
1549
- formattedAmount: formatTxAmount(tx.amount ?? null, tx.chain, tx.network ?? null, tokenAddr, deps.db),
1550
- amountUsd: tx.amountUsd ?? null,
1551
- network: tx.network ?? null,
1552
- txHash: tx.txHash ?? null,
1553
- createdAt: tx.createdAt instanceof Date
1554
- ? Math.floor(tx.createdAt.getTime() / 1000)
1555
- : (typeof tx.createdAt === 'number' ? tx.createdAt : null),
1556
- };
1557
- });
1558
- return c.json({ items, total }, 200);
1559
- });
1560
- // ---------------------------------------------------------------------------
1561
- // GET /admin/wallets/:id/balance
1562
- // ---------------------------------------------------------------------------
1563
- router.openapi(adminWalletBalanceRoute, async (c) => {
1564
- const { id } = c.req.valid('param');
1565
- // Verify wallet exists
1566
- const wallet = deps.db.select().from(wallets).where(eq(wallets.id, id)).get();
1567
- if (!wallet) {
1568
- throw new WAIaaSError('WALLET_NOT_FOUND');
1569
- }
1570
- // If no adapter pool, return empty balances
1571
- if (!deps.adapterPool) {
1572
- return c.json({ balances: [] }, 200);
1573
- }
1574
- const chain = wallet.chain;
1575
- const env = wallet.environment;
1576
- const networks = getNetworksForEnvironment(chain, env);
1577
- const results = await Promise.allSettled(networks.map(async (network) => {
1578
- const rpcUrl = resolveRpcUrl(deps.daemonConfig.rpc, wallet.chain, network);
1579
- if (!rpcUrl) {
1580
- return { network, native: null, tokens: [], error: 'RPC endpoint not configured' };
1581
- }
1582
- const adapter = await deps.adapterPool.resolve(chain, network, rpcUrl);
1583
- const balanceInfo = await adapter.getBalance(wallet.publicKey);
1584
- const nativeBalance = (Number(balanceInfo.balance) / 10 ** balanceInfo.decimals).toString();
1585
- // Resolve USD price for native token if price oracle is available
1586
- let nativeUsd = null;
1587
- if (deps.priceOracle) {
1588
- try {
1589
- const priceInfo = await deps.priceOracle.getNativePrice(chain);
1590
- nativeUsd = Number(nativeBalance) * priceInfo.usdPrice;
1591
- }
1592
- catch { /* non-critical: USD price unavailable */ }
1593
- }
1594
- const assets = await adapter.getAssets(wallet.publicKey);
1595
- const tokens = assets
1596
- .filter((a) => !a.isNative)
1597
- .map((a) => ({
1598
- symbol: a.symbol,
1599
- balance: (Number(a.balance) / 10 ** a.decimals).toString(),
1600
- address: a.mint,
1601
- }));
1602
- return {
1603
- network,
1604
- native: { balance: nativeBalance, symbol: balanceInfo.symbol, usd: nativeUsd },
1605
- tokens,
1606
- };
1607
- }));
1608
- const balances = results.map((r, i) => {
1609
- if (r.status === 'fulfilled')
1610
- return r.value;
1611
- const errorMessage = r.reason instanceof Error ? r.reason.message : String(r.reason);
1612
- return { network: networks[i], native: null, tokens: [], error: errorMessage };
1613
- });
1614
- return c.json({ balances }, 200);
1615
- });
1616
- // ---------------------------------------------------------------------------
1617
- // GET /admin/wallets/:id/staking
1618
- // ---------------------------------------------------------------------------
1619
- router.openapi(adminWalletStakingRoute, async (c) => {
1620
- const { id } = c.req.valid('param');
1621
- // Verify wallet exists
1622
- const wallet = deps.db.select().from(wallets).where(eq(wallets.id, id)).get();
1623
- if (!wallet) {
1624
- throw new WAIaaSError('WALLET_NOT_FOUND');
1625
- }
1626
- const positions = [];
1627
- if (!deps.sqlite) {
1628
- return c.json({ walletId: id, positions }, 200);
1629
- }
1630
- const LIDO_APY = '~3.5%';
1631
- const JITO_APY = '~7.5%';
1632
- // Reuse aggregation logic inline (same as staking.ts)
1633
- function aggregateProvider(walletId, providerKey) {
1634
- const stakeRows = deps.sqlite.prepare(`SELECT amount, bridge_status, created_at, metadata
1635
- FROM transactions
1636
- WHERE wallet_id = ? AND status IN ('CONFIRMED', 'COMPLETED')
1637
- AND metadata LIKE ?
1638
- ORDER BY created_at ASC`).all(walletId, `%${providerKey}%`);
1639
- let totalStaked = 0n;
1640
- let totalUnstaked = 0n;
1641
- for (const row of stakeRows) {
1642
- // Fallback: if amount is NULL, try extracting from metadata (CONTRACT_CALL value)
1643
- let effectiveAmount = row.amount;
1644
- if (!effectiveAmount && row.metadata) {
1645
- try {
1646
- const meta = JSON.parse(row.metadata);
1647
- const origReq = meta.originalRequest;
1648
- if (origReq?.value && typeof origReq.value === 'string') {
1649
- effectiveAmount = origReq.value;
1650
- }
1651
- }
1652
- catch { /* ignore */ }
1653
- }
1654
- if (!effectiveAmount)
1655
- continue;
1656
- let isUnstake = false;
1657
- if (row.metadata) {
1658
- try {
1659
- const meta = JSON.parse(row.metadata);
1660
- if (meta.action === 'unstake' || meta.actionName === 'unstake')
1661
- isUnstake = true;
1662
- }
1663
- catch { /* ignore */ }
1664
- }
1665
- try {
1666
- const amountBig = BigInt(effectiveAmount);
1667
- if (isUnstake)
1668
- totalUnstaked += amountBig;
1669
- else
1670
- totalStaked += amountBig;
1671
- }
1672
- catch { /* skip */ }
1673
- }
1674
- const pendingRow = deps.sqlite.prepare(`SELECT amount, bridge_status, created_at
1675
- FROM transactions
1676
- WHERE wallet_id = ? AND bridge_status = 'PENDING'
1677
- AND metadata LIKE ?
1678
- ORDER BY created_at DESC
1679
- LIMIT 1`).get(walletId, `%${providerKey}%`);
1680
- let pendingUnstake = null;
1681
- if (pendingRow?.amount) {
1682
- pendingUnstake = {
1683
- amount: pendingRow.amount,
1684
- status: (pendingRow.bridge_status ?? 'PENDING'),
1685
- requestedAt: pendingRow.created_at ?? null,
1686
- };
1687
- }
1688
- const balanceWei = totalStaked > totalUnstaked ? totalStaked - totalUnstaked : 0n;
1689
- return { balanceWei, pendingUnstake };
1690
- }
1691
- // Ethereum wallet -> Lido
1692
- if (wallet.chain === 'ethereum') {
1693
- const { balanceWei, pendingUnstake } = aggregateProvider(id, 'lido_staking');
1694
- if (balanceWei > 0n || pendingUnstake) {
1695
- let balanceUsd = null;
1696
- if (deps.priceOracle && balanceWei > 0n) {
1697
- try {
1698
- const priceInfo = await deps.priceOracle.getNativePrice('ethereum');
1699
- balanceUsd = (Number(balanceWei) / 1e18 * priceInfo.usdPrice).toFixed(2);
1700
- }
1701
- catch { /* price unavailable */ }
1702
- }
1703
- positions.push({ protocol: 'lido', chain: 'ethereum', asset: 'stETH', balance: balanceWei.toString(), balanceUsd, apy: LIDO_APY, pendingUnstake });
1704
- }
1705
- }
1706
- // Solana wallet -> Jito
1707
- if (wallet.chain === 'solana') {
1708
- const { balanceWei: balanceLamports, pendingUnstake } = aggregateProvider(id, 'jito_staking');
1709
- if (balanceLamports > 0n || pendingUnstake) {
1710
- let balanceUsd = null;
1711
- if (deps.priceOracle && balanceLamports > 0n) {
1712
- try {
1713
- const priceInfo = await deps.priceOracle.getNativePrice('solana');
1714
- balanceUsd = (Number(balanceLamports) / 1e9 * priceInfo.usdPrice).toFixed(2);
1715
- }
1716
- catch { /* price unavailable */ }
1717
- }
1718
- positions.push({ protocol: 'jito', chain: 'solana', asset: 'JitoSOL', balance: balanceLamports.toString(), balanceUsd, apy: JITO_APY, pendingUnstake });
1719
- }
1720
- }
1721
- return c.json({ walletId: id, positions }, 200);
1722
- });
1723
- // ---------------------------------------------------------------------------
1724
- // GET /admin/telegram-users
1725
- // ---------------------------------------------------------------------------
1726
- router.openapi(telegramUsersListRoute, async (c) => {
1727
- if (!deps.sqlite) {
1728
- return c.json({ users: [], total: 0 }, 200);
1729
- }
1730
- const rows = deps.sqlite
1731
- .prepare('SELECT chat_id, username, role, registered_at, approved_at FROM telegram_users ORDER BY registered_at DESC')
1732
- .all();
1733
- return c.json({ users: rows, total: rows.length }, 200);
1734
- });
1735
- // ---------------------------------------------------------------------------
1736
- // PUT /admin/telegram-users/:chatId
1737
- // ---------------------------------------------------------------------------
1738
- router.openapi(telegramUserUpdateRoute, async (c) => {
1739
- if (!deps.sqlite) {
1740
- throw new WAIaaSError('ADAPTER_NOT_AVAILABLE', {
1741
- message: 'SQLite not available',
1742
- });
1743
- }
1744
- const { chatId } = c.req.valid('param');
1745
- const body = c.req.valid('json');
1746
- const now = Math.floor(Date.now() / 1000);
1747
- const result = deps.sqlite
1748
- .prepare('UPDATE telegram_users SET role = ?, approved_at = ? WHERE chat_id = ?')
1749
- .run(body.role, now, chatId);
1750
- if (result.changes === 0) {
1751
- throw new WAIaaSError('WALLET_NOT_FOUND', {
1752
- message: `Telegram user not found: ${chatId}`,
1753
- });
1754
- }
1755
- return c.json({ success: true, chat_id: chatId, role: body.role }, 200);
1756
- });
1757
- // ---------------------------------------------------------------------------
1758
- // DELETE /admin/telegram-users/:chatId
1759
- // ---------------------------------------------------------------------------
1760
- router.openapi(telegramUserDeleteRoute, async (c) => {
1761
- if (!deps.sqlite) {
1762
- throw new WAIaaSError('ADAPTER_NOT_AVAILABLE', {
1763
- message: 'SQLite not available',
1764
- });
1765
- }
1766
- const { chatId } = c.req.valid('param');
1767
- const result = deps.sqlite
1768
- .prepare('DELETE FROM telegram_users WHERE chat_id = ?')
1769
- .run(chatId);
1770
- if (result.changes === 0) {
1771
- throw new WAIaaSError('WALLET_NOT_FOUND', {
1772
- message: `Telegram user not found: ${chatId}`,
1773
- });
1774
- }
1775
- return c.json({ success: true }, 200);
1776
- });
1777
- // ---------------------------------------------------------------------------
1778
- // GET /admin/transactions (cross-wallet transaction list)
1779
- // ---------------------------------------------------------------------------
1780
- router.openapi(adminTransactionsRoute, async (c) => {
1781
- const query = c.req.valid('query');
1782
- const offset = query.offset ?? 0;
1783
- const limit = query.limit ?? 20;
1784
- // Build WHERE conditions
1785
- const conditions = [];
1786
- if (query.wallet_id) {
1787
- conditions.push(eq(transactions.walletId, query.wallet_id));
1788
- }
1789
- if (query.type) {
1790
- conditions.push(eq(transactions.type, query.type));
1791
- }
1792
- if (query.status) {
1793
- conditions.push(eq(transactions.status, query.status));
1794
- }
1795
- if (query.network) {
1796
- conditions.push(eq(transactions.network, query.network));
1797
- }
1798
- if (query.since !== undefined) {
1799
- conditions.push(sql `${transactions.createdAt} >= ${query.since}`);
1800
- }
1801
- if (query.until !== undefined) {
1802
- conditions.push(sql `${transactions.createdAt} <= ${query.until}`);
1803
- }
1804
- if (query.search) {
1805
- const pattern = `%${query.search}%`;
1806
- conditions.push(sql `(${transactions.txHash} LIKE ${pattern} OR ${transactions.toAddress} LIKE ${pattern})`);
1807
- }
1808
- const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
1809
- // Count total
1810
- const totalResult = deps.db
1811
- .select({ count: drizzleCount() })
1812
- .from(transactions)
1813
- .where(whereClause)
1814
- .get();
1815
- const total = totalResult?.count ?? 0;
1816
- // Query with JOIN for walletName
1817
- const rows = deps.db
1818
- .select({
1819
- id: transactions.id,
1820
- walletId: transactions.walletId,
1821
- walletName: wallets.name,
1822
- type: transactions.type,
1823
- status: transactions.status,
1824
- tier: transactions.tier,
1825
- toAddress: transactions.toAddress,
1826
- amount: transactions.amount,
1827
- amountUsd: transactions.amountUsd,
1828
- network: transactions.network,
1829
- txHash: transactions.txHash,
1830
- chain: transactions.chain,
1831
- createdAt: transactions.createdAt,
1832
- tokenMint: transactions.tokenMint,
1833
- contractAddress: transactions.contractAddress,
1834
- })
1835
- .from(transactions)
1836
- .leftJoin(wallets, eq(transactions.walletId, wallets.id))
1837
- .where(whereClause)
1838
- .orderBy(desc(transactions.createdAt))
1839
- .offset(offset)
1840
- .limit(limit)
1841
- .all();
1842
- const items = rows.map((row) => {
1843
- const tokenAddr = row.tokenMint ?? row.contractAddress ?? null;
1844
- return {
1845
- id: row.id,
1846
- walletId: row.walletId,
1847
- walletName: row.walletName ?? null,
1848
- type: row.type,
1849
- status: row.status,
1850
- tier: row.tier ?? null,
1851
- toAddress: row.toAddress ?? null,
1852
- amount: row.amount ?? null,
1853
- formattedAmount: formatTxAmount(row.amount ?? null, row.chain, row.network ?? null, tokenAddr, deps.db),
1854
- amountUsd: row.amountUsd ?? null,
1855
- network: row.network ?? null,
1856
- txHash: row.txHash ?? null,
1857
- chain: row.chain,
1858
- createdAt: row.createdAt instanceof Date
1859
- ? Math.floor(row.createdAt.getTime() / 1000)
1860
- : (typeof row.createdAt === 'number' ? row.createdAt : null),
1861
- };
1862
- });
1863
- return c.json({ items, total, offset, limit }, 200);
1864
- });
1865
- // ---------------------------------------------------------------------------
1866
- // GET /admin/incoming (cross-wallet incoming transaction list)
1867
- // ---------------------------------------------------------------------------
1868
- router.openapi(adminIncomingRoute, async (c) => {
1869
- const query = c.req.valid('query');
1870
- const offset = query.offset ?? 0;
1871
- const limit = query.limit ?? 20;
1872
- // Build WHERE conditions (no default status filter -- admin sees all)
1873
- const conditions = [];
1874
- if (query.wallet_id) {
1875
- conditions.push(eq(incomingTransactions.walletId, query.wallet_id));
1876
- }
1877
- if (query.chain) {
1878
- conditions.push(eq(incomingTransactions.chain, query.chain));
1879
- }
1880
- if (query.status) {
1881
- conditions.push(eq(incomingTransactions.status, query.status));
1882
- }
1883
- if (query.suspicious !== undefined) {
1884
- conditions.push(eq(incomingTransactions.isSuspicious, query.suspicious === 'true'));
1885
- }
1886
- const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
1887
- // Count total
1888
- const totalResult = deps.db
1889
- .select({ count: drizzleCount() })
1890
- .from(incomingTransactions)
1891
- .where(whereClause)
1892
- .get();
1893
- const total = totalResult?.count ?? 0;
1894
- // Query with JOIN for walletName
1895
- const rows = deps.db
1896
- .select({
1897
- id: incomingTransactions.id,
1898
- txHash: incomingTransactions.txHash,
1899
- walletId: incomingTransactions.walletId,
1900
- walletName: wallets.name,
1901
- fromAddress: incomingTransactions.fromAddress,
1902
- amount: incomingTransactions.amount,
1903
- tokenAddress: incomingTransactions.tokenAddress,
1904
- chain: incomingTransactions.chain,
1905
- network: incomingTransactions.network,
1906
- status: incomingTransactions.status,
1907
- blockNumber: incomingTransactions.blockNumber,
1908
- detectedAt: incomingTransactions.detectedAt,
1909
- confirmedAt: incomingTransactions.confirmedAt,
1910
- isSuspicious: incomingTransactions.isSuspicious,
1911
- })
1912
- .from(incomingTransactions)
1913
- .leftJoin(wallets, eq(incomingTransactions.walletId, wallets.id))
1914
- .where(whereClause)
1915
- .orderBy(desc(incomingTransactions.detectedAt))
1916
- .offset(offset)
1917
- .limit(limit)
1918
- .all();
1919
- const items = rows.map((row) => ({
1920
- id: row.id,
1921
- txHash: row.txHash,
1922
- walletId: row.walletId,
1923
- walletName: row.walletName ?? null,
1924
- fromAddress: row.fromAddress,
1925
- amount: row.amount,
1926
- formattedAmount: formatTxAmount(row.amount, row.chain, row.network, row.tokenAddress ?? null, deps.db),
1927
- tokenAddress: row.tokenAddress ?? null,
1928
- chain: row.chain,
1929
- network: row.network,
1930
- status: row.status,
1931
- blockNumber: row.blockNumber ?? null,
1932
- detectedAt: row.detectedAt instanceof Date
1933
- ? Math.floor(row.detectedAt.getTime() / 1000)
1934
- : (typeof row.detectedAt === 'number' ? row.detectedAt : null),
1935
- confirmedAt: row.confirmedAt instanceof Date
1936
- ? Math.floor(row.confirmedAt.getTime() / 1000)
1937
- : (typeof row.confirmedAt === 'number' ? row.confirmedAt : null),
1938
- suspicious: row.isSuspicious ?? false,
1939
- }));
1940
- return c.json({ items, total, offset, limit }, 200);
1941
- });
1942
- // ---------------------------------------------------------------------------
1943
- // POST /admin/agent-prompt — Generate agent connection prompt
1944
- // ---------------------------------------------------------------------------
1945
- const agentPromptRoute = createRoute({
1946
- method: 'post',
1947
- path: '/admin/agent-prompt',
1948
- tags: ['Admin'],
1949
- summary: 'Generate agent connection prompt (magic word)',
1950
- request: {
1951
- body: {
1952
- content: { 'application/json': { schema: AgentPromptRequestSchema } },
1953
- },
1954
- },
1955
- responses: {
1956
- 201: {
1957
- description: 'Agent prompt generated',
1958
- content: { 'application/json': { schema: AgentPromptResponseSchema } },
1959
- },
1960
- ...buildErrorResponses(['ADAPTER_NOT_AVAILABLE']),
1961
- },
1962
- });
1963
- router.openapi(agentPromptRoute, async (c) => {
1964
- if (!deps.jwtSecretManager || !deps.daemonConfig) {
1965
- throw new WAIaaSError('ADAPTER_NOT_AVAILABLE', { message: 'JWT signing not available' });
1966
- }
1967
- const body = c.req.valid('json');
1968
- const nowSec = Math.floor(Date.now() / 1000);
1969
- // v29.9: per-session TTL; omit = unlimited
1970
- const ttl = body.ttl; // undefined = unlimited session
1971
- const expiresAt = ttl !== undefined ? nowSec + ttl : 0; // 0 = unlimited
1972
- // Get target wallets (with environment for prompt builder)
1973
- let targetWallets;
1974
- if (body.walletIds && body.walletIds.length > 0) {
1975
- targetWallets = body.walletIds
1976
- .map((wid) => deps.db.select().from(wallets).where(eq(wallets.id, wid)).get())
1977
- .filter((w) => w != null && w.status === 'ACTIVE')
1978
- .map((w) => ({ id: w.id, name: w.name, chain: w.chain, environment: w.environment, publicKey: w.publicKey }));
1979
- }
1980
- else {
1981
- targetWallets = deps.db
1982
- .select()
1983
- .from(wallets)
1984
- .where(eq(wallets.status, 'ACTIVE'))
1985
- .all()
1986
- .map((w) => ({ id: w.id, name: w.name, chain: w.chain, environment: w.environment, publicKey: w.publicKey }));
1987
- }
1988
- if (targetWallets.length === 0) {
1989
- return c.json({ prompt: '', walletCount: 0, sessionsCreated: 0, sessionReused: false, expiresAt }, 201);
1990
- }
1991
- // Try to reuse an existing valid session covering all target wallets
1992
- const defaultWallet = targetWallets[0];
1993
- const targetWalletIds = targetWallets.map((w) => w.id);
1994
- let sessionId;
1995
- let sessionReused = false;
1996
- let sessionsCreated = 1;
1997
- let actualExpiresAt = expiresAt;
1998
- // Find active sessions that cover all target wallets
1999
- // For unlimited sessions, include those with expiresAt=0
2000
- const candidateSessions = deps.db
2001
- .select({
2002
- id: sessions.id,
2003
- expiresAt: sessions.expiresAt,
2004
- })
2005
- .from(sessions)
2006
- .where(and(isNull(sessions.revokedAt), ttl !== undefined
2007
- ? gt(sessions.expiresAt, new Date((nowSec + Math.max(Math.floor(ttl * 0.1), 3600)) * 1000))
2008
- : sql `(${sessions.expiresAt} = 0 OR ${sessions.expiresAt} > ${nowSec})`))
2009
- .all();
2010
- let reusableSessionId = null;
2011
- let reusableExpiresAt = 0;
2012
- for (const candidate of candidateSessions) {
2013
- // Count how many of our target wallets are linked to this session
2014
- const linkedCount = deps.db
2015
- .select({ cnt: drizzleCount() })
2016
- .from(sessionWallets)
2017
- .where(and(eq(sessionWallets.sessionId, candidate.id), sql `${sessionWallets.walletId} IN (${sql.join(targetWalletIds.map((id) => sql `${id}`), sql `, `)})`))
2018
- .get();
2019
- if (linkedCount && linkedCount.cnt === targetWalletIds.length) {
2020
- reusableSessionId = candidate.id;
2021
- reusableExpiresAt = candidate.expiresAt instanceof Date
2022
- ? Math.floor(candidate.expiresAt.getTime() / 1000)
2023
- : candidate.expiresAt;
2024
- break;
2025
- }
2026
- }
2027
- if (reusableSessionId) {
2028
- // Reuse existing session — re-sign JWT with existing session ID
2029
- sessionId = reusableSessionId;
2030
- sessionReused = true;
2031
- sessionsCreated = 0;
2032
- actualExpiresAt = reusableExpiresAt;
2033
- }
2034
- else {
2035
- // Create a new multi-wallet session
2036
- sessionId = generateId();
2037
- deps.db.insert(sessions).values({
2038
- id: sessionId,
2039
- tokenHash: '',
2040
- expiresAt: new Date(expiresAt * 1000),
2041
- absoluteExpiresAt: new Date(0), // unlimited
2042
- createdAt: new Date(nowSec * 1000),
2043
- renewalCount: 0,
2044
- maxRenewals: 0, // unlimited
2045
- constraints: null,
2046
- source: 'api',
2047
- }).run();
2048
- // Insert N rows into session_wallets
2049
- for (let i = 0; i < targetWallets.length; i++) {
2050
- const w = targetWallets[i];
2051
- deps.db.insert(sessionWallets).values({
2052
- sessionId,
2053
- walletId: w.id,
2054
- createdAt: new Date(nowSec * 1000),
2055
- }).run();
2056
- }
2057
- void deps.notificationService?.notify('SESSION_CREATED', defaultWallet.id, { sessionId });
2058
- }
2059
- // Sign JWT (new or re-signed for reused session; unlimited sessions have no exp)
2060
- const jwtPayload = {
2061
- sub: sessionId,
2062
- iat: nowSec,
2063
- exp: actualExpiresAt > 0 ? actualExpiresAt : undefined,
2064
- };
2065
- const token = await deps.jwtSecretManager.signToken(jwtPayload);
2066
- if (!sessionReused) {
2067
- const tokenHash = createHash('sha256').update(token).digest('hex');
2068
- deps.db.update(sessions).set({ tokenHash }).where(eq(sessions.id, sessionId)).run();
2069
- }
2070
- // Query per-wallet policies for prompt builder
2071
- const promptWallets = targetWallets.map((w) => {
2072
- const walletPolicies = deps.db
2073
- .select({ type: policies.type })
2074
- .from(policies)
2075
- .where(and(eq(policies.walletId, w.id), eq(policies.enabled, true)))
2076
- .all();
2077
- const networks = getNetworksForEnvironment(w.chain, w.environment);
2078
- return {
2079
- id: w.id,
2080
- name: w.name,
2081
- chain: w.chain,
2082
- environment: w.environment,
2083
- address: w.publicKey,
2084
- networks: networks.map((n) => n),
2085
- policies: walletPolicies,
2086
- };
2087
- });
2088
- // Compute capabilities dynamically (same logic as connect-info)
2089
- const capabilities = ['transfer', 'token_transfer', 'balance', 'assets'];
2090
- if (deps.settingsService) {
2091
- try {
2092
- if (deps.settingsService.get('signing_sdk.enabled') === 'true') {
2093
- capabilities.push('sign');
2094
- }
2095
- }
2096
- catch {
2097
- // Setting key not found -- signing not available
2098
- }
2099
- }
2100
- if (deps.settingsService && deps.actionProviderRegistry) {
2101
- try {
2102
- const providers = deps.actionProviderRegistry.listProviders();
2103
- const hasAnyKey = providers.some((p) => p.requiresApiKey && deps.settingsService.hasApiKey(p.name));
2104
- if (hasAnyKey) {
2105
- capabilities.push('actions');
2106
- }
2107
- }
2108
- catch {
2109
- // Settings service not available
2110
- }
2111
- }
2112
- if (deps.daemonConfig?.x402?.enabled === true) {
2113
- capabilities.push('x402');
2114
- }
2115
- // Read default-deny toggles
2116
- const defaultDeny = {
2117
- tokenTransfers: deps.settingsService?.get('policy.default_deny_tokens') !== 'false',
2118
- contractCalls: deps.settingsService?.get('policy.default_deny_contracts') !== 'false',
2119
- tokenApprovals: deps.settingsService?.get('policy.default_deny_spenders') !== 'false',
2120
- x402Domains: deps.settingsService?.get('policy.default_deny_x402_domains') !== 'false',
2121
- };
2122
- // Build prompt using shared prompt builder
2123
- const host = c.req.header('Host') ?? 'localhost:3100';
2124
- const protocol = c.req.header('X-Forwarded-Proto') ?? 'http';
2125
- const baseUrl = `${protocol}://${host}`;
2126
- const prompt = buildConnectInfoPrompt({
2127
- wallets: promptWallets,
2128
- capabilities,
2129
- defaultDeny,
2130
- baseUrl,
2131
- version: deps.version,
2132
- });
2133
- // Append session token so the agent can start using it immediately
2134
- const fullPrompt = `${prompt}\n\nSession Token: ${token}\nSession ID: ${sessionId}`;
2135
- return c.json({
2136
- prompt: fullPrompt,
2137
- walletCount: targetWallets.length,
2138
- sessionsCreated,
2139
- sessionReused,
2140
- expiresAt: actualExpiresAt,
2141
- }, 201);
2142
- });
2143
- // ---------------------------------------------------------------------------
2144
- // POST /admin/sessions/:id/reissue — Reissue session token
2145
- // ---------------------------------------------------------------------------
2146
- const sessionReissueRoute = createRoute({
2147
- method: 'post',
2148
- path: '/admin/sessions/{id}/reissue',
2149
- tags: ['Admin'],
2150
- summary: 'Reissue session token (re-sign JWT for existing session)',
2151
- request: {
2152
- params: z.object({ id: z.string().uuid() }),
2153
- },
2154
- responses: {
2155
- 200: {
2156
- description: 'Token reissued',
2157
- content: { 'application/json': { schema: SessionReissueResponseSchema } },
2158
- },
2159
- ...buildErrorResponses(['SESSION_NOT_FOUND', 'SESSION_REVOKED']),
2160
- },
2161
- });
2162
- router.openapi(sessionReissueRoute, async (c) => {
2163
- if (!deps.jwtSecretManager) {
2164
- throw new WAIaaSError('ADAPTER_NOT_AVAILABLE', { message: 'JWT signing not available' });
2165
- }
2166
- const { id: sessionId } = c.req.valid('param');
2167
- const nowSec = Math.floor(Date.now() / 1000);
2168
- // Find session
2169
- const session = deps.db
2170
- .select()
2171
- .from(sessions)
2172
- .where(eq(sessions.id, sessionId))
2173
- .get();
2174
- if (!session) {
2175
- throw new WAIaaSError('SESSION_NOT_FOUND');
2176
- }
2177
- if (session.revokedAt) {
2178
- throw new WAIaaSError('SESSION_REVOKED');
2179
- }
2180
- const expiresAtSec = session.expiresAt instanceof Date
2181
- ? Math.floor(session.expiresAt.getTime() / 1000)
2182
- : session.expiresAt;
2183
- if (expiresAtSec > 0 && expiresAtSec <= nowSec) {
2184
- throw new WAIaaSError('SESSION_NOT_FOUND', { message: 'Session expired' });
2185
- }
2186
- // Re-sign JWT (no wallet claim needed -- walletId resolved at request time)
2187
- const jwtPayload = {
2188
- sub: sessionId,
2189
- iat: nowSec,
2190
- ...(expiresAtSec > 0 ? { exp: expiresAtSec } : {}),
2191
- };
2192
- const token = await deps.jwtSecretManager.signToken(jwtPayload);
2193
- // Increment token_issued_count
2194
- const newCount = (session.tokenIssuedCount ?? 1) + 1;
2195
- deps.db.update(sessions)
2196
- .set({ tokenIssuedCount: newCount })
2197
- .where(eq(sessions.id, sessionId))
2198
- .run();
2199
- return c.json({
2200
- token,
2201
- sessionId,
2202
- tokenIssuedCount: newCount,
2203
- expiresAt: expiresAtSec,
2204
- }, 200);
2205
- });
2206
- // ---------------------------------------------------------------------------
2207
- // POST /admin/transactions/:id/cancel — Cancel a QUEUED (DELAY) transaction
2208
- // ---------------------------------------------------------------------------
2209
- const adminTxCancelRoute = createRoute({
2210
- method: 'post',
2211
- path: '/admin/transactions/{id}/cancel',
2212
- tags: ['Admin'],
2213
- summary: 'Cancel a delayed (QUEUED) transaction',
2214
- request: {
2215
- params: z.object({ id: z.string().uuid() }),
2216
- },
2217
- responses: {
2218
- 200: {
2219
- description: 'Transaction cancelled',
2220
- content: {
2221
- 'application/json': {
2222
- schema: z.object({
2223
- id: z.string(),
2224
- status: z.literal('CANCELLED'),
2225
- }),
2226
- },
2227
- },
2228
- },
2229
- ...buildErrorResponses(['TX_NOT_FOUND']),
2230
- },
2231
- });
2232
- router.openapi(adminTxCancelRoute, async (c) => {
2233
- const { id: txId } = c.req.valid('param');
2234
- if (!deps.delayQueue) {
2235
- throw new WAIaaSError('ADAPTER_NOT_AVAILABLE', {
2236
- message: 'Delay queue not available',
2237
- });
2238
- }
2239
- deps.delayQueue.cancelDelay(txId);
2240
- return c.json({ id: txId, status: 'CANCELLED' }, 200);
2241
- });
2242
- // ---------------------------------------------------------------------------
2243
- // POST /admin/transactions/:id/reject — Reject a pending (APPROVAL) transaction
2244
- // ---------------------------------------------------------------------------
2245
- const adminTxRejectRoute = createRoute({
2246
- method: 'post',
2247
- path: '/admin/transactions/{id}/reject',
2248
- tags: ['Admin'],
2249
- summary: 'Reject a pending approval transaction',
2250
- request: {
2251
- params: z.object({ id: z.string().uuid() }),
2252
- },
2253
- responses: {
2254
- 200: {
2255
- description: 'Transaction rejected',
2256
- content: {
2257
- 'application/json': {
2258
- schema: z.object({
2259
- id: z.string(),
2260
- status: z.literal('CANCELLED'),
2261
- rejectedAt: z.number(),
2262
- }),
2263
- },
2264
- },
2265
- },
2266
- ...buildErrorResponses(['TX_NOT_FOUND']),
2267
- },
2268
- });
2269
- router.openapi(adminTxRejectRoute, async (c) => {
2270
- const { id: txId } = c.req.valid('param');
2271
- if (!deps.approvalWorkflow) {
2272
- throw new WAIaaSError('ADAPTER_NOT_AVAILABLE', {
2273
- message: 'Approval workflow not available',
2274
- });
2275
- }
2276
- const result = deps.approvalWorkflow.reject(txId);
2277
- return c.json({
2278
- id: txId,
2279
- status: 'CANCELLED',
2280
- rejectedAt: result.rejectedAt,
2281
- }, 200);
2282
- });
2283
- // ---------------------------------------------------------------------------
2284
- // GET /admin/rpc-status
2285
- // ---------------------------------------------------------------------------
2286
- router.openapi(rpcStatusRoute, async (c) => {
2287
- const networks = {};
2288
- if (deps.rpcPool) {
2289
- for (const network of deps.rpcPool.getNetworks()) {
2290
- networks[network] = deps.rpcPool.getStatus(network);
2291
- }
2292
- }
2293
- // Provide built-in URL defaults so Admin UI doesn't need hardcoded mirror (#197)
2294
- const builtinUrls = {};
2295
- for (const [network, urls] of Object.entries(BUILT_IN_RPC_DEFAULTS)) {
2296
- builtinUrls[network] = [...urls];
2297
- }
2298
- return c.json({ networks, builtinUrls }, 200);
2299
- });
2300
- // ---------------------------------------------------------------------------
2301
- // GET /admin/defi/positions
2302
- // ---------------------------------------------------------------------------
2303
- router.openapi(adminDefiPositionsRoute, async (c) => {
2304
- const { wallet_id } = c.req.valid('query');
2305
- if (!deps.sqlite) {
2306
- return c.json({ positions: [], totalValueUsd: null, worstHealthFactor: null, activeCount: 0 }, 200);
2307
- }
2308
- // Cross-wallet DeFi positions query
2309
- let rows;
2310
- if (wallet_id) {
2311
- rows = deps.sqlite.prepare(`SELECT id, wallet_id, category, provider, chain, network, asset_id,
2312
- amount, amount_usd, metadata, status, opened_at, last_synced_at
2313
- FROM defi_positions
2314
- WHERE wallet_id = ? AND status = 'ACTIVE'
2315
- ORDER BY category, provider`).all(wallet_id);
2316
- }
2317
- else {
2318
- rows = deps.sqlite.prepare(`SELECT id, wallet_id, category, provider, chain, network, asset_id,
2319
- amount, amount_usd, metadata, status, opened_at, last_synced_at
2320
- FROM defi_positions
2321
- WHERE status = 'ACTIVE'
2322
- ORDER BY category, provider`).all();
2323
- }
2324
- const positions = rows.map((row) => ({
2325
- id: row.id,
2326
- walletId: row.wallet_id,
2327
- category: row.category,
2328
- provider: row.provider,
2329
- chain: row.chain,
2330
- network: row.network,
2331
- assetId: row.asset_id,
2332
- amount: row.amount,
2333
- amountUsd: row.amount_usd,
2334
- status: row.status,
2335
- openedAt: row.opened_at,
2336
- lastSyncedAt: row.last_synced_at,
2337
- }));
2338
- // Aggregate totalValueUsd
2339
- const usdValues = positions.map((p) => p.amountUsd).filter((v) => v !== null);
2340
- const totalValueUsd = usdValues.length > 0 ? usdValues.reduce((a, b) => a + b, 0) : null;
2341
- // Worst health factor from metadata JSON
2342
- let worstHealthFactor = null;
2343
- for (const row of rows) {
2344
- if (row.category === 'LENDING' && row.metadata) {
2345
- try {
2346
- const meta = JSON.parse(row.metadata);
2347
- if (typeof meta.healthFactor === 'number' && meta.healthFactor > 0) {
2348
- if (worstHealthFactor === null || meta.healthFactor < worstHealthFactor) {
2349
- worstHealthFactor = meta.healthFactor;
2350
- }
2351
- }
2352
- }
2353
- catch { /* skip */ }
2354
- }
2355
- }
2356
- return c.json({
2357
- positions,
2358
- totalValueUsd,
2359
- worstHealthFactor,
2360
- activeCount: positions.length,
2361
- }, 200);
2362
- });
2363
- // ---------------------------------------------------------------------------
2364
- // POST /admin/backup (create encrypted backup)
2365
- // ---------------------------------------------------------------------------
2366
- const createBackupRoute = createRoute({
2367
- method: 'post',
2368
- path: '/admin/backup',
2369
- tags: ['Admin'],
2370
- summary: 'Create an encrypted backup',
2371
- responses: {
2372
- 200: {
2373
- description: 'Backup created successfully',
2374
- content: { 'application/json': { schema: BackupInfoResponseSchema } },
2375
- },
2376
- 401: {
2377
- description: 'Master password not available',
2378
- content: { 'application/json': { schema: ErrorResponseSchema } },
2379
- },
2380
- 501: {
2381
- description: 'Backup service not configured',
2382
- content: { 'application/json': { schema: ErrorResponseSchema } },
2383
- },
2384
- ...buildErrorResponses(['INVALID_MASTER_PASSWORD']),
2385
- },
2386
- });
2387
- router.openapi(createBackupRoute, async (c) => {
2388
- if (!deps.encryptedBackupService) {
2389
- return c.json({ code: 'NOT_CONFIGURED', message: 'Backup service not configured', retryable: false }, 501);
2390
- }
2391
- if (!deps.passwordRef?.password) {
2392
- return c.json({ code: 'INVALID_MASTER_PASSWORD', message: 'Master password not available', retryable: false }, 401);
2393
- }
2394
- const info = await deps.encryptedBackupService.createBackup(deps.passwordRef.password);
2395
- return c.json(info, 200);
2396
- });
2397
- // ---------------------------------------------------------------------------
2398
- // GET /admin/backups (list backups)
2399
- // ---------------------------------------------------------------------------
2400
- const listBackupsRoute = createRoute({
2401
- method: 'get',
2402
- path: '/admin/backups',
2403
- tags: ['Admin'],
2404
- summary: 'List available backups',
2405
- responses: {
2406
- 200: {
2407
- description: 'Backup list',
2408
- content: { 'application/json': { schema: BackupListResponseSchema } },
2409
- },
2410
- 501: {
2411
- description: 'Backup service not configured',
2412
- content: { 'application/json': { schema: ErrorResponseSchema } },
2413
- },
2414
- },
2415
- });
2416
- router.openapi(listBackupsRoute, async (c) => {
2417
- if (!deps.encryptedBackupService) {
2418
- return c.json({ code: 'NOT_CONFIGURED', message: 'Backup service not configured', retryable: false }, 501);
2419
- }
2420
- const backups = deps.encryptedBackupService.listBackups();
2421
- const retentionCount = deps.daemonConfig?.backup?.retention_count ?? 7;
2422
- return c.json({ backups, total: backups.length, retention_count: retentionCount }, 200);
2423
- });
2424
- // ---------------------------------------------------------------------------
2425
- // GET /admin/stats -- 7-category operational statistics (STAT-01)
2426
- // ---------------------------------------------------------------------------
2427
- const adminStatsRoute = createRoute({
2428
- method: 'get',
2429
- path: '/admin/stats',
2430
- tags: ['Admin'],
2431
- summary: 'Get operational statistics',
2432
- responses: {
2433
- 200: {
2434
- description: 'Operational statistics (7 categories)',
2435
- content: { 'application/json': { schema: z.any() } },
2436
- },
2437
- },
2438
- });
2439
- router.openapi(adminStatsRoute, async (c) => {
2440
- if (!deps.adminStatsService) {
2441
- return c.json({ code: 'NOT_CONFIGURED', message: 'Stats service not configured', retryable: false }, 503);
2442
- }
2443
- const stats = deps.adminStatsService.getStats();
2444
- return c.json(stats, 200);
2445
- });
2446
- // ---------------------------------------------------------------------------
2447
- // GET /admin/autostop/rules -- List AutoStop rules with status (PLUG-03)
2448
- // ---------------------------------------------------------------------------
2449
- const autostopRulesRoute = createRoute({
2450
- method: 'get',
2451
- path: '/admin/autostop/rules',
2452
- tags: ['Admin'],
2453
- summary: 'List AutoStop rules with status',
2454
- responses: {
2455
- 200: {
2456
- description: 'AutoStop rules list',
2457
- content: { 'application/json': { schema: z.any() } },
2458
- },
2459
- },
2460
- });
2461
- router.openapi(autostopRulesRoute, async (c) => {
2462
- if (!deps.autoStopService) {
2463
- return c.json({ globalEnabled: false, rules: [] }, 200);
2464
- }
2465
- const status = deps.autoStopService.getStatus();
2466
- const registry = deps.autoStopService.registry;
2467
- const rules = registry.getRules().map((r) => {
2468
- const ruleStatus = r.getStatus();
2469
- return {
2470
- id: r.id,
2471
- displayName: r.displayName,
2472
- description: r.description,
2473
- enabled: r.enabled,
2474
- subscribedEvents: r.subscribedEvents,
2475
- config: ruleStatus.config,
2476
- state: ruleStatus.state,
2477
- };
2478
- });
2479
- return c.json({ globalEnabled: status.enabled, rules }, 200);
2480
- });
2481
- // ---------------------------------------------------------------------------
2482
- // PUT /admin/autostop/rules/:id -- Update AutoStop rule (PLUG-03)
2483
- // ---------------------------------------------------------------------------
2484
- const autostopRuleUpdateRoute = createRoute({
2485
- method: 'put',
2486
- path: '/admin/autostop/rules/{id}',
2487
- tags: ['Admin'],
2488
- summary: 'Update AutoStop rule enabled/config',
2489
- request: {
2490
- params: z.object({ id: z.string() }),
2491
- body: {
2492
- content: {
2493
- 'application/json': {
2494
- schema: z.object({
2495
- enabled: z.boolean().optional(),
2496
- config: z.record(z.unknown()).optional(),
2497
- }),
2498
- },
2499
- },
2500
- },
2501
- },
2502
- responses: {
2503
- 200: {
2504
- description: 'Rule updated',
2505
- content: { 'application/json': { schema: z.any() } },
2506
- },
2507
- 404: {
2508
- description: 'Rule not found',
2509
- content: { 'application/json': { schema: z.any() } },
2510
- },
2511
- },
2512
- });
2513
- router.openapi(autostopRuleUpdateRoute, async (c) => {
2514
- if (!deps.autoStopService) {
2515
- throw new WAIaaSError('RULE_NOT_FOUND');
2516
- }
2517
- const { id } = c.req.valid('param');
2518
- const body = c.req.valid('json');
2519
- const registry = deps.autoStopService.registry;
2520
- const rule = registry.getRule(id);
2521
- if (!rule) {
2522
- throw new WAIaaSError('RULE_NOT_FOUND');
2523
- }
2524
- // Update enabled state
2525
- if (body.enabled !== undefined) {
2526
- registry.setEnabled(id, body.enabled);
2527
- // Persist to Admin Settings
2528
- if (deps.settingsService) {
2529
- deps.settingsService.set(`autostop.rule.${id}.enabled`, String(body.enabled));
2530
- }
2531
- }
2532
- // Update config
2533
- if (body.config) {
2534
- rule.updateConfig(body.config);
2535
- }
2536
- // Return updated rule info
2537
- const ruleStatus = rule.getStatus();
2538
- return c.json({
2539
- id: rule.id,
2540
- displayName: rule.displayName,
2541
- description: rule.description,
2542
- enabled: rule.enabled,
2543
- subscribedEvents: rule.subscribedEvents,
2544
- config: ruleStatus.config,
2545
- state: ruleStatus.state,
2546
- }, 200);
2547
- });
27
+ registerAdminAuthRoutes(router, deps);
28
+ registerAdminNotificationRoutes(router, deps);
29
+ registerAdminSettingsRoutes(router, deps);
30
+ registerAdminWalletRoutes(router, deps);
31
+ registerAdminMonitoringRoutes(router, deps);
2548
32
  return router;
2549
33
  }
2550
34
  //# sourceMappingURL=admin.js.map