cc-viewer 1.9.0 → 1.9.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 (160) hide show
  1. package/README.md +3 -1
  2. package/cli.js +33 -4
  3. package/concepts/ar/UltraPlan.md +1 -1
  4. package/concepts/da/UltraPlan.md +1 -1
  5. package/concepts/de/UltraPlan.md +1 -1
  6. package/concepts/en/UltraPlan.md +1 -1
  7. package/concepts/es/UltraPlan.md +1 -1
  8. package/concepts/fr/UltraPlan.md +1 -1
  9. package/concepts/it/UltraPlan.md +1 -1
  10. package/concepts/ja/UltraPlan.md +1 -1
  11. package/concepts/ko/UltraPlan.md +1 -1
  12. package/concepts/no/UltraPlan.md +1 -1
  13. package/concepts/pl/UltraPlan.md +1 -1
  14. package/concepts/pt-BR/UltraPlan.md +1 -1
  15. package/concepts/ru/UltraPlan.md +1 -1
  16. package/concepts/th/UltraPlan.md +1 -1
  17. package/concepts/tr/UltraPlan.md +1 -1
  18. package/concepts/uk/UltraPlan.md +1 -1
  19. package/concepts/zh/UltraPlan.md +1 -1
  20. package/concepts/zh-TW/UltraPlan.md +1 -1
  21. package/dist/assets/App-DJ4yNvwz.css +1 -0
  22. package/dist/assets/App-DPRn6oGn.js +2 -0
  23. package/dist/assets/{MdxEditorPanel-3j8rS3IT.js → MdxEditorPanel-Ci4ldhd7.js} +1 -1
  24. package/dist/assets/{MdxEditorPanel-Dpn-4ydO.css → MdxEditorPanel-DTfdj6GS.css} +1 -1
  25. package/dist/assets/Mobile-Dq-KcO1s.js +1 -0
  26. package/dist/assets/ProxyStatsModal-CUFpqt1s.js +1 -0
  27. package/dist/assets/{_baseUniq-B3e4JaL0.js → _baseUniq-CwQykUuc.js} +1 -1
  28. package/dist/assets/{arc-DnoPn_qY.js → arc-EdUdBxtG.js} +1 -1
  29. package/dist/assets/{architectureDiagram-Q4EWVU46-DrPKTbxH.js → architectureDiagram-Q4EWVU46-DtBIdElY.js} +1 -1
  30. package/dist/assets/{blockDiagram-DXYQGD6D-CEaO0Vcw.js → blockDiagram-DXYQGD6D-guNZIKkZ.js} +1 -1
  31. package/dist/assets/{c4Diagram-AHTNJAMY-BLaawFeg.js → c4Diagram-AHTNJAMY-xV5vB-Z9.js} +1 -1
  32. package/dist/assets/{channel-DnYR1fF4.js → channel-DYEktNap.js} +1 -1
  33. package/dist/assets/{chunk-4BX2VUAB-Bfd4edKZ.js → chunk-4BX2VUAB-ZTkItpvj.js} +1 -1
  34. package/dist/assets/{chunk-4TB4RGXK-t2nMQmY7.js → chunk-4TB4RGXK-dYIewyyR.js} +1 -1
  35. package/dist/assets/{chunk-55IACEB6-C6tLOjGX.js → chunk-55IACEB6-CSsPNgPX.js} +1 -1
  36. package/dist/assets/{chunk-EDXVE4YY-dWCvxbCb.js → chunk-EDXVE4YY-CnMiuqzQ.js} +1 -1
  37. package/dist/assets/{chunk-FMBD7UC4-BHELZLPK.js → chunk-FMBD7UC4-CVpATPZ3.js} +1 -1
  38. package/dist/assets/{chunk-OYMX7WX6-C8XTkH1S.js → chunk-OYMX7WX6-Dpq4s8dW.js} +1 -1
  39. package/dist/assets/{chunk-QZHKN3VN-CD0j6SAs.js → chunk-QZHKN3VN-Bnojh6X6.js} +1 -1
  40. package/dist/assets/{chunk-YZCP3GAM-D4dKfXJJ.js → chunk-YZCP3GAM-BYqK4Dsa.js} +1 -1
  41. package/dist/assets/classDiagram-6PBFFD2Q-ClGF33bF.js +1 -0
  42. package/dist/assets/classDiagram-v2-HSJHXN6E-ClGF33bF.js +1 -0
  43. package/dist/assets/clone-CFj55h49.js +1 -0
  44. package/dist/assets/{cose-bilkent-S5V4N54A-BF2HsiSD.js → cose-bilkent-S5V4N54A-OJ7mmWyV.js} +1 -1
  45. package/dist/assets/{dagre-KV5264BT-f3h967yx.js → dagre-KV5264BT-B1Kwd_Rk.js} +1 -1
  46. package/dist/assets/{diagram-5BDNPKRD-B9PzHQfh.js → diagram-5BDNPKRD-D8qrw8sp.js} +1 -1
  47. package/dist/assets/{diagram-G4DWMVQ6-kR953Bqm.js → diagram-G4DWMVQ6-ip-N4ho8.js} +1 -1
  48. package/dist/assets/{diagram-MMDJMWI5-DAewa2eg.js → diagram-MMDJMWI5-2PZaskUF.js} +1 -1
  49. package/dist/assets/{diagram-TYMM5635-C8HNTH6I.js → diagram-TYMM5635-CeAOQUZP.js} +1 -1
  50. package/dist/assets/{erDiagram-SMLLAGMA-BQ5P8wWK.js → erDiagram-SMLLAGMA-BCBaEYEb.js} +1 -1
  51. package/dist/assets/{flowDiagram-DWJPFMVM-BIpEkrMR.js → flowDiagram-DWJPFMVM-CsGyIqcX.js} +1 -1
  52. package/dist/assets/{ganttDiagram-T4ZO3ILL-CWqSfB5v.js → ganttDiagram-T4ZO3ILL-COsj3FZ-.js} +1 -1
  53. package/dist/assets/{gitGraphDiagram-UUTBAWPF-Ba-9yEbQ.js → gitGraphDiagram-UUTBAWPF-DvQVQGJG.js} +1 -1
  54. package/dist/assets/{graph-D3zXKQ6P.js → graph-BSB9fDSP.js} +1 -1
  55. package/dist/assets/index-BLzca-ta.css +1 -0
  56. package/dist/assets/{index-G3xd-gLo.js → index-BRw4Iz2O.js} +1 -1
  57. package/dist/assets/{index-DClD_EkN.js → index-Bo-12bug.js} +1 -1
  58. package/dist/assets/{index-DSd5P3WQ.js → index-BuVComcY.js} +1 -1
  59. package/dist/assets/{index-HLrnYsI1.js → index-CNNAYjkF.js} +1 -1
  60. package/dist/assets/index-Ci38xZiX.js +2 -0
  61. package/dist/assets/{index-Bm3A5VLb.js → index-DKufsAzx.js} +1 -1
  62. package/dist/assets/{index-C9N0rZ7Z.js → index-DXBfwPYD.js} +1 -1
  63. package/dist/assets/{index-Q8wp4Nof.js → index-eM2ih5n7.js} +1 -1
  64. package/dist/assets/{infoDiagram-42DDH7IO-Cxm79ZJR.js → infoDiagram-42DDH7IO-eNucS4iW.js} +1 -1
  65. package/dist/assets/{ishikawaDiagram-UXIWVN3A-Cd0vZP1B.js → ishikawaDiagram-UXIWVN3A-Dxxj-gUX.js} +1 -1
  66. package/dist/assets/{journeyDiagram-VCZTEJTY-B2b57HXk.js → journeyDiagram-VCZTEJTY-yJJzVEBb.js} +1 -1
  67. package/dist/assets/{jszip.min-Bj5uiEb2.js → jszip.min-ByI0ds5u.js} +1 -1
  68. package/dist/assets/{kanban-definition-6JOO6SKY-YCERiUCO.js → kanban-definition-6JOO6SKY-2rfTi2zw.js} +1 -1
  69. package/dist/assets/{layout-Do5TlO2U.js → layout-DTmHj0Wm.js} +1 -1
  70. package/dist/assets/{linear-BiJNTDgN.js → linear-BOA3a9FE.js} +1 -1
  71. package/dist/assets/{mermaid.core-Dg0McRZt.js → mermaid.core-5H-NR79H.js} +3 -3
  72. package/dist/assets/{min-T51iefht.js → min-CpjFTEec.js} +1 -1
  73. package/dist/assets/{mindmap-definition-QFDTVHPH-Cu_GJxY3.js → mindmap-definition-QFDTVHPH-B8KCjFgB.js} +1 -1
  74. package/dist/assets/{pieDiagram-DEJITSTG-DldHHZXi.js → pieDiagram-DEJITSTG-BARy96g5.js} +1 -1
  75. package/dist/assets/{quadrantDiagram-34T5L4WZ-BzyTPNAJ.js → quadrantDiagram-34T5L4WZ-DYoTapuI.js} +1 -1
  76. package/dist/assets/{requirementDiagram-MS252O5E-C5xxFkvG.js → requirementDiagram-MS252O5E-EeANLLK2.js} +1 -1
  77. package/dist/assets/{sankeyDiagram-XADWPNL6-OxdjoaE1.js → sankeyDiagram-XADWPNL6-DmQ-YVPj.js} +1 -1
  78. package/dist/assets/seqResourceLoaders-B0p7e_lM.js +2 -0
  79. package/dist/assets/seqResourceLoaders-BsMFNfvR.css +41 -0
  80. package/dist/assets/{sequenceDiagram-FGHM5R23-C7c89uIS.js → sequenceDiagram-FGHM5R23-SZtW0rYC.js} +1 -1
  81. package/dist/assets/{stateDiagram-FHFEXIEX-ssdJnDrO.js → stateDiagram-FHFEXIEX-CEygsIld.js} +1 -1
  82. package/dist/assets/{stateDiagram-v2-QKLJ7IA2-BP-0WClK.js → stateDiagram-v2-QKLJ7IA2-BU1XPhBe.js} +1 -1
  83. package/dist/assets/{timeline-definition-GMOUNBTQ-sWScEpSM.js → timeline-definition-GMOUNBTQ-DhPBaQBt.js} +1 -1
  84. package/dist/assets/{vendor-antd-DKIytXho.js → vendor-antd-CS_S2mBN.js} +1 -1
  85. package/dist/assets/{vendor-codemirror-4qgXq03k.js → vendor-codemirror-B5l-VbBO.js} +1 -1
  86. package/dist/assets/{vendor-mdxeditor-D5Z5D9fq.js → vendor-mdxeditor-Cz09a1_I.js} +2 -2
  87. package/dist/assets/{vendor-qrcode-B7lb5qk1.js → vendor-qrcode-qbbntD6-.js} +1 -1
  88. package/dist/assets/{vendor-virtuoso-BqoXlbhH.js → vendor-virtuoso-Sk8fQ4Hx.js} +1 -1
  89. package/dist/assets/{vennDiagram-DHZGUBPP-DNfXR0ge.js → vennDiagram-DHZGUBPP-NniTHxDw.js} +1 -1
  90. package/dist/assets/{wardley-RL74JXVD-JXd--CRD.js → wardley-RL74JXVD-D9SNOrDw.js} +1 -1
  91. package/dist/assets/{wardleyDiagram-NUSXRM2D-DHmNyVPM.js → wardleyDiagram-NUSXRM2D-DvHXKNza.js} +1 -1
  92. package/dist/assets/{xychartDiagram-5P7HB3ND-CTb0gltZ.js → xychartDiagram-5P7HB3ND-CaiwPTQB.js} +1 -1
  93. package/dist/index.html +5 -5
  94. package/package.json +1 -1
  95. package/server/interceptor.js +192 -24
  96. package/server/lib/ask/ask-store.js +7 -3
  97. package/server/lib/async-file-lock.js +29 -5
  98. package/server/lib/auth.js +71 -54
  99. package/server/lib/chat-queue.js +67 -4
  100. package/server/lib/config-backup.js +8 -5
  101. package/server/lib/credential-access.js +233 -0
  102. package/server/lib/credential-migrate.js +147 -0
  103. package/server/lib/credential-store.js +120 -0
  104. package/server/lib/credential-vault.js +112 -0
  105. package/server/lib/file-access-policy.js +16 -0
  106. package/server/lib/git-diff.js +49 -20
  107. package/server/lib/im/im-config.js +81 -31
  108. package/server/lib/im-deny.js +22 -8
  109. package/server/lib/json-store.js +264 -0
  110. package/server/lib/log-watcher.js +9 -1
  111. package/server/lib/prefs-store.js +8 -33
  112. package/server/lib/same-origin.js +22 -0
  113. package/server/lib/session-pin-store.js +7 -14
  114. package/server/lib/v2/convert.js +1 -1
  115. package/server/lib/v2/layout.js +53 -3
  116. package/server/lib/v2/live-feed.js +41 -4
  117. package/server/lib/v2/resume-list.js +150 -0
  118. package/server/lib/v2/session-select.js +63 -14
  119. package/server/lib/v2/v2-writer.js +172 -34
  120. package/server/lib/v2/view-router.js +83 -0
  121. package/server/lib/view-root.js +209 -0
  122. package/server/pty-manager.js +809 -163
  123. package/server/routes/ask-perm.js +21 -1
  124. package/server/routes/auth.js +21 -1
  125. package/server/routes/events.js +123 -7
  126. package/server/routes/expert.js +46 -14
  127. package/server/routes/files-content.js +72 -7
  128. package/server/routes/files-fs.js +28 -3
  129. package/server/routes/git.js +31 -5
  130. package/server/routes/im.js +15 -2
  131. package/server/routes/preferences.js +59 -46
  132. package/server/routes/project-meta.js +32 -3
  133. package/server/routes/proxy-stats.js +33 -5
  134. package/server/routes/resume.js +457 -0
  135. package/server/routes/search.js +23 -1
  136. package/server/routes/skills.js +18 -2
  137. package/server/routes/v2.js +6 -1
  138. package/server/routes/workspaces.js +18 -5
  139. package/server/server.js +199 -25
  140. package/server/system-prompt-templates/presets/GLM-5.2.md +0 -1
  141. package/server/system-prompt-templates/presets/GLM-5.3.md +0 -1
  142. package/server/system-prompt-templates/presets/Qwen-3.md +0 -1
  143. package/server/system-prompt-templates/presets/deepseek-v4-flash.md +0 -1
  144. package/server/system-prompt-templates/presets/deepseek-v4-pro.md +0 -1
  145. package/server/system-prompt-templates/presets/kimi-k2.7-code.md +0 -1
  146. package/server/system-prompt-templates/presets/kimi-k3.md +1 -1
  147. package/server/system-prompt-templates/systemPromptModel.md +0 -1
  148. package/server/workspace-registry.js +150 -75
  149. package/ultraAgents/code-expert.json +1 -1
  150. package/dist/assets/App-M61pqQEx.css +0 -1
  151. package/dist/assets/App-_HfxMJvv.js +0 -2
  152. package/dist/assets/Mobile-DaRLB7Gh.js +0 -1
  153. package/dist/assets/ProxyStatsModal-dlPIDlx-.js +0 -1
  154. package/dist/assets/classDiagram-6PBFFD2Q-BecPAAuk.js +0 -1
  155. package/dist/assets/classDiagram-v2-HSJHXN6E-BecPAAuk.js +0 -1
  156. package/dist/assets/clone-B0_ghF3F.js +0 -1
  157. package/dist/assets/index-CjbBUDGP.js +0 -2
  158. package/dist/assets/index-Nvk8r3tO.css +0 -1
  159. package/dist/assets/seqResourceLoaders-B_FSQh3U.css +0 -41
  160. package/dist/assets/seqResourceLoaders-hqLhsiGO.js +0 -2
@@ -67,11 +67,12 @@ function _broadcast() {
67
67
  catch (err) { reportSwallowed('chat-queue.broadcast', err); }
68
68
  }
69
69
 
70
- function _makeItem(text) {
70
+ function _makeItem(text, anchor) {
71
71
  return {
72
72
  id: 'q_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8),
73
73
  text,
74
74
  ts: Date.now(),
75
+ anchor: anchor || null, // { project, sessionId } from the enqueueing client (2026-10-05)
75
76
  };
76
77
  }
77
78
 
@@ -95,6 +96,13 @@ function _clearEscTimer() {
95
96
  * NOTE 2: `isStreaming` (streamingState.active) flickers false between tool calls mid-turn;
96
97
  * injecting into that gap is harmless (the TUI natively queues the paste) but it means an
97
98
  * "immediate" inject can land inside the current turn's chain — accepted risk class.
99
+ * KNOWN LIMITATION (multi-project, 2026-10-05): the busy gate is global/bound-scoped —
100
+ * `isStreaming` reads the bound project's streamingState and `getPtyKind` follows the
101
+ * global active PTY, and `_items` is a single FIFO across projects. A message queued on
102
+ * project A can be head-of-line blocked by (or misjudge the busyness of) project B.
103
+ * Injection itself IS anchor-routed (item.anchor), so a queued message still lands on
104
+ * the project it was written in; only the timing/busy heuristic is not per-project.
105
+ * Per-project streaming state + queue isolation is a follow-up.
98
106
  */
99
107
  function _safeToInject() {
100
108
  if (!_deps) return false;
@@ -126,7 +134,12 @@ function _injectHead() {
126
134
  _injecting = true;
127
135
  const gen = _gen; // invalidate on clear()/stop(): a stale completion must not re-park
128
136
  try {
129
- _deps.writeToPtySequential(bracketPasteSubmit(item.text), (ok) => {
137
+ // Route the inject by the enqueueing client's anchor when one was captured,
138
+ // so a message queued while viewing project A never lands in project B's PTY.
139
+ const _write = _deps.writeToPtySequentialFor && item.anchor
140
+ ? (chunks, done, opts) => _deps.writeToPtySequentialFor(chunks, done, opts, item.anchor)
141
+ : _deps.writeToPtySequential;
142
+ _write(bracketPasteSubmit(item.text), (ok) => {
130
143
  _injecting = false;
131
144
  if (gen !== _gen) return;
132
145
  if (!ok) {
@@ -175,7 +188,7 @@ function _pollUntilSafe(fn, onTimeout) {
175
188
  * `\x1b[201~` would otherwise break the paste frame), broadcasts, and arms a rescue timer for
176
189
  * the stale-busy case (frontend's isStreaming lags the real turn end by up to its debounce).
177
190
  */
178
- export function enqueue(text) {
191
+ export function enqueue(text, anchor) {
179
192
  if (!_deps) return null;
180
193
  const clean = sanitizeInbound(text == null ? '' : String(text)).trim();
181
194
  if (!clean) return null;
@@ -186,7 +199,7 @@ export function enqueue(text) {
186
199
  catch (err) { reportSwallowed('chat-queue.reject-broadcast', err); }
187
200
  return null;
188
201
  }
189
- const item = _makeItem(clean);
202
+ const item = _makeItem(clean, anchor);
190
203
  _items.push(item);
191
204
  _clearRescueTimer();
192
205
  _broadcast();
@@ -335,6 +348,56 @@ export function stop() {
335
348
  _gen++;
336
349
  }
337
350
 
351
+ const RESUME_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
352
+
353
+ /**
354
+ * True /resume (2026-10-06): inject `/resume <uuid>` into a target project's running claude
355
+ * PTY so the process switches INTO that session in place (Claude Code's native SessionStart
356
+ * hook then re-binds the v2 writer via beginResumeSwitch). This is the in-process switch —
357
+ * NOT a respawn — and must only happen when the TUI is observably idle.
358
+ *
359
+ * Promise-returning so the route can map the outcome to HTTP. Resolves:
360
+ * { ok:true } — the paste was written
361
+ * { ok:false, reason:'busy' } — TUI streaming / approval open / another inject or poll in
362
+ * flight / timed out waiting for idle (never blind-injects)
363
+ * { ok:false, reason:'bad-uuid' } — sessionUuid is not a UUID shape
364
+ * { ok:false, reason:'no-deps' } — initChatQueue never ran (SDK mode / not wired)
365
+ * { ok:false, reason:'write-failed' } — the PTY write failed or threw
366
+ *
367
+ * Shares the single `_injecting`/`_pollTimer` slots with the drain/sendNow paths, so it
368
+ * refuses up-front when either is owned (mirrors sendNow's guard) instead of clobbering a
369
+ * pending drain timer and stranding a queued message. The completion handler resets
370
+ * `_injecting` unconditionally (both callback and catch) so a mid-inject clear()/stop()
371
+ * generation bump can never wedge the queue.
372
+ */
373
+ export function injectResumeCommand(sessionUuid, anchor) {
374
+ return new Promise((resolve) => {
375
+ if (!_deps) { resolve({ ok: false, reason: 'no-deps' }); return; }
376
+ const uuid = sanitizeInbound(String(sessionUuid == null ? '' : sessionUuid)).trim();
377
+ if (!RESUME_UUID_RE.test(uuid)) { resolve({ ok: false, reason: 'bad-uuid' }); return; }
378
+ // Refuse BEFORE touching _pollUntilSafe: a drain's poll owns the single _pollTimer slot,
379
+ // and an in-flight inject owns _injecting — barging in would strand a queued message.
380
+ if (_injecting || _pollTimer) { resolve({ ok: false, reason: 'busy' }); return; }
381
+ let settled = false;
382
+ const done = (r) => { if (!settled) { settled = true; resolve(r); } };
383
+ const inject = () => {
384
+ _injecting = true;
385
+ try {
386
+ _deps.writeToPtySequentialFor(bracketPasteSubmit(`/resume ${uuid}`), (ok) => {
387
+ _injecting = false;
388
+ done(ok ? { ok: true } : { ok: false, reason: 'write-failed' });
389
+ }, { settleMs: 250 }, anchor || {});
390
+ } catch (err) {
391
+ _injecting = false;
392
+ reportSwallowed('chat-queue.resume.inject', err);
393
+ done({ ok: false, reason: 'write-failed' });
394
+ }
395
+ };
396
+ if (_safeToInject()) { inject(); return; }
397
+ _pollUntilSafe(inject, () => done({ ok: false, reason: 'busy' }));
398
+ });
399
+ }
400
+
338
401
  export function __resetForTests() {
339
402
  stop();
340
403
  _deps = null;
@@ -1,13 +1,16 @@
1
1
  // 启动期配置备份 — 2026-06-06 LOG_DIR 整树删除事故防再犯。
2
- // preferences.json(auth/IM token/偏好)、profile.json(代理热切换)、workspaces.json(工作区注册表)
3
- // 是无法从日志/会话重建的"静态设置";本模块在 server 启动时把它们备份到 LOG_DIR **之外**
4
- // 的兄弟目录(默认 ~/.claude/cc-viewer-config-backups/<时间戳>/),滚动保留最近 KEEP 份。
5
- // 全程 best-effort:任何失败只返回 {ok:false},绝不抛错阻塞启动。
2
+ // preferences.json(auth/IM/偏好)、profile.json(代理热切换)、workspaces.json(工作区注册表)、
3
+ // credentials.json + master.key(凭证 vault 密文与主密钥) 是无法从日志/会话重建的"静态设置";
4
+ // 本模块在 server 启动时把它们备份到 LOG_DIR **之外**的兄弟目录
5
+ // (默认 ~/.claude/cc-viewer-config-backups/<时间戳>/),滚动保留最近 KEEP 份。
6
+ // master.key 一并备份:credentials.json 是密文,没有 key 就是废数据;二者同地 0600,恢复路径才完整。
7
+ // 历史明文备份(迁移前含 base64/明文密钥的 preferences/profile)不自动删除——它们是唯一的老密钥
8
+ // 恢复来源,在确认 vault 可读前绝不清理。全程 best-effort:任何失败只返回 {ok:false},绝不抛错阻塞启动。
6
9
  import { existsSync, mkdirSync, copyFileSync, readdirSync, rmSync, chmodSync } from 'node:fs';
7
10
  import { join, dirname } from 'node:path';
8
11
  import { LOG_DIR } from '../../findcc.js';
9
12
 
10
- const CONFIG_FILES = ['preferences.json', 'profile.json', 'workspaces.json'];
13
+ const CONFIG_FILES = ['preferences.json', 'profile.json', 'workspaces.json', 'credentials.json', 'master.key'];
11
14
  const KEEP = 10;
12
15
  // 备份子目录名:严格时间戳形态。prune 只删匹配此形态的目录,绝不波及其它内容。
13
16
  const STAMP_RE = /^\d{8}_\d{6}$/;
@@ -0,0 +1,233 @@
1
+ // Credential access layer — the single place config modules touch the vault.
2
+ //
3
+ // credential-vault.js / credential-store.js are pure path-injected primitives; this module is
4
+ // the seam that binds them to cc-viewer's data root and encodes the cross-cutting rules that
5
+ // every credential class shares, so auth.js / im-config.js / interceptor.js / preferences.js
6
+ // don't each re-derive (and mis-derive) them:
7
+ //
8
+ // 1. ONE credential root, captured at module load. PROFILE_PATH is frozen at load
9
+ // (interceptor.js) and the vault is frozen alongside it; LOG_DIR is a live binding (setLogDir)
10
+ // that a logDir move (ccv --log-dir at runtime, POST /api/preferences {logDir}) can change.
11
+ // A frozen vault does NOT follow that move — which is the SAFE half of the trade: after a
12
+ // logDir switch the reader still finds the vault at the ORIGINAL root and the gate keeps
13
+ // working. (A live/following vault would resolve at the NEW, empty root while preferences.json
14
+ // is carried over with auth.enabled=true → password resolves '' → LAN gate opens. That
15
+ // fail-open is why the root is deliberately NOT live-bound; see the 739f49a6 re-review.)
16
+ // 2. master.key is created ONLY when the vault is empty/absent. A read path that finds
17
+ // ciphertext but no key must hard-fail (unreadable), never mint a fresh key that orphans
18
+ // every existing secret.
19
+ // 3. "Unreadable" is a first-class outcome, distinct from "absent". The LAN password gate
20
+ // treats an empty password as allow-all (auth.js), so a decrypt failure must surface as
21
+ // unreadable=true — never as ''.
22
+ //
23
+ // Boundary: L1-lib (imports json-store + credential-store/vault + findcc for the load-time root).
24
+ import { existsSync, readFileSync } from 'node:fs';
25
+ import { reportSwallowed } from '@ccv/core/error-report';
26
+ import { LOG_DIR } from '../../findcc.js';
27
+ import {
28
+ setSecret, getSecret, hasSecret, deleteSecret, credentialsFileFor, masterKeyPathFor, vaultUnreadable,
29
+ } from './credential-store.js';
30
+ import { _resetKeyCache } from './credential-vault.js';
31
+
32
+ // Captured at module load — deliberately NOT a live binding. See header note (1).
33
+ const CREDENTIALS_FILE = credentialsFileFor(LOG_DIR);
34
+ const MASTER_KEY_PATH = masterKeyPathFor(LOG_DIR);
35
+
36
+ export function getCredentialsFile() { return CREDENTIALS_FILE; }
37
+ export function getMasterKeyPath() { return MASTER_KEY_PATH; }
38
+
39
+ /** True when the vault holds at least one entry (so a missing master.key is a hard error). */
40
+ function _vaultHasEntries() {
41
+ try {
42
+ if (!existsSync(CREDENTIALS_FILE)) return false;
43
+ const data = JSON.parse(readFileSync(CREDENTIALS_FILE, 'utf-8'));
44
+ return !!(data && typeof data === 'object' && data.creds && Object.keys(data.creds).length > 0);
45
+ } catch { return false; }
46
+ }
47
+
48
+ /** True when credentials.json exists but cannot be parsed — the fail-CLOSED signal (P0-A). */
49
+ function _vaultUnreadable() {
50
+ return vaultUnreadable(CREDENTIALS_FILE);
51
+ }
52
+
53
+ /**
54
+ * Guard the key-creation rule. Returns { ok:true } when a key may be used/created, or
55
+ * { ok:false, reason } when ciphertext exists (or the vault is unreadable) but the key is gone
56
+ * (hard-fail — do not mint). An unreadable vault is treated as "has entries": minting a fresh
57
+ * key over it would let the next write overwrite ciphertext we can no longer read.
58
+ */
59
+ function _keyUsable() {
60
+ if (existsSync(MASTER_KEY_PATH)) return { ok: true };
61
+ if (_vaultUnreadable()) {
62
+ return { ok: false, reason: 'master.key is missing and credentials.json is unreadable; refusing to mint (would orphan existing ciphertext)' };
63
+ }
64
+ if (_vaultHasEntries()) {
65
+ return { ok: false, reason: 'master.key is missing but credentials.json holds entries; refusing to mint a new key (existing secrets would be orphaned)' };
66
+ }
67
+ return { ok: true }; // empty/absent vault → first-ever key may be created on demand
68
+ }
69
+
70
+ /**
71
+ * Read a secret back to plaintext.
72
+ * Returns { value: string, unreadable: boolean }.
73
+ * - vault entry decrypts → { value, unreadable:false }
74
+ * - no vault entry → { value: fallbackPlain ?? '', unreadable:false } (legacy/pre-migration)
75
+ * - vault entry exists but key/decrypt fails → { value: fallbackPlain ?? '', unreadable:true }
76
+ * - the vault FILE itself is unreadable/corrupt → { value: fallbackPlain ?? '', unreadable:true }
77
+ * `fallbackPlain` is the caller's legacy on-disk value (plaintext or already-base64-DECODED),
78
+ * used only when there is no readable vault entry (the one-version read-fallback).
79
+ */
80
+ export function readSecretOr(kind, ref, fallbackPlain = '') {
81
+ try {
82
+ // Fail-closed on an unreadable vault FILE (P0-A): hasSecret below would report "no entry"
83
+ // (readJsonSafe swallows the parse error → {}), and a gate that treats empty as allow-all
84
+ // would then open. Surface unreadable so callers deny / drop instead.
85
+ if (_vaultUnreadable()) {
86
+ return { value: fallbackPlain || '', unreadable: true };
87
+ }
88
+ if (!hasSecret(CREDENTIALS_FILE, kind, ref)) {
89
+ return { value: fallbackPlain || '', unreadable: false };
90
+ }
91
+ const usable = _keyUsable();
92
+ if (!usable.ok) {
93
+ reportSwallowed('credential-access.key-unusable', new Error(usable.reason));
94
+ return { value: fallbackPlain || '', unreadable: true };
95
+ }
96
+ const value = getSecret(CREDENTIALS_FILE, MASTER_KEY_PATH, kind, ref);
97
+ return { value, unreadable: false };
98
+ } catch (err) {
99
+ // Tampered ciphertext / wrong key / IO error → unreadable, never '' silently.
100
+ reportSwallowed('credential-access.read', err);
101
+ return { value: fallbackPlain || '', unreadable: true };
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Write a secret to the vault. `plain` empty removes the entry. Returns true on success; on
107
+ * failure reports and returns false (callers must NOT then strip the legacy field).
108
+ */
109
+ export function writeSecret(kind, ref, plain) {
110
+ try {
111
+ const usable = _keyUsable();
112
+ if (!usable.ok) {
113
+ reportSwallowed('credential-access.key-unusable', new Error(usable.reason));
114
+ return false;
115
+ }
116
+ setSecret(CREDENTIALS_FILE, MASTER_KEY_PATH, kind, ref, plain);
117
+ return true;
118
+ } catch (err) {
119
+ reportSwallowed('credential-access.write', err);
120
+ return false;
121
+ }
122
+ }
123
+
124
+ /** Remove a secret. Returns true on success. */
125
+ export function removeSecret(kind, ref) {
126
+ try {
127
+ deleteSecret(CREDENTIALS_FILE, kind, ref);
128
+ return true;
129
+ } catch (err) {
130
+ reportSwallowed('credential-access.remove', err);
131
+ return false;
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Idempotent one-field migration with read-back verification (the data-safety invariant):
137
+ * a) if the legacy plaintext is empty → nothing to do, return { migrated:false, cleared:false }.
138
+ * b) write the plaintext to the vault, then READ IT BACK and compare. Only when the read-back
139
+ * matches do we say it is safe to clear the legacy field.
140
+ * c) if the vault already holds a DIFFERENT value, the on-disk plaintext is the user's later
141
+ * intent (hand-edit / restored backup / old binary) → it wins and overwrites the vault.
142
+ * `clearLegacy()` is invoked by the CALLER only when this returns { migrated:true, cleared:ok }
143
+ * — it performs the source-file strip inside the caller's own file lock. We never clear here.
144
+ * Returns { migrated:boolean, verified:boolean }.
145
+ */
146
+ export function migrateFieldWithVerify(kind, ref, legacyPlain) {
147
+ if (!legacyPlain) return { migrated: false, verified: false };
148
+ try {
149
+ // If the vault already holds this exact value, nothing to write.
150
+ if (hasSecret(CREDENTIALS_FILE, kind, ref)) {
151
+ const existing = getSecret(CREDENTIALS_FILE, MASTER_KEY_PATH, kind, ref);
152
+ if (existing === legacyPlain) return { migrated: true, verified: true };
153
+ // Differing value: fall through and let the on-disk plaintext overwrite (user's later intent).
154
+ }
155
+ if (!writeSecret(kind, ref, legacyPlain)) return { migrated: false, verified: false };
156
+ const readBack = getSecret(CREDENTIALS_FILE, MASTER_KEY_PATH, kind, ref);
157
+ if (readBack !== legacyPlain) {
158
+ reportSwallowed('credential-access.migrate-verify', new Error(`read-back mismatch for ${kind}:${ref}`));
159
+ return { migrated: false, verified: false };
160
+ }
161
+ return { migrated: true, verified: true };
162
+ } catch (err) {
163
+ reportSwallowed('credential-access.migrate', err);
164
+ return { migrated: false, verified: false };
165
+ }
166
+ }
167
+
168
+ /** Test hook: drop cached key state so a fresh root/key is picked up per test. */
169
+ export function _resetCredentialAccess() {
170
+ _resetKeyCache(); // clear all cached keys across roots (test hook / root change)
171
+ }
172
+
173
+ /**
174
+ * Persist the apiKeys of an incoming profile list into the vault, and return the list with
175
+ * apiKey STRIPPED for writing to profile.json (the file must never carry the key again).
176
+ *
177
+ * Per profile (kind=profile-apiKey, ref=profile.id):
178
+ * - masked value (isMaskedFn true) → the client echoed the mask back unchanged: DO NOT touch
179
+ * the vault entry (preserves the existing key) and do not write the sentinel anywhere.
180
+ * - non-empty plaintext → writeSecret into the vault.
181
+ * - empty/absent → the profile genuinely has no key: remove the vault entry.
182
+ * Profiles removed from the list (present in `existingProfiles` but absent in `incomingProfiles`)
183
+ * have their vault entry deleted — but only for a FULL-REPLACEMENT write (pass the on-disk list),
184
+ * never for a merge (cc-switch import passes no existingProfiles → no deletion).
185
+ *
186
+ * Returns the stripped list (apiKey:'') to persist in profile.json.
187
+ */
188
+ export function persistProfilesApiKeys(incomingProfiles, existingProfiles, { isMaskedFn } = {}) {
189
+ const isMasked = typeof isMaskedFn === 'function' ? isMaskedFn : () => false;
190
+ const existingById = new Map((Array.isArray(existingProfiles) ? existingProfiles : [])
191
+ .filter(p => p && typeof p.id === 'string').map(p => [p.id, p]));
192
+ const incomingIds = new Set();
193
+ const stripped = (Array.isArray(incomingProfiles) ? incomingProfiles : []).map((p) => {
194
+ if (!p || typeof p.id !== 'string') return p;
195
+ incomingIds.add(p.id);
196
+ const key = typeof p.apiKey === 'string' ? p.apiKey : '';
197
+ if (key && isMasked(key)) {
198
+ // masked echo → the client didn't change the key, so preserve the existing one. Normally the
199
+ // vault already holds it (leave it untouched). But a profile.json that still carries a
200
+ // PLAINTEXT key (pre-migration) has no vault entry yet — migrate that plaintext in now,
201
+ // otherwise stripping the field below would lose the only copy. If that rescue write FAILS
202
+ // (vault unusable), keep the on-disk plaintext rather than stripping it into oblivion.
203
+ if (!hasSecret(getCredentialsFile(), 'profile-apiKey', p.id)) {
204
+ const legacyPlain = existingById.get(p.id)?.apiKey;
205
+ if (legacyPlain && !isMasked(legacyPlain)) {
206
+ const ok = writeSecret('profile-apiKey', p.id, legacyPlain);
207
+ if (!ok) return { ...p, apiKey: legacyPlain }; // vault write failed → keep the on-disk key
208
+ }
209
+ }
210
+ return { ...p, apiKey: '' };
211
+ }
212
+ if (key) {
213
+ // New plaintext → vault. On failure keep the plaintext on disk (do NOT strip): mirroring the
214
+ // auth/IM "preserve legacy on failed vault write" rule, so the key is never lost.
215
+ const ok = writeSecret('profile-apiKey', p.id, key);
216
+ return ok ? { ...p, apiKey: '' } : { ...p, apiKey: key };
217
+ }
218
+ // Empty key: preserve any existing vault entry. An empty apiKey here usually means the key
219
+ // simply wasn't served (an unreadable vault hydrates ''), so deleting on empty would destroy
220
+ // the real ciphertext on a no-op save. A profile's key is removed only by removing the whole
221
+ // profile (the full-replacement diff below), never by an empty-string inference.
222
+ return { ...p, apiKey: '' };
223
+ });
224
+ // Full-replacement: delete vault entries for profiles no longer in the list.
225
+ if (Array.isArray(existingProfiles)) {
226
+ for (const old of existingProfiles) {
227
+ if (old && typeof old.id === 'string' && !incomingIds.has(old.id)) {
228
+ removeSecret('profile-apiKey', old.id);
229
+ }
230
+ }
231
+ }
232
+ return stripped;
233
+ }
@@ -0,0 +1,147 @@
1
+ // One-time credential migration — moves legacy plaintext/base64 secrets into the vault.
2
+ //
3
+ // Runs ONCE at server startup (fire-and-forget setImmediate, after config-backup so a pre-strip
4
+ // copy exists). For each legacy secret field it: writes the value to the vault, READS IT BACK and
5
+ // compares (the data-safety invariant), and only then strips the legacy field from the source
6
+ // file — all inside the source file's lock. Idempotent by data, not by a marker: a field that
7
+ // already matches the vault is a no-op; a field whose on-disk value DIFFERS from the vault wins
8
+ // (it is the user's later intent — hand edit / restored backup / old binary) and overwrites.
9
+ //
10
+ // The migration marker written at the end is INFORMATIONAL only (for logging + the backup-clean
11
+ // decision); it never gates re-migration, so a later plaintext reintroduction is still picked up.
12
+ //
13
+ // Boundary: L1-lib. Imports json-store + credential-access + findcc (load-time root).
14
+ import { existsSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import { reportSwallowed } from '@ccv/core/error-report';
17
+ import { LOG_DIR } from '../../findcc.js';
18
+ import { mutateJsonSync } from './json-store.js';
19
+ import { migrateFieldWithVerify, getCredentialsFile } from './credential-access.js';
20
+ import { refFor as authRefFor } from './auth.js';
21
+ import { secretRef as imSecretRef } from './im/im-config.js';
22
+
23
+ // Load-time roots, aligned with credential-access / PROFILE_PATH (not live-bound).
24
+ const PREFS_FILE = join(LOG_DIR, 'preferences.json');
25
+ const PROFILE_FILE = join(LOG_DIR, 'profile.json');
26
+
27
+ // Ref builders are imported from the OWNING runtime modules (auth.js refFor / im-config secretRef)
28
+ // so the migrated entries are keyed EXACTLY as the runtime readers expect — a hand-copied format
29
+ // here would silently orphan every migrated secret on a one-char divergence.
30
+
31
+ function _b64Decode(stored) {
32
+ if (!stored || typeof stored !== 'string') return '';
33
+ try { return Buffer.from(stored, 'base64').toString('utf-8'); } catch { return ''; }
34
+ }
35
+
36
+ // IM platform → secret field keys (mirrors im-config DESCRIPTORS; only `secret`-type fields).
37
+ // cred fields (appKey/appId/botId) stay base64 — they are low-sensitivity and out of scope.
38
+ const IM_SECRET_FIELDS = {
39
+ dingtalk: ['appSecret'],
40
+ feishu: ['appSecret'],
41
+ wecom: ['secret'],
42
+ discord: ['botToken'],
43
+ };
44
+
45
+ /**
46
+ * Migrate auth.password / authByProject.*.password (base64) into the vault, then strip them from
47
+ * preferences.json (leaving { enabled }).
48
+ * Returns { migrated, skipped, failed } counts.
49
+ */
50
+ function migrateAuthPasswords() {
51
+ const result = { migrated: 0, skipped: 0, failed: 0 };
52
+ if (!existsSync(PREFS_FILE)) return result;
53
+ mutateJsonSync(PREFS_FILE, (prefs) => {
54
+ if (!prefs || typeof prefs !== 'object') return;
55
+ // global
56
+ if (prefs.auth && typeof prefs.auth === 'object' && prefs.auth.password) {
57
+ const plain = _b64Decode(prefs.auth.password);
58
+ const r = migrateFieldWithVerify('lan-password', authRefFor(null), plain);
59
+ if (r.migrated && r.verified) { delete prefs.auth.password; result.migrated++; }
60
+ else result.failed++;
61
+ }
62
+ // per-project
63
+ if (prefs.authByProject && typeof prefs.authByProject === 'object') {
64
+ for (const [dir, entry] of Object.entries(prefs.authByProject)) {
65
+ if (!entry || typeof entry !== 'object' || !entry.password) { result.skipped++; continue; }
66
+ const plain = _b64Decode(entry.password);
67
+ const r = migrateFieldWithVerify('lan-password', authRefFor(dir), plain);
68
+ if (r.migrated && r.verified) { delete entry.password; result.migrated++; }
69
+ else result.failed++;
70
+ }
71
+ }
72
+ }, { mode: 0o600 });
73
+ return result;
74
+ }
75
+
76
+ /**
77
+ * Migrate IM platform secret fields (base64) into the vault, then strip them (leaving '').
78
+ */
79
+ function migrateImSecrets() {
80
+ const result = { migrated: 0, skipped: 0, failed: 0 };
81
+ if (!existsSync(PREFS_FILE)) return result;
82
+ mutateJsonSync(PREFS_FILE, (prefs) => {
83
+ if (!prefs || typeof prefs !== 'object') return;
84
+ for (const [platform, fields] of Object.entries(IM_SECRET_FIELDS)) {
85
+ const cfg = prefs[platform];
86
+ if (!cfg || typeof cfg !== 'object') { result.skipped += fields.length; continue; }
87
+ for (const fieldKey of fields) {
88
+ const stored = cfg[fieldKey];
89
+ if (!stored) { result.skipped++; continue; }
90
+ const plain = _b64Decode(stored);
91
+ const r = migrateFieldWithVerify('im-secret', imSecretRef(platform, fieldKey), plain);
92
+ if (r.migrated && r.verified) { cfg[fieldKey] = ''; result.migrated++; }
93
+ else result.failed++;
94
+ }
95
+ }
96
+ }, { mode: 0o600 });
97
+ return result;
98
+ }
99
+
100
+ /**
101
+ * Migrate profile.json plaintext apiKeys into the vault, then strip them (leaving '').
102
+ * Reads the file directly (the startup strip is the ONLY writer that removes the field).
103
+ */
104
+ function migrateProfileApiKeys() {
105
+ const result = { migrated: 0, skipped: 0, failed: 0 };
106
+ if (!existsSync(PROFILE_FILE)) return result;
107
+ mutateJsonSync(PROFILE_FILE, (data) => {
108
+ if (!data || typeof data !== 'object' || !Array.isArray(data.profiles)) return;
109
+ for (const p of data.profiles) {
110
+ if (!p || typeof p.id !== 'string') { result.skipped++; continue; }
111
+ const plain = typeof p.apiKey === 'string' ? p.apiKey : '';
112
+ if (!plain) { result.skipped++; continue; }
113
+ const r = migrateFieldWithVerify('profile-apiKey', p.id, plain);
114
+ if (r.migrated && r.verified) { p.apiKey = ''; result.migrated++; }
115
+ else result.failed++;
116
+ }
117
+ }, { mode: 0o600, strictCorrupt: true }); // never overwrite a corrupt profile.json with a {} fallback
118
+ return result;
119
+ }
120
+
121
+ /**
122
+ * Run the full one-time migration. Best-effort: per-class failures are reported and counted,
123
+ * never thrown (a failed field keeps its legacy value, so nothing is lost and the next boot
124
+ * retries). Returns a summary; also writes an informational marker into credentials.json.
125
+ */
126
+ export function migrateCredentialsToVault() {
127
+ const summary = { auth: null, im: null, profiles: null, ok: true };
128
+ try { summary.auth = migrateAuthPasswords(); } catch (e) { summary.ok = false; reportSwallowed('credential-migrate.auth', e); }
129
+ try { summary.im = migrateImSecrets(); } catch (e) { summary.ok = false; reportSwallowed('credential-migrate.im', e); }
130
+ try { summary.profiles = migrateProfileApiKeys(); } catch (e) { summary.ok = false; reportSwallowed('credential-migrate.profiles', e); }
131
+
132
+ const total = (s) => (s ? s.migrated + s.failed : 0);
133
+ const touched = total(summary.auth) + total(summary.im) + total(summary.profiles);
134
+ if (touched > 0) {
135
+ // Informational marker only — never used to gate re-migration.
136
+ try {
137
+ mutateJsonSync(getCredentialsFile(), (data) => {
138
+ if (data && typeof data === 'object') data._credsMigratedAt = new Date().toISOString();
139
+ }, { mode: 0o600, strictCorrupt: true });
140
+ } catch (e) { reportSwallowed('credential-migrate.marker', e); }
141
+ // Loud, single-line startup signal so an operator can see the vault came online.
142
+ console.error(`[cc-viewer] credential vault migration: auth=${fmt(summary.auth)} im=${fmt(summary.im)} profiles=${fmt(summary.profiles)}`);
143
+ }
144
+ return summary;
145
+ }
146
+
147
+ function fmt(s) { return s ? `${s.migrated} migrated/${s.failed} failed` : 'n/a'; }
@@ -0,0 +1,120 @@
1
+ // Credential store — one encrypted home for cc-viewer's secrets, separate from plain config.
2
+ //
3
+ // Splitting secrets OUT of preferences.json / profile.json into their own credentials.json is
4
+ // what makes them safe to handle differently: the plain config can sync freely, while secrets
5
+ // live only as vault ciphertext (see credential-vault.js) in a 0600 file that is excluded from
6
+ // config-backup. It also means a future "sync plain config" path never drags a key along by
7
+ // accident — the secret is in a different file entirely.
8
+ //
9
+ // On-disk shape (credentials.json, 0600):
10
+ // { version: 1, creds: { "<kind>:<ref>": "<base64(iv|tag|ct)>" } }
11
+ // kind ∈ profile-apiKey | lan-password | im-secret. ref scopes the secret (profile id /
12
+ // project dir / "<platform>.<field>"). Values are ciphertext from credential-vault.
13
+ //
14
+ // Reads/writes go through the unified json-store kernel (sync locked atomic write), sharing the
15
+ // same per-file lock discipline as every other config file. Path-injected; the caller supplies
16
+ // both the credentials file and the master key path (both derive from LOG_DIR at the call site).
17
+ import { join } from 'node:path';
18
+ import { existsSync, readFileSync } from 'node:fs';
19
+ import { readJsonSafe, mutateJsonSync } from './json-store.js';
20
+ import { encryptSecret, decryptSecret, looksEncrypted } from './credential-vault.js';
21
+
22
+ const VERSION = 1;
23
+
24
+ /** Compose the storage key for a secret. */
25
+ export function credKey(kind, ref) { return `${kind}:${ref}`; }
26
+
27
+ // Distinguish "credentials file is ABSENT" from "present but UNREADABLE/corrupt". Read paths
28
+ // (hasSecret/getSecret) treat an unreadable file as "no entries", which is fail-OPEN for a gate
29
+ // that treats empty as allow-all. vaultUnreadable() lets the access layer fail CLOSED instead.
30
+ function _readCreds(file) {
31
+ const data = readJsonSafe(file, {});
32
+ return (data && typeof data === 'object' && data.creds && typeof data.creds === 'object')
33
+ ? data.creds
34
+ : {};
35
+ }
36
+
37
+ /**
38
+ * True when `file` exists but cannot be parsed as a credentials file (corrupt/torn/EACCES).
39
+ * Absent → false. This is the fail-closed signal: a vault that SHOULD have entries but can't be
40
+ * read must never be treated as "empty" (→ empty password / empty apiKey).
41
+ */
42
+ export function vaultUnreadable(file) {
43
+ if (!existsSync(file)) return false;
44
+ try {
45
+ const data = JSON.parse(readFileSync(file, 'utf-8'));
46
+ // A parsed file is "readable" iff it is a plain object (it may simply have no creds yet).
47
+ return !(data && typeof data === 'object' && !Array.isArray(data));
48
+ } catch {
49
+ return true;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Store a secret. `plain` empty/'' removes the entry (secrets are deleted, never stored empty).
55
+ * Returns true on success.
56
+ */
57
+ export function setSecret(file, keyPath, kind, ref, plain) {
58
+ const key = credKey(kind, ref);
59
+ // strictCorrupt: a corrupt credentials.json must never be overwritten by the fallback — losing
60
+ // the whole vault (every secret) is unacceptable, unlike a self-healing preferences.json.
61
+ mutateJsonSync(file, (data) => {
62
+ if (!data || typeof data !== 'object') data = {};
63
+ if (!data.creds || typeof data.creds !== 'object') data.creds = {};
64
+ if (!plain) {
65
+ delete data.creds[key];
66
+ } else {
67
+ data.creds[key] = encryptSecret(keyPath, plain);
68
+ }
69
+ data.version = VERSION;
70
+ }, { mode: 0o600, fallback: {}, strictCorrupt: true });
71
+ return true;
72
+ }
73
+
74
+ /**
75
+ * Read a secret back to plaintext. Returns '' when absent. Throws only on tampered/wrong-key
76
+ * ciphertext (auth-tag failure) — the caller decides whether that is fatal.
77
+ */
78
+ export function getSecret(file, keyPath, kind, ref) {
79
+ const stored = _readCreds(file)[credKey(kind, ref)];
80
+ if (!stored) return '';
81
+ return decryptSecret(keyPath, stored);
82
+ }
83
+
84
+ /** Does a ciphertext entry exist for this secret? */
85
+ export function hasSecret(file, kind, ref) {
86
+ return Boolean(_readCreds(file)[credKey(kind, ref)]);
87
+ }
88
+
89
+ /** Remove a secret. No-op if absent. */
90
+ export function deleteSecret(file, kind, ref) {
91
+ const key = credKey(kind, ref);
92
+ mutateJsonSync(file, (data) => {
93
+ if (data && typeof data === 'object' && data.creds && typeof data.creds === 'object') {
94
+ delete data.creds[key];
95
+ }
96
+ }, { mode: 0o600, fallback: {}, strictCorrupt: true });
97
+ }
98
+
99
+ /**
100
+ * Idempotent one-time migration helper: given a legacy value (plaintext or base64), ensure a
101
+ * ciphertext entry exists. If the entry already holds ciphertext, leave it (idempotent). If the
102
+ * legacy value is empty, do nothing. Returns true if it wrote a NEW ciphertext entry.
103
+ * `decodeLegacy` turns the legacy on-disk value into plaintext (identity for plaintext fields,
104
+ * base64-decode for the base64 fields).
105
+ */
106
+ export function migrateLegacySecret(file, keyPath, kind, ref, legacyOnDisk, decodeLegacy) {
107
+ if (!legacyOnDisk) return false;
108
+ if (hasSecret(file, kind, ref)) return false; // already migrated
109
+ const plain = decodeLegacy(legacyOnDisk);
110
+ if (!plain) return false;
111
+ setSecret(file, keyPath, kind, ref, plain);
112
+ return true;
113
+ }
114
+
115
+ // Re-export so migration callers can detect already-encrypted legacy values without a second import.
116
+ export { looksEncrypted };
117
+
118
+ /** Canonical paths for the credentials file and master key, derived from a data root. */
119
+ export function credentialsFileFor(logDir) { return join(logDir, 'credentials.json'); }
120
+ export function masterKeyPathFor(logDir) { return join(logDir, 'master.key'); }