@zhin.js/host-http 1.1.2 → 1.1.4

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/README.md CHANGED
@@ -12,6 +12,10 @@ only the `HttpHost` routing port: they may register HTTP/WS routes but cannot li
12
12
  shared server. Registrations are bound to generation admission, so candidate routes stay invisible
13
13
  until snapshot commit and retired routes stop matching immediately while their leases drain.
14
14
 
15
+ `ProcessHttpHost.close()` is terminal and idempotent. It waits for a pending
16
+ `listen()` operation to settle and releases its TCP listener. Concurrent `listen()`
17
+ calls share one operation; calling `listen()` after close rejects.
18
+
15
19
  ## Capabilities (this slice)
16
20
 
17
21
  - Path-scoped WebSocket upgrades (`ws(path)`)
@@ -59,3 +63,10 @@ Extended Console RPC uses `src/console-rpc-extended/index.ts` as its module boun
59
63
  only maps protocol method names to schedule, inbox, login, Endpoint-management, and Workroom control
60
64
  handlers. Shared request parsing and generation-leased Endpoint execution live behind internal helper
61
65
  ports, so adding one RPC domain does not enlarge the central dispatcher or couple unrelated domains.
66
+
67
+ Console project-file operations resolve the project root and existing filesystem targets before
68
+ access. Internal symlinks remain usable only when their resolved target is inside an allowed project
69
+ path; links outside the root, blocked path segments, and dangling links are denied. New files are
70
+ checked through their nearest existing parent and checked again after parent creation. File-tree
71
+ and environment-file inventory use the same boundary. This covers existing symlink layouts; it does
72
+ not provide an OS sandbox against a local process concurrently replacing directories during access.
@@ -54,28 +54,58 @@ function registerLogsRoutes(route, base, ctx) {
54
54
  };
55
55
  const unavailableNote = 'SystemLog 模型不可用(Database 未启动或未注册 DatabaseLogTransport)';
56
56
  route('GET', `${base}/logs`, async (_request, response, url) => {
57
- const limit = Math.min(Math.max(parseInt(url.searchParams.get('limit') ?? '100', 10) || 100, 1), 1000);
58
- const level = url.searchParams.get('level') ?? undefined;
57
+ const pageSize = clampPositiveInt(parsePositiveInt(url.searchParams.get('pageSize') ?? url.searchParams.get('limit'), 100), 100, 1000);
58
+ const requestedPage = clampPositiveInt(parsePositiveInt(url.searchParams.get('page'), 1), 1, Number.MAX_SAFE_INTEGER);
59
+ const level = url.searchParams.get('level');
60
+ const source = url.searchParams.get('source')?.trim();
61
+ const query = url.searchParams.get('q')?.trim();
62
+ const baseWhere = {
63
+ ...(level && level !== 'all' ? { level } : {}),
64
+ };
65
+ const where = { ...baseWhere, ...(source ? { source } : {}) };
59
66
  const LogModel = getModel();
60
67
  if (!LogModel) {
61
- writeJson(response, 200, { success: true, data: [], total: 0, note: unavailableNote });
68
+ writeJson(response, 200, { success: true, data: [], total: 0, page: 1, pageSize, totalPages: 1, sources: [], note: unavailableNote });
62
69
  return;
63
70
  }
64
- let selection = LogModel.select();
65
- if (level && level !== 'all') {
66
- selection = selection.where({ level });
71
+ // Database $like has no portable ESCAPE contract. Keep q literal (including
72
+ // %/_ and Unicode) and filter actual retained rows on the Host, not wildcard SQL.
73
+ const matchesQuery = (row) => !query || ['message', 'name', 'source']
74
+ .some(field => String(row[field] ?? '').toLowerCase().includes(query.toLowerCase()));
75
+ const selection = LogModel.select().where(where).orderBy('timestamp', 'DESC').orderBy('id', 'DESC');
76
+ const queriedRows = query ? (await selection).filter(matchesQuery) : undefined;
77
+ const total = queriedRows?.length ?? (typeof LogModel.count === 'function'
78
+ ? await LogModel.count(where)
79
+ : (await LogModel.select('id').where(where)).length);
80
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
81
+ const page = Math.min(requestedPage, totalPages);
82
+ const offset = (page - 1) * pageSize;
83
+ const logs = queriedRows
84
+ ? queriedRows.slice(offset, offset + pageSize)
85
+ : selection.offset
86
+ ? await selection.offset(offset).limit(pageSize)
87
+ : (await selection).slice(offset, offset + pageSize);
88
+ // Source counts match level/q, but exclude the selected source and page window.
89
+ const sourceRows = await LogModel.select('source', 'name', 'message').where(baseWhere);
90
+ const counts = new Map();
91
+ for (const row of sourceRows) {
92
+ const name = row.source;
93
+ if (!name || !matchesQuery(row))
94
+ continue;
95
+ counts.set(name, (counts.get(name) ?? 0) + 1);
67
96
  }
68
- const logs = await selection.orderBy('timestamp', 'DESC').limit(limit);
69
97
  writeJson(response, 200, {
70
98
  success: true,
71
99
  data: logs.map((log) => ({
100
+ id: log.id,
72
101
  level: log.level,
73
102
  name: log.name,
74
103
  message: log.message,
75
104
  source: log.source,
76
105
  timestamp: log.timestamp instanceof Date ? log.timestamp.toISOString() : log.timestamp,
77
106
  })),
78
- total: logs.length,
107
+ total, page, pageSize, totalPages,
108
+ sources: [...counts].sort(([a], [b]) => a.localeCompare(b)).map(([source, count]) => ({ source, count })),
79
109
  });
80
110
  }, { summary: 'List system logs', tags: ['console', 'logs'] });
81
111
  route('DELETE', `${base}/logs`, async (_request, response, _url, authScope) => {
@@ -195,8 +225,8 @@ function registerMarketplaceRoutes(route, base, ctx, fetchFn, pluginRegistryUrl,
195
225
  version: p.version || '',
196
226
  description: p.description || '',
197
227
  author: p.author || '',
198
- isOfficial: !!p.isOfficial,
199
- official: !!p.isOfficial,
228
+ isOfficial: typeof p.name === 'string' && /^@zhin\.js\/[a-z0-9][a-z0-9._-]*$/.test(p.name),
229
+ official: typeof p.name === 'string' && /^@zhin\.js\/[a-z0-9][a-z0-9._-]*$/.test(p.name),
200
230
  category: p.category || 'util',
201
231
  keywords: p.tags || [],
202
232
  npm: p.npm || `https://www.npmjs.com/package/${p.name}`,
@@ -1,5 +1,5 @@
1
1
  import { assertDemoConsoleRpcAllowed } from '@zhin.js/console-protocol';
2
- import { addCron, listSchedule, mutateCron } from './schedule-rpc.js';
2
+ import { addCron, listSchedule, mutateCron, validateCron } from './schedule-rpc.js';
3
3
  import { actOnRequest, listInbox, listInboxMessages, listPendingRequests, listRecentInboxMessages, mapNoticeRow, mapRequestRow, markInboxConsumed, TABLE_NOTICE, TABLE_REQUEST, } from './inbox-rpc.js';
4
4
  import { cancelLogin, listLoginPending, submitLogin } from './login-rpc.js';
5
5
  import { deleteFriend, groupWriteOp, listChannels, listFriends, listGroupMembers, listGroups, } from './endpoint-rpc.js';
@@ -15,6 +15,8 @@ export async function dispatchExtendedConsoleRpc(type, data, ctx) {
15
15
  case 'schedule:list':
16
16
  case 'cron:list':
17
17
  return listSchedule(ctx);
18
+ case 'cron:validate':
19
+ return validateCron(d);
18
20
  case 'cron:add':
19
21
  return addCron(d, ctx);
20
22
  case 'cron:remove':
@@ -3,4 +3,6 @@ export declare function listSchedule(ctx: ConsoleScheduleListCtx): Promise<Exten
3
3
  type ConsoleScheduleListCtx = ConsoleRpcExtendedCtx;
4
4
  export declare function addCron(d: Record<string, unknown>, ctx: ConsoleRpcExtendedCtx): Promise<ExtendedRpcResult>;
5
5
  export declare function mutateCron(type: 'cron:remove' | 'cron:pause' | 'cron:resume', d: Record<string, unknown>, ctx: ConsoleRpcExtendedCtx): Promise<ExtendedRpcResult>;
6
+ /** Uses the same parser as the persistent schedule engine; never creates a job. */
7
+ export declare function validateCron(d: Record<string, unknown>): ExtendedRpcResult;
6
8
  export {};
@@ -1,3 +1,4 @@
1
+ import { parseCron } from '@zhin.js/schedule';
1
2
  import { errorMessage } from './rpc-values.js';
2
3
  const CRON_NOT_WIRED = '持久化调度未接线:当前 Plugin Runtime ScheduleHost 仅支持插件注册的内存任务(list),' +
3
4
  '不支持 add/remove/pause/resume(需要 @zhin.js/agent 持久化调度引擎)';
@@ -23,7 +24,7 @@ export function listSchedule(ctx) {
23
24
  ...(job.nextExecution
24
25
  ? { nextExecution: job.nextExecution } : {}),
25
26
  }));
26
- return listPersistentJobs(ctx).then((persistent) => ({ data: { memory, persistent } }));
27
+ return listPersistentJobs(ctx).then((persistent) => ({ data: { memory, persistent, capabilities: { persistent: !!ctx.resolveScheduleEngine?.() && ctx.fullScope } } })).catch((error) => ({ error: errorMessage(error) }));
27
28
  }
28
29
  catch (error) {
29
30
  return { error: errorMessage(error) };
@@ -51,8 +52,8 @@ async function listPersistentJobs(ctx) {
51
52
  lastStatus: job.state?.lastStatus,
52
53
  }));
53
54
  }
54
- catch {
55
- return [];
55
+ catch (error) {
56
+ throw new Error(`读取持久化任务失败: ${errorMessage(error)}`);
56
57
  }
57
58
  }
58
59
  export async function addCron(d, ctx) {
@@ -65,6 +66,12 @@ export async function addCron(d, ctx) {
65
66
  return { error: 'cronExpression is required' };
66
67
  if (!prompt)
67
68
  return { error: 'prompt is required' };
69
+ try {
70
+ parseCron(cronExpression);
71
+ }
72
+ catch (error) {
73
+ return { error: `Cron 表达式无效: ${errorMessage(error)}` };
74
+ }
68
75
  const label = typeof d.label === 'string' && d.label.trim() ? d.label.trim() : undefined;
69
76
  const context = (d.context ?? {});
70
77
  const target = context.target ?? context.channel;
@@ -107,3 +114,13 @@ export async function mutateCron(type, d, ctx) {
107
114
  }
108
115
  }
109
116
  // ---------------------------------------------------------------- inbox
117
+ /** Uses the same parser as the persistent schedule engine; never creates a job. */
118
+ export function validateCron(d) {
119
+ try {
120
+ parseCron(String(d.cronExpression ?? '').trim());
121
+ return { data: { valid: true } };
122
+ }
123
+ catch (error) {
124
+ return { data: { valid: false, error: errorMessage(error) } };
125
+ }
126
+ }
@@ -652,6 +652,8 @@ export async function dispatchRuntimeConsoleRpc(message, ctx) {
652
652
  reachable: endpoint.connected && endpoint.status === 'online',
653
653
  connected: endpoint.connected,
654
654
  status: endpoint.status,
655
+ admitted: endpoint.admitted,
656
+ transportState: endpoint.transportState,
655
657
  phase: endpoint.phase ?? (endpoint.connected ? 'online' : 'pending'),
656
658
  pendingLogin: endpoint.pendingLogin ?? false,
657
659
  latencyMs: Date.now() - startedAt,
package/lib/http-host.js CHANGED
@@ -30,6 +30,7 @@ export function createHttpHost(options = {}) {
30
30
  let address;
31
31
  let closed = false;
32
32
  let closeResult;
33
+ let listenResult;
33
34
  const secure = options.tls !== undefined;
34
35
  const handleRequest = (request, response) => {
35
36
  void dispatchHttp(request, response);
@@ -205,7 +206,10 @@ export function createHttpHost(options = {}) {
205
206
  if (!auth.ok) {
206
207
  if (corsOk && origin)
207
208
  applyCors(response, origin, corsOrigins);
208
- writeJson(response, 401, { success: false, error: 'Invalid or missing token' });
209
+ writeJson(response, auth.forbidden ? 403 : 401, {
210
+ success: false,
211
+ error: auth.forbidden ? 'Token scope does not allow this route' : 'Invalid or missing token',
212
+ });
209
213
  return;
210
214
  }
211
215
  const route = matchHttpRoute(httpRoutes, method, pathname);
@@ -280,7 +284,7 @@ export function createHttpHost(options = {}) {
280
284
  if (scope === 'demo') {
281
285
  const method = (request.method ?? 'GET').toUpperCase();
282
286
  if (!isDemoHttpAllowed(method, pathname, apiBase))
283
- return { ok: false };
287
+ return { ok: false, forbidden: true };
284
288
  }
285
289
  const principal = tokenRegistry.resolvePrincipal(token);
286
290
  return { ok: true, scope, ...(principal ? { principal } : {}) };
@@ -365,12 +369,24 @@ export function createHttpHost(options = {}) {
365
369
  return tokenRegistry;
366
370
  },
367
371
  listen() {
372
+ if (closed)
373
+ return Promise.reject(new Error('HTTP Host is closed'));
374
+ if (listenResult)
375
+ return listenResult;
368
376
  if (address)
369
377
  return Promise.resolve(address);
370
- return new Promise((resolve, reject) => {
371
- server.once('error', reject);
378
+ listenResult = new Promise((resolve, reject) => {
379
+ const onError = (error) => {
380
+ listenResult = undefined;
381
+ reject(error);
382
+ };
383
+ server.once('error', onError);
372
384
  server.listen({ host, port }, () => {
373
- server.off('error', reject);
385
+ server.off('error', onError);
386
+ if (closed) {
387
+ reject(new Error('HTTP Host closed before listen completed'));
388
+ return;
389
+ }
374
390
  const bound = server.address();
375
391
  const listenPort = typeof bound === 'object' && bound ? bound.port : port;
376
392
  const publicHost = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host;
@@ -390,6 +406,7 @@ export function createHttpHost(options = {}) {
390
406
  resolve(address);
391
407
  });
392
408
  });
409
+ return listenResult;
393
410
  },
394
411
  close() {
395
412
  if (closeResult)
@@ -397,37 +414,40 @@ export function createHttpHost(options = {}) {
397
414
  closed = true;
398
415
  wsRoutes.clear();
399
416
  httpRoutes.length = 0;
400
- // Rollback paths dispose a host whose generation never reached listen().
401
- if (!server.listening) {
402
- address = undefined;
403
- closeResult = Promise.resolve();
404
- return closeResult;
405
- }
406
- closeResult = new Promise((resolve, reject) => {
407
- // Long-lived connections (SSE, keep-alive, WS) never end on their own:
408
- // destroy tracked sockets and terminate WS clients before close(),
409
- // otherwise the close callbacks would never fire.
410
- for (const socket of sockets)
411
- socket.destroy();
412
- sockets.clear();
413
- for (const client of wss.clients)
414
- client.terminate();
415
- wss.close((wssError) => {
416
- if (wssError) {
417
- reject(wssError);
418
- return;
419
- }
420
- server.close((serverError) => {
421
- if (serverError) {
422
- reject(serverError);
417
+ closeResult = (async () => {
418
+ // A pending listen must settle before deciding whether there is a
419
+ // listener to close. Otherwise it can bind after close has resolved.
420
+ await listenResult?.catch(() => undefined);
421
+ if (!server.listening) {
422
+ address = undefined;
423
+ return;
424
+ }
425
+ await new Promise((resolve, reject) => {
426
+ // Long-lived connections (SSE, keep-alive, WS) never end on their own:
427
+ // destroy tracked sockets and terminate WS clients before close(),
428
+ // otherwise the close callbacks would never fire.
429
+ for (const socket of sockets)
430
+ socket.destroy();
431
+ sockets.clear();
432
+ for (const client of wss.clients)
433
+ client.terminate();
434
+ wss.close((wssError) => {
435
+ if (wssError) {
436
+ reject(wssError);
423
437
  return;
424
438
  }
425
- logger.debug(formatCompact({ op: 'http_host_closed' }));
426
- address = undefined;
427
- resolve();
439
+ server.close((serverError) => {
440
+ if (serverError) {
441
+ reject(serverError);
442
+ return;
443
+ }
444
+ logger.debug(formatCompact({ op: 'http_host_closed' }));
445
+ address = undefined;
446
+ resolve();
447
+ });
428
448
  });
429
449
  });
430
- });
450
+ })();
431
451
  return closeResult;
432
452
  },
433
453
  };
@@ -1,4 +1,4 @@
1
- import { access, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
1
+ import { access, lstat, mkdir, readdir, readFile, realpath, stat, writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  /** Align with Remote Console file manager (paths relative to project root). */
4
4
  export const FILE_MANAGER_ALLOWED = Object.freeze([
@@ -39,8 +39,7 @@ export function isProjectPathAllowed(relativePath) {
39
39
  if (relativePath.includes('..') || path.isAbsolute(relativePath))
40
40
  return false;
41
41
  const normalized = relativePath.replace(/\\/gu, '/').replace(/^\.\//u, '');
42
- const firstSegment = normalized.split('/')[0] ?? '';
43
- if (FILE_MANAGER_BLOCKED.has(firstSegment))
42
+ if (normalized.split('/').some((segment) => FILE_MANAGER_BLOCKED.has(segment)))
44
43
  return false;
45
44
  return FILE_MANAGER_ALLOWED.some((allowed) => normalized === allowed || normalized.startsWith(`${allowed}/`));
46
45
  }
@@ -49,7 +48,9 @@ export async function buildProjectFileTree(projectRoot) {
49
48
  for (const entry of FILE_MANAGER_ALLOWED) {
50
49
  if (entry.includes('/'))
51
50
  continue;
52
- const absPath = path.resolve(projectRoot, entry);
51
+ const absPath = await resolveAllowedPath(projectRoot, entry);
52
+ if (!absPath)
53
+ continue;
53
54
  if (!(await pathExists(absPath)))
54
55
  continue;
55
56
  const info = await safeStat(absPath);
@@ -70,7 +71,7 @@ export async function buildProjectFileTree(projectRoot) {
70
71
  return tree.sort(compareTreeNodes);
71
72
  }
72
73
  export async function readProjectFile(projectRoot, relativePath) {
73
- const absPath = resolveAllowedPath(projectRoot, relativePath);
74
+ const absPath = await resolveAllowedPath(projectRoot, relativePath);
74
75
  if (!absPath)
75
76
  throw new Error(`Access denied: ${relativePath}`);
76
77
  if (!(await pathExists(absPath)))
@@ -85,29 +86,69 @@ export async function readProjectFile(projectRoot, relativePath) {
85
86
  return { content, size: info.size };
86
87
  }
87
88
  export async function saveProjectFile(projectRoot, relativePath, content) {
88
- const absPath = resolveAllowedPath(projectRoot, relativePath);
89
+ const absPath = await resolveAllowedPath(projectRoot, relativePath);
89
90
  if (!absPath)
90
91
  throw new Error(`Access denied: ${relativePath}`);
91
92
  await mkdir(path.dirname(absPath), { recursive: true });
92
- await writeFile(absPath, content, 'utf8');
93
+ const checkedPath = await resolveAllowedPath(projectRoot, relativePath);
94
+ if (!checkedPath)
95
+ throw new Error(`Access denied: ${relativePath}`);
96
+ await writeFile(checkedPath, content, 'utf8');
93
97
  }
94
98
  export async function listEnvFiles(projectRoot) {
95
99
  const names = ['.env', '.env.development', '.env.production'];
96
- const result = await Promise.all(names.map(async (name) => ({
97
- name,
98
- exists: await pathExists(path.resolve(projectRoot, name)),
99
- })));
100
+ const result = await Promise.all(names.map(async (name) => {
101
+ const resolved = await resolveAllowedPath(projectRoot, name);
102
+ return { name, exists: resolved !== null && await pathExists(resolved) };
103
+ }));
100
104
  return Object.freeze(result);
101
105
  }
102
- function resolveAllowedPath(projectRoot, relativePath) {
106
+ /** Resolve existing links, or the nearest existing parent for a new file. */
107
+ async function resolveAllowedPath(projectRoot, relativePath) {
103
108
  if (!isProjectPathAllowed(relativePath))
104
109
  return null;
105
- return path.resolve(projectRoot, relativePath);
110
+ try {
111
+ const root = await realpath(projectRoot);
112
+ let candidate = path.resolve(root, relativePath);
113
+ const missing = [];
114
+ for (;;) {
115
+ try {
116
+ // A dangling link must fail closed rather than be treated as a missing directory.
117
+ await lstat(candidate);
118
+ const resolved = path.resolve(await realpath(candidate), ...missing);
119
+ const relative = path.relative(root, resolved);
120
+ return isProjectPathAllowed(relative) ? resolved : null;
121
+ }
122
+ catch (error) {
123
+ if (error.code !== 'ENOENT')
124
+ return null;
125
+ // lstat succeeds for dangling symlinks even though realpath fails.
126
+ try {
127
+ await lstat(candidate);
128
+ return null;
129
+ }
130
+ catch (missingError) {
131
+ if (missingError.code !== 'ENOENT')
132
+ return null;
133
+ }
134
+ const parent = path.dirname(candidate);
135
+ if (candidate === root || parent === candidate)
136
+ return null;
137
+ missing.unshift(path.basename(candidate));
138
+ candidate = parent;
139
+ }
140
+ }
141
+ }
142
+ catch {
143
+ return null;
144
+ }
106
145
  }
107
146
  async function buildDirectoryTree(projectRoot, relativePath, maxDepth) {
108
147
  if (maxDepth <= 0)
109
148
  return [];
110
- const absDir = path.resolve(projectRoot, relativePath);
149
+ const absDir = await resolveAllowedPath(projectRoot, relativePath);
150
+ if (!absDir)
151
+ return [];
111
152
  const info = await safeStat(absDir);
112
153
  if (!info?.isDirectory())
113
154
  return [];
@@ -117,7 +158,11 @@ async function buildDirectoryTree(projectRoot, relativePath, maxDepth) {
117
158
  if (FILE_MANAGER_BLOCKED.has(entry.name) || entry.name.startsWith('.'))
118
159
  continue;
119
160
  const childRelative = relativePath ? `${relativePath}/${entry.name}` : entry.name;
120
- if (entry.isDirectory()) {
161
+ const childPath = await resolveAllowedPath(projectRoot, childRelative);
162
+ if (!childPath)
163
+ continue;
164
+ const childInfo = await safeStat(childPath);
165
+ if (childInfo?.isDirectory()) {
121
166
  result.push({
122
167
  name: entry.name,
123
168
  path: childRelative,
@@ -125,7 +170,7 @@ async function buildDirectoryTree(projectRoot, relativePath, maxDepth) {
125
170
  children: await buildDirectoryTree(projectRoot, childRelative, maxDepth - 1),
126
171
  });
127
172
  }
128
- else if (entry.isFile()) {
173
+ else if (childInfo?.isFile()) {
129
174
  result.push({ name: entry.name, path: childRelative, type: 'file' });
130
175
  }
131
176
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/host-http",
3
- "version": "1.1.2",
3
+ "version": "1.1.4",
4
4
  "description": "Minimal HTTP and WebSocket host for Plugin Runtime (no Koa)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -18,10 +18,11 @@
18
18
  ],
19
19
  "dependencies": {
20
20
  "ws": "^8.21.1",
21
- "@zhin.js/console-protocol": "1.1.6",
21
+ "@zhin.js/console-protocol": "1.1.7",
22
22
  "@zhin.js/host-http-contract": "1.1.0",
23
23
  "@zhin.js/logger": "1.1.1",
24
- "@zhin.js/plugin-runtime": "1.1.11"
24
+ "@zhin.js/schedule": "1.1.2",
25
+ "@zhin.js/plugin-runtime": "1.1.12"
25
26
  },
26
27
  "devDependencies": {
27
28
  "@types/node": "^26.1.2",
@@ -108,6 +108,7 @@ type LogSelectionLike = PromiseLike<LogRow[]> & {
108
108
  where(where: Record<string, unknown>): LogSelectionLike;
109
109
  orderBy(field: string, direction: 'ASC' | 'DESC'): LogSelectionLike;
110
110
  limit(count: number): Promise<LogRow[]>;
111
+ offset?(count: number): LogSelectionLike;
111
112
  };
112
113
 
113
114
  type LogRow = {
@@ -187,35 +188,57 @@ function registerLogsRoutes(
187
188
  const unavailableNote = 'SystemLog 模型不可用(Database 未启动或未注册 DatabaseLogTransport)';
188
189
 
189
190
  route('GET', `${base}/logs`, async (_request, response, url) => {
190
- const limit = Math.min(
191
- Math.max(parseInt(url.searchParams.get('limit') ?? '100', 10) || 100, 1),
192
- 1000,
193
- );
194
- const level = url.searchParams.get('level') ?? undefined;
191
+ const pageSize = clampPositiveInt(parsePositiveInt(url.searchParams.get('pageSize') ?? url.searchParams.get('limit'), 100), 100, 1000);
192
+ const requestedPage = clampPositiveInt(parsePositiveInt(url.searchParams.get('page'), 1), 1, Number.MAX_SAFE_INTEGER);
193
+ const level = url.searchParams.get('level');
194
+ const source = url.searchParams.get('source')?.trim();
195
+ const query = url.searchParams.get('q')?.trim();
196
+ const baseWhere: Record<string, unknown> = {
197
+ ...(level && level !== 'all' ? { level } : {}),
198
+ };
199
+ const where = { ...baseWhere, ...(source ? { source } : {}) };
195
200
  const LogModel = getModel();
196
201
  if (!LogModel) {
197
- writeJson(response, 200, { success: true, data: [], total: 0, note: unavailableNote });
202
+ writeJson(response, 200, { success: true, data: [], total: 0, page: 1, pageSize, totalPages: 1, sources: [], note: unavailableNote });
198
203
  return;
199
204
  }
200
-
201
- let selection = LogModel.select();
202
- if (level && level !== 'all') {
203
- selection = selection.where({ level });
205
+ // Database $like has no portable ESCAPE contract. Keep q literal (including
206
+ // %/_ and Unicode) and filter actual retained rows on the Host, not wildcard SQL.
207
+ const matchesQuery = (row: LogRow): boolean => !query || ['message', 'name', 'source']
208
+ .some(field => String(row[field as keyof LogRow] ?? '').toLowerCase().includes(query.toLowerCase()));
209
+ const selection = LogModel.select().where(where).orderBy('timestamp', 'DESC').orderBy('id', 'DESC');
210
+ const queriedRows = query ? (await selection).filter(matchesQuery) : undefined;
211
+ const total = queriedRows?.length ?? (typeof LogModel.count === 'function'
212
+ ? await LogModel.count(where)
213
+ : (await LogModel.select('id').where(where)).length);
214
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
215
+ const page = Math.min(requestedPage, totalPages);
216
+ const offset = (page - 1) * pageSize;
217
+ const logs = queriedRows
218
+ ? queriedRows.slice(offset, offset + pageSize)
219
+ : selection.offset
220
+ ? await selection.offset(offset).limit(pageSize)
221
+ : (await selection).slice(offset, offset + pageSize);
222
+ // Source counts match level/q, but exclude the selected source and page window.
223
+ const sourceRows = await LogModel.select('source', 'name', 'message').where(baseWhere);
224
+ const counts = new Map<string, number>();
225
+ for (const row of sourceRows) {
226
+ const name = row.source;
227
+ if (!name || !matchesQuery(row)) continue;
228
+ counts.set(name, (counts.get(name) ?? 0) + 1);
204
229
  }
205
-
206
- const logs = await selection.orderBy('timestamp', 'DESC').limit(limit);
207
-
208
230
  writeJson(response, 200, {
209
231
  success: true,
210
232
  data: logs.map((log) => ({
233
+ id: log.id,
211
234
  level: log.level,
212
235
  name: log.name,
213
236
  message: log.message,
214
237
  source: log.source,
215
- timestamp:
216
- log.timestamp instanceof Date ? log.timestamp.toISOString() : log.timestamp,
238
+ timestamp: log.timestamp instanceof Date ? log.timestamp.toISOString() : log.timestamp,
217
239
  })),
218
- total: logs.length,
240
+ total, page, pageSize, totalPages,
241
+ sources: [...counts].sort(([a], [b]) => a.localeCompare(b)).map(([source, count]) => ({ source, count })),
219
242
  });
220
243
  }, { summary: 'List system logs', tags: ['console', 'logs'] });
221
244
 
@@ -363,8 +386,8 @@ function registerMarketplaceRoutes(
363
386
  version: p.version || '',
364
387
  description: p.description || '',
365
388
  author: p.author || '',
366
- isOfficial: !!p.isOfficial,
367
- official: !!p.isOfficial,
389
+ isOfficial: typeof p.name === 'string' && /^@zhin\.js\/[a-z0-9][a-z0-9._-]*$/.test(p.name),
390
+ official: typeof p.name === 'string' && /^@zhin\.js\/[a-z0-9][a-z0-9._-]*$/.test(p.name),
368
391
  category: p.category || 'util',
369
392
  keywords: (p.tags as string[]) || [],
370
393
  npm: p.npm || `https://www.npmjs.com/package/${p.name}`,
@@ -1,6 +1,6 @@
1
1
  import { assertDemoConsoleRpcAllowed } from '@zhin.js/console-protocol';
2
2
  import type { ConsoleRpcExtendedCtx, ExtendedRpcResult } from './contracts.js';
3
- import { addCron, listSchedule, mutateCron } from './schedule-rpc.js';
3
+ import { addCron, listSchedule, mutateCron, validateCron } from './schedule-rpc.js';
4
4
  import {
5
5
  actOnRequest, listInbox, listInboxMessages, listPendingRequests, listRecentInboxMessages,
6
6
  mapNoticeRow, mapRequestRow, markInboxConsumed, TABLE_NOTICE, TABLE_REQUEST,
@@ -28,6 +28,9 @@ export async function dispatchExtendedConsoleRpc(
28
28
  case 'cron:list':
29
29
  return listSchedule(ctx);
30
30
 
31
+ case 'cron:validate':
32
+ return validateCron(d);
33
+
31
34
  case 'cron:add':
32
35
  return addCron(d, ctx);
33
36
  case 'cron:remove':
@@ -1,3 +1,4 @@
1
+ import { parseCron } from '@zhin.js/schedule';
1
2
  import type { ConsoleRpcExtendedCtx, ConsoleScheduleEngine, ExtendedRpcResult, ScheduleJobRow } from './contracts.js';
2
3
  import { errorMessage, strField } from './rpc-values.js';
3
4
 
@@ -28,7 +29,7 @@ export function listSchedule(ctx: ConsoleScheduleListCtx): Promise<ExtendedRpcRe
28
29
  ...((job as { nextExecution?: number }).nextExecution
29
30
  ? { nextExecution: (job as { nextExecution?: number }).nextExecution } : {}),
30
31
  }));
31
- return listPersistentJobs(ctx).then((persistent) => ({ data: { memory, persistent } }));
32
+ return listPersistentJobs(ctx).then((persistent) => ({ data: { memory, persistent, capabilities: { persistent: !!ctx.resolveScheduleEngine?.() && ctx.fullScope } } })).catch((error) => ({ error: errorMessage(error) }));
32
33
  } catch (error) {
33
34
  return { error: errorMessage(error) };
34
35
  }
@@ -56,8 +57,8 @@ async function listPersistentJobs(ctx: ConsoleRpcExtendedCtx): Promise<Record<st
56
57
  lastExecutedAt: (job.state as { lastExecutedAt?: number } | undefined)?.lastExecutedAt,
57
58
  lastStatus: (job.state as { lastStatus?: string } | undefined)?.lastStatus,
58
59
  }));
59
- } catch {
60
- return [];
60
+ } catch (error) {
61
+ throw new Error(`读取持久化任务失败: ${errorMessage(error)}`);
61
62
  }
62
63
  }
63
64
 
@@ -71,6 +72,11 @@ export async function addCron(
71
72
  const prompt = String(d.prompt ?? '').trim();
72
73
  if (!cronExpression) return { error: 'cronExpression is required' };
73
74
  if (!prompt) return { error: 'prompt is required' };
75
+ try {
76
+ parseCron(cronExpression);
77
+ } catch (error) {
78
+ return { error: `Cron 表达式无效: ${errorMessage(error)}` };
79
+ }
74
80
  const label = typeof d.label === 'string' && d.label.trim() ? d.label.trim() : undefined;
75
81
  const context = (d.context ?? {}) as Record<string, unknown>;
76
82
  const target = context.target ?? context.channel;
@@ -115,3 +121,13 @@ export async function mutateCron(
115
121
  }
116
122
 
117
123
  // ---------------------------------------------------------------- inbox
124
+
125
+ /** Uses the same parser as the persistent schedule engine; never creates a job. */
126
+ export function validateCron(d: Record<string, unknown>): ExtendedRpcResult {
127
+ try {
128
+ parseCron(String(d.cronExpression ?? '').trim());
129
+ return { data: { valid: true } };
130
+ } catch (error) {
131
+ return { data: { valid: false, error: errorMessage(error) } };
132
+ }
133
+ }
@@ -812,6 +812,8 @@ export async function dispatchRuntimeConsoleRpc(
812
812
  reachable: endpoint.connected && endpoint.status === 'online',
813
813
  connected: endpoint.connected,
814
814
  status: endpoint.status,
815
+ admitted: endpoint.admitted,
816
+ transportState: endpoint.transportState,
815
817
  phase: endpoint.phase ?? (endpoint.connected ? 'online' : 'pending'),
816
818
  pendingLogin: endpoint.pendingLogin ?? false,
817
819
  latencyMs: Date.now() - startedAt,
package/src/http-host.ts CHANGED
@@ -170,6 +170,7 @@ export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
170
170
  let address: HttpHostAddress | undefined;
171
171
  let closed = false;
172
172
  let closeResult: Promise<void> | undefined;
173
+ let listenResult: Promise<HttpHostAddress> | undefined;
173
174
 
174
175
  const secure = options.tls !== undefined;
175
176
  const handleRequest = (request: IncomingMessage, response: ServerResponse): void => {
@@ -345,7 +346,10 @@ export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
345
346
  const auth = authenticateHttp(request, url, pathname);
346
347
  if (!auth.ok) {
347
348
  if (corsOk && origin) applyCors(response, origin, corsOrigins);
348
- writeJson(response, 401, { success: false, error: 'Invalid or missing token' });
349
+ writeJson(response, auth.forbidden ? 403 : 401, {
350
+ success: false,
351
+ error: auth.forbidden ? 'Token scope does not allow this route' : 'Invalid or missing token',
352
+ });
349
353
  return;
350
354
  }
351
355
 
@@ -407,7 +411,7 @@ export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
407
411
  request: IncomingMessage,
408
412
  url: URL,
409
413
  pathname: string,
410
- ): { ok: true; scope: AuthScope; principal?: AuthenticatedTokenPrincipal } | { ok: false } {
414
+ ): { ok: true; scope: AuthScope; principal?: AuthenticatedTokenPrincipal } | { ok: false; forbidden?: boolean } {
411
415
  if (!requiresHttpAuth(pathname, apiBase, authExempt)) {
412
416
  return { ok: true, scope: 'full' };
413
417
  }
@@ -422,7 +426,7 @@ export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
422
426
  if (!scope) return { ok: false };
423
427
  if (scope === 'demo') {
424
428
  const method = (request.method ?? 'GET').toUpperCase();
425
- if (!isDemoHttpAllowed(method, pathname, apiBase)) return { ok: false };
429
+ if (!isDemoHttpAllowed(method, pathname, apiBase)) return { ok: false, forbidden: true };
426
430
  }
427
431
  const principal = tokenRegistry.resolvePrincipal(token);
428
432
  return { ok: true, scope, ...(principal ? { principal } : {}) };
@@ -534,11 +538,21 @@ export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
534
538
  },
535
539
 
536
540
  listen(): Promise<HttpHostAddress> {
541
+ if (closed) return Promise.reject(new Error('HTTP Host is closed'));
542
+ if (listenResult) return listenResult;
537
543
  if (address) return Promise.resolve(address);
538
- return new Promise((resolve, reject) => {
539
- server.once('error', reject);
544
+ listenResult = new Promise((resolve, reject) => {
545
+ const onError = (error: Error): void => {
546
+ listenResult = undefined;
547
+ reject(error);
548
+ };
549
+ server.once('error', onError);
540
550
  server.listen({ host, port }, () => {
541
- server.off('error', reject);
551
+ server.off('error', onError);
552
+ if (closed) {
553
+ reject(new Error('HTTP Host closed before listen completed'));
554
+ return;
555
+ }
542
556
  const bound = server.address();
543
557
  const listenPort = typeof bound === 'object' && bound ? bound.port : port;
544
558
  const publicHost = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host;
@@ -562,6 +576,7 @@ export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
562
576
  resolve(address);
563
577
  });
564
578
  });
579
+ return listenResult;
565
580
  },
566
581
 
567
582
  close(): Promise<void> {
@@ -569,35 +584,38 @@ export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
569
584
  closed = true;
570
585
  wsRoutes.clear();
571
586
  httpRoutes.length = 0;
572
- // Rollback paths dispose a host whose generation never reached listen().
573
- if (!server.listening) {
574
- address = undefined;
575
- closeResult = Promise.resolve();
576
- return closeResult;
577
- }
578
- closeResult = new Promise((resolve, reject) => {
579
- // Long-lived connections (SSE, keep-alive, WS) never end on their own:
580
- // destroy tracked sockets and terminate WS clients before close(),
581
- // otherwise the close callbacks would never fire.
582
- for (const socket of sockets) socket.destroy();
583
- sockets.clear();
584
- for (const client of wss.clients) client.terminate();
585
- wss.close((wssError) => {
586
- if (wssError) {
587
- reject(wssError);
588
- return;
589
- }
590
- server.close((serverError) => {
591
- if (serverError) {
592
- reject(serverError);
587
+ closeResult = (async () => {
588
+ // A pending listen must settle before deciding whether there is a
589
+ // listener to close. Otherwise it can bind after close has resolved.
590
+ await listenResult?.catch(() => undefined);
591
+ if (!server.listening) {
592
+ address = undefined;
593
+ return;
594
+ }
595
+ await new Promise<void>((resolve, reject) => {
596
+ // Long-lived connections (SSE, keep-alive, WS) never end on their own:
597
+ // destroy tracked sockets and terminate WS clients before close(),
598
+ // otherwise the close callbacks would never fire.
599
+ for (const socket of sockets) socket.destroy();
600
+ sockets.clear();
601
+ for (const client of wss.clients) client.terminate();
602
+ wss.close((wssError) => {
603
+ if (wssError) {
604
+ reject(wssError);
593
605
  return;
594
606
  }
595
- logger.debug(formatCompact({ op: 'http_host_closed' }));
596
- address = undefined;
597
- resolve();
607
+ server.close((serverError) => {
608
+ if (serverError) {
609
+ reject(serverError);
610
+ return;
611
+ }
612
+ logger.debug(formatCompact({ op: 'http_host_closed' }));
613
+ address = undefined;
614
+ resolve();
615
+ });
598
616
  });
599
617
  });
600
- });
618
+ })();
601
619
  return closeResult;
602
620
  },
603
621
  };
@@ -1,4 +1,4 @@
1
- import { access, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
1
+ import { access, lstat, mkdir, readdir, readFile, realpath, stat, writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
 
4
4
  /** Align with Remote Console file manager (paths relative to project root). */
@@ -48,8 +48,7 @@ export type FileTreeNode = {
48
48
  export function isProjectPathAllowed(relativePath: string): boolean {
49
49
  if (relativePath.includes('..') || path.isAbsolute(relativePath)) return false;
50
50
  const normalized = relativePath.replace(/\\/gu, '/').replace(/^\.\//u, '');
51
- const firstSegment = normalized.split('/')[0] ?? '';
52
- if (FILE_MANAGER_BLOCKED.has(firstSegment)) return false;
51
+ if (normalized.split('/').some((segment) => FILE_MANAGER_BLOCKED.has(segment))) return false;
53
52
  return FILE_MANAGER_ALLOWED.some(
54
53
  (allowed) => normalized === allowed || normalized.startsWith(`${allowed}/`),
55
54
  );
@@ -59,7 +58,8 @@ export async function buildProjectFileTree(projectRoot: string): Promise<FileTre
59
58
  const tree: FileTreeNode[] = [];
60
59
  for (const entry of FILE_MANAGER_ALLOWED) {
61
60
  if (entry.includes('/')) continue;
62
- const absPath = path.resolve(projectRoot, entry);
61
+ const absPath = await resolveAllowedPath(projectRoot, entry);
62
+ if (!absPath) continue;
63
63
  if (!(await pathExists(absPath))) continue;
64
64
  const info = await safeStat(absPath);
65
65
  if (!info) continue;
@@ -81,7 +81,7 @@ export async function readProjectFile(
81
81
  projectRoot: string,
82
82
  relativePath: string,
83
83
  ): Promise<{ content: string; size: number }> {
84
- const absPath = resolveAllowedPath(projectRoot, relativePath);
84
+ const absPath = await resolveAllowedPath(projectRoot, relativePath);
85
85
  if (!absPath) throw new Error(`Access denied: ${relativePath}`);
86
86
  if (!(await pathExists(absPath))) throw new Error(`File not found: ${relativePath}`);
87
87
  const info = await safeStat(absPath);
@@ -98,26 +98,54 @@ export async function saveProjectFile(
98
98
  relativePath: string,
99
99
  content: string,
100
100
  ): Promise<void> {
101
- const absPath = resolveAllowedPath(projectRoot, relativePath);
101
+ const absPath = await resolveAllowedPath(projectRoot, relativePath);
102
102
  if (!absPath) throw new Error(`Access denied: ${relativePath}`);
103
103
  await mkdir(path.dirname(absPath), { recursive: true });
104
- await writeFile(absPath, content, 'utf8');
104
+ const checkedPath = await resolveAllowedPath(projectRoot, relativePath);
105
+ if (!checkedPath) throw new Error(`Access denied: ${relativePath}`);
106
+ await writeFile(checkedPath, content, 'utf8');
105
107
  }
106
108
 
107
109
  export async function listEnvFiles(
108
110
  projectRoot: string,
109
111
  ): Promise<readonly { name: string; exists: boolean }[]> {
110
112
  const names = ['.env', '.env.development', '.env.production'] as const;
111
- const result = await Promise.all(names.map(async (name) => ({
112
- name,
113
- exists: await pathExists(path.resolve(projectRoot, name)),
114
- })));
113
+ const result = await Promise.all(names.map(async (name) => {
114
+ const resolved = await resolveAllowedPath(projectRoot, name);
115
+ return { name, exists: resolved !== null && await pathExists(resolved) };
116
+ }));
115
117
  return Object.freeze(result);
116
118
  }
117
119
 
118
- function resolveAllowedPath(projectRoot: string, relativePath: string): string | null {
120
+ /** Resolve existing links, or the nearest existing parent for a new file. */
121
+ async function resolveAllowedPath(projectRoot: string, relativePath: string): Promise<string | null> {
119
122
  if (!isProjectPathAllowed(relativePath)) return null;
120
- return path.resolve(projectRoot, relativePath);
123
+ try {
124
+ const root = await realpath(projectRoot);
125
+ let candidate = path.resolve(root, relativePath);
126
+ const missing: string[] = [];
127
+ for (;;) {
128
+ try {
129
+ // A dangling link must fail closed rather than be treated as a missing directory.
130
+ await lstat(candidate);
131
+ const resolved = path.resolve(await realpath(candidate), ...missing);
132
+ const relative = path.relative(root, resolved);
133
+ return isProjectPathAllowed(relative) ? resolved : null;
134
+ } catch (error) {
135
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') return null;
136
+ // lstat succeeds for dangling symlinks even though realpath fails.
137
+ try { await lstat(candidate); return null; } catch (missingError) {
138
+ if ((missingError as NodeJS.ErrnoException).code !== 'ENOENT') return null;
139
+ }
140
+ const parent = path.dirname(candidate);
141
+ if (candidate === root || parent === candidate) return null;
142
+ missing.unshift(path.basename(candidate));
143
+ candidate = parent;
144
+ }
145
+ }
146
+ } catch {
147
+ return null;
148
+ }
121
149
  }
122
150
 
123
151
  async function buildDirectoryTree(
@@ -126,7 +154,8 @@ async function buildDirectoryTree(
126
154
  maxDepth: number,
127
155
  ): Promise<FileTreeNode[]> {
128
156
  if (maxDepth <= 0) return [];
129
- const absDir = path.resolve(projectRoot, relativePath);
157
+ const absDir = await resolveAllowedPath(projectRoot, relativePath);
158
+ if (!absDir) return [];
130
159
  const info = await safeStat(absDir);
131
160
  if (!info?.isDirectory()) return [];
132
161
  const entries = await readdir(absDir, { withFileTypes: true });
@@ -134,14 +163,17 @@ async function buildDirectoryTree(
134
163
  for (const entry of entries) {
135
164
  if (FILE_MANAGER_BLOCKED.has(entry.name) || entry.name.startsWith('.')) continue;
136
165
  const childRelative = relativePath ? `${relativePath}/${entry.name}` : entry.name;
137
- if (entry.isDirectory()) {
166
+ const childPath = await resolveAllowedPath(projectRoot, childRelative);
167
+ if (!childPath) continue;
168
+ const childInfo = await safeStat(childPath);
169
+ if (childInfo?.isDirectory()) {
138
170
  result.push({
139
171
  name: entry.name,
140
172
  path: childRelative,
141
173
  type: 'directory',
142
174
  children: await buildDirectoryTree(projectRoot, childRelative, maxDepth - 1),
143
175
  });
144
- } else if (entry.isFile()) {
176
+ } else if (childInfo?.isFile()) {
145
177
  result.push({ name: entry.name, path: childRelative, type: 'file' });
146
178
  }
147
179
  }