@mebular/mcp 0.0.0-stage → 0.1.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/src/admin.mjs ADDED
@@ -0,0 +1,1320 @@
1
+ // 控制台只读 API 的数据组装(D1)
2
+ //
3
+ // 只使用 @mebular/core 的公共 API:eventLog / storage / node / sync / 门面
4
+ // 只读入口(getSubscriptions / getBlockedDevices / 域公告)与 MemoryService.status()。
5
+ // 这里不做任何写入,也不替其他设备做决定——只回答「本机给谁看什么」「与谁连接」。
6
+ //
7
+ // 契约(见 docs/console/interaction-draft.md):
8
+ // GET /admin/api/overview { device, status, onlinePeers[], revokedCount }
9
+ // GET /admin/api/devices [{ deviceId, online, peerId?, addrs?, lastSyncAt?,
10
+ // pendingEventCount?, grantedByMe[], grantedToMe[], revoked }]
11
+ // GET /admin/api/policy [{ eventId, type, issuer, subject, namespaces?, grantId?, at, valid }]
12
+ // GET /admin/api/namespaces [{ namespace, count, lastUpdatedAt, stateHash }]
13
+
14
+ import { existsSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import { readFile } from 'node:fs/promises';
17
+ import { POLICY_NAMESPACE, normalizeNamespace } from '@mebular/core';
18
+ import { configPath } from './config.mjs';
19
+ import { cloudError, displayPath, readRegistry } from './profiles.mjs';
20
+ import { TOOL_NAMES } from './tools.mjs';
21
+ import { hotPaths, readApplyPending, readApplyResult } from './config-apply.mjs';
22
+ import {
23
+ CONFIG_SCHEMA_BY_PATH,
24
+ EDITABLE_PATHS,
25
+ WRITABLE_INTERNAL_PATHS,
26
+ PENDING_RESTART_PATHS,
27
+ STATUS_ONLY_PATHS,
28
+ consoleSchema,
29
+ consoleStatusSchema,
30
+ } from './config-schema.mjs';
31
+
32
+ export const POLICY_NS = POLICY_NAMESPACE;
33
+
34
+ /** hex → Uint8Array(证书 devicePublicKey 为 hex 编码) */
35
+ export function hexToBytes(hex) {
36
+ if (typeof hex !== 'string' || hex.length % 2 !== 0) return new Uint8Array();
37
+ const out = new Uint8Array(hex.length / 2);
38
+ for (let i = 0; i < out.length; i += 1) out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
39
+ return out;
40
+ }
41
+
42
+ function nsOf(value) {
43
+ try {
44
+ return normalizeNamespace(value);
45
+ } catch {
46
+ return value === undefined || value === null || value === '' ? 'default' : String(value);
47
+ }
48
+ }
49
+
50
+ function unique(list) {
51
+ return [...new Set(list.filter((v) => typeof v === 'string' && v.length > 0))];
52
+ }
53
+
54
+ /**
55
+ * 从 `__policy__` 事件流推导只读审计视图。
56
+ * 注意:这里不逐条做用户主密钥信任链校验(GraphNamespacePolicy 已在校验),
57
+ * 仅做保守的 `valid` 标注,用于展示而非权威判定。
58
+ */
59
+ export async function collectPolicy(app) {
60
+ const events = await app.eventLog.listEvents({ namespace: POLICY_NS });
61
+ const subscriptions = [];
62
+ const declarations = [];
63
+ const deviceRevokes = [];
64
+ const deviceRestores = [];
65
+ const handoffs = [];
66
+ const securityTiers = [];
67
+ const securityApprovals = [];
68
+ const securityRecoveries = [];
69
+ const securityTotps = [];
70
+ const authors = [];
71
+ let previousTier = 'low';
72
+
73
+ for (const event of events) {
74
+ authors.push(event.author);
75
+ if (event.type === 'namespace_subscription') {
76
+ const subscription = event.data?.subscription;
77
+ if (subscription && typeof subscription.member === 'string' && typeof subscription.namespace === 'string') {
78
+ subscriptions.push({
79
+ eventId: event.id,
80
+ issuer: event.author,
81
+ at: event.timestamp,
82
+ member: subscription.member,
83
+ namespace: nsOf(subscription.namespace),
84
+ active: subscription.active !== false,
85
+ note: subscription.note,
86
+ cosignApprover: subscription.cosign?.approver,
87
+ breakGlassKeyId: subscription.breakGlass?.keyId,
88
+ selfDeclared: subscription.member === event.author,
89
+ });
90
+ }
91
+ } else if (event.type === 'namespace_declare') {
92
+ const declaration = event.data?.declaration;
93
+ if (declaration && typeof declaration.namespace === 'string') {
94
+ declarations.push({
95
+ eventId: event.id,
96
+ issuer: event.author,
97
+ at: event.timestamp,
98
+ namespace: nsOf(declaration.namespace),
99
+ note: declaration.note,
100
+ });
101
+ }
102
+ } else if (event.type === 'device_revoke') {
103
+ const deviceRevoke = event.data?.deviceRevoke;
104
+ if (deviceRevoke && typeof deviceRevoke.subject === 'string') {
105
+ deviceRevokes.push({
106
+ eventId: event.id,
107
+ issuer: event.author,
108
+ at: event.timestamp,
109
+ subject: deviceRevoke.subject,
110
+ note: deviceRevoke.note,
111
+ cosignApprover: deviceRevoke.cosign?.approver,
112
+ breakGlassKeyId: deviceRevoke.breakGlass?.keyId,
113
+ });
114
+ }
115
+ } else if (event.type === 'device_restore') {
116
+ const deviceRestore = event.data?.deviceRestore;
117
+ if (deviceRestore && typeof deviceRestore.subject === 'string') {
118
+ deviceRestores.push({
119
+ eventId: event.id,
120
+ issuer: event.author,
121
+ at: event.timestamp,
122
+ subject: deviceRestore.subject,
123
+ note: deviceRestore.note,
124
+ cosignApprover: deviceRestore.cosign?.approver,
125
+ breakGlassKeyId: deviceRestore.breakGlass?.keyId,
126
+ });
127
+ }
128
+ } else if (event.type === 'namespace_handoff') {
129
+ const handoff = event.data?.handoff;
130
+ if (handoff && typeof handoff.namespace === 'string') {
131
+ handoffs.push({
132
+ eventId: event.id,
133
+ issuer: event.author,
134
+ at: event.timestamp,
135
+ handoffId: handoff.handoffId,
136
+ namespace: nsOf(handoff.namespace),
137
+ successor: handoff.successor,
138
+ forced: handoff.forced === true,
139
+ pendingCount: typeof handoff.pendingCount === 'number' ? handoff.pendingCount : 0,
140
+ missingAuthors: Array.isArray(handoff.missingAuthors) ? handoff.missingAuthors : [],
141
+ note: handoff.note,
142
+ });
143
+ }
144
+ } else if (event.type === 'security_tier') {
145
+ const tier = event.data?.securityTier;
146
+ if (tier && ['low', 'medium', 'high'].includes(tier.level)) {
147
+ securityTiers.push({
148
+ eventId: event.id,
149
+ issuer: event.author,
150
+ at: event.timestamp,
151
+ level: tier.level,
152
+ fromLevel: previousTier,
153
+ note: tier.note,
154
+ cosignApprover: tier.cosign?.approver,
155
+ breakGlassKeyId: tier.breakGlass?.keyId,
156
+ nonce: tier.nonce,
157
+ });
158
+ previousTier = tier.level;
159
+ }
160
+ } else if (event.type === 'security_approval') {
161
+ const approval = event.data?.securityApproval;
162
+ if (approval && typeof approval.approvalId === 'string') {
163
+ securityApprovals.push({
164
+ eventId: event.id,
165
+ issuer: event.author,
166
+ subject: approval.target ?? approval.initiator,
167
+ at: event.timestamp,
168
+ approvalId: approval.approvalId,
169
+ op: approval.op,
170
+ decision: approval.decision,
171
+ initiator: approval.initiator,
172
+ target: approval.target,
173
+ namespace: approval.namespace !== undefined ? nsOf(approval.namespace) : undefined,
174
+ level: approval.level,
175
+ pairingCode: approval.pairingCode,
176
+ expiresAt: approval.expiresAt,
177
+ cosignApprover: approval.cosign?.approver,
178
+ note: approval.note,
179
+ });
180
+ }
181
+ } else if (event.type === 'security_recovery') {
182
+ const recovery = event.data?.securityRecovery;
183
+ if (recovery && typeof recovery.keyId === 'string') {
184
+ securityRecoveries.push({
185
+ eventId: event.id,
186
+ issuer: event.author,
187
+ at: event.timestamp,
188
+ keyId: recovery.keyId,
189
+ mode: recovery.mode === 'rotate' ? 'rotate' : 'set',
190
+ note: recovery.note,
191
+ cosignApprover: recovery.cosign?.approver,
192
+ breakGlassKeyId: recovery.breakGlass?.keyId,
193
+ });
194
+ }
195
+ } else if (event.type === 'security_totp') {
196
+ const totp = event.data?.securityTotp;
197
+ if (totp && typeof totp.fingerprint === 'string') {
198
+ securityTotps.push({
199
+ eventId: event.id,
200
+ issuer: event.author,
201
+ at: event.timestamp,
202
+ device: totp.device,
203
+ subject: totp.device,
204
+ fingerprint: totp.fingerprint,
205
+ epoch: totp.epoch,
206
+ note: totp.note,
207
+ });
208
+ }
209
+ }
210
+ }
211
+
212
+ let blockedDevices = [];
213
+ try {
214
+ blockedDevices = await app.getBlockedDevices();
215
+ } catch {
216
+ blockedDevices = [];
217
+ }
218
+ const blockedSet = new Set(blockedDevices.filter((d) => typeof d === 'string'));
219
+
220
+ const timeline = [
221
+ ...subscriptions.map((s) => ({
222
+ eventId: s.eventId,
223
+ type: 'namespace_subscription',
224
+ issuer: s.issuer,
225
+ subject: s.member,
226
+ namespace: s.namespace,
227
+ namespaces: [s.namespace],
228
+ active: s.active,
229
+ at: s.at,
230
+ note: s.note,
231
+ cosignApprover: s.cosignApprover,
232
+ breakGlassKeyId: s.breakGlassKeyId,
233
+ valid: s.selfDeclared && !blockedSet.has(s.issuer) && !blockedSet.has(s.member),
234
+ ...(s.selfDeclared ? {} : { rejectReason: 'foreign-subscription' }),
235
+ })),
236
+ ...declarations.map((d) => ({
237
+ eventId: d.eventId,
238
+ type: 'namespace_declare',
239
+ issuer: d.issuer,
240
+ subject: d.issuer,
241
+ namespace: d.namespace,
242
+ namespaces: [d.namespace],
243
+ at: d.at,
244
+ note: d.note,
245
+ valid: !blockedSet.has(d.issuer),
246
+ })),
247
+ ...deviceRevokes.map((d) => ({
248
+ eventId: d.eventId,
249
+ type: 'device_revoke',
250
+ issuer: d.issuer,
251
+ subject: d.subject,
252
+ at: d.at,
253
+ note: d.note,
254
+ cosignApprover: d.cosignApprover,
255
+ breakGlassKeyId: d.breakGlassKeyId,
256
+ valid: !blockedSet.has(d.issuer) && blockedSet.has(d.subject),
257
+ })),
258
+ ...deviceRestores.map((d) => ({
259
+ eventId: d.eventId,
260
+ type: 'device_restore',
261
+ issuer: d.issuer,
262
+ subject: d.subject,
263
+ at: d.at,
264
+ note: d.note,
265
+ cosignApprover: d.cosignApprover,
266
+ breakGlassKeyId: d.breakGlassKeyId,
267
+ valid: !blockedSet.has(d.issuer),
268
+ })),
269
+ ...handoffs.map((h) => ({
270
+ eventId: h.eventId,
271
+ type: 'namespace_handoff',
272
+ issuer: h.issuer,
273
+ subject: h.successor,
274
+ namespace: h.namespace,
275
+ handoffId: h.handoffId,
276
+ forced: h.forced,
277
+ pendingCount: h.pendingCount,
278
+ missingAuthors: h.missingAuthors,
279
+ at: h.at,
280
+ note: h.note,
281
+ valid: true,
282
+ })),
283
+ ...securityTiers.map((t) => ({
284
+ eventId: t.eventId,
285
+ type: 'security_tier',
286
+ issuer: t.issuer,
287
+ subject: t.issuer,
288
+ level: t.level,
289
+ fromLevel: t.fromLevel,
290
+ at: t.at,
291
+ note: t.note,
292
+ cosignApprover: t.cosignApprover,
293
+ breakGlassKeyId: t.breakGlassKeyId,
294
+ valid: !blockedSet.has(t.issuer),
295
+ })),
296
+ ...securityApprovals.map((a) => ({
297
+ eventId: a.eventId,
298
+ type: 'security_approval',
299
+ issuer: a.issuer,
300
+ subject: a.subject,
301
+ approvalId: a.approvalId,
302
+ op: a.op,
303
+ decision: a.decision,
304
+ initiator: a.initiator,
305
+ target: a.target,
306
+ level: a.level,
307
+ pairingCode: a.pairingCode,
308
+ expiresAt: a.expiresAt,
309
+ cosignApprover: a.cosignApprover,
310
+ ...(a.namespace !== undefined ? { namespace: a.namespace, namespaces: [a.namespace] } : {}),
311
+ at: a.at,
312
+ note: a.note,
313
+ valid: true,
314
+ })),
315
+ ...securityRecoveries.map((r) => ({
316
+ eventId: r.eventId,
317
+ type: 'security_recovery',
318
+ issuer: r.issuer,
319
+ subject: r.issuer,
320
+ keyId: r.keyId,
321
+ mode: r.mode,
322
+ at: r.at,
323
+ note: r.note,
324
+ cosignApprover: r.cosignApprover,
325
+ breakGlassKeyId: r.breakGlassKeyId,
326
+ valid: !blockedSet.has(r.issuer),
327
+ })),
328
+ ...securityTotps.map((t) => ({
329
+ eventId: t.eventId,
330
+ type: 'security_totp',
331
+ issuer: t.issuer,
332
+ subject: t.device,
333
+ device: t.device,
334
+ fingerprint: t.fingerprint,
335
+ epoch: t.epoch,
336
+ at: t.at,
337
+ note: t.note,
338
+ valid: true,
339
+ })),
340
+ ].sort((a, b) => b.at - a.at);
341
+
342
+ // 拒绝不静默——用 core 的权威诊断给时间线行加 rejectReason(valid=false)。
343
+ let securityState = null;
344
+ if (typeof app.getSecurityTier === 'function') {
345
+ try {
346
+ const raw = await app.getSecurityTier();
347
+ if (raw !== null && typeof raw === 'object') securityState = raw;
348
+ } catch {
349
+ securityState = null;
350
+ }
351
+ }
352
+ const rejectionByEvent = new Map(
353
+ (Array.isArray(securityState?.rejections) ? securityState.rejections : [])
354
+ .filter((row) => row && typeof row.eventId === 'string' && typeof row.reason === 'string')
355
+ .map((row) => [row.eventId, row.reason]),
356
+ );
357
+ if (rejectionByEvent.size > 0) {
358
+ for (const item of timeline) {
359
+ const reason = rejectionByEvent.get(item.eventId);
360
+ if (reason !== undefined) {
361
+ item.valid = false;
362
+ item.rejectReason = reason;
363
+ }
364
+ }
365
+ }
366
+
367
+ return {
368
+ events,
369
+ subscriptions,
370
+ declarations,
371
+ deviceRevokes,
372
+ deviceRestores,
373
+ handoffs,
374
+ securityTiers,
375
+ securityApprovals,
376
+ securityRecoveries,
377
+ securityTotps,
378
+ securityState,
379
+ blockedSet,
380
+ timeline,
381
+ authors: unique(authors),
382
+ };
383
+ }
384
+
385
+ /** 在线对端(已认证)与 deviceId 映射 */
386
+ export function collectOnlinePeers(app) {
387
+ const node = app.node;
388
+ if (!node || !node.isRunning()) return { peers: [], byDeviceId: new Map() };
389
+ const connectionManager = node.getConnectionManager();
390
+ const handshake = node.getHandshake();
391
+ const peers = [];
392
+ const byDeviceId = new Map();
393
+ for (const connection of connectionManager.getConnections()) {
394
+ const session = handshake.getSession(connection.peerId);
395
+ const authenticated = session?.state === 'authenticated';
396
+ const deviceId = session?.certificate?.deviceId ?? null;
397
+ const entry = {
398
+ peerId: connection.peerId.id,
399
+ deviceId,
400
+ remoteAddress: connection.remoteAddress,
401
+ state: connection.state,
402
+ authenticated: Boolean(authenticated),
403
+ };
404
+ peers.push(entry);
405
+ if (deviceId && authenticated) byDeviceId.set(deviceId, entry);
406
+ }
407
+ return { peers, byDeviceId };
408
+ }
409
+
410
+ /** GET /admin/api/overview */
411
+ export async function buildOverview({ app, service, cloud, config, runtime }) {
412
+ const status = await service.status();
413
+ const { peers } = collectOnlinePeers(app);
414
+ let blockedCount = 0;
415
+ try {
416
+ blockedCount = (await app.getBlockedDevices()).length;
417
+ } catch {
418
+ blockedCount = 0;
419
+ }
420
+ // P3:收敛健康度汇总(未启用 = 无字段;「关于本机」只读展示)
421
+ let convergence = null;
422
+ try {
423
+ const conv = app.getConvergenceStatus?.();
424
+ if (conv) convergence = { at: conv.at, summary: conv.summary };
425
+ } catch {
426
+ convergence = null;
427
+ }
428
+ // 设备展示名:网络派生优先;无共享事件 → 本机 config/runtime 回退。
429
+ let deviceNames = {};
430
+ try {
431
+ deviceNames = (await app.getDeviceNames?.()) ?? {};
432
+ } catch {
433
+ deviceNames = {};
434
+ }
435
+ const selfDeviceName = deviceNames[app.deviceId]?.name
436
+ ?? runtime?.deviceName
437
+ ?? config?.deviceName
438
+ ?? null;
439
+ // 忆云身份:旧守护无此字段 → 前端自行隐藏;注册表不可读时同样省略(不阻塞总览)。
440
+ let cloudView = null;
441
+ if (cloud?.available) {
442
+ try {
443
+ const state = await readRegistry(cloud.root);
444
+ const current = state.registry.profiles.find((profile) => profile.id === state.registry.current) ?? null;
445
+ if (current) {
446
+ // 网络级名字优先(任意设备改名后同步到这里);registry 只做 fallback。
447
+ let derived = null;
448
+ try {
449
+ derived = (await app.getCloudName?.()) ?? null;
450
+ } catch {
451
+ derived = null;
452
+ }
453
+ cloudView = {
454
+ id: current.id,
455
+ name: derived?.name ?? current.name,
456
+ masterFingerprint: current.masterFingerprint ?? null,
457
+ current: true,
458
+ nameSource: derived ? 'network' : 'registry',
459
+ nameUpdatedAt: derived
460
+ ? (derived.at ? new Date(derived.at).toISOString() : (current.nameUpdatedAt ?? null))
461
+ : (current.nameUpdatedAt ?? current.createdAt ?? null),
462
+ };
463
+ }
464
+ } catch {
465
+ cloudView = null;
466
+ }
467
+ }
468
+ return {
469
+ device: {
470
+ deviceId: app.deviceId,
471
+ name: selfDeviceName ?? app.deviceId,
472
+ peerId: status.peerId,
473
+ multiaddrs: status.listenAddrs,
474
+ relays: status.relays,
475
+ },
476
+ status,
477
+ onlinePeers: peers.filter((p) => p.authenticated),
478
+ blockedCount,
479
+ ...(convergence ? { convergence } : {}),
480
+ ...(cloudView ? { cloud: cloudView } : {}),
481
+ };
482
+ }
483
+
484
+ const sameDisplayName = (a, b) => String(a ?? '').trim().toLocaleLowerCase()
485
+ === String(b ?? '').trim().toLocaleLowerCase();
486
+
487
+ const parsedTime = (value) => {
488
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
489
+ if (typeof value === 'string') {
490
+ const parsed = Date.parse(value);
491
+ if (Number.isFinite(parsed)) return parsed;
492
+ }
493
+ return 0;
494
+ };
495
+
496
+ /**
497
+ * 本机多忆云重名标记(app 层计算;跨网络允许同名,但本机列表内重名要提示):
498
+ * - 同名项都标 `duplicateName`;
499
+ * - 其中名字更新时间最近的一项标 `suggestedRename`;
500
+ * - 当前忆云被标记时,UI 在顶栏徽章加警告点。
501
+ */
502
+ function markNameConflicts(rows) {
503
+ const groups = new Map();
504
+ for (const row of rows) {
505
+ const key = String(row.name ?? '').trim().toLocaleLowerCase();
506
+ const list = groups.get(key) ?? [];
507
+ list.push(row);
508
+ groups.set(key, list);
509
+ }
510
+ const duplicateNames = [];
511
+ const suggestedIds = [];
512
+ for (const [key, list] of groups) {
513
+ if (list.length < 2 || key.length === 0) continue;
514
+ duplicateNames.push(list[0].name);
515
+ let suggested = list[0];
516
+ for (const row of list.slice(1)) {
517
+ if (row._updatedAt > suggested._updatedAt) {
518
+ suggested = row;
519
+ } else if (row._updatedAt === suggested._updatedAt && row.id < suggested.id) {
520
+ suggested = row;
521
+ }
522
+ }
523
+ suggestedIds.push(suggested.id);
524
+ }
525
+ for (const row of rows) {
526
+ row.duplicateName = duplicateNames.some((name) => sameDisplayName(name, row.name));
527
+ row.suggestedRename = row.duplicateName && suggestedIds.includes(row.id);
528
+ delete row._updatedAt;
529
+ }
530
+ return { duplicateNames, suggestedIds };
531
+ }
532
+
533
+ /** GET /admin/api/clouds:注册表 v1 的只读视图(不含秘密)。 */
534
+ export async function buildClouds({ app, cloud }) {
535
+ if (!cloud?.available) {
536
+ throw cloudError(
537
+ 'CLOUD_UNAVAILABLE',
538
+ '当前为自定义 home / 受限 env:忆云档案功能不可用(默认 ~/.mebular 不受影响)',
539
+ { reason: cloud?.reason ?? 'unavailable' },
540
+ );
541
+ }
542
+ const state = await readRegistry(cloud.root);
543
+ const current = state.registry.current;
544
+ // 当前忆云的名字优先读网络派生(remote rename 后本机 registry 可能尚未刷新)。
545
+ let derived = null;
546
+ try {
547
+ derived = (await app?.getCloudName?.()) ?? null;
548
+ } catch {
549
+ derived = null;
550
+ }
551
+ const rows = state.registry.profiles.map((profile) => {
552
+ const isCurrent = profile.id === current;
553
+ const name = isCurrent && derived ? derived.name : profile.name;
554
+ const updatedAt = isCurrent && derived
555
+ ? parsedTime(derived.at || derived.issuedAt)
556
+ : parsedTime(profile.nameUpdatedAt) || parsedTime(profile.createdAt);
557
+ return {
558
+ id: profile.id,
559
+ name,
560
+ home: profile.home,
561
+ kind: profile.kind,
562
+ createdAt: profile.createdAt ?? null,
563
+ lastUsedAt: profile.lastUsedAt ?? null,
564
+ nameUpdatedAt: isCurrent && derived
565
+ ? (derived.at ? new Date(derived.at).toISOString() : profile.nameUpdatedAt ?? null)
566
+ : (profile.nameUpdatedAt ?? null),
567
+ masterFingerprint: profile.masterFingerprint ?? null,
568
+ deviceId: profile.deviceId ?? null,
569
+ current: isCurrent,
570
+ nameSource: isCurrent && derived ? 'network' : 'registry',
571
+ _updatedAt: updatedAt,
572
+ };
573
+ });
574
+ const conflicts = markNameConflicts(rows);
575
+ const currentRow = rows.find((row) => row.current) ?? null;
576
+ return {
577
+ ok: true,
578
+ root: displayPath(cloud.root),
579
+ current,
580
+ profiles: rows,
581
+ nameConflicts: {
582
+ names: conflicts.duplicateNames,
583
+ suggestedIds: conflicts.suggestedIds,
584
+ currentDuplicate: Boolean(currentRow?.duplicateName),
585
+ },
586
+ ...(state.warning ? { warning: state.warning } : {}),
587
+ };
588
+ }
589
+
590
+ /** 已知设备全集(节点 = 设备) */
591
+ export async function collectKnownDevices(app, policy, online) {
592
+ const self = app.deviceId;
593
+ const known = new Set([self]);
594
+ for (const author of policy.authors) known.add(author);
595
+ for (const subscription of policy.subscriptions) {
596
+ known.add(subscription.issuer);
597
+ known.add(subscription.member);
598
+ }
599
+ for (const declaration of policy.declarations) known.add(declaration.issuer);
600
+ for (const deviceRevoke of policy.deviceRevokes) known.add(deviceRevoke.subject);
601
+ for (const deviceRestore of policy.deviceRestores) known.add(deviceRestore.subject);
602
+ for (const handoff of policy.handoffs ?? []) {
603
+ if (handoff.successor) known.add(handoff.successor);
604
+ }
605
+ for (const deviceId of online.byDeviceId.keys()) known.add(deviceId);
606
+ known.delete('');
607
+ return [...known];
608
+ }
609
+
610
+ /** GET /admin/api/devices */
611
+ export async function buildDevices({ app, config, runtime }) {
612
+ const self = app.deviceId;
613
+ const online = collectOnlinePeers(app);
614
+ const policy = await collectPolicy(app);
615
+ const known = await collectKnownDevices(app, policy, online);
616
+ // 设备名来自网络派生(本人可改);无事件时本机用 config/runtime 回退,其他设备只显示 deviceId。
617
+ let deviceNames = {};
618
+ try {
619
+ deviceNames = (await app.getDeviceNames?.()) ?? {};
620
+ } catch {
621
+ deviceNames = {};
622
+ }
623
+ const selfNameFallback = runtime?.deviceName ?? config?.deviceName ?? null;
624
+
625
+ let syncStatus = null;
626
+ try {
627
+ syncStatus = await app.sync.getSyncStatus();
628
+ } catch {
629
+ syncStatus = null;
630
+ }
631
+ const lastSyncPeer = syncStatus?.lastResult?.peerDeviceId ?? null;
632
+
633
+ // P3:收敛健康度(每对端只读状态;未启用 = 无)
634
+ let convergenceByPeer = new Map();
635
+ try {
636
+ const convergence = app.getConvergenceStatus?.();
637
+ for (const peer of convergence?.peers ?? []) convergenceByPeer.set(peer.peerDeviceId, peer);
638
+ } catch {
639
+ convergenceByPeer = new Map();
640
+ }
641
+
642
+ let selfSubscriptions = [];
643
+ try {
644
+ selfSubscriptions = await app.getSubscriptions(self);
645
+ } catch {
646
+ selfSubscriptions = [];
647
+ }
648
+ const runtimeSubscriptions = app.sync?.getSubscriptionNamespaces?.() ?? [];
649
+ for (const ns of runtimeSubscriptions) {
650
+ if (!selfSubscriptions.includes(ns)) selfSubscriptions.push(ns);
651
+ }
652
+ if (runtimeSubscriptions.length === 0) {
653
+ try {
654
+ for (const ns of await app.getNamespaceDirectory()) {
655
+ if (!selfSubscriptions.includes(ns)) selfSubscriptions.push(ns);
656
+ }
657
+ } catch {
658
+ // 门面未就绪:按空订阅集处理
659
+ }
660
+ }
661
+ const selfSubscribedSet = new Set(selfSubscriptions);
662
+
663
+ const devices = [];
664
+ for (const deviceId of known) {
665
+ const connection = online.byDeviceId.get(deviceId) ?? null;
666
+ let subscriptions = [];
667
+ try {
668
+ subscriptions = await app.getSubscriptions(deviceId);
669
+ } catch {
670
+ subscriptions = [];
671
+ }
672
+ const sharesWithMe = subscriptions.filter((ns) => selfSubscribedSet.has(ns)).sort();
673
+ let pendingEventCount = null;
674
+ try {
675
+ pendingEventCount = (await app.sync.getPendingEvents(deviceId)).length;
676
+ } catch {
677
+ pendingEventCount = null;
678
+ }
679
+ const name = deviceNames[deviceId]?.name
680
+ ?? (deviceId === self ? selfNameFallback : null);
681
+ devices.push({
682
+ deviceId,
683
+ ...(name ? { name } : {}),
684
+ // 本机是本地节点:不依赖入站连接,始终 online(否则前端可达性会把 self 算成离线)。
685
+ // 对端仍按真实已认证连接判定。
686
+ online: deviceId === self ? true : Boolean(connection),
687
+ ...(connection ? { peerId: connection.peerId, addrs: [connection.remoteAddress] } : {}),
688
+ ...(lastSyncPeer === deviceId && syncStatus?.lastSyncAt ? { lastSyncAt: syncStatus.lastSyncAt } : {}),
689
+ ...(pendingEventCount !== null ? { pendingEventCount } : {}),
690
+ ...(convergenceByPeer.has(deviceId) ? { convergence: convergenceByPeer.get(deviceId) } : {}),
691
+ subscriptions: [...subscriptions].sort(),
692
+ sharesWithMe,
693
+ blocked: policy.blockedSet.has(deviceId),
694
+ });
695
+ }
696
+
697
+ // 本机置顶,其余在线优先、再按 deviceId
698
+ devices.sort((a, b) => {
699
+ if (a.deviceId === self) return -1;
700
+ if (b.deviceId === self) return 1;
701
+ if (a.online !== b.online) return a.online ? -1 : 1;
702
+ return a.deviceId < b.deviceId ? -1 : 1;
703
+ });
704
+ return devices;
705
+ }
706
+
707
+ /** GET /admin/api/policy */
708
+ export async function buildPolicy({ app }) {
709
+ const policy = await collectPolicy(app);
710
+ return policy.timeline;
711
+ }
712
+
713
+ /** GET /admin/api/namespaces:目录 = 域公告 ∪ 本地节点(未订阅也显示)。 */
714
+ export async function buildNamespaces({ app, service, config }) {
715
+ const [nodes, status, policy] = await Promise.all([
716
+ app.storage.listNodes(),
717
+ service.status(),
718
+ collectPolicy(app),
719
+ ]);
720
+ const byNamespace = new Map();
721
+ for (const declaration of policy.declarations) {
722
+ byNamespace.set(declaration.namespace, {
723
+ namespace: declaration.namespace,
724
+ count: 0,
725
+ lastUpdatedAt: 0,
726
+ declared: true,
727
+ declaredAt: declaration.at,
728
+ declaredBy: declaration.issuer,
729
+ });
730
+ }
731
+ for (const node of nodes) {
732
+ const namespace = nsOf(node.namespace);
733
+ const entry = byNamespace.get(namespace) ?? {
734
+ namespace,
735
+ count: 0,
736
+ lastUpdatedAt: 0,
737
+ declared: false,
738
+ declaredAt: null,
739
+ declaredBy: null,
740
+ };
741
+ entry.count += 1;
742
+ const at = Math.max(node.updatedAt ?? 0, node.createdAt ?? 0);
743
+ if (at > entry.lastUpdatedAt) entry.lastUpdatedAt = at;
744
+ byNamespace.set(namespace, entry);
745
+ }
746
+ const hashes = status.stateHashByNamespace ?? {};
747
+ const subscription = app.sync?.getSubscriptionNamespaces?.()
748
+ ?? (Array.isArray(config?.sync?.namespaces) ? config.sync.namespaces : []);
749
+ const subscribedToAll = subscription.length === 0;
750
+
751
+ const result = [];
752
+ for (const entry of byNamespace.values()) {
753
+ const ns = entry.namespace;
754
+ let subscriptionDeclared = false;
755
+ let subscribers = [];
756
+ try {
757
+ const info = await app.getNamespaceSubscriptions(ns);
758
+ subscriptionDeclared = info.active;
759
+ subscribers = info.subscribers;
760
+ } catch {
761
+ // 门面未就绪:按未声明处理
762
+ }
763
+ let rejoinReset = false;
764
+ try {
765
+ rejoinReset = await app.hasRejoinReset(ns);
766
+ } catch {
767
+ rejoinReset = false;
768
+ }
769
+ const subscribed = subscribedToAll || subscription.includes(ns);
770
+ result.push({
771
+ ...entry,
772
+ lastUpdatedAt: entry.lastUpdatedAt || null,
773
+ stateHash: hashes[ns] ?? null,
774
+ subscriptionDeclared,
775
+ subscribers,
776
+ subscribed,
777
+ selfSubscribed: subscribed,
778
+ rejoinReset,
779
+ });
780
+ }
781
+ return result.sort((a, b) => (a.namespace < b.namespace ? -1 : 1));
782
+ }
783
+
784
+ // ---------- 待重启(pendingRestart):磁盘 config.json 与「启动快照」不一致 ----------
785
+ //
786
+ // 守护只在启动时读取一次 config.json(createMebular),运行期没有热加载。设置页保存后
787
+ // 磁盘已变,但当前实例仍按上次启动的快照工作——这里把它显式暴露给控制台。
788
+ // **路径/归一函数均来自 config-schema.mjs 单一真源**(`read` 把「未设置」归一为生效默认值,
789
+ // 避免默认值被误报为不一致);`envVar` 命中的路径磁盘值不会被重启采纳,故不计入待重启。
790
+ const HOT_PATHS = new Set(hotPaths());
791
+ const RESTART_CONFIG_PATHS = PENDING_RESTART_PATHS.filter((entry) => !HOT_PATHS.has(entry.path)).map((entry) => ({
792
+ path: entry.path,
793
+ label: entry.ui?.label ?? entry.path,
794
+ ...(entry.envVar ? { envVar: entry.envVar } : {}),
795
+ read: entry.read,
796
+ }));
797
+
798
+ /**
799
+ * 比较「当前磁盘 config.json」与「本次启动快照」的 curated 重启类配置。
800
+ * 返回 `{ pending, envOverridden }`:pending 元素形如 `{ path, file, running }`;
801
+ * 受环境变量优先控制的路径跳过(重启也不会采纳磁盘值),并在 envOverridden 中列出。
802
+ */
803
+ function computePendingRestart(diskConfig, startupConfig) {
804
+ const pending = [];
805
+ const envOverridden = [];
806
+ for (const spec of RESTART_CONFIG_PATHS) {
807
+ if (spec.envVar && process.env[spec.envVar] !== undefined) {
808
+ envOverridden.push(spec.path);
809
+ continue;
810
+ }
811
+ const file = spec.read(diskConfig) ?? null;
812
+ const running = spec.read(startupConfig) ?? null;
813
+ if (JSON.stringify(file) !== JSON.stringify(running)) {
814
+ // D:三元组第三列「未生效原因」——服务端计算,前端只渲染
815
+ pending.push({
816
+ path: spec.path,
817
+ label: spec.label,
818
+ file,
819
+ running,
820
+ reason: '待重启:已保存的值将在重启后生效(当前实例按上次启动的配置工作)',
821
+ });
822
+ }
823
+ }
824
+ return { pending, envOverridden };
825
+ }
826
+
827
+ /** join 启动失败根因(server.mjs 在 joinService 起不来时落盘;成功启动即清除)。 */
828
+ async function readJoinError(home) {
829
+ if (!home) return null;
830
+ try {
831
+ const record = JSON.parse(await readFile(join(home, 'join.error.json'), 'utf-8'));
832
+ return record && typeof record === 'object' ? record : null;
833
+ } catch {
834
+ return null;
835
+ }
836
+ }
837
+
838
+ /**
839
+ * D:三元组(已配置 X / 实际 Y / **未生效原因**)——覆盖 editable 20 项 + status-only 只读行。
840
+ * 原因优先级:环境变量优先 > 待重启 > 条目自定义原因(缺依赖 / 端口被占 / 回环不可达 / 缺证书…)> 已生效。
841
+ */
842
+ function buildEffectiveRows({ settings, config, diskConfig, pending, envOverridden }) {
843
+ const pendingPaths = new Set(pending.map((item) => item.path));
844
+ const envSet = new Set(envOverridden);
845
+ const rows = [];
846
+ for (const path of [...EDITABLE_PATHS, ...STATUS_ONLY_PATHS, ...WRITABLE_INTERNAL_PATHS]) { // writable-internal 也进诊断三元组(API 可写)
847
+ const entry = CONFIG_SCHEMA_BY_PATH.get(path);
848
+ let reason = '已生效';
849
+ if (HOT_PATHS.has(path)) reason = '已声明为热生效(本部署 MEBULAR_CONFIG_HOT_PATHS)';
850
+ else if (envSet.has(path)) reason = '环境变量优先:磁盘值不会被重启采纳(以 env 为准)';
851
+ else if (pendingPaths.has(path)) reason = '待重启:已保存的值将在重启后生效';
852
+ else if (typeof entry.reason === 'function') reason = entry.reason({ settings, config, diskConfig }) ?? '已生效';
853
+ rows.push({
854
+ path,
855
+ label: entry.ui?.label ?? path,
856
+ exposure: entry.exposure,
857
+ requiresRestart: entry.requiresRestart === true,
858
+ configured: entry.read ? (entry.read(diskConfig) ?? null) : null,
859
+ actual: typeof entry.effective === 'function' ? (entry.effective(settings) ?? null) : null,
860
+ reason,
861
+ pending: pendingPaths.has(path),
862
+ });
863
+ }
864
+ return rows;
865
+ }
866
+
867
+ /** 读取服务心跳(serve 常驻写 <home>/service.heartbeat);供 UI 选择重启命令文案。 */
868
+ async function readServiceHeartbeat(home) {
869
+ if (!home) return false;
870
+ try {
871
+ const record = JSON.parse(await readFile(join(home, 'service.heartbeat'), 'utf-8'));
872
+ return typeof record?.ts === 'number'
873
+ && Date.now() - record.ts <= 30_000
874
+ && record?.role === 'mebular-serve';
875
+ } catch {
876
+ return false;
877
+ }
878
+ }
879
+
880
+ /** GET /admin/api/settings(只读、脱敏;不含任何密钥/token) */
881
+ export async function buildSettings({ app, service, config, runtime, home }) {
882
+ const status = await service.status();
883
+ const joinError = await readJoinError(home);
884
+ // G:最近一次「保存即生效」的应用结果(pending = 正在重启中)
885
+ const [applyResult, applyPending] = await Promise.all([readApplyResult(home), readApplyPending(home)]);
886
+ let declaredNamespaces = [];
887
+ let blockedCount = 0;
888
+ try {
889
+ declaredNamespaces = await app.getNamespaceDeclarations();
890
+ blockedCount = (await app.getBlockedDevices()).length;
891
+ } catch {
892
+ // 忽略:门面未就绪
893
+ }
894
+ // 设备名:网络派生优先;无共享事件时回退 config/runtime(旧安装迁移)。
895
+ let selfDeviceName = null;
896
+ try {
897
+ const names = (await app.getDeviceNames?.()) ?? {};
898
+ selfDeviceName = names[app.deviceId]?.name ?? null;
899
+ } catch {
900
+ selfDeviceName = null;
901
+ }
902
+ const sync = config?.sync ?? {};
903
+ const antiEntropy = sync.antiEntropy ?? { enabled: true, intervalMs: 600000, jitterRatio: 0.2 };
904
+ const payload = {
905
+ identity: {
906
+ deviceId: app.deviceId,
907
+ name: selfDeviceName ?? runtime?.deviceName ?? config?.deviceName ?? null,
908
+ nameSource: selfDeviceName ? 'network' : (runtime?.deviceName ?? config?.deviceName ? 'local' : null),
909
+ // W2:root(持主私钥)/ delegated(仅委派链)
910
+ mode: runtime?.identityMode ?? config?.identity?.mode ?? null,
911
+ peerId: status.peerId,
912
+ multiaddrs: status.listenAddrs,
913
+ relays: status.relays,
914
+ },
915
+ storage: {
916
+ path: runtime?.storagePath ?? config?.storagePath ?? null,
917
+ adapter: runtime?.storageAdapter ?? config?.storageAdapter ?? 'json',
918
+ },
919
+ encryption: {
920
+ level: runtime?.encryptionLevel ?? config?.encryption?.level ?? 'none',
921
+ atRest: app.atRestEncryption,
922
+ },
923
+ network: {
924
+ enabled: Boolean(status.running),
925
+ // listen = 实际监听(含 /p2p/、0.0.0.0 展开);listenConfigured = config 原始值(供编辑/复制)
926
+ listen: status.listenAddrs,
927
+ listenConfigured: Array.isArray(config?.network?.libp2p?.listen) ? config.network.libp2p.listen : [],
928
+ relays: status.relays,
929
+ relayUnlimited: config?.network?.libp2p?.relayUnlimited === true,
930
+ },
931
+ sync: {
932
+ autoSync: runtime?.autoSync ?? sync.autoSync ?? true,
933
+ pushOnWrite: runtime?.pushOnWrite ?? sync.pushOnWrite ?? true, // serve/MCP 常驻默认开;环境变量可覆盖
934
+ pushOnWriteThrottleMs: runtime?.pushOnWriteThrottleMs ?? sync.pushOnWriteThrottleMs ?? 50,
935
+ antiEntropy,
936
+ snapshotThreshold: sync.snapshotThreshold ?? null,
937
+ subscriptions: app.sync?.getSubscriptionNamespaces?.() ?? (Array.isArray(sync.namespaces) ? sync.namespaces : []),
938
+ // 对端白名单(设备级连接准入;不是域授权)
939
+ peerWhitelist: runtime?.peerWhitelist ?? (Array.isArray(sync.peerWhitelist) ? sync.peerWhitelist : []),
940
+ },
941
+ declaredNamespaces,
942
+ blockedCount,
943
+ semantic: {
944
+ enabled: app.semanticVectorIndex !== null,
945
+ minScore: runtime?.semanticMinScore ?? config?.semantic?.minScore ?? 0.2,
946
+ },
947
+ mcp: {
948
+ host: runtime?.mcp?.host ?? config?.mcp?.http?.host ?? '127.0.0.1',
949
+ port: runtime?.mcp?.port ?? config?.mcp?.http?.port ?? 7331,
950
+ auth: runtime?.mcp?.auth ?? config?.mcp?.http?.auth ?? 'none',
951
+ tls: runtime?.mcp?.tls ?? config?.mcp?.http?.tls === true,
952
+ },
953
+ join: {
954
+ // 新设备上车(令牌 → 委派证书);由 config.joinService 控制
955
+ enabled: runtime?.join?.enabled ?? config?.joinService?.enabled === true,
956
+ bind: runtime?.join?.bind ?? config?.joinService?.bind ?? '0.0.0.0',
957
+ port: runtime?.join?.port ?? config?.joinService?.port ?? 4002,
958
+ // F-C6:令牌里写死的端点(实际值)+ 是否为回环(回环则新设备不可达)
959
+ endpoint: runtime?.join?.endpoint ?? null,
960
+ endpointLoopback: runtime?.join?.endpointLoopback ?? null,
961
+ // E/D:上次 join 启动失败的根因(端口被占等);成功启动后文件已清除
962
+ ...(joinError ? { lastError: joinError } : {}),
963
+ },
964
+ ...(await buildFleetView(home)),
965
+ // C2:对端连接路径(只读;来自 core 地址簿的 path 状态)+ 地址簿元信息
966
+ peers: buildPeerPaths(app),
967
+ // C6:内建 relay 角色(只读;mode/serving/reason/allowedClients + 当前经哪个桥)
968
+ relay: buildRelayView(app),
969
+ // C5:地址自动广播(只读;档位/已发布/已采用/忽略原因)
970
+ net: buildNetView(app),
971
+ // C4:NAT 穿透(只读;AutoNAT/DCUtR 是否启用、直连升级与 relay 连接计数)
972
+ nat: buildNatView(app),
973
+ // 实际可调用面(MCP 工具 = `mebular <name>` CLI,逐字同名同 handler)
974
+ tools: TOOL_NAMES,
975
+ };
976
+
977
+ // 待重启:读取**当前磁盘** config.json,与「本次启动快照 config」对比 curated 重启类路径。
978
+ // 注意:mcp.* 以启动快照为基准(`--port 0` 的实际端口由系统分配,不代表磁盘值未生效)。
979
+ let diskConfig = config ?? {};
980
+ if (home) {
981
+ try {
982
+ diskConfig = JSON.parse(await readFile(configPath(home), 'utf-8'));
983
+ } catch {
984
+ diskConfig = config ?? {};
985
+ }
986
+ }
987
+ const { pending, envOverridden } = computePendingRestart(diskConfig, config);
988
+ const effectiveRows = buildEffectiveRows({ settings: payload, config, diskConfig, pending, envOverridden });
989
+ return {
990
+ ...payload,
991
+ pendingRestart: pending,
992
+ ...(envOverridden.length > 0 ? { pendingRestartEnvOverrides: envOverridden } : {}),
993
+ // A/D:三元组 + 暴露面(单一真源驱动;GUI 编辑器改由 configSchema 渲染)
994
+ effective: effectiveRows,
995
+ // G:应用结果(green=已生效 / red=未按预期生效或已回滚 / in-progress=重启中)
996
+ lastApply: applyPending
997
+ ? { status: 'in-progress', at: applyPending.at ?? null, fields: applyPending.fields ?? [], attempt: applyPending.attempt ?? null }
998
+ : (applyResult ?? null),
999
+ configSchema: consoleSchema(),
1000
+ statusSchema: consoleStatusSchema(),
1001
+ restart: {
1002
+ // 服务心跳(serve 常驻写):用于选择重启命令文案
1003
+ serviceHeartbeat: await readServiceHeartbeat(home),
1004
+ // 由 launchd/systemd/计划任务拉起时注入(见 @mebular/service buildSpec);普通 nohup 启动无此标记
1005
+ serviceManaged: Boolean(process.env.MEBULAR_SERVICE_KIND || process.env.MEBULAR_SERVICE_SHA),
1006
+ },
1007
+ };
1008
+ }
1009
+
1010
+ /**
1011
+ * C2:对端路径视图(只读)。
1012
+ * `paths` = 地址簿里每个键的当前生效路径(kind/address/since/lastError);
1013
+ * 授权状态不在此处暴露(由 policy API 负责),这里只回答「连得上吗、走的哪条路」。
1014
+ */
1015
+ function buildPeerPaths(app) {
1016
+ try {
1017
+ const book = app?.endpointBook;
1018
+ if (!book) return { enabled: false, paths: [] };
1019
+ const keys = typeof book.keys === 'function' ? book.keys() : [];
1020
+ const paths = keys
1021
+ .map((key) => {
1022
+ const path = book.getPath(key);
1023
+ const candidates = typeof book.list === 'function' ? book.list(key) : [];
1024
+ return {
1025
+ key,
1026
+ connected: Boolean(path),
1027
+ kind: path?.kind ?? null,
1028
+ address: path?.address ?? null,
1029
+ since: path?.since ?? null,
1030
+ lastError: path?.lastError ?? candidates.find((c) => c.lastError)?.lastError ?? null,
1031
+ candidates: candidates.length,
1032
+ };
1033
+ })
1034
+ .sort((a, b) => Number(b.connected) - Number(a.connected) || a.key.localeCompare(b.key));
1035
+ return { enabled: true, paths };
1036
+ } catch {
1037
+ return { enabled: false, paths: [] };
1038
+ }
1039
+ }
1040
+
1041
+ /**
1042
+ * C6:内建 relay 角色视图(只读)。`reason` 说明为何开/关;`bridge` 是当前路径为 relay 时的桥地址。
1043
+ * 注意:relay 角色不落任何记忆/授权状态,这里只回答「本机当不当桥、我现在经谁中转」。
1044
+ */
1045
+ function buildRelayView(app) {
1046
+ try {
1047
+ const status = app?.node?.getRelayStatus?.() ?? null;
1048
+ if (!status) return { mode: 'off', serving: false, reason: '网络未启用', publicAddrs: [], allowedClients: 0, bridge: null };
1049
+ const book = app?.endpointBook;
1050
+ let bridge = null;
1051
+ if (book && typeof book.keys === 'function') {
1052
+ for (const key of book.keys()) {
1053
+ const path = book.getPath(key);
1054
+ if (path?.kind === 'relay') { bridge = { peer: key, address: path.address, since: path.since }; break; }
1055
+ }
1056
+ }
1057
+ return { ...status, bridge };
1058
+ } catch {
1059
+ return { mode: 'off', serving: false, reason: 'relay 状态不可读', publicAddrs: [], allowedClients: 0, bridge: null };
1060
+ }
1061
+ }
1062
+
1063
+ /**
1064
+ * C5:地址自动广播视图(只读)。
1065
+ * 记录只作 hints(永不参与授权);此处只回答「我广播了什么、我采用了谁的、忽略了什么」。
1066
+ */
1067
+ function buildNetView(app) {
1068
+ try {
1069
+ const status = app?.getNetEndpointsStatus?.() ?? null;
1070
+ if (!status) return { enabled: false, mode: 'off', ttlMs: 0, published: 0, applied: 0, ignored: {}, lastPublishedAt: null, lastAcceptedSubjects: [] };
1071
+ return status;
1072
+ } catch {
1073
+ return { enabled: false, mode: 'off', ttlMs: 0, published: 0, applied: 0, ignored: {}, lastPublishedAt: null, lastAcceptedSubjects: [] };
1074
+ }
1075
+ }
1076
+
1077
+ /**
1078
+ * C4:NAT 穿透视图(只读)。网络未启用/未装配 libp2p 时返回**固定形状**(而非缺字段),
1079
+ * 便于控制台与门禁稳定断言。
1080
+ */
1081
+ function buildNatView(app) {
1082
+ const base = {
1083
+ enabled: false,
1084
+ autonatEnabled: false,
1085
+ dcutrEnabled: false,
1086
+ loadError: null,
1087
+ directUpgrades: 0,
1088
+ relayConnections: 0,
1089
+ lastUpgradeAt: null,
1090
+ lastError: null,
1091
+ };
1092
+ try {
1093
+ const status = app?.getNatStatus?.();
1094
+ return status ? { ...base, ...status } : base;
1095
+ } catch {
1096
+ return base;
1097
+ }
1098
+ }
1099
+
1100
+ /** GET /admin/api/namespaces/:ns/handoff-plan?successor=…(只读预检) */
1101
+ export async function buildHandoffPlan({ app }, namespace, successor) {
1102
+ if (typeof successor !== 'string' || successor.length === 0) {
1103
+ return { status: 400, body: { error: 'bad_request', errorKey: 'handoff_successor_required', message: '需要 ?successor=<deviceId>' } };
1104
+ }
1105
+ try {
1106
+ const plan = await app.planNamespaceHandoff({ namespace, successor });
1107
+ return { status: 200, body: plan };
1108
+ } catch (error) {
1109
+ return { status: 400, body: { error: 'bad_request', message: String(error?.message ?? error) } };
1110
+ }
1111
+ }
1112
+
1113
+ /** 任务/舰队面摘要(fleet.config.json;只读,不暴露路径细节以外的内容) */
1114
+ async function buildFleetView(home) {
1115
+ const path = join(home, 'fleet.config.json');
1116
+ if (!existsSync(path)) {
1117
+ return { fleet: { configured: false, path } };
1118
+ }
1119
+ try {
1120
+ const cfg = JSON.parse(await readFile(path, 'utf-8'));
1121
+ return {
1122
+ fleet: {
1123
+ configured: true,
1124
+ path,
1125
+ namespace: typeof cfg.namespace === 'string' ? cfg.namespace : null,
1126
+ peers: Array.isArray(cfg.peers) ? cfg.peers.length : 0,
1127
+ agents: Array.isArray(cfg.agents) ? cfg.agents.length : 0,
1128
+ },
1129
+ };
1130
+ } catch (error) {
1131
+ return { fleet: { configured: true, path, parseError: String(error?.message ?? error) } };
1132
+ }
1133
+ }
1134
+
1135
+ /** GET /admin/api/config:磁盘上的原始配置(只读;供查看与复制) */
1136
+ export async function buildConfigView({ config, home }) {
1137
+ const path = configPath(home);
1138
+ let raw = null;
1139
+ let parseError = null;
1140
+ try {
1141
+ raw = JSON.parse(await readFile(path, 'utf-8'));
1142
+ } catch (error) {
1143
+ parseError = String(error?.message ?? error);
1144
+ }
1145
+ return {
1146
+ path,
1147
+ exists: existsSync(path),
1148
+ parseError,
1149
+ config: raw ?? config ?? {},
1150
+ };
1151
+ }
1152
+
1153
+ /**
1154
+ * G-ML-1:记忆列表(动作式 GUI 的读面)。
1155
+ * 默认**过滤已删除(墓碑)与已归档**;`includeDeleted=1` / `includeArchived=1` 可切换查看。
1156
+ * 只读、脱敏(只给预览,不回显完整 metadata)。
1157
+ */
1158
+ export async function buildMemories({ app, url }) {
1159
+ const params = url?.searchParams ?? new URLSearchParams();
1160
+ const flag = (name) => params.get(name) === '1' || params.get(name) === 'true';
1161
+ const includeArchived = flag('includeArchived');
1162
+ const includeDeleted = flag('includeDeleted');
1163
+ const namespace = params.get('namespace') ?? undefined;
1164
+ const limitRaw = Number(params.get('limit') ?? '50');
1165
+ const limit = Number.isFinite(limitRaw) ? Math.min(Math.max(Math.trunc(limitRaw), 1), 200) : 50;
1166
+
1167
+ const archivedOf = (node) => typeof node?.metadata?.archivedAt === 'number';
1168
+ let nodes = [];
1169
+ try {
1170
+ nodes = await app.storage.listNodes();
1171
+ } catch {
1172
+ nodes = [];
1173
+ }
1174
+ const filtered = nodes.filter((node) => {
1175
+ if (!includeDeleted && node.deletedAt) return false;
1176
+ if (!includeArchived && archivedOf(node)) return false;
1177
+ if (namespace !== undefined && (node.namespace ?? 'default') !== namespace) return false;
1178
+ return true;
1179
+ });
1180
+ const previewOf = (node) => {
1181
+ const content = node?.content ?? {};
1182
+ if (typeof content === 'string') return content.slice(0, 160);
1183
+ if (node?.type === 'fact') {
1184
+ const f = content;
1185
+ const triple = `${f.subject ?? ''} ${f.predicate ?? ''} ${f.object ?? ''}`.trim();
1186
+ const text = typeof f.object === 'string' && f.object.length > 0 ? f.object : triple;
1187
+ return text.slice(0, 160);
1188
+ }
1189
+ const text = content.content ?? content.name ?? content.description ?? content.value ?? '';
1190
+ return String(text).slice(0, 160);
1191
+ };
1192
+ const memories = filtered
1193
+ .slice()
1194
+ .sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0))
1195
+ .slice(0, limit)
1196
+ .map((node) => ({
1197
+ id: node.id,
1198
+ type: node.type,
1199
+ namespace: node.namespace ?? 'default',
1200
+ preview: previewOf(node),
1201
+ createdAt: node.createdAt ?? null,
1202
+ updatedAt: node.updatedAt ?? null,
1203
+ archived: archivedOf(node),
1204
+ archivedAt: node.metadata?.archivedAt ?? null,
1205
+ deleted: Boolean(node.deletedAt),
1206
+ deletedAt: node.deletedAt ?? null,
1207
+ tags: Array.isArray(node.tags) ? node.tags : [],
1208
+ }));
1209
+ return {
1210
+ total: filtered.length,
1211
+ limit,
1212
+ includeArchived,
1213
+ includeDeleted,
1214
+ ...(namespace !== undefined ? { namespace } : {}),
1215
+ memories,
1216
+ };
1217
+ }
1218
+
1219
+ /** 只读 API 路由表:路径 → 构造器 */
1220
+ /** F-CLI-2 · GET /admin/api/net:守护本机网络真状态(只读;`doctor --net` 守护感知复用)。 */
1221
+ export async function buildNetStatusView({ app }) {
1222
+ let paths = [];
1223
+ try {
1224
+ const book = app?.endpointBook;
1225
+ paths = book ? book.keys().map((key) => ({ key, path: book.getPath(key) })) : [];
1226
+ } catch {
1227
+ paths = [];
1228
+ }
1229
+ return {
1230
+ enabled: Boolean(app?.node?.isRunning?.()),
1231
+ listen: app?.node?.getLocalMultiaddrs?.() ?? [],
1232
+ paths,
1233
+ lan: app?.node?.getLanStatus?.() ?? null,
1234
+ net: app?.getNetEndpointsStatus?.() ?? null,
1235
+ nat: app?.getNatStatus?.() ?? null,
1236
+ relay: app?.node?.getRelayStatus?.() ?? null,
1237
+ };
1238
+ }
1239
+
1240
+ /** H-1 · GET /admin/api/service:常驻运行状态(registered/running/autostart/heartbeat)。 */
1241
+ export async function buildServiceView({ home: instanceHome }) {
1242
+ const { serviceStatusForHome, registeredForHome, resolveServiceHome, SERVICE_KIND } = await import('./service-control.mjs');
1243
+ const platform = process.platform;
1244
+ // 两套 home:home 是实例身份(descriptor 的 MEBULAR_HOME/心跳),serviceHome 是 unit/manifest 落盘位置。
1245
+ // 旧实现把两者混用 → 控制台去实例 home 下找 unit,对 CLI 装在 OS home 的服务误报「未常驻」。
1246
+ const serviceHome = resolveServiceHome();
1247
+ let status = null;
1248
+ let error = null;
1249
+ try {
1250
+ status = serviceStatusForHome({ home: instanceHome, serviceHome, platform });
1251
+ } catch (cause) {
1252
+ error = String(cause?.message ?? cause);
1253
+ }
1254
+ let registeredKinds = [];
1255
+ try {
1256
+ registeredKinds = registeredForHome({ home: instanceHome, serviceHome, platform });
1257
+ } catch {
1258
+ registeredKinds = [];
1259
+ }
1260
+ return {
1261
+ platform,
1262
+ kind: SERVICE_KIND,
1263
+ home: instanceHome,
1264
+ serviceHome,
1265
+ serviceManaged: Boolean(process.env.MEBULAR_SERVICE_KIND || process.env.MEBULAR_SERVICE_SHA),
1266
+ registered: status?.registered ?? registeredKinds.includes(SERVICE_KIND),
1267
+ running: status?.running ?? false,
1268
+ autostart: status?.autostart ?? null,
1269
+ unitPath: status?.unitPath ?? null,
1270
+ sha: status?.sha ?? null,
1271
+ heartbeat: status?.heartbeat ?? null,
1272
+ ...(error ? { error } : {}),
1273
+ };
1274
+ }
1275
+
1276
+ /** UX-2 · GET /admin/api/clients:助手注册表(内置 ∪ 自定义)三态视图(每助手一张卡的状态)。 */
1277
+ export async function buildClientsView({ home, url }) {
1278
+ const { listAssistantsAsync, assistantUserHome, daemonMcpUrl } = await import('./clients.mjs');
1279
+ // F-FIX-A:assistant 路径以**用户主目录**为基;`home` 只用于 Mebular 自有文件(assistants.custom.json)
1280
+ const userHome = assistantUserHome();
1281
+ let clients = [];
1282
+ let customError = null;
1283
+ let customPath = null;
1284
+ let cached = false;
1285
+ let error = null;
1286
+ try {
1287
+ // F-PERF-2:请求路径走**异步探针 + 短 TTL 缓存**(`?refresh=1` 绕过;并发单飞)
1288
+ const refresh = typeof url?.searchParams?.get === 'function' && url.searchParams.get('refresh') === '1';
1289
+ // F-FIX-B:条目/片段指向**本 serve 实例的守护 HTTP /mcp**(由请求 Host/scheme 推导)
1290
+ const mcpUrl = daemonMcpUrl({ host: url?.host, encrypted: url?.protocol === 'https:' });
1291
+ const out = await listAssistantsAsync({ home, userHome, cwd: process.cwd(), refresh, mcpUrl });
1292
+ clients = out.assistants;
1293
+ customError = out.customError;
1294
+ customPath = out.customPath;
1295
+ cached = out.cached === true;
1296
+ } catch (cause) {
1297
+ error = String(cause?.message ?? cause);
1298
+ }
1299
+ return {
1300
+ clients,
1301
+ cached,
1302
+ ...(customError ? { customError } : {}),
1303
+ ...(customPath ? { customPath } : {}),
1304
+ ...(error ? { error } : {}),
1305
+ };
1306
+ }
1307
+
1308
+ export const READ_ROUTES = {
1309
+ '/admin/api/overview': buildOverview,
1310
+ '/admin/api/clouds': buildClouds,
1311
+ '/admin/api/devices': buildDevices,
1312
+ '/admin/api/policy': buildPolicy,
1313
+ '/admin/api/namespaces': buildNamespaces,
1314
+ '/admin/api/settings': buildSettings,
1315
+ '/admin/api/config': buildConfigView,
1316
+ '/admin/api/memories': buildMemories,
1317
+ '/admin/api/net': buildNetStatusView,
1318
+ '/admin/api/service': buildServiceView,
1319
+ '/admin/api/clients': buildClientsView,
1320
+ };