@zhin.js/host-http 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 +49 -0
- package/lib/console-rest-pages.d.ts +70 -0
- package/lib/console-rest-pages.js +680 -0
- package/lib/console-rpc-extended.d.ts +36 -0
- package/lib/console-rpc-extended.js +579 -0
- package/lib/console-rpc.d.ts +98 -0
- package/lib/console-rpc.js +687 -0
- package/lib/http-host.d.ts +50 -0
- package/lib/http-host.js +410 -0
- package/lib/index.d.ts +8 -0
- package/lib/index.js +8 -0
- package/lib/json-body.d.ts +12 -0
- package/lib/json-body.js +37 -0
- package/lib/openapi.d.ts +31 -0
- package/lib/openapi.js +136 -0
- package/lib/project-files.d.ts +20 -0
- package/lib/project-files.js +145 -0
- package/lib/timing-safe-equal.d.ts +2 -0
- package/lib/timing-safe-equal.js +10 -0
- package/lib/token-registry.d.ts +20 -0
- package/lib/token-registry.js +41 -0
- package/package.json +48 -0
- package/src/console-rest-pages.ts +880 -0
- package/src/console-rpc-extended.ts +696 -0
- package/src/console-rpc.ts +759 -0
- package/src/http-host.ts +533 -0
- package/src/index.ts +63 -0
- package/src/json-body.ts +41 -0
- package/src/openapi.ts +172 -0
- package/src/project-files.ts +161 -0
- package/src/timing-safe-equal.ts +9 -0
- package/src/token-registry.ts +58 -0
|
@@ -0,0 +1,880 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Console REST pages — logs / marketplace / introspection / agent sessions。
|
|
3
|
+
*
|
|
4
|
+
* 响应形状对齐 legacy `packages/host/api/src/rest/`:
|
|
5
|
+
* - logs-rest-api.ts(SystemLog 模型)
|
|
6
|
+
* - marketplace-rest-api.ts(plugins.json + npmmirror)
|
|
7
|
+
* - introspection-rest-api.ts(分页内省列表)
|
|
8
|
+
* - agent-sessions-rest-api.ts(ADR 0010 D3 session tree)
|
|
9
|
+
*
|
|
10
|
+
* 数据源不可用时降级:读操作返回空数组 + `note` 说明,session tree 返回 503;
|
|
11
|
+
* 写操作要求 `ctx.fullScope && authScope === 'full'`,否则 403。
|
|
12
|
+
*/
|
|
13
|
+
import type { ServerResponse } from 'node:http';
|
|
14
|
+
import { readFile } from 'node:fs/promises';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import type { HttpHost } from './http-host.js';
|
|
17
|
+
import { HttpBodyError, readJsonBody } from './json-body.js';
|
|
18
|
+
import type { AuthScope } from './token-registry.js';
|
|
19
|
+
|
|
20
|
+
/** 与 basic/cli 接线方对齐的上下文约定。 */
|
|
21
|
+
export interface ConsoleRestCtx {
|
|
22
|
+
/** false = demo 部署:只读 GET 放行,写操作一律 403。 */
|
|
23
|
+
readonly fullScope: boolean;
|
|
24
|
+
readonly projectRoot: string;
|
|
25
|
+
readonly getEndpoints?: () => readonly {
|
|
26
|
+
name: string;
|
|
27
|
+
adapter: string;
|
|
28
|
+
connected: boolean;
|
|
29
|
+
status: string;
|
|
30
|
+
}[];
|
|
31
|
+
/**
|
|
32
|
+
* 可选:返回新 Runtime 的 agent 门面(见 {@link ConsoleAgentRuntime})。
|
|
33
|
+
* 未接线时 introspection 降级为空列表 + note,session tree 返回 503。
|
|
34
|
+
*/
|
|
35
|
+
readonly getAgentRuntime?: () => unknown;
|
|
36
|
+
/** Database host(logs 页数据源:SystemLog 模型)。 */
|
|
37
|
+
readonly databaseHost?: {
|
|
38
|
+
readonly dialect: string;
|
|
39
|
+
readonly started: boolean;
|
|
40
|
+
readonly models: { get(name: string): unknown };
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 内省数据门面 — 由 basic/cli 用 agent 包 collectIntrospection* 装配。 */
|
|
45
|
+
export interface ConsoleAgentIntrospection {
|
|
46
|
+
commands?(): readonly unknown[];
|
|
47
|
+
bindings?(): readonly unknown[];
|
|
48
|
+
tools?(): readonly unknown[];
|
|
49
|
+
mcp?(): { rows: readonly unknown[]; note?: string } | readonly unknown[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Session tree 门面 — 形状对齐 agent 包 SessionTreeRuntimeHandle。 */
|
|
53
|
+
export interface ConsoleAgentSessionTree {
|
|
54
|
+
resolveActiveSessionId(sessionKey: string): Promise<string | null>;
|
|
55
|
+
agentSessionStore: {
|
|
56
|
+
getBySessionId(sessionId: string): Promise<
|
|
57
|
+
{ active_leaf_message_id?: number | null } | null | undefined
|
|
58
|
+
>;
|
|
59
|
+
};
|
|
60
|
+
listBranchPoints(sessionId: string): Promise<readonly unknown[]>;
|
|
61
|
+
switchActiveLeaf(sessionId: string, messageId: number): Promise<boolean>;
|
|
62
|
+
jumpToBranchIndex(
|
|
63
|
+
sessionId: string,
|
|
64
|
+
index: number,
|
|
65
|
+
): Promise<{ ok: boolean; message: string }>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** `ctx.getAgentRuntime()` 返回值的最小结构约定(全部可选,逐项降级)。 */
|
|
69
|
+
export interface ConsoleAgentRuntime {
|
|
70
|
+
readonly introspection?: ConsoleAgentIntrospection;
|
|
71
|
+
readonly sessionTree?: ConsoleAgentSessionTree;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ConsoleRestPagesOptions {
|
|
75
|
+
/** 默认 `/api`。 */
|
|
76
|
+
readonly apiBase?: string;
|
|
77
|
+
/** 测试注入用;默认全局 fetch。 */
|
|
78
|
+
readonly fetchFn?: typeof fetch;
|
|
79
|
+
/** 默认 `https://zhin.js.org/plugins.json`(legacy 同源)。 */
|
|
80
|
+
readonly pluginRegistryUrl?: string;
|
|
81
|
+
/** 默认 `https://registry.npmmirror.com`(legacy 同源)。 */
|
|
82
|
+
readonly npmRegistryUrl?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
type LogModelLike = {
|
|
86
|
+
select(...fields: string[]): LogSelectionLike;
|
|
87
|
+
delete(where: Record<string, unknown>): Promise<unknown>;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
type LogSelectionLike = PromiseLike<LogRow[]> & {
|
|
91
|
+
where(where: Record<string, unknown>): LogSelectionLike;
|
|
92
|
+
orderBy(field: string, direction: 'ASC' | 'DESC'): LogSelectionLike;
|
|
93
|
+
limit(count: number): Promise<LogRow[]>;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
type LogRow = {
|
|
97
|
+
id?: unknown;
|
|
98
|
+
level?: string;
|
|
99
|
+
name?: string;
|
|
100
|
+
message?: string;
|
|
101
|
+
source?: string;
|
|
102
|
+
timestamp?: string | Date;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const INTROSPECTION_PAGE_SIZES = {
|
|
106
|
+
commands: 25,
|
|
107
|
+
tools: 15,
|
|
108
|
+
endpoints: 30,
|
|
109
|
+
bindings: 30,
|
|
110
|
+
mcp: 30,
|
|
111
|
+
} as const;
|
|
112
|
+
|
|
113
|
+
type IntrospectionKind = keyof typeof INTROSPECTION_PAGE_SIZES;
|
|
114
|
+
|
|
115
|
+
let pluginsCache: { data: unknown[]; ts: number } | null = null;
|
|
116
|
+
const PLUGINS_CACHE_TTL = 5 * 60 * 1000;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 注册 Console 页面 REST 路由。返回注销函数(注销全部已注册路由)。
|
|
120
|
+
*/
|
|
121
|
+
export function registerConsoleRestPages(
|
|
122
|
+
host: HttpHost,
|
|
123
|
+
ctx: ConsoleRestCtx,
|
|
124
|
+
options: ConsoleRestPagesOptions = {},
|
|
125
|
+
): () => void {
|
|
126
|
+
const base = normalizeBase(options.apiBase ?? '/api');
|
|
127
|
+
const fetchFn = options.fetchFn ?? fetch;
|
|
128
|
+
const pluginRegistryUrl = options.pluginRegistryUrl ?? 'https://zhin.js.org/plugins.json';
|
|
129
|
+
const npmRegistryUrl = (options.npmRegistryUrl ?? 'https://registry.npmmirror.com')
|
|
130
|
+
.replace(/\/+$/u, '');
|
|
131
|
+
const disposers: Array<() => void> = [];
|
|
132
|
+
const route: typeof host.route = (method, path, handler, meta) => {
|
|
133
|
+
const dispose = host.route(method, path, handler, meta);
|
|
134
|
+
disposers.push(dispose);
|
|
135
|
+
return dispose;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
registerLogsRoutes(route, base, ctx);
|
|
139
|
+
registerMarketplaceRoutes(route, base, ctx, fetchFn, pluginRegistryUrl, npmRegistryUrl);
|
|
140
|
+
registerIntrospectionRoutes(route, base, ctx);
|
|
141
|
+
registerAgentSessionRoutes(route, base, ctx);
|
|
142
|
+
|
|
143
|
+
return () => {
|
|
144
|
+
for (const dispose of disposers.splice(0)) dispose();
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// logs(legacy logs-rest-api.ts;数据源 SystemLog 模型)
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
function registerLogsRoutes(
|
|
153
|
+
route: HttpHost['route'],
|
|
154
|
+
base: string,
|
|
155
|
+
ctx: ConsoleRestCtx,
|
|
156
|
+
): void {
|
|
157
|
+
const getModel = (): LogModelLike | null => {
|
|
158
|
+
const host = ctx.databaseHost;
|
|
159
|
+
if (!host?.started) return null;
|
|
160
|
+
try {
|
|
161
|
+
const model = host.models.get('SystemLog') as LogModelLike | null | undefined;
|
|
162
|
+
return model && typeof model.select === 'function' ? model : null;
|
|
163
|
+
} catch {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
const unavailableNote = 'SystemLog 模型不可用(Database 未启动或未注册 DatabaseLogTransport)';
|
|
168
|
+
|
|
169
|
+
route('GET', `${base}/logs`, async (_request, response, url) => {
|
|
170
|
+
const limit = Math.min(
|
|
171
|
+
Math.max(parseInt(url.searchParams.get('limit') ?? '100', 10) || 100, 1),
|
|
172
|
+
1000,
|
|
173
|
+
);
|
|
174
|
+
const level = url.searchParams.get('level') ?? undefined;
|
|
175
|
+
const LogModel = getModel();
|
|
176
|
+
if (!LogModel) {
|
|
177
|
+
writeJson(response, 200, { success: true, data: [], total: 0, note: unavailableNote });
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let selection = LogModel.select();
|
|
182
|
+
if (level && level !== 'all') {
|
|
183
|
+
selection = selection.where({ level });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const logs = await selection.orderBy('timestamp', 'DESC').limit(limit);
|
|
187
|
+
|
|
188
|
+
writeJson(response, 200, {
|
|
189
|
+
success: true,
|
|
190
|
+
data: logs.map((log) => ({
|
|
191
|
+
level: log.level,
|
|
192
|
+
name: log.name,
|
|
193
|
+
message: log.message,
|
|
194
|
+
source: log.source,
|
|
195
|
+
timestamp:
|
|
196
|
+
log.timestamp instanceof Date ? log.timestamp.toISOString() : log.timestamp,
|
|
197
|
+
})),
|
|
198
|
+
total: logs.length,
|
|
199
|
+
});
|
|
200
|
+
}, { summary: 'List system logs', tags: ['console', 'logs'] });
|
|
201
|
+
|
|
202
|
+
route('DELETE', `${base}/logs`, async (_request, response, _url, authScope) => {
|
|
203
|
+
if (!requireWriteScope(response, ctx, authScope)) return;
|
|
204
|
+
const LogModel = getModel();
|
|
205
|
+
if (LogModel) {
|
|
206
|
+
await LogModel.delete({});
|
|
207
|
+
}
|
|
208
|
+
writeJson(response, 200, { success: true, message: '日志已清空' });
|
|
209
|
+
}, { summary: 'Clear system logs', tags: ['console', 'logs'] });
|
|
210
|
+
|
|
211
|
+
route('GET', `${base}/logs/stats`, async (_request, response) => {
|
|
212
|
+
const LogModel = getModel();
|
|
213
|
+
if (!LogModel) {
|
|
214
|
+
writeJson(response, 200, {
|
|
215
|
+
success: true,
|
|
216
|
+
data: {
|
|
217
|
+
total: 0,
|
|
218
|
+
byLevel: { info: 0, warn: 0, error: 0 },
|
|
219
|
+
oldestTimestamp: null,
|
|
220
|
+
},
|
|
221
|
+
note: unavailableNote,
|
|
222
|
+
});
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const total = await LogModel.select();
|
|
227
|
+
const levels = ['info', 'warn', 'error'];
|
|
228
|
+
const levelCounts: Record<string, number> = {};
|
|
229
|
+
|
|
230
|
+
for (const level of levels) {
|
|
231
|
+
const count = await LogModel.select().where({ level });
|
|
232
|
+
levelCounts[level] = count.length;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const oldestLog = await LogModel.select('timestamp')
|
|
236
|
+
.orderBy('timestamp', 'ASC')
|
|
237
|
+
.limit(1);
|
|
238
|
+
const oldestTimestamp =
|
|
239
|
+
oldestLog.length > 0
|
|
240
|
+
? oldestLog[0].timestamp instanceof Date
|
|
241
|
+
? oldestLog[0].timestamp.toISOString()
|
|
242
|
+
: (oldestLog[0].timestamp ?? null)
|
|
243
|
+
: null;
|
|
244
|
+
|
|
245
|
+
writeJson(response, 200, {
|
|
246
|
+
success: true,
|
|
247
|
+
data: { total: total.length, byLevel: levelCounts, oldestTimestamp },
|
|
248
|
+
});
|
|
249
|
+
}, { summary: 'System log stats', tags: ['console', 'logs'] });
|
|
250
|
+
|
|
251
|
+
route('POST', `${base}/logs/cleanup`, async (request, response, _url, authScope) => {
|
|
252
|
+
if (!requireWriteScope(response, ctx, authScope)) return;
|
|
253
|
+
const LogModel = getModel();
|
|
254
|
+
if (!LogModel) {
|
|
255
|
+
writeJson(response, 200, {
|
|
256
|
+
success: true,
|
|
257
|
+
message: '已清理 0 条日志',
|
|
258
|
+
deletedCount: 0,
|
|
259
|
+
note: unavailableNote,
|
|
260
|
+
});
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const body = (await readJsonBody(request) ?? {}) as {
|
|
265
|
+
days?: number;
|
|
266
|
+
maxRecords?: number;
|
|
267
|
+
};
|
|
268
|
+
const { days, maxRecords } = body;
|
|
269
|
+
let deletedCount = 0;
|
|
270
|
+
|
|
271
|
+
if (days && typeof days === 'number' && days > 0) {
|
|
272
|
+
const cutoffDate = new Date();
|
|
273
|
+
cutoffDate.setDate(cutoffDate.getDate() - days);
|
|
274
|
+
const deleted = await LogModel.delete({ timestamp: { $lt: cutoffDate } });
|
|
275
|
+
deletedCount += typeof deleted === 'number' ? deleted : (deleted as unknown[] | undefined)?.length || 0;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (maxRecords && typeof maxRecords === 'number' && maxRecords > 0) {
|
|
279
|
+
const totalLogs = await LogModel.select();
|
|
280
|
+
if (totalLogs.length > maxRecords) {
|
|
281
|
+
const excessCount = totalLogs.length - maxRecords;
|
|
282
|
+
const oldestLogs = await LogModel.select('id', 'timestamp')
|
|
283
|
+
.orderBy('timestamp', 'ASC')
|
|
284
|
+
.limit(excessCount);
|
|
285
|
+
const idsToDelete = oldestLogs.map((log) => log.id);
|
|
286
|
+
|
|
287
|
+
if (idsToDelete.length > 0) {
|
|
288
|
+
const deleted = await LogModel.delete({ id: { $in: idsToDelete } });
|
|
289
|
+
deletedCount += typeof deleted === 'number' ? deleted : (deleted as unknown[] | undefined)?.length || 0;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
writeJson(response, 200, {
|
|
295
|
+
success: true,
|
|
296
|
+
message: `已清理 ${deletedCount} 条日志`,
|
|
297
|
+
deletedCount,
|
|
298
|
+
});
|
|
299
|
+
}, { summary: 'Cleanup old system logs', tags: ['console', 'logs'] });
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
// marketplace(legacy marketplace-rest-api.ts;plugins.json + npmmirror)
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
function registerMarketplaceRoutes(
|
|
307
|
+
route: HttpHost['route'],
|
|
308
|
+
base: string,
|
|
309
|
+
ctx: ConsoleRestCtx,
|
|
310
|
+
fetchFn: typeof fetch,
|
|
311
|
+
pluginRegistryUrl: string,
|
|
312
|
+
npmRegistryUrl: string,
|
|
313
|
+
): void {
|
|
314
|
+
const fetchPluginRegistry = async (): Promise<unknown[]> => {
|
|
315
|
+
if (pluginsCache && Date.now() - pluginsCache.ts < PLUGINS_CACHE_TTL) {
|
|
316
|
+
return pluginsCache.data;
|
|
317
|
+
}
|
|
318
|
+
const resp = await fetchFn(pluginRegistryUrl, {
|
|
319
|
+
signal: AbortSignal.timeout(30_000),
|
|
320
|
+
});
|
|
321
|
+
if (!resp.ok) throw new Error(`plugins.json fetch failed: ${resp.status}`);
|
|
322
|
+
const json = (await resp.json()) as { plugins?: unknown[] };
|
|
323
|
+
const list = json.plugins || [];
|
|
324
|
+
pluginsCache = { data: list, ts: Date.now() };
|
|
325
|
+
return list;
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
route('GET', '/pub/marketplace/search', async (_request, response, url) => {
|
|
329
|
+
const q = url.searchParams.get('q') ?? url.searchParams.get('keyword') ?? '';
|
|
330
|
+
const pageNum = Math.max(1, parseInt(url.searchParams.get('page') ?? '1', 10) || 1);
|
|
331
|
+
const pageSize = Math.min(
|
|
332
|
+
100,
|
|
333
|
+
Math.max(1, parseInt(url.searchParams.get('size') ?? url.searchParams.get('limit') ?? '20', 10) || 20),
|
|
334
|
+
);
|
|
335
|
+
const category = url.searchParams.get('category') ?? undefined;
|
|
336
|
+
const official = url.searchParams.get('official') ?? undefined;
|
|
337
|
+
const searchKeyword = q.trim().toLowerCase();
|
|
338
|
+
|
|
339
|
+
try {
|
|
340
|
+
const allPlugins = (await fetchPluginRegistry()) as Array<Record<string, unknown>>;
|
|
341
|
+
let plugins = allPlugins.map((p) => ({
|
|
342
|
+
name: p.name,
|
|
343
|
+
displayName: p.displayName || '',
|
|
344
|
+
version: p.version || '',
|
|
345
|
+
description: p.description || '',
|
|
346
|
+
author: p.author || '',
|
|
347
|
+
isOfficial: !!p.isOfficial,
|
|
348
|
+
official: !!p.isOfficial,
|
|
349
|
+
category: p.category || 'util',
|
|
350
|
+
keywords: (p.tags as string[]) || [],
|
|
351
|
+
npm: p.npm || `https://www.npmjs.com/package/${p.name}`,
|
|
352
|
+
date: p.lastUpdate || '',
|
|
353
|
+
downloads: p.downloads || { weekly: 0, monthly: 0 },
|
|
354
|
+
readme: p.readme || '',
|
|
355
|
+
license: p.license || '',
|
|
356
|
+
}));
|
|
357
|
+
|
|
358
|
+
if (searchKeyword) {
|
|
359
|
+
plugins = plugins.filter((p) => {
|
|
360
|
+
const haystack = [p.name, p.displayName, p.description, ...(p.keywords || [])]
|
|
361
|
+
.join(' ')
|
|
362
|
+
.toLowerCase();
|
|
363
|
+
return haystack.includes(searchKeyword);
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
if (category) plugins = plugins.filter((p) => p.category === category);
|
|
367
|
+
if (official === 'true') plugins = plugins.filter((p) => p.official);
|
|
368
|
+
if (official === 'false') plugins = plugins.filter((p) => !p.official);
|
|
369
|
+
|
|
370
|
+
const total = plugins.length;
|
|
371
|
+
const start = (pageNum - 1) * pageSize;
|
|
372
|
+
const items = plugins.slice(start, start + pageSize);
|
|
373
|
+
writeJson(response, 200, {
|
|
374
|
+
success: true,
|
|
375
|
+
data: items,
|
|
376
|
+
total,
|
|
377
|
+
page: pageNum,
|
|
378
|
+
size: pageSize,
|
|
379
|
+
});
|
|
380
|
+
} catch (err) {
|
|
381
|
+
writeJson(response, 502, {
|
|
382
|
+
success: false,
|
|
383
|
+
error: err instanceof Error ? err.message : 'Search failed',
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}, { summary: 'Search plugin marketplace', tags: ['pub', 'marketplace'] });
|
|
387
|
+
|
|
388
|
+
route('GET', '/pub/marketplace/detail/*', async (_request, response, url) => {
|
|
389
|
+
const prefix = '/pub/marketplace/detail/';
|
|
390
|
+
const rawName = url.pathname.startsWith(prefix)
|
|
391
|
+
? url.pathname.slice(prefix.length).replace(/\/+$/u, '')
|
|
392
|
+
: '';
|
|
393
|
+
if (!rawName) {
|
|
394
|
+
writeJson(response, 404, { success: false, error: 'Package name is required' });
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const pkgName = decodePathParam(rawName);
|
|
398
|
+
try {
|
|
399
|
+
let cachedDownloads = { weekly: 0, monthly: 0 };
|
|
400
|
+
try {
|
|
401
|
+
const registry = (await fetchPluginRegistry()) as Array<{
|
|
402
|
+
name?: string;
|
|
403
|
+
downloads?: unknown;
|
|
404
|
+
}>;
|
|
405
|
+
const cached = registry.find((p) => p.name === pkgName);
|
|
406
|
+
if (cached?.downloads && typeof cached.downloads === 'object') {
|
|
407
|
+
cachedDownloads = cached.downloads as { weekly: number; monthly: number };
|
|
408
|
+
}
|
|
409
|
+
} catch {
|
|
410
|
+
/* ignore cache miss */
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const metaResp = await fetchFn(
|
|
414
|
+
`${npmRegistryUrl}/${encodeURIComponent(pkgName)}`,
|
|
415
|
+
{ signal: AbortSignal.timeout(30_000) },
|
|
416
|
+
);
|
|
417
|
+
if (!metaResp.ok) throw new Error(`Package not found: ${metaResp.status}`);
|
|
418
|
+
const meta = (await metaResp.json()) as Record<string, unknown>;
|
|
419
|
+
const latest = (meta['dist-tags'] as Record<string, string> | undefined)?.latest;
|
|
420
|
+
const versions = meta.versions as Record<string, Record<string, unknown>> | undefined;
|
|
421
|
+
const latestInfo = latest && versions ? versions[latest] : undefined;
|
|
422
|
+
const time = meta.time as Record<string, string> | undefined;
|
|
423
|
+
writeJson(response, 200, {
|
|
424
|
+
success: true,
|
|
425
|
+
data: {
|
|
426
|
+
name: meta.name,
|
|
427
|
+
version: latest,
|
|
428
|
+
description: meta.description || '',
|
|
429
|
+
readme: meta.readme || '',
|
|
430
|
+
license: meta.license || latestInfo?.license || '',
|
|
431
|
+
homepage: meta.homepage || latestInfo?.homepage || '',
|
|
432
|
+
repository:
|
|
433
|
+
(meta.repository as { url?: string } | undefined)?.url ||
|
|
434
|
+
(latestInfo?.repository as { url?: string } | undefined)?.url ||
|
|
435
|
+
'',
|
|
436
|
+
author:
|
|
437
|
+
typeof meta.author === 'string'
|
|
438
|
+
? meta.author
|
|
439
|
+
: (meta.author as { name?: string } | undefined)?.name || '',
|
|
440
|
+
keywords: (latestInfo?.keywords as string[]) || [],
|
|
441
|
+
engines: latestInfo?.engines || {},
|
|
442
|
+
peerDependencies: latestInfo?.peerDependencies || {},
|
|
443
|
+
downloads: cachedDownloads,
|
|
444
|
+
versions: Object.keys(versions || {}),
|
|
445
|
+
lastPublish: (latest && time?.[latest]) || '',
|
|
446
|
+
},
|
|
447
|
+
});
|
|
448
|
+
} catch (err) {
|
|
449
|
+
writeJson(response, 502, {
|
|
450
|
+
success: false,
|
|
451
|
+
error: err instanceof Error ? err.message : 'Detail fetch failed',
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
}, { summary: 'Plugin marketplace detail', tags: ['pub', 'marketplace'] });
|
|
455
|
+
|
|
456
|
+
route('GET', `${base}/marketplace/updates`, async (_request, response) => {
|
|
457
|
+
try {
|
|
458
|
+
const installed = await listInstalledPluginPackages(ctx.projectRoot);
|
|
459
|
+
if (!installed.length) {
|
|
460
|
+
writeJson(response, 200, { success: true, data: [] });
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
const updates = await Promise.all(
|
|
464
|
+
installed.map(async ({ name, current }) => {
|
|
465
|
+
try {
|
|
466
|
+
const resp = await fetchFn(
|
|
467
|
+
`${npmRegistryUrl}/${encodeURIComponent(name)}/latest`,
|
|
468
|
+
{ signal: AbortSignal.timeout(15_000) },
|
|
469
|
+
);
|
|
470
|
+
if (!resp.ok) return null;
|
|
471
|
+
const pkg = (await resp.json()) as { version?: string; description?: string };
|
|
472
|
+
return {
|
|
473
|
+
name,
|
|
474
|
+
latest: pkg.version,
|
|
475
|
+
description: pkg.description || '',
|
|
476
|
+
// 本地已装版本对照:读不到 package.json 时省略该字段。
|
|
477
|
+
...(current ? { current } : {}),
|
|
478
|
+
};
|
|
479
|
+
} catch {
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
}),
|
|
483
|
+
);
|
|
484
|
+
writeJson(response, 200, { success: true, data: updates.filter(Boolean) });
|
|
485
|
+
} catch (err) {
|
|
486
|
+
writeJson(response, 500, {
|
|
487
|
+
success: false,
|
|
488
|
+
error: err instanceof Error ? err.message : 'Update check failed',
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
}, { summary: 'Check installed plugin updates', tags: ['console', 'marketplace'] });
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* 本地已装插件包:projectRoot/package.json 的 `zhin.plugins`({package, instanceKey}[]),
|
|
496
|
+
* 按包名去重;版本读 `node_modules/<pkg>/package.json`(读不到则省略)。
|
|
497
|
+
*/
|
|
498
|
+
async function listInstalledPluginPackages(
|
|
499
|
+
projectRoot: string,
|
|
500
|
+
): Promise<Array<{ name: string; current?: string }>> {
|
|
501
|
+
const names = new Set<string>();
|
|
502
|
+
try {
|
|
503
|
+
const pkg = JSON.parse(await readFile(join(projectRoot, 'package.json'), 'utf8')) as {
|
|
504
|
+
readonly zhin?: { readonly plugins?: unknown };
|
|
505
|
+
};
|
|
506
|
+
const list = pkg.zhin?.plugins;
|
|
507
|
+
if (Array.isArray(list)) {
|
|
508
|
+
for (const item of list) {
|
|
509
|
+
if (typeof item === 'string' && item) {
|
|
510
|
+
names.add(item);
|
|
511
|
+
} else if (item && typeof item === 'object') {
|
|
512
|
+
const entry = (item as { package?: unknown }).package;
|
|
513
|
+
if (typeof entry === 'string' && entry) names.add(entry);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
} catch {
|
|
518
|
+
// 无 package.json 或格式不符 — 返回空列表
|
|
519
|
+
}
|
|
520
|
+
return Promise.all([...names].map(async (name) => {
|
|
521
|
+
try {
|
|
522
|
+
const installed = JSON.parse(
|
|
523
|
+
await readFile(join(projectRoot, 'node_modules', name, 'package.json'), 'utf8'),
|
|
524
|
+
) as { version?: unknown };
|
|
525
|
+
const current = typeof installed.version === 'string' ? installed.version : undefined;
|
|
526
|
+
return current ? { name, current } : { name };
|
|
527
|
+
} catch {
|
|
528
|
+
return { name };
|
|
529
|
+
}
|
|
530
|
+
}));
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// ---------------------------------------------------------------------------
|
|
534
|
+
// introspection(legacy introspection-rest-api.ts;分页形状对齐 IntrospectionJsonResponse)
|
|
535
|
+
// ---------------------------------------------------------------------------
|
|
536
|
+
|
|
537
|
+
function registerIntrospectionRoutes(
|
|
538
|
+
route: HttpHost['route'],
|
|
539
|
+
base: string,
|
|
540
|
+
ctx: ConsoleRestCtx,
|
|
541
|
+
): void {
|
|
542
|
+
const collectors: Array<{
|
|
543
|
+
kind: IntrospectionKind;
|
|
544
|
+
collect: () => { rows: readonly unknown[]; note?: string };
|
|
545
|
+
fields: Array<(item: unknown) => string | undefined>;
|
|
546
|
+
}> = [
|
|
547
|
+
{
|
|
548
|
+
kind: 'commands',
|
|
549
|
+
collect: () => ({ rows: agentIntrospection(ctx)?.commands?.() ?? [] }),
|
|
550
|
+
fields: [
|
|
551
|
+
(c) => stringProp(c, 'pattern'),
|
|
552
|
+
(c) => stringProp(c, 'desc'),
|
|
553
|
+
(c) => stringProp(c, 'plugin'),
|
|
554
|
+
],
|
|
555
|
+
},
|
|
556
|
+
{
|
|
557
|
+
kind: 'endpoints',
|
|
558
|
+
collect: () => ({
|
|
559
|
+
rows: (ctx.getEndpoints?.() ?? []).map((endpoint) => ({
|
|
560
|
+
adapter: endpoint.adapter,
|
|
561
|
+
name: endpoint.name,
|
|
562
|
+
online: endpoint.connected,
|
|
563
|
+
status: endpoint.status,
|
|
564
|
+
})),
|
|
565
|
+
}),
|
|
566
|
+
fields: [(b) => stringProp(b, 'adapter'), (b) => stringProp(b, 'name')],
|
|
567
|
+
},
|
|
568
|
+
{
|
|
569
|
+
kind: 'bindings',
|
|
570
|
+
collect: () => ({ rows: agentIntrospection(ctx)?.bindings?.() ?? [] }),
|
|
571
|
+
fields: [
|
|
572
|
+
(a) => stringProp(a, 'name'),
|
|
573
|
+
(a) => stringProp(a, 'provider'),
|
|
574
|
+
(a) => stringProp(a, 'model'),
|
|
575
|
+
],
|
|
576
|
+
},
|
|
577
|
+
{
|
|
578
|
+
kind: 'tools',
|
|
579
|
+
collect: () => ({ rows: agentIntrospection(ctx)?.tools?.() ?? [] }),
|
|
580
|
+
fields: [
|
|
581
|
+
(t) => stringProp(t, 'name'),
|
|
582
|
+
(t) => stringProp(t, 'source'),
|
|
583
|
+
(t) => stringProp(t, 'description'),
|
|
584
|
+
],
|
|
585
|
+
},
|
|
586
|
+
{
|
|
587
|
+
kind: 'mcp',
|
|
588
|
+
collect: () => {
|
|
589
|
+
const result = agentIntrospection(ctx)?.mcp?.();
|
|
590
|
+
if (result == null) return { rows: [] as readonly unknown[] };
|
|
591
|
+
// readonly 数组不能用 Array.isArray 收窄,按对象形状判断
|
|
592
|
+
if (typeof result === 'object' && 'rows' in result) {
|
|
593
|
+
return { rows: result.rows, note: result.note };
|
|
594
|
+
}
|
|
595
|
+
return { rows: result as readonly unknown[] };
|
|
596
|
+
},
|
|
597
|
+
fields: [(s) => stringProp(s, 'name')],
|
|
598
|
+
},
|
|
599
|
+
];
|
|
600
|
+
|
|
601
|
+
for (const { kind, collect, fields } of collectors) {
|
|
602
|
+
route('GET', `${base}/introspection/${kind}`, async (_request, response, url) => {
|
|
603
|
+
try {
|
|
604
|
+
const { rows, note } = collect();
|
|
605
|
+
const query = url.searchParams;
|
|
606
|
+
const filter = query.get('filter')?.trim() || undefined;
|
|
607
|
+
const filtered = filterByFields([...rows], filter, fields);
|
|
608
|
+
const slice = paginateItems(
|
|
609
|
+
filtered,
|
|
610
|
+
parsePositiveInt(query.get('page'), 1),
|
|
611
|
+
parsePositiveInt(query.get('pageSize'), INTROSPECTION_PAGE_SIZES[kind]),
|
|
612
|
+
);
|
|
613
|
+
const missing = kind === 'endpoints'
|
|
614
|
+
? !ctx.getEndpoints
|
|
615
|
+
: !agentIntrospection(ctx);
|
|
616
|
+
const degradedNote = note ?? (missing
|
|
617
|
+
? kind === 'endpoints'
|
|
618
|
+
? 'Endpoints 数据源未接线(ctx.getEndpoints 缺失)'
|
|
619
|
+
: 'Agent runtime 未装配(basic/cli 未接线 getAgentRuntime)'
|
|
620
|
+
: undefined);
|
|
621
|
+
writeJson(response, 200, {
|
|
622
|
+
success: true,
|
|
623
|
+
data: { ...slice, filter, note: degradedNote },
|
|
624
|
+
});
|
|
625
|
+
} catch (err) {
|
|
626
|
+
writeJson(response, 503, {
|
|
627
|
+
success: false,
|
|
628
|
+
data: { items: [], page: 1, pageSize: 0, total: 0, totalPages: 0 },
|
|
629
|
+
error: err instanceof Error ? err.message : String(err),
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
}, { summary: `Introspection: ${kind}`, tags: ['console', 'introspection'] });
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function agentIntrospection(ctx: ConsoleRestCtx): ConsoleAgentIntrospection | undefined {
|
|
637
|
+
const runtime = ctx.getAgentRuntime?.() as ConsoleAgentRuntime | null | undefined;
|
|
638
|
+
return runtime && typeof runtime === 'object' ? runtime.introspection : undefined;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// ---------------------------------------------------------------------------
|
|
642
|
+
// agent sessions(legacy agent-sessions-rest-api.ts;ADR 0010 D3)
|
|
643
|
+
// ---------------------------------------------------------------------------
|
|
644
|
+
|
|
645
|
+
function registerAgentSessionRoutes(
|
|
646
|
+
route: HttpHost['route'],
|
|
647
|
+
base: string,
|
|
648
|
+
ctx: ConsoleRestCtx,
|
|
649
|
+
): void {
|
|
650
|
+
const prefix = `${base}/agent/sessions/`;
|
|
651
|
+
const getSessionTree = (): ConsoleAgentSessionTree | null => {
|
|
652
|
+
const runtime = ctx.getAgentRuntime?.() as ConsoleAgentRuntime | null | undefined;
|
|
653
|
+
const tree = runtime && typeof runtime === 'object' ? runtime.sessionTree : undefined;
|
|
654
|
+
if (!tree) return null;
|
|
655
|
+
if (typeof tree.resolveActiveSessionId !== 'function') return null;
|
|
656
|
+
return tree;
|
|
657
|
+
};
|
|
658
|
+
const unavailable = (response: ServerResponse) => {
|
|
659
|
+
writeJson(response, 503, {
|
|
660
|
+
success: false,
|
|
661
|
+
error: 'Agent session tree runtime 未就绪(新 Runtime 需经 getAgentRuntime 装配 sessionTree)',
|
|
662
|
+
});
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
// HttpHost 无 `:param` 路由,用前缀路由匹配 `<sessionKey>/tree|leaf`。
|
|
666
|
+
route('GET', `${base}/agent/sessions/*`, async (_request, response, url) => {
|
|
667
|
+
const parsed = parseSessionAction(url.pathname, prefix, 'tree');
|
|
668
|
+
if (!parsed) {
|
|
669
|
+
writeJson(response, 404, { success: false, error: 'Not found' });
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
const runtime = getSessionTree();
|
|
673
|
+
if (!runtime) {
|
|
674
|
+
unavailable(response);
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const sessionId = await runtime.resolveActiveSessionId(parsed.sessionKey);
|
|
679
|
+
if (!sessionId) {
|
|
680
|
+
writeJson(response, 404, {
|
|
681
|
+
success: false,
|
|
682
|
+
error: `未找到活跃会话:${parsed.sessionKey}`,
|
|
683
|
+
});
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const session = await runtime.agentSessionStore.getBySessionId(sessionId);
|
|
688
|
+
const points = await runtime.listBranchPoints(sessionId);
|
|
689
|
+
writeJson(response, 200, {
|
|
690
|
+
success: true,
|
|
691
|
+
data: {
|
|
692
|
+
sessionKey: parsed.sessionKey,
|
|
693
|
+
sessionId,
|
|
694
|
+
activeLeafMessageId: session?.active_leaf_message_id ?? null,
|
|
695
|
+
points,
|
|
696
|
+
},
|
|
697
|
+
});
|
|
698
|
+
}, { summary: 'Get agent session tree', tags: ['console', 'agent'] });
|
|
699
|
+
|
|
700
|
+
route('POST', `${base}/agent/sessions/*`, async (request, response, url, authScope) => {
|
|
701
|
+
const parsed = parseSessionAction(url.pathname, prefix, 'leaf');
|
|
702
|
+
if (!parsed) {
|
|
703
|
+
writeJson(response, 404, { success: false, error: 'Not found' });
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
if (!requireWriteScope(response, ctx, authScope)) return;
|
|
707
|
+
const runtime = getSessionTree();
|
|
708
|
+
if (!runtime) {
|
|
709
|
+
unavailable(response);
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const sessionId = await runtime.resolveActiveSessionId(parsed.sessionKey);
|
|
714
|
+
if (!sessionId) {
|
|
715
|
+
writeJson(response, 404, {
|
|
716
|
+
success: false,
|
|
717
|
+
error: `未找到活跃会话:${parsed.sessionKey}`,
|
|
718
|
+
});
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
let body: Record<string, unknown>;
|
|
723
|
+
try {
|
|
724
|
+
body = (await readJsonBody(request) ?? {}) as Record<string, unknown>;
|
|
725
|
+
} catch (error) {
|
|
726
|
+
if (error instanceof HttpBodyError) {
|
|
727
|
+
writeJson(response, error.statusCode, { success: false, error: error.message });
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
throw error;
|
|
731
|
+
}
|
|
732
|
+
const messageIdRaw = body.messageId;
|
|
733
|
+
const indexRaw = body.index;
|
|
734
|
+
|
|
735
|
+
let messageId: number | undefined;
|
|
736
|
+
if (messageIdRaw != null && messageIdRaw !== '') {
|
|
737
|
+
const n = Number(messageIdRaw);
|
|
738
|
+
if (!Number.isFinite(n) || n < 1) {
|
|
739
|
+
writeJson(response, 400, { success: false, error: 'messageId 须为正整数' });
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
messageId = n;
|
|
743
|
+
} else if (indexRaw != null && indexRaw !== '') {
|
|
744
|
+
const index = Number(indexRaw);
|
|
745
|
+
if (!Number.isFinite(index) || index < 1) {
|
|
746
|
+
writeJson(response, 400, { success: false, error: 'index 须为正整数' });
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
const result = await runtime.jumpToBranchIndex(sessionId, index);
|
|
750
|
+
const session = await runtime.agentSessionStore.getBySessionId(sessionId);
|
|
751
|
+
writeJson(response, result.ok ? 200 : 400, {
|
|
752
|
+
success: result.ok,
|
|
753
|
+
message: result.message,
|
|
754
|
+
data: {
|
|
755
|
+
sessionKey: parsed.sessionKey,
|
|
756
|
+
sessionId,
|
|
757
|
+
activeLeafMessageId: session?.active_leaf_message_id ?? null,
|
|
758
|
+
},
|
|
759
|
+
});
|
|
760
|
+
return;
|
|
761
|
+
} else {
|
|
762
|
+
writeJson(response, 400, { success: false, error: '需要 messageId 或 index 之一' });
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const ok = await runtime.switchActiveLeaf(sessionId, messageId);
|
|
767
|
+
const session = await runtime.agentSessionStore.getBySessionId(sessionId);
|
|
768
|
+
writeJson(response, ok ? 200 : 400, {
|
|
769
|
+
success: ok,
|
|
770
|
+
message: ok ? `已切换 active leaf 至消息 #${messageId}` : '切换失败',
|
|
771
|
+
data: {
|
|
772
|
+
sessionKey: parsed.sessionKey,
|
|
773
|
+
sessionId,
|
|
774
|
+
activeLeafMessageId: session?.active_leaf_message_id ?? null,
|
|
775
|
+
},
|
|
776
|
+
});
|
|
777
|
+
}, { summary: 'Switch agent session active leaf', tags: ['console', 'agent'] });
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function parseSessionAction(
|
|
781
|
+
pathname: string,
|
|
782
|
+
prefix: string,
|
|
783
|
+
action: 'tree' | 'leaf',
|
|
784
|
+
): { sessionKey: string } | null {
|
|
785
|
+
if (!pathname.startsWith(prefix)) return null;
|
|
786
|
+
const rest = pathname.slice(prefix.length).replace(/\/+$/u, '');
|
|
787
|
+
const suffix = `/${action}`;
|
|
788
|
+
if (!rest.endsWith(suffix)) return null;
|
|
789
|
+
const rawKey = rest.slice(0, -suffix.length);
|
|
790
|
+
if (!rawKey) return null;
|
|
791
|
+
return { sessionKey: decodePathParam(rawKey) };
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// ---------------------------------------------------------------------------
|
|
795
|
+
// 内部分页 / 过滤(语义对齐 agent 包 introspection-pagination.ts)
|
|
796
|
+
// ---------------------------------------------------------------------------
|
|
797
|
+
|
|
798
|
+
function paginateItems<T>(
|
|
799
|
+
all: T[],
|
|
800
|
+
page: number,
|
|
801
|
+
pageSize: number,
|
|
802
|
+
): { items: T[]; page: number; pageSize: number; total: number; totalPages: number } {
|
|
803
|
+
const total = all.length;
|
|
804
|
+
const totalPages = Math.max(1, Math.ceil(total / pageSize) || 1);
|
|
805
|
+
const safePage = Math.min(Math.max(1, page), totalPages);
|
|
806
|
+
const start = (safePage - 1) * pageSize;
|
|
807
|
+
return {
|
|
808
|
+
items: all.slice(start, start + pageSize),
|
|
809
|
+
page: safePage,
|
|
810
|
+
pageSize,
|
|
811
|
+
total,
|
|
812
|
+
totalPages,
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function filterByFields<T>(
|
|
817
|
+
items: T[],
|
|
818
|
+
filter: string | undefined,
|
|
819
|
+
fields: Array<(item: T) => string | undefined>,
|
|
820
|
+
): T[] {
|
|
821
|
+
if (!filter?.trim()) return items;
|
|
822
|
+
return items.filter((item) => {
|
|
823
|
+
const blob = fields
|
|
824
|
+
.map((f) => f(item) ?? '')
|
|
825
|
+
.join(' ')
|
|
826
|
+
.toLowerCase();
|
|
827
|
+
return blob.includes(filter.trim().toLowerCase());
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function stringProp(value: unknown, key: string): string | undefined {
|
|
832
|
+
if (!value || typeof value !== 'object') return undefined;
|
|
833
|
+
const prop = (value as Record<string, unknown>)[key];
|
|
834
|
+
return typeof prop === 'string' ? prop : undefined;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function parsePositiveInt(raw: string | null, fallback: number): number {
|
|
838
|
+
if (raw && /^\d+$/u.test(raw)) return Math.max(1, parseInt(raw, 10));
|
|
839
|
+
return fallback;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// ---------------------------------------------------------------------------
|
|
843
|
+
// helpers
|
|
844
|
+
// ---------------------------------------------------------------------------
|
|
845
|
+
|
|
846
|
+
/** 写操作门禁:demo 部署(ctx.fullScope=false)或 demo token 均 403。 */
|
|
847
|
+
function requireWriteScope(
|
|
848
|
+
response: ServerResponse,
|
|
849
|
+
ctx: ConsoleRestCtx,
|
|
850
|
+
authScope: AuthScope,
|
|
851
|
+
): boolean {
|
|
852
|
+
if (ctx.fullScope && authScope === 'full') return true;
|
|
853
|
+
writeJson(response, 403, {
|
|
854
|
+
success: false,
|
|
855
|
+
error: 'Demo scope: 只读访问,写操作被拒绝',
|
|
856
|
+
});
|
|
857
|
+
return false;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function decodePathParam(raw: string): string {
|
|
861
|
+
try {
|
|
862
|
+
return decodeURIComponent(raw);
|
|
863
|
+
} catch {
|
|
864
|
+
return raw;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function normalizeBase(value: string): string {
|
|
869
|
+
if (!value.startsWith('/')) return `/${value}`;
|
|
870
|
+
return value.replace(/\/+$/u, '') || '/api';
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function writeJson(response: ServerResponse, status: number, body: unknown): void {
|
|
874
|
+
const payload = JSON.stringify(body);
|
|
875
|
+
response.writeHead(status, {
|
|
876
|
+
'content-type': 'application/json; charset=utf-8',
|
|
877
|
+
'content-length': Buffer.byteLength(payload),
|
|
878
|
+
});
|
|
879
|
+
response.end(payload);
|
|
880
|
+
}
|