zipcalm 0.0.0-stage → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +260 -2
  3. package/bin/zipcalm.js +191 -0
  4. package/package.json +67 -4
  5. package/server/app.js +309 -0
  6. package/server/boot.js +145 -0
  7. package/server/config/defaults.js +153 -0
  8. package/server/config/index.js +140 -0
  9. package/server/config/schema.js +244 -0
  10. package/server/core/auth/password.js +89 -0
  11. package/server/core/auth/store.js +261 -0
  12. package/server/core/compress/blacklist.js +236 -0
  13. package/server/core/compress/capRegistry.js +171 -0
  14. package/server/core/compress/deleter.js +285 -0
  15. package/server/core/compress/hardlinkScan.js +67 -0
  16. package/server/core/compress/orchestrator.js +783 -0
  17. package/server/core/compress/preflight.js +376 -0
  18. package/server/core/compress/registry.js +240 -0
  19. package/server/core/compress/sourceWalk.js +84 -0
  20. package/server/core/compress/verify.js +47 -0
  21. package/server/core/engineRunner.js +369 -0
  22. package/server/core/fs/list.js +164 -0
  23. package/server/core/fs/platformProfile.js +167 -0
  24. package/server/core/fs/repairNames.js +150 -0
  25. package/server/core/fs/roots.js +178 -0
  26. package/server/core/fs/safePath.js +173 -0
  27. package/server/core/fs/search.js +108 -0
  28. package/server/core/fs/stat.js +110 -0
  29. package/server/core/fs/systemTrash.js +212 -0
  30. package/server/core/fs/writeOps.js +257 -0
  31. package/server/core/hardlink/executor.js +106 -0
  32. package/server/core/hardlink/scanner.js +193 -0
  33. package/server/core/metrics/collector.js +279 -0
  34. package/server/core/metrics/procfs.js +107 -0
  35. package/server/core/task/events.js +82 -0
  36. package/server/core/task/manager.js +987 -0
  37. package/server/core/task/recovery.js +116 -0
  38. package/server/core/task/store.js +240 -0
  39. package/server/http/middleware/auth.js +130 -0
  40. package/server/http/middleware/error.js +54 -0
  41. package/server/http/middleware/logger.js +27 -0
  42. package/server/http/middleware/pathGuard.js +119 -0
  43. package/server/http/router.js +29 -0
  44. package/server/http/routes/auth.js +115 -0
  45. package/server/http/routes/blacklist.js +97 -0
  46. package/server/http/routes/capabilities.js +21 -0
  47. package/server/http/routes/compress.js +339 -0
  48. package/server/http/routes/fs.js +109 -0
  49. package/server/http/routes/hardlink.js +160 -0
  50. package/server/http/routes/health.js +52 -0
  51. package/server/http/routes/metrics.js +36 -0
  52. package/server/http/routes/tasks.js +304 -0
  53. package/server/lib/atomic-json.js +118 -0
  54. package/server/lib/errors.js +145 -0
  55. package/server/lib/fs-probe.js +25 -0
  56. package/server/lib/logger.js +68 -0
  57. package/server/lib/runtime-guard.js +49 -0
  58. package/server/lib/throttle.js +42 -0
  59. package/server/plugins/capabilities/password/index.js +300 -0
  60. package/server/plugins/capabilities/password/methods/aes256.js +31 -0
  61. package/server/plugins/capabilities/password/methods/zip20.js +29 -0
  62. package/server/plugins/formats/_shared/createGuards.js +26 -0
  63. package/server/plugins/formats/_shared/entrySafety.js +174 -0
  64. package/server/plugins/formats/_shared/nameRepair.js +123 -0
  65. package/server/plugins/formats/_shared/streams.js +18 -0
  66. package/server/plugins/formats/_shared/tar/compression.js +114 -0
  67. package/server/plugins/formats/_shared/tar/reader.js +74 -0
  68. package/server/plugins/formats/_shared/tar/writer.js +206 -0
  69. package/server/plugins/formats/_shared/tarFamily.js +207 -0
  70. package/server/plugins/formats/rar/index.js +354 -0
  71. package/server/plugins/formats/rar/js-engine.js +122 -0
  72. package/server/plugins/formats/rar/js-runner.mjs +129 -0
  73. package/server/plugins/formats/rar/unrar.js +266 -0
  74. package/server/plugins/formats/sevenzip/engine.js +355 -0
  75. package/server/plugins/formats/sevenzip/index.js +461 -0
  76. package/server/plugins/formats/sevenzip/wasm-runner.mjs +72 -0
  77. package/server/plugins/formats/tar/index.js +19 -0
  78. package/server/plugins/formats/targz/index.js +19 -0
  79. package/server/plugins/formats/tarzst/index.js +26 -0
  80. package/server/plugins/formats/zip/index.js +587 -0
  81. package/shared/app-version.js +7 -0
  82. package/shared/capabilities.js +21 -0
  83. package/shared/compress-modes.js +38 -0
  84. package/shared/error-codes.js +117 -0
  85. package/shared/format-list.js +101 -0
  86. package/shared/hardlink-hint.js +21 -0
  87. package/shared/task-phase.js +62 -0
  88. package/shared/task-status.js +84 -0
  89. package/web/dist/assets/index-D3zjq7N2.js +131 -0
  90. package/web/dist/assets/index-D9Vdmrb9.css +1 -0
  91. package/web/dist/index.html +18 -0
@@ -0,0 +1,119 @@
1
+ // 路径白名单中间件(M1 安全红线,INV-06)
2
+ //
3
+ // 这一层做两件事:
4
+ // 1. 把存储根与限制参数固化成 `app.fsContext`,全局只有一份(避免每个路由各自读配置);
5
+ // 2. 提供 preHandler 工厂 `app.guardPath`:**在进入业务逻辑之前**先做形态校验 + 白名单解析,
6
+ // 把结果挂到 request.userPath / request.absPath。业务层(core/fs/*)还会再校验一次,
7
+ // 这是刻意的 defense in depth:中间件被绕过也还有兜底。
8
+ //
9
+ // 真正的算法在 server/core/fs/safePath.js(拆段拒绝 .. + 带分隔符的前缀校验 + realpath 二次校验)。
10
+
11
+ import { resolveRequestPath, resolveWithinRoot } from '../../core/fs/safePath.js';
12
+ import { DEFAULT_ROOT_ID, listRoots, resolveRoot } from '../../core/fs/roots.js';
13
+
14
+ /** 请求里的 rootId(query 优先、body 兜底);空串视作未指定 */
15
+ function requestedRootIdOf(request) {
16
+ const fromQuery = request.query && request.query.rootId;
17
+ const fromBody = request.body && request.body.rootId;
18
+ const preferred = typeof fromQuery === 'string' && fromQuery.length > 0 ? fromQuery : fromBody;
19
+ return typeof preferred === 'string' && preferred.length > 0 ? preferred : undefined;
20
+ }
21
+
22
+ export async function registerPathGuard(app, { config, logger }) {
23
+ const fsContext = {
24
+ rootAbs: config.storage.root,
25
+ /** 可切换的根(docs/16):id/label/path 都是绝对路径;空数组=自动发现(由 /fs/volumes 现扫) */
26
+ roots: Array.isArray(config.storage.roots) ? config.storage.roots : [],
27
+ defaultRootId: DEFAULT_ROOT_ID,
28
+ maxPathLength: config.limits.maxPathLength,
29
+ maxPathSegmentLength: config.limits.maxPathSegmentLength,
30
+ maxListEntries: config.limits.maxListEntries,
31
+ defaultPageSize: config.limits.defaultPageSize,
32
+ maxMoveItems: config.limits.maxMoveItems,
33
+ maxDeleteItems: config.limits.maxDeleteItems,
34
+ };
35
+
36
+ // 启动时发现一次根(群晖共享 / 卷),让带 rootId 的请求**无需先调 /fs/volumes** 就能用;
37
+ // 之后 /fs/volumes 每次应答会用最新结果回写这里(加共享不用重启)。
38
+ const discovered = await listRoots({ storage: config.storage, env: process.env });
39
+ fsContext.platform = discovered.platform;
40
+ fsContext.mode = discovered.mode;
41
+ fsContext.roots = discovered.roots;
42
+
43
+ /** 用最新发现结果刷新缓存(/fs/volumes 调用后执行) */
44
+ app.decorate('refreshRoots', (fresh) => {
45
+ if (!fresh || !Array.isArray(fresh.roots)) return;
46
+ fsContext.platform = fresh.platform;
47
+ fsContext.mode = fresh.mode;
48
+ fsContext.roots = fresh.roots;
49
+ });
50
+
51
+ app.decorate('fsContext', fsContext);
52
+
53
+ /**
54
+ * 取(或生成)本请求的上下文:按 rootId 切基准,返回 { ...fsContext, rootAbs }。
55
+ * 有 `guardPath` 的路由已经生成过;写操作类路由(没走 guardPath)用它自己生成 ——
56
+ * 关键点:**每个请求一份副本**,绝不像改全局 rootAbs 那样让并发请求互相污染。
57
+ */
58
+ app.decorate('requestContext', function requestContext(request) {
59
+ if (request.fsContext) return request.fsContext;
60
+ const root = resolveRoot(fsContext, requestedRootIdOf(request));
61
+ request.rootId = root.id;
62
+ request.rootAbs = root.absPath;
63
+ request.fsContext = { ...fsContext, rootAbs: root.absPath };
64
+ return request.fsContext;
65
+ });
66
+ app.decorate('fsGuard', {
67
+ /** 同步形态校验(不碰磁盘) */
68
+ resolve(raw) {
69
+ return resolveWithinRoot(fsContext.rootAbs, raw, { maxPathLength: fsContext.maxPathLength });
70
+ },
71
+ /** 含符号链接二次校验(会访问磁盘) */
72
+ async resolveReal(raw) {
73
+ return resolveRequestPath(fsContext, raw);
74
+ },
75
+ /** 在**指定根**下解析(多根:压缩任务用自己的根,与默认根无关) */
76
+ async resolveRealIn(rootAbs, raw) {
77
+ return resolveRequestPath({ ...fsContext, rootAbs }, raw);
78
+ },
79
+ /** 按 rootId 取根(未知 id 抛 VALIDATION_FAILED,不静默回落) */
80
+ root(rootId) {
81
+ return resolveRoot(fsContext, rootId);
82
+ },
83
+ });
84
+
85
+ /**
86
+ * 生成受保护路由的 preHandler。
87
+ * @param {{source?: 'query'|'body', required?: boolean, key?: string}} options
88
+ */
89
+ app.decorate('guardPath', function guardPath(options = {}) {
90
+ const source = options.source || 'query';
91
+ const key = options.key || 'path';
92
+ const required = options.required !== false;
93
+
94
+ return async function pathGuardPreHandler(request) {
95
+ // 根先定下来:后面的白名单解析全部以它为基准
96
+ const root = resolveRoot(fsContext, requestedRootIdOf(request));
97
+ const requestContext = { ...fsContext, rootAbs: root.absPath };
98
+ request.rootId = root.id;
99
+ request.rootAbs = root.absPath;
100
+ request.fsContext = requestContext;
101
+
102
+ const container = source === 'body' ? request.body : request.query;
103
+ const raw = container ? container[key] : undefined;
104
+
105
+ if (raw === undefined || raw === null || raw === '') {
106
+ if (!required) return;
107
+ // 缺省视为存储根(API 规范:根为 /)
108
+ }
109
+
110
+ const target = raw === undefined || raw === null || raw === '' ? '/' : raw;
111
+ const { userPath, absPath, realPath } = await resolveRequestPath(requestContext, target);
112
+
113
+ request.userPath = userPath;
114
+ request.absPath = absPath;
115
+ request.realPath = realPath;
116
+ logger.debug('path guard resolved', { userPath, requestId: request.requestId });
117
+ };
118
+ });
119
+ }
@@ -0,0 +1,29 @@
1
+ // 路由注册表(docs/12 §二:http/router.js 只做装配,不写业务)
2
+ //
3
+ // 所有业务路由统一挂在 /api/v1 前缀下(契约 §一:API 版本化)。
4
+
5
+ import authRoutes from './routes/auth.js';
6
+ import blacklistRoutes from './routes/blacklist.js';
7
+ import capabilityRoutes from './routes/capabilities.js';
8
+ import compressRoutes from './routes/compress.js';
9
+ import fsRoutes from './routes/fs.js';
10
+ import hardlinkRoutes from './routes/hardlink.js';
11
+ import healthRoutes from './routes/health.js';
12
+ import metricsRoutes from './routes/metrics.js';
13
+ import taskRoutes from './routes/tasks.js';
14
+
15
+ export default async function router(app, options) {
16
+ const { config, logger, store, meta, verifyPassword } = options;
17
+
18
+ app.decorate('appLogger', logger);
19
+
20
+ await app.register(authRoutes, { config: config.auth, store, verifyPassword });
21
+ await app.register(fsRoutes, {});
22
+ await app.register(compressRoutes, {});
23
+ await app.register(blacklistRoutes, {});
24
+ await app.register(capabilityRoutes, {});
25
+ await app.register(hardlinkRoutes, {});
26
+ await app.register(metricsRoutes, {});
27
+ await app.register(taskRoutes, {});
28
+ await app.register(healthRoutes, { meta });
29
+ }
@@ -0,0 +1,115 @@
1
+ // 认证路由(docs/M0/API规范.md §4.1)
2
+ //
3
+ // 端点:login / logout / me / token/refresh
4
+ // 要点:
5
+ // - 登录一次同时下发 Session Cookie 与 Bearer Token(契约 §二);
6
+ // - 密码只比对哈希(scrypt),明文永不落盘、不入日志;
7
+ // - 失败限流用内存计数(配置 auth.loginRateLimit)。
8
+ //
9
+ // 结构:每个端点的处理是模块级函数(接收 ctx),工厂只做路由注册 —— 这样单个函数短、可单测。
10
+
11
+ import { ERROR_CODES, apiError } from '../../lib/errors.js';
12
+
13
+ /** 请求体统一取成对象(Fastify 在无 body 时给 undefined) */
14
+ function bodyOf(request) {
15
+ return request.body && typeof request.body === 'object' ? request.body : {};
16
+ }
17
+
18
+ /** 从 Authorization 头里取出 Bearer token 原文(没有则空串) */
19
+ function bearerTokenOf(request) {
20
+ const header = request.headers.authorization || '';
21
+ return header.replace(/^Bearer\s+/i, '').trim();
22
+ }
23
+
24
+ /** POST /auth/login:校验 → 限流 → 比对 → 下发 session + token */
25
+ async function handleLogin(ctx, request, reply) {
26
+ const { username, password } = bodyOf(request);
27
+
28
+ if (typeof username !== 'string' || username.length === 0 || typeof password !== 'string' || password.length === 0) {
29
+ throw apiError(ERROR_CODES.VALIDATION_FAILED, 'username 与 password 必填', { fields: ['username', 'password'] });
30
+ }
31
+
32
+ const ip = request.ip || 'unknown';
33
+ if (ctx.store.isLoginBlocked(ip, ctx.config.loginRateLimit)) {
34
+ throw apiError(ERROR_CODES.RATE_LIMITED, '登录失败次数过多,请稍后再试');
35
+ }
36
+
37
+ const usernameMatches = username === ctx.config.username;
38
+ const passwordMatches = await ctx.verifyPassword(password);
39
+
40
+ if (!usernameMatches || !passwordMatches) {
41
+ ctx.store.recordLoginFailure(ip, ctx.config.loginRateLimit);
42
+ // 不区分"用户名错"与"密码错"(避免账号枚举)
43
+ throw apiError(ERROR_CODES.AUTH_INVALID, '用户名或密码错误');
44
+ }
45
+
46
+ ctx.store.clearLoginFailures(ip);
47
+
48
+ const session = ctx.store.createSession(username);
49
+ const token = ctx.store.createToken(username);
50
+ ctx.app.setSessionCookie(reply, session.sid, ctx.store.sessionTtlMs);
51
+
52
+ ctx.app.appLogger.info('login succeeded', { requestId: request.requestId, user: username, authType: 'session+bearer' });
53
+
54
+ reply.ok({
55
+ user: { name: username },
56
+ token: token.token,
57
+ tokenExpiresAt: token.expiresAt,
58
+ sessionExpiresAt: session.expiresAt,
59
+ });
60
+ }
61
+
62
+ /** POST /auth/logout:撤销当前会话(与 Bearer token),可选全设备下线;幂等 */
63
+ async function handleLogout(ctx, request, reply) {
64
+ const body = bodyOf(request);
65
+
66
+ if (request.sessionId) ctx.store.revokeSession(request.sessionId);
67
+ if (request.auth && request.auth.type === 'bearer') {
68
+ ctx.store.revokeToken(bearerTokenOf(request));
69
+ }
70
+
71
+ let revokedTokens = 0;
72
+ if (body.allDevices === true) {
73
+ revokedTokens = ctx.store.revokeAllTokens ? ctx.store.revokeAllTokens() : 0;
74
+ }
75
+
76
+ ctx.app.clearSessionCookie(reply);
77
+ // 幂等:未登录调用也返回成功(API 规范 §4.1)
78
+ reply.ok({ loggedOut: true, revokedTokens });
79
+ }
80
+
81
+ /** GET /auth/me:回显当前身份与过期时间 */
82
+ async function handleMe(request, reply) {
83
+ reply.ok({
84
+ user: { name: request.auth.user },
85
+ authType: request.auth.type,
86
+ expiresAt: request.auth.expiresAt,
87
+ });
88
+ }
89
+
90
+ /** POST /auth/token/refresh:默认轮换(旧 token 立即失效) */
91
+ async function handleTokenRefresh(ctx, request, reply) {
92
+ const body = bodyOf(request);
93
+ const rotate = body.rotate !== false;
94
+
95
+ if (rotate && request.auth.type === 'bearer') {
96
+ ctx.store.revokeToken(bearerTokenOf(request));
97
+ }
98
+
99
+ const token = ctx.store.createToken(request.auth.user);
100
+ reply.ok({ token: token.token, tokenExpiresAt: token.expiresAt });
101
+ }
102
+
103
+ export default async function authRoutes(app, options) {
104
+ const ctx = {
105
+ app,
106
+ config: options.config,
107
+ store: options.store,
108
+ verifyPassword: options.verifyPassword,
109
+ };
110
+
111
+ app.post('/auth/login', (request, reply) => handleLogin(ctx, request, reply));
112
+ app.post('/auth/logout', (request, reply) => handleLogout(ctx, request, reply));
113
+ app.get('/auth/me', { preHandler: [app.requireAuth] }, (request, reply) => handleMe(request, reply));
114
+ app.post('/auth/token/refresh', { preHandler: [app.requireAuth] }, (request, reply) => handleTokenRefresh(ctx, request, reply));
115
+ }
@@ -0,0 +1,97 @@
1
+ // 黑名单端点(docs/M0/API规范.md §4.5,形状严格照契约,不自创字段)
2
+ //
3
+ // - GET /blacklist 三层读取(global / user / effectiveCount)
4
+ // - PUT /blacklist/user 用户层全量替换(幂等;校验单条 ≤128、总数 ≤200、拒绝对路径与 /)
5
+ // - POST /blacklist/preview { paths[], extraPatterns[] } → data.skipped[{path,pattern,layer}] + data.kept[]
6
+ //
7
+ // 三个端点都需要登录(黑名单属于用户设置);匹配与判定全在服务端(docs/09 §五)。
8
+
9
+ import { ERROR_CODES, apiError } from '../../lib/errors.js';
10
+ import { matchEntry } from '../../core/compress/blacklist.js';
11
+ import { collectFilesUnder } from '../../core/compress/sourceWalk.js';
12
+
13
+ /**
14
+ * 单个源的预览:返回该源命中的文件与"是否还有可压的文件"。
15
+ * 抽出来是为了让路由本体只做"解析参数 → 逐个源调用 → 拼响应"(单函数不超 50 行)。
16
+ */
17
+ async function previewOneSource(app, rawPath, layers, rootAbs) {
18
+ if (typeof rawPath !== 'string' || rawPath.length === 0) {
19
+ throw apiError(ERROR_CODES.VALIDATION_FAILED, 'paths 里每一项都必须是非空字符串');
20
+ }
21
+ const resolved = await app.fsGuard.resolveRealIn(rootAbs, rawPath);
22
+ const files = await collectFilesUnder(resolved.absPath);
23
+ const skipped = [];
24
+ let keptFiles = 0;
25
+ for (const file of files) {
26
+ const hit = matchEntry(layers, { name: file.name, segments: file.segments });
27
+ if (hit) skipped.push({ path: joinUserPath(resolved.userPath, file.relative), pattern: hit.pattern, layer: hit.layer });
28
+ else keptFiles += 1;
29
+ }
30
+ return { skipped, keptFiles, userPath: resolved.userPath, fileCount: files.length };
31
+ }
32
+
33
+ /**
34
+ * 预览多源:解析参数 → 逐个源判定 → 汇总 skipped/kept。
35
+ * 放在模块作用域(而非路由闭包里)是为了让路由表只做挂载,也让两段逻辑各自可读、可单测。
36
+ */
37
+ async function previewManySources(app, body) {
38
+ const rawPaths = body.paths;
39
+ if (!Array.isArray(rawPaths) || rawPaths.length === 0) {
40
+ throw apiError(ERROR_CODES.VALIDATION_FAILED, 'paths 必须是非空字符串数组');
41
+ }
42
+
43
+ const taskPatterns = Array.isArray(body.extraPatterns) ? body.extraPatterns : [];
44
+ const layers = await app.blacklist.layersFor({ enabled: true, taskPatterns });
45
+ // 根:与 fs 路由同一套语义(未知 rootId → VALIDATION_FAILED)
46
+ const root = app.fsGuard.root(body.rootId);
47
+ const skipped = [];
48
+ const kept = [];
49
+
50
+ for (const rawPath of rawPaths) {
51
+ const one = await previewOneSource(app, rawPath, layers, root.absPath);
52
+ skipped.push(...one.skipped);
53
+ // 空目录/不可读:不算"跳过",如实放进 kept(真正原因由压缩预检去报);
54
+ // 有文件留下也算 kept;整源都被跳过时不放进 kept(前端据此提示"该目录没有可压缩文件")
55
+ if (one.fileCount === 0 || one.keptFiles > 0) kept.push(one.userPath);
56
+ }
57
+
58
+ return { skipped, kept };
59
+ }
60
+
61
+ export default async function blacklistRoutes(app) {
62
+ /** 三层读取:global 恒 locked(不可改/不可删),user 来自 storage/blacklist-user.json */
63
+ app.get('/blacklist', { preHandler: [app.requireAuth] }, async (request, reply) => {
64
+ reply.ok(await app.blacklist.snapshot());
65
+ });
66
+
67
+ /** 用户层全量替换(幂等):契约 §4.5 的校验规则由 blacklist.validatePatterns 统一实现 */
68
+ app.put('/blacklist/user', { preHandler: [app.requireAuth] }, async (request, reply) => {
69
+ const body = request.body || {};
70
+ if (!Array.isArray(body.patterns)) {
71
+ throw apiError(ERROR_CODES.VALIDATION_FAILED, 'patterns 必须是字符串数组');
72
+ }
73
+ try {
74
+ const user = await app.blacklist.replaceUser({ patterns: body.patterns, enabled: body.enabled !== false });
75
+ reply.ok({ user });
76
+ } catch (error) {
77
+ // validatePatterns 抛的是带 validation 标记的普通 Error:翻译成契约错误码
78
+ throw apiError(ERROR_CODES.VALIDATION_FAILED, error.message);
79
+ }
80
+ });
81
+
82
+ /**
83
+ * 命中预览:压缩设置抽屉用它显示「X 个文件已跳过(已压缩格式)」。
84
+ * 契约:data.skipped[] = { path, pattern, layer }(**userPath**,绝不带 absPath);data.kept[] 只给路径。
85
+ * 路径校验逐个走 fsGuard.resolveReal(与 fs 路由同一套白名单 + 符号链接复核)。
86
+ */
87
+ app.post('/blacklist/preview', { preHandler: [app.requireAuth] }, async (request, reply) => {
88
+ reply.ok(await previewManySources(app, request.body || {}));
89
+ });
90
+ }
91
+
92
+ /** 用户路径拼接(POSIX 风格,避免 path.join 在 Windows 上换成反斜杠) */
93
+ function joinUserPath(base, relative) {
94
+ if (!relative) return base;
95
+ const prefix = base === '/' ? '' : base.replace(/\/$/, '');
96
+ return `${prefix}/${relative}`;
97
+ }
@@ -0,0 +1,21 @@
1
+ // 能力元数据端点(docs/19 §三:前端不硬编码能力列表,运行时从 API 拉取)
2
+ //
3
+ // GET /capabilities/:id —— 返回能力元数据(name/appliesTo/methods 含 caveat),
4
+ // 绝不包含任何密码值(PW-1)。id 必须在已知能力白名单内(shared/capabilities.js)。
5
+
6
+ import { ERROR_CODES, apiError } from '../../lib/errors.js';
7
+ import { CAPABILITY_IDS } from '../../../shared/capabilities.js';
8
+
9
+ export default async function capabilityRoutes(app) {
10
+ app.get('/capabilities/:id', { preHandler: [app.requireAuth] }, async (request, reply) => {
11
+ const { id } = request.params;
12
+ if (!CAPABILITY_IDS.includes(id)) {
13
+ throw apiError(ERROR_CODES.PATH_NOT_FOUND, `未知能力:${String(id)}`);
14
+ }
15
+ const cap = app.m2.capRegistry && app.m2.capRegistry.get(id);
16
+ if (!cap || typeof cap.describe !== 'function') {
17
+ throw apiError(ERROR_CODES.PATH_NOT_FOUND, `能力 ${id} 未加载`);
18
+ }
19
+ reply.ok(cap.describe());
20
+ });
21
+ }