devassist-agent 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +467 -0
- package/bin/devassist.js +220 -0
- package/package.json +44 -0
- package/src/ai/adapter.js +464 -0
- package/src/ai/providers/claude.js +80 -0
- package/src/ai/providers/hunyuan.js +87 -0
- package/src/ai/providers/openai.js +74 -0
- package/src/ai/providers/qwen.js +81 -0
- package/src/cli/commands/ai.js +944 -0
- package/src/cli/commands/ask.js +79 -0
- package/src/cli/commands/backup.js +30 -0
- package/src/cli/commands/check.js +327 -0
- package/src/cli/commands/clean.js +130 -0
- package/src/cli/commands/comparison-report.js +326 -0
- package/src/cli/commands/convention.js +91 -0
- package/src/cli/commands/debt.js +49 -0
- package/src/cli/commands/deploy.js +88 -0
- package/src/cli/commands/diff.js +193 -0
- package/src/cli/commands/doctor.js +186 -0
- package/src/cli/commands/fix.js +195 -0
- package/src/cli/commands/init.js +431 -0
- package/src/cli/commands/inject.js +254 -0
- package/src/cli/commands/report.js +310 -0
- package/src/cli/commands/restore.js +78 -0
- package/src/cli/commands/schema.js +93 -0
- package/src/cli/commands/watch.js +212 -0
- package/src/cli/shared/code-context.js +51 -0
- package/src/cli/shared/config-loader.js +89 -0
- package/src/cli/shared/file-collector.js +116 -0
- package/src/cli/shared/inline-ignore.js +142 -0
- package/src/cli/shared/watch-list.js +281 -0
- package/src/core/event-bus.js +83 -0
- package/src/core/fsm.js +103 -0
- package/src/core/logger.js +54 -0
- package/src/core/rule-engine.js +117 -0
- package/src/index.js +64 -0
- package/src/modules/dev-time/arch-risk-assessor.js +250 -0
- package/src/modules/dev-time/code-quality-guard.js +1340 -0
- package/src/modules/dev-time/convention-store.js +201 -0
- package/src/modules/dev-time/impact-analyzer.js +292 -0
- package/src/modules/dev-time/pre-deploy-guard.js +284 -0
- package/src/modules/dev-time/schema-registry.js +284 -0
- package/src/modules/dev-time/tech-debt-tracker.js +225 -0
- package/src/modules/dev-time/version-manager.js +280 -0
- package/src/modules/runtime/channel-agent.js +404 -0
- package/src/modules/runtime/channel-middleware.js +316 -0
- package/src/modules/runtime/index.js +64 -0
- package/src/modules/runtime/infrastructure-guard.js +582 -0
- package/src/modules/runtime/notification-center.js +443 -0
- package/src/modules/runtime/schema-gatekeeper.js +664 -0
- package/src/modules/runtime/tool-registry.js +329 -0
- package/templates/ci/github-actions.yml +60 -0
- package/templates/ci/gitlab-ci.yml +30 -0
- package/templates/ci/pre-commit-hook.sh +18 -0
- package/templates/git-hooks/pre-commit +61 -0
- package/tests/run-all.js +434 -0
- package/tests/test-layer2.js +461 -0
- package/tests/test-new-rules.js +157 -0
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChannelAgent (Server-side) - Protocol handler and intelligent router
|
|
3
|
+
*
|
|
4
|
+
* This is the server-side counterpart to the client-side ChannelAgent in app.js.
|
|
5
|
+
* It receives protocol messages, verifies HMAC signatures, routes actions to
|
|
6
|
+
* ToolRegistry, and manages the server-side FSM.
|
|
7
|
+
*
|
|
8
|
+
* Protocol format (from client):
|
|
9
|
+
* { protocol, seq, action, timestamp, token, payload, signature }
|
|
10
|
+
*
|
|
11
|
+
* Protocol response (to client):
|
|
12
|
+
* { protocol, code, data, message, seq, timestamp }
|
|
13
|
+
*
|
|
14
|
+
* FSM States: UNINITIALIZED -> HANDSHAKING -> ONLINE -> OFFLINE -> ERROR
|
|
15
|
+
*
|
|
16
|
+
* Design from 智能体能力分析报告.md:
|
|
17
|
+
* - PERCEIVE -> DECIDE -> ACT -> LEARN closed loop
|
|
18
|
+
* - Adaptive heartbeat and telemetry frequency
|
|
19
|
+
* - Server-client mutual state awareness (互感知)
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const crypto = require('crypto');
|
|
23
|
+
const { bus } = require('../../core/event-bus');
|
|
24
|
+
const { Logger } = require('../../core/logger');
|
|
25
|
+
|
|
26
|
+
const log = new Logger('ChannelAgent');
|
|
27
|
+
|
|
28
|
+
const PROTOCOL_VERSION = 'xiaomai-channel/1.0';
|
|
29
|
+
|
|
30
|
+
const STATE = {
|
|
31
|
+
UNINITIALIZED: 'uninitialized',
|
|
32
|
+
HANDSHAKING: 'handshaking',
|
|
33
|
+
ONLINE: 'online',
|
|
34
|
+
OFFLINE: 'offline',
|
|
35
|
+
ERROR: 'error',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const PRIORITY = {
|
|
39
|
+
CRITICAL: 0,
|
|
40
|
+
HIGH: 1,
|
|
41
|
+
NORMAL: 2,
|
|
42
|
+
LOW: 3,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Action route table — maps action names to tool ids in ToolRegistry.
|
|
47
|
+
* This is the routing layer between the protocol and the tool system.
|
|
48
|
+
*/
|
|
49
|
+
class ActionRouter {
|
|
50
|
+
constructor() {
|
|
51
|
+
this._routes = new Map();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Register an action route.
|
|
56
|
+
* @param {string} action - Action name (e.g. 'chat.send')
|
|
57
|
+
* @param {object} route - { toolId, priority, middleware: [] }
|
|
58
|
+
*/
|
|
59
|
+
register(action, route) {
|
|
60
|
+
if (this._routes.has(action)) {
|
|
61
|
+
log.warn(`动作路由被覆盖:${action}`);
|
|
62
|
+
}
|
|
63
|
+
this._routes.set(action, {
|
|
64
|
+
toolId: route.toolId,
|
|
65
|
+
priority: route.priority || PRIORITY.NORMAL,
|
|
66
|
+
middleware: route.middleware || [],
|
|
67
|
+
registeredAt: Date.now(),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
unregister(action) {
|
|
72
|
+
this._routes.delete(action);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
resolve(action) {
|
|
76
|
+
return this._routes.get(action) || null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
list() {
|
|
80
|
+
return Array.from(this._routes.entries()).map(([action, route]) => ({
|
|
81
|
+
action,
|
|
82
|
+
toolId: route.toolId,
|
|
83
|
+
priority: route.priority,
|
|
84
|
+
}));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Server-side ChannelAgent.
|
|
90
|
+
*
|
|
91
|
+
* Usage:
|
|
92
|
+
* const agent = new ChannelAgent({ registry, secret });
|
|
93
|
+
* await agent.init();
|
|
94
|
+
*
|
|
95
|
+
* // In your HTTP handler:
|
|
96
|
+
* const result = await agent.handleRequest(req.body, req);
|
|
97
|
+
*/
|
|
98
|
+
class ChannelAgent {
|
|
99
|
+
constructor(options = {}) {
|
|
100
|
+
this._registry = options.registry;
|
|
101
|
+
this._secret = options.secret || null;
|
|
102
|
+
this._router = new ActionRouter();
|
|
103
|
+
this._state = STATE.UNINITIALIZED;
|
|
104
|
+
this._listeners = [];
|
|
105
|
+
this._telemetryStore = new Map(); // clientId -> telemetry data
|
|
106
|
+
this._seqCounter = 0;
|
|
107
|
+
this._maxTelemetryAge = 5 * 60 * 1000; // 5 min
|
|
108
|
+
|
|
109
|
+
// Learning engine (server-side) — tracks client behavior patterns
|
|
110
|
+
this._learning = {
|
|
111
|
+
totalRequests: 0,
|
|
112
|
+
totalErrors: 0,
|
|
113
|
+
avgResponseTime: 0,
|
|
114
|
+
activeClients: 0,
|
|
115
|
+
errorRate: 0,
|
|
116
|
+
|
|
117
|
+
record(success, elapsedMs) {
|
|
118
|
+
this.totalRequests++;
|
|
119
|
+
if (!success) this.totalErrors++;
|
|
120
|
+
if (this.avgResponseTime === 0) {
|
|
121
|
+
this.avgResponseTime = elapsedMs;
|
|
122
|
+
} else {
|
|
123
|
+
// EMA smoothing
|
|
124
|
+
this.avgResponseTime = this.avgResponseTime * 0.7 + elapsedMs * 0.3;
|
|
125
|
+
}
|
|
126
|
+
this.errorRate = this.totalErrors / this.totalRequests;
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
getReport() {
|
|
130
|
+
return {
|
|
131
|
+
totalRequests: this.totalRequests,
|
|
132
|
+
totalErrors: this.totalErrors,
|
|
133
|
+
avgResponseTime: Math.round(this.avgResponseTime),
|
|
134
|
+
errorRate: Math.round(this.errorRate * 1000) / 1000,
|
|
135
|
+
activeClients: this.activeClients,
|
|
136
|
+
};
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
if (!this._registry) {
|
|
141
|
+
throw new Error('ChannelAgent requires a ToolRegistry instance');
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Initialize the agent. Registers built-in actions.
|
|
147
|
+
*/
|
|
148
|
+
async init() {
|
|
149
|
+
if (this._state !== STATE.UNINITIALIZED) return;
|
|
150
|
+
this._transition(STATE.HANDSHAKING);
|
|
151
|
+
|
|
152
|
+
// Register built-in actions
|
|
153
|
+
this._router.register('heartbeat.ping', {
|
|
154
|
+
toolId: null, // Handled directly, no tool needed
|
|
155
|
+
priority: PRIORITY.LOW,
|
|
156
|
+
});
|
|
157
|
+
this._router.register('channel.state', {
|
|
158
|
+
toolId: null,
|
|
159
|
+
priority: PRIORITY.LOW,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
this._transition(STATE.ONLINE);
|
|
163
|
+
log.info(`ChannelAgent 已初始化(协议:${PROTOCOL_VERSION})`);
|
|
164
|
+
bus.emit('agent:online', { protocol: PROTOCOL_VERSION });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Register an action route that maps to a tool.
|
|
169
|
+
*/
|
|
170
|
+
route(action, toolId, opts = {}) {
|
|
171
|
+
this._router.register(action, {
|
|
172
|
+
toolId,
|
|
173
|
+
priority: opts.priority || PRIORITY.NORMAL,
|
|
174
|
+
middleware: opts.middleware || [],
|
|
175
|
+
});
|
|
176
|
+
log.info(`路由已注册:${action} -> ${toolId}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Handle an incoming protocol request.
|
|
181
|
+
*
|
|
182
|
+
* @param {object} body - The raw request body
|
|
183
|
+
* @param {object} ctx - Additional context (ip, headers, etc.)
|
|
184
|
+
* @returns {Promise<object>} Protocol response
|
|
185
|
+
*/
|
|
186
|
+
async handleRequest(body, ctx = {}) {
|
|
187
|
+
const startTime = Date.now();
|
|
188
|
+
this._seqCounter++;
|
|
189
|
+
|
|
190
|
+
// --- Parse and validate protocol envelope ---
|
|
191
|
+
if (!body || typeof body !== 'object') {
|
|
192
|
+
return this._errorResponse(400, 'Invalid request body', this._seqCounter);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Check if this is a protocol-wrapped request or a plain JSON request
|
|
196
|
+
const isProtocol = body.protocol === PROTOCOL_VERSION;
|
|
197
|
+
|
|
198
|
+
let action, payload, token, seq, timestamp, signature;
|
|
199
|
+
if (isProtocol) {
|
|
200
|
+
action = body.action;
|
|
201
|
+
payload = body.payload || {};
|
|
202
|
+
token = body.token;
|
|
203
|
+
seq = body.seq || this._seqCounter;
|
|
204
|
+
timestamp = body.timestamp;
|
|
205
|
+
signature = body.signature;
|
|
206
|
+
|
|
207
|
+
// --- Verify HMAC signature (if secret is configured) ---
|
|
208
|
+
if (this._secret && signature !== undefined) {
|
|
209
|
+
const expectedSig = this._sign(this._buildSignString(body));
|
|
210
|
+
if (signature !== expectedSig) {
|
|
211
|
+
log.warn(`动作签名验证失败:${action}`);
|
|
212
|
+
bus.emit('agent:auth_failed', { action, seq, ip: ctx.ip });
|
|
213
|
+
return this._protocolResponse(401, null, 'Signature verification failed', seq);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
} else {
|
|
217
|
+
// Plain JSON (degraded mode) — extract action from URL path or body
|
|
218
|
+
action = body.action || ctx.action;
|
|
219
|
+
payload = body;
|
|
220
|
+
token = ctx.token;
|
|
221
|
+
seq = this._seqCounter;
|
|
222
|
+
timestamp = Date.now();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (!action) {
|
|
226
|
+
return this._errorResponse(400, 'Missing action', seq);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// --- Resolve route ---
|
|
230
|
+
const route = this._router.resolve(action);
|
|
231
|
+
if (!route) {
|
|
232
|
+
log.warn(`未知动作:${action}`);
|
|
233
|
+
return this._protocolResponse(404, null, `Unknown action: ${action}`, seq);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// --- Handle built-in actions (no tool needed) ---
|
|
237
|
+
if (route.toolId === null) {
|
|
238
|
+
return this._handleBuiltin(action, payload, ctx, seq);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// --- Invoke tool ---
|
|
242
|
+
const toolCtx = {
|
|
243
|
+
...ctx,
|
|
244
|
+
requestId: `${seq}-${Date.now()}`,
|
|
245
|
+
token,
|
|
246
|
+
action,
|
|
247
|
+
priority: route.priority,
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
const result = await this._registry.invoke(route.toolId, payload, toolCtx);
|
|
252
|
+
const elapsed = Date.now() - startTime;
|
|
253
|
+
this._learning.record(true, elapsed);
|
|
254
|
+
|
|
255
|
+
bus.emit('agent:request', {
|
|
256
|
+
action, success: true, elapsed, seq, toolId: route.toolId,
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
return this._protocolResponse(200, result, null, seq);
|
|
260
|
+
|
|
261
|
+
} catch (err) {
|
|
262
|
+
const elapsed = Date.now() - startTime;
|
|
263
|
+
this._learning.record(false, elapsed);
|
|
264
|
+
|
|
265
|
+
bus.emit('agent:request', {
|
|
266
|
+
action, success: false, elapsed, seq, error: err.message,
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
log.error(`动作 '${action}' 失败:${err.message}`);
|
|
270
|
+
return this._protocolResponse(500, null, err.message, seq);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Handle built-in actions (heartbeat, state, telemetry).
|
|
276
|
+
*/
|
|
277
|
+
_handleBuiltin(action, payload, ctx, seq) {
|
|
278
|
+
if (action === 'heartbeat.ping') {
|
|
279
|
+
return this._protocolResponse(200, { alive: true, timestamp: Date.now() }, null, seq);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (action === 'channel.state') {
|
|
283
|
+
return this._protocolResponse(200, {
|
|
284
|
+
state: this._state,
|
|
285
|
+
protocol: PROTOCOL_VERSION,
|
|
286
|
+
serverTime: Date.now(),
|
|
287
|
+
learning: this._learning.getReport(),
|
|
288
|
+
activeClients: this._learning.activeClients,
|
|
289
|
+
}, null, seq);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return this._protocolResponse(404, null, `Unknown builtin action: ${action}`, seq);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Store client telemetry data (received via /api/channel/telemetry).
|
|
297
|
+
*/
|
|
298
|
+
storeTelemetry(clientId, data) {
|
|
299
|
+
data.receivedAt = Date.now();
|
|
300
|
+
this._telemetryStore.set(clientId, data);
|
|
301
|
+
this._learning.activeClients = this._countActiveClients();
|
|
302
|
+
bus.emit('agent:telemetry', { clientId, data });
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Get server state for client mutual awareness (互感知).
|
|
307
|
+
*/
|
|
308
|
+
getServerState() {
|
|
309
|
+
return {
|
|
310
|
+
state: this._state,
|
|
311
|
+
protocol: PROTOCOL_VERSION,
|
|
312
|
+
learning: this._learning.getReport(),
|
|
313
|
+
activeClients: this._learning.activeClients,
|
|
314
|
+
uptime: process.uptime(),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Get learning report.
|
|
320
|
+
*/
|
|
321
|
+
getLearningReport() {
|
|
322
|
+
return this._learning.getReport();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Get the action router (for external route management).
|
|
327
|
+
*/
|
|
328
|
+
get router() {
|
|
329
|
+
return this._router;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
get state() {
|
|
333
|
+
return this._state;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// --- FSM ---
|
|
337
|
+
_transition(newState) {
|
|
338
|
+
if (this._state === newState) return;
|
|
339
|
+
const old = this._state;
|
|
340
|
+
this._state = newState;
|
|
341
|
+
this._listeners.forEach(fn => {
|
|
342
|
+
try { fn(newState, old); } catch (_) {}
|
|
343
|
+
});
|
|
344
|
+
bus.emit('agent:state_change', { from: old, to: newState });
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
onStateChange(fn) {
|
|
348
|
+
this._listeners.push(fn);
|
|
349
|
+
return () => {
|
|
350
|
+
this._listeners = this._listeners.filter(f => f !== fn);
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// --- Protocol helpers ---
|
|
355
|
+
_protocolResponse(code, data, message, seq) {
|
|
356
|
+
return {
|
|
357
|
+
protocol: PROTOCOL_VERSION,
|
|
358
|
+
code,
|
|
359
|
+
data: data || null,
|
|
360
|
+
message: message || (code === 200 ? 'OK' : 'Error'),
|
|
361
|
+
seq,
|
|
362
|
+
timestamp: Date.now(),
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
_errorResponse(code, message, seq) {
|
|
367
|
+
return this._protocolResponse(code, null, message, seq);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
_buildSignString(body) {
|
|
371
|
+
return `${body.protocol}${body.seq}${body.timestamp}${body.action}${JSON.stringify(body.payload)}`;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
_sign(data) {
|
|
375
|
+
return crypto
|
|
376
|
+
.createHmac('sha256', this._secret)
|
|
377
|
+
.update(data)
|
|
378
|
+
.digest('hex');
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
_countActiveClients() {
|
|
382
|
+
const now = Date.now();
|
|
383
|
+
let count = 0;
|
|
384
|
+
for (const [, data] of this._telemetryStore) {
|
|
385
|
+
if (data.receivedAt && now - data.receivedAt < this._maxTelemetryAge) {
|
|
386
|
+
count++;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return count;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Graceful shutdown.
|
|
394
|
+
*/
|
|
395
|
+
destroy() {
|
|
396
|
+
this._transition(STATE.OFFLINE);
|
|
397
|
+
this._listeners = [];
|
|
398
|
+
this._telemetryStore.clear();
|
|
399
|
+
log.info('ChannelAgent 已销毁');
|
|
400
|
+
bus.emit('agent:offline', {});
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
module.exports = { ChannelAgent, ActionRouter, PROTOCOL_VERSION, STATE, PRIORITY };
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChannelMiddleware - Request/response middleware chain
|
|
3
|
+
*
|
|
4
|
+
* Provides a composable middleware system for processing requests before they
|
|
5
|
+
* reach the ChannelAgent/tool handlers, and responses before they go back to
|
|
6
|
+
* the client.
|
|
7
|
+
*
|
|
8
|
+
* Built-in middlewares:
|
|
9
|
+
* - requestLogger: Logs every request with timing
|
|
10
|
+
* - authGuard: Verifies JWT token
|
|
11
|
+
* - rateLimiter: Per-IP rate limiting (sliding window)
|
|
12
|
+
* - schemaValidator: Validates input against route schema
|
|
13
|
+
* - errorHandler: Catches errors and formats protocol response
|
|
14
|
+
*
|
|
15
|
+
* Design pattern: Standard Koa-style compose() — each middleware can
|
|
16
|
+
* short-circuit the chain by not calling next().
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const { bus } = require('../../core/event-bus');
|
|
20
|
+
const { Logger } = require('../../core/logger');
|
|
21
|
+
|
|
22
|
+
const log = new Logger('ChannelMiddleware');
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Compose an array of middleware into a single function.
|
|
26
|
+
* Standard async middleware composition.
|
|
27
|
+
*
|
|
28
|
+
* @param {function[]} middlewares - Array of (ctx, next) => {}
|
|
29
|
+
* @returns {function} Composed middleware
|
|
30
|
+
*/
|
|
31
|
+
function compose(middlewares) {
|
|
32
|
+
if (!Array.isArray(middlewares)) {
|
|
33
|
+
throw new TypeError('Middleware stack must be an array');
|
|
34
|
+
}
|
|
35
|
+
for (const fn of middlewares) {
|
|
36
|
+
if (typeof fn !== 'function') {
|
|
37
|
+
throw new TypeError('Middleware must be a function');
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return function composed(ctx, next) {
|
|
42
|
+
let index = -1;
|
|
43
|
+
|
|
44
|
+
function dispatch(i) {
|
|
45
|
+
if (i <= index) {
|
|
46
|
+
return Promise.reject(new Error('next() called multiple times'));
|
|
47
|
+
}
|
|
48
|
+
index = i;
|
|
49
|
+
let fn = middlewares[i];
|
|
50
|
+
if (i === middlewares.length) fn = next;
|
|
51
|
+
if (!fn) return Promise.resolve();
|
|
52
|
+
try {
|
|
53
|
+
return Promise.resolve(fn(ctx, function nextFn() {
|
|
54
|
+
return dispatch(i + 1);
|
|
55
|
+
}));
|
|
56
|
+
} catch (err) {
|
|
57
|
+
return Promise.reject(err);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return dispatch(0);
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ============ Built-in Middlewares ============
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Request Logger — logs every request with method, action, and timing.
|
|
69
|
+
*/
|
|
70
|
+
function requestLogger() {
|
|
71
|
+
return async function (ctx, next) {
|
|
72
|
+
const start = Date.now();
|
|
73
|
+
ctx._startTime = start;
|
|
74
|
+
|
|
75
|
+
await next();
|
|
76
|
+
|
|
77
|
+
const elapsed = Date.now() - start;
|
|
78
|
+
const action = ctx.action || ctx.body?.action || 'unknown';
|
|
79
|
+
const code = ctx.response?.code || 200;
|
|
80
|
+
|
|
81
|
+
if (code === 200) {
|
|
82
|
+
log.info(`${ctx.ip || '未知'} ${action} ${code} ${elapsed}ms`);
|
|
83
|
+
} else {
|
|
84
|
+
log.warn(`${ctx.ip || '未知'} ${action} ${code} ${elapsed}ms`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
bus.emit('middleware:logged', { action, code, elapsed, ip: ctx.ip });
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Auth Guard — verifies JWT token.
|
|
93
|
+
*
|
|
94
|
+
* @param {object} opts - { exclude: ['action1', 'action2'] } actions that skip auth
|
|
95
|
+
*/
|
|
96
|
+
function authGuard(opts = {}) {
|
|
97
|
+
const exclude = new Set(opts.exclude || [
|
|
98
|
+
'heartbeat.ping',
|
|
99
|
+
'channel.state',
|
|
100
|
+
'config.greetings',
|
|
101
|
+
'auth.send_code',
|
|
102
|
+
'auth.login',
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
return async function (ctx, next) {
|
|
106
|
+
const action = ctx.action || ctx.body?.action;
|
|
107
|
+
|
|
108
|
+
// Skip auth for excluded actions
|
|
109
|
+
if (exclude.has(action)) {
|
|
110
|
+
await next();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const token = ctx.token || ctx.body?.token || ctx.headers?.authorization?.replace('Bearer ', '');
|
|
115
|
+
|
|
116
|
+
if (!token) {
|
|
117
|
+
ctx.response = {
|
|
118
|
+
protocol: 'xiaomai-channel/1.0',
|
|
119
|
+
code: 401,
|
|
120
|
+
data: null,
|
|
121
|
+
message: 'Authentication required',
|
|
122
|
+
seq: ctx.body?.seq || 0,
|
|
123
|
+
timestamp: Date.now(),
|
|
124
|
+
};
|
|
125
|
+
return; // Short-circuit
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Attach decoded token to context (actual JWT verification left to the app)
|
|
129
|
+
ctx.token = token;
|
|
130
|
+
try {
|
|
131
|
+
const parts = token.split('.');
|
|
132
|
+
if (parts.length === 3) {
|
|
133
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
|
|
134
|
+
ctx.user = { id: payload.sub, exp: payload.exp };
|
|
135
|
+
ctx.tokenExpiresAt = payload.exp;
|
|
136
|
+
}
|
|
137
|
+
} catch (_) {
|
|
138
|
+
// Token decode failed — let the tool handler decide
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
await next();
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Rate Limiter — per-IP sliding window rate limiting.
|
|
147
|
+
*
|
|
148
|
+
* @param {object} opts - { windowMs, max, keyFn }
|
|
149
|
+
*/
|
|
150
|
+
function rateLimiter(opts = {}) {
|
|
151
|
+
const windowMs = opts.windowMs || 60 * 1000; // 1 minute
|
|
152
|
+
const max = opts.max || 100; // 100 requests per window
|
|
153
|
+
const keyFn = opts.keyFn || ((ctx) => ctx.ip || 'unknown');
|
|
154
|
+
|
|
155
|
+
const buckets = new Map();
|
|
156
|
+
|
|
157
|
+
// Periodic cleanup
|
|
158
|
+
setInterval(() => {
|
|
159
|
+
const now = Date.now();
|
|
160
|
+
for (const [key, entries] of buckets) {
|
|
161
|
+
const valid = entries.filter(t => now - t < windowMs);
|
|
162
|
+
if (valid.length === 0) {
|
|
163
|
+
buckets.delete(key);
|
|
164
|
+
} else {
|
|
165
|
+
buckets.set(key, valid);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}, windowMs).unref?.();
|
|
169
|
+
|
|
170
|
+
return async function (ctx, next) {
|
|
171
|
+
const key = keyFn(ctx);
|
|
172
|
+
const now = Date.now();
|
|
173
|
+
|
|
174
|
+
if (!buckets.has(key)) {
|
|
175
|
+
buckets.set(key, []);
|
|
176
|
+
}
|
|
177
|
+
const entries = buckets.get(key);
|
|
178
|
+
const valid = entries.filter(t => now - t < windowMs);
|
|
179
|
+
entries.length = 0;
|
|
180
|
+
entries.push(...valid, now);
|
|
181
|
+
|
|
182
|
+
if (entries.length > max) {
|
|
183
|
+
log.warn(`${key} 触发速率限制:${entries.length}/${max}`);
|
|
184
|
+
ctx.response = {
|
|
185
|
+
protocol: 'xiaomai-channel/1.0',
|
|
186
|
+
code: 429,
|
|
187
|
+
data: null,
|
|
188
|
+
message: 'Rate limit exceeded. Please try again later.',
|
|
189
|
+
seq: ctx.body?.seq || 0,
|
|
190
|
+
timestamp: Date.now(),
|
|
191
|
+
retryAfter: Math.ceil(windowMs / 1000),
|
|
192
|
+
};
|
|
193
|
+
bus.emit('middleware:rate_limited', { key, count: entries.length, max });
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
await next();
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Schema Validator — validates input against a provided schema.
|
|
203
|
+
* Lightweight JSON-schema-like validation.
|
|
204
|
+
*
|
|
205
|
+
* @param {object} opts - { getSchema: (action) => schema }
|
|
206
|
+
*/
|
|
207
|
+
function schemaValidator(opts = {}) {
|
|
208
|
+
const getSchema = opts.getSchema || (() => null);
|
|
209
|
+
|
|
210
|
+
return async function (ctx, next) {
|
|
211
|
+
const action = ctx.action || ctx.body?.action;
|
|
212
|
+
const schema = getSchema(action);
|
|
213
|
+
|
|
214
|
+
if (!schema) {
|
|
215
|
+
await next();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const input = ctx.body?.payload || ctx.body || {};
|
|
220
|
+
|
|
221
|
+
// Check required fields
|
|
222
|
+
if (schema.required) {
|
|
223
|
+
for (const field of schema.required) {
|
|
224
|
+
if (input[field] === undefined || input[field] === null) {
|
|
225
|
+
ctx.response = {
|
|
226
|
+
protocol: 'xiaomai-channel/1.0',
|
|
227
|
+
code: 400,
|
|
228
|
+
data: null,
|
|
229
|
+
message: `Missing required field: ${field}`,
|
|
230
|
+
seq: ctx.body?.seq || 0,
|
|
231
|
+
timestamp: Date.now(),
|
|
232
|
+
};
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Check types
|
|
239
|
+
if (schema.properties) {
|
|
240
|
+
for (const [field, type] of Object.entries(schema.properties)) {
|
|
241
|
+
if (input[field] !== undefined) {
|
|
242
|
+
const expectedType = typeof type === 'string' ? type : type.type;
|
|
243
|
+
const actualType = Array.isArray(input[field]) ? 'array' : typeof input[field];
|
|
244
|
+
if (expectedType && actualType !== expectedType) {
|
|
245
|
+
ctx.response = {
|
|
246
|
+
protocol: 'xiaomai-channel/1.0',
|
|
247
|
+
code: 400,
|
|
248
|
+
data: null,
|
|
249
|
+
message: `Field '${field}' must be ${expectedType}, got ${actualType}`,
|
|
250
|
+
seq: ctx.body?.seq || 0,
|
|
251
|
+
timestamp: Date.now(),
|
|
252
|
+
};
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
await next();
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Error Handler — catches errors from downstream middleware/tools.
|
|
265
|
+
* Should be the FIRST middleware in the chain (outermost).
|
|
266
|
+
*/
|
|
267
|
+
function errorHandler() {
|
|
268
|
+
return async function (ctx, next) {
|
|
269
|
+
try {
|
|
270
|
+
await next();
|
|
271
|
+
} catch (err) {
|
|
272
|
+
log.error(`未处理的错误:${err.message}`);
|
|
273
|
+
bus.emit('middleware:error', { error: err.message, stack: err.stack });
|
|
274
|
+
|
|
275
|
+
ctx.response = {
|
|
276
|
+
protocol: 'xiaomai-channel/1.0',
|
|
277
|
+
code: 500,
|
|
278
|
+
data: null,
|
|
279
|
+
message: process.env.NODE_ENV === 'production'
|
|
280
|
+
? 'Internal server error'
|
|
281
|
+
: err.message,
|
|
282
|
+
seq: ctx.body?.seq || 0,
|
|
283
|
+
timestamp: Date.now(),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* CORS Handler — adds CORS headers to context.
|
|
291
|
+
*/
|
|
292
|
+
function corsHandler(opts = {}) {
|
|
293
|
+
const origins = opts.origins || ['*'];
|
|
294
|
+
|
|
295
|
+
return async function (ctx, next) {
|
|
296
|
+
const origin = ctx.headers?.origin;
|
|
297
|
+
if (origins.includes('*') || (origin && origins.includes(origin))) {
|
|
298
|
+
ctx.corsHeaders = {
|
|
299
|
+
'Access-Control-Allow-Origin': origins.includes('*') ? '*' : origin,
|
|
300
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
301
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
await next();
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
module.exports = {
|
|
309
|
+
compose,
|
|
310
|
+
requestLogger,
|
|
311
|
+
authGuard,
|
|
312
|
+
rateLimiter,
|
|
313
|
+
schemaValidator,
|
|
314
|
+
errorHandler,
|
|
315
|
+
corsHandler,
|
|
316
|
+
};
|