cc-viewer 1.8.9 → 1.8.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +13 -0
  2. package/dist/assets/App-vpm6omRY.js +2 -0
  3. package/dist/assets/{MdxEditorPanel-BznPWiig.js → MdxEditorPanel-CpknU7SR.js} +1 -1
  4. package/dist/assets/{Mobile-ph7aKFZy.js → Mobile-BHIBdqHs.js} +1 -1
  5. package/dist/assets/{ProxyStatsModal-B7iew2Tv.js → ProxyStatsModal-DdV7wN2V.js} +1 -1
  6. package/dist/assets/{_baseUniq-D2aO8mv4.js → _baseUniq-C2FCldN4.js} +1 -1
  7. package/dist/assets/{arc-DOjcnBTh.js → arc-CSzd_bdT.js} +1 -1
  8. package/dist/assets/{architectureDiagram-Q4EWVU46-Drea2H7X.js → architectureDiagram-Q4EWVU46-CjEbBe2R.js} +1 -1
  9. package/dist/assets/{blockDiagram-DXYQGD6D-Bc9eijBD.js → blockDiagram-DXYQGD6D-BYzl2ukW.js} +1 -1
  10. package/dist/assets/{c4Diagram-AHTNJAMY-D-umzFnM.js → c4Diagram-AHTNJAMY-0x0_8O_X.js} +1 -1
  11. package/dist/assets/{channel-DUff3FBt.js → channel-xMmQtLq9.js} +1 -1
  12. package/dist/assets/{chunk-4BX2VUAB-CFnsROfz.js → chunk-4BX2VUAB-DnFoMzQG.js} +1 -1
  13. package/dist/assets/{chunk-4TB4RGXK-C95jMPjB.js → chunk-4TB4RGXK-Dc5reuwv.js} +1 -1
  14. package/dist/assets/{chunk-55IACEB6-CeWf2Y2b.js → chunk-55IACEB6-DTKTLKcJ.js} +1 -1
  15. package/dist/assets/{chunk-EDXVE4YY-ClUoTJxv.js → chunk-EDXVE4YY-BNqgeN9r.js} +1 -1
  16. package/dist/assets/{chunk-FMBD7UC4-Dk62kqb8.js → chunk-FMBD7UC4-DzJVZ11t.js} +1 -1
  17. package/dist/assets/{chunk-OYMX7WX6-CZuykSsu.js → chunk-OYMX7WX6-CjYwzMor.js} +1 -1
  18. package/dist/assets/{chunk-QZHKN3VN-DYtx68Um.js → chunk-QZHKN3VN-C0KVKecr.js} +1 -1
  19. package/dist/assets/{chunk-YZCP3GAM-rLSq0tSC.js → chunk-YZCP3GAM-3VhNeNVm.js} +1 -1
  20. package/dist/assets/classDiagram-6PBFFD2Q-DdUtH789.js +1 -0
  21. package/dist/assets/classDiagram-v2-HSJHXN6E-DdUtH789.js +1 -0
  22. package/dist/assets/clone-C6d2XDvR.js +1 -0
  23. package/dist/assets/{cose-bilkent-S5V4N54A-B5H6ewon.js → cose-bilkent-S5V4N54A-DGE02sJS.js} +1 -1
  24. package/dist/assets/{dagre-KV5264BT-BO2R2WP7.js → dagre-KV5264BT-CY4eCcwS.js} +1 -1
  25. package/dist/assets/{diagram-5BDNPKRD-Y3GNif-g.js → diagram-5BDNPKRD-DHq2THYn.js} +1 -1
  26. package/dist/assets/{diagram-G4DWMVQ6-Du3_TylQ.js → diagram-G4DWMVQ6-DD_76oJg.js} +1 -1
  27. package/dist/assets/{diagram-MMDJMWI5-DB_Ynyjc.js → diagram-MMDJMWI5-BvQxk1jl.js} +1 -1
  28. package/dist/assets/{diagram-TYMM5635-_Rfjm82W.js → diagram-TYMM5635-B_tMXPJl.js} +1 -1
  29. package/dist/assets/{erDiagram-SMLLAGMA-D5XhBiiV.js → erDiagram-SMLLAGMA-DpuGbN9e.js} +1 -1
  30. package/dist/assets/{flowDiagram-DWJPFMVM-CxWJ7sAQ.js → flowDiagram-DWJPFMVM-BW0xGFBf.js} +1 -1
  31. package/dist/assets/{ganttDiagram-T4ZO3ILL-D-a8fh00.js → ganttDiagram-T4ZO3ILL-BSzTiT93.js} +1 -1
  32. package/dist/assets/{gitGraphDiagram-UUTBAWPF-q5WMk0Ce.js → gitGraphDiagram-UUTBAWPF-1tsgBlcn.js} +1 -1
  33. package/dist/assets/{graph-Bi8xMAuo.js → graph-CwiXMdRR.js} +1 -1
  34. package/dist/assets/{index-BRtbpMRz.js → index-9e6BGquk.js} +1 -1
  35. package/dist/assets/{index-BWPHHS6P.js → index-BmpXfVQ8.js} +1 -1
  36. package/dist/assets/{index-O2Mx9uLd.js → index-CZWNaOo8.js} +1 -1
  37. package/dist/assets/{index-BBEH6Ppx.js → index-Cixs7aRD.js} +1 -1
  38. package/dist/assets/{index-C3wsDB-y.js → index-Ct6Fi103.js} +1 -1
  39. package/dist/assets/index-D1yGak8I.css +1 -0
  40. package/dist/assets/{index-3Z3ucdlB.js → index-DIAMN9QO.js} +1 -1
  41. package/dist/assets/{index-BB66PLo6.js → index-DhjwHRxk.js} +1 -1
  42. package/dist/assets/{index-BbF89uTe.js → index-tnWldUm3.js} +2 -2
  43. package/dist/assets/{infoDiagram-42DDH7IO-CE2mUuLF.js → infoDiagram-42DDH7IO-Dk1ESZFk.js} +1 -1
  44. package/dist/assets/{ishikawaDiagram-UXIWVN3A-C5sNQIVL.js → ishikawaDiagram-UXIWVN3A-DcPyNwkI.js} +1 -1
  45. package/dist/assets/{journeyDiagram-VCZTEJTY-g7HXOeyX.js → journeyDiagram-VCZTEJTY-CZWieu1U.js} +1 -1
  46. package/dist/assets/{jszip.min-x3xOZxY_.js → jszip.min-Bq1JxqcN.js} +1 -1
  47. package/dist/assets/{kanban-definition-6JOO6SKY-D3Mus6hc.js → kanban-definition-6JOO6SKY-S1sSSTKx.js} +1 -1
  48. package/dist/assets/{layout-05qDuweB.js → layout-BQFiWq71.js} +1 -1
  49. package/dist/assets/{linear-DQEG_N-F.js → linear-BNV4po81.js} +1 -1
  50. package/dist/assets/{mermaid.core-DcaXqkk5.js → mermaid.core-DvwEdatr.js} +2 -2
  51. package/dist/assets/{min-DSpvw5zG.js → min-DFik8k4L.js} +1 -1
  52. package/dist/assets/{mindmap-definition-QFDTVHPH-DomUIop7.js → mindmap-definition-QFDTVHPH-CebeX76u.js} +1 -1
  53. package/dist/assets/{pieDiagram-DEJITSTG-COTLcC5G.js → pieDiagram-DEJITSTG-DMB5JbMP.js} +1 -1
  54. package/dist/assets/{quadrantDiagram-34T5L4WZ-DMg8C3Up.js → quadrantDiagram-34T5L4WZ-BAYzoLVo.js} +1 -1
  55. package/dist/assets/{requirementDiagram-MS252O5E-DF9beYd7.js → requirementDiagram-MS252O5E-CUQoAuOc.js} +1 -1
  56. package/dist/assets/{sankeyDiagram-XADWPNL6-MvERK2UP.js → sankeyDiagram-XADWPNL6-Dt0AQuLW.js} +1 -1
  57. package/dist/assets/seqResourceLoaders-C-QHRkUq.js +2 -0
  58. package/dist/assets/{sequenceDiagram-FGHM5R23-B8k_nyzc.js → sequenceDiagram-FGHM5R23-HyZFUlMU.js} +1 -1
  59. package/dist/assets/{stateDiagram-FHFEXIEX-D-9fTYas.js → stateDiagram-FHFEXIEX-B8x24fuD.js} +1 -1
  60. package/dist/assets/stateDiagram-v2-QKLJ7IA2-2qtHlnwO.js +1 -0
  61. package/dist/assets/{timeline-definition-GMOUNBTQ-CbK1g9CO.js → timeline-definition-GMOUNBTQ-TnbjDVtB.js} +1 -1
  62. package/dist/assets/{vendor-antd-FhK5cQx7.js → vendor-antd-BTBRwbax.js} +1 -1
  63. package/dist/assets/{vendor-codemirror-DL3yyKRQ.js → vendor-codemirror-CPWlhuQ1.js} +1 -1
  64. package/dist/assets/{vendor-mdxeditor-DgyrvHzS.js → vendor-mdxeditor-Cy5Xg_90.js} +2 -2
  65. package/dist/assets/{vendor-qrcode-BAwwP1Nd.js → vendor-qrcode-E9ld0lSz.js} +1 -1
  66. package/dist/assets/{vendor-virtuoso-B_7lc2Ot.js → vendor-virtuoso-Bq6QMhgG.js} +1 -1
  67. package/dist/assets/{vennDiagram-DHZGUBPP-DYjGtMA5.js → vennDiagram-DHZGUBPP-BqgIjkBO.js} +1 -1
  68. package/dist/assets/{wardley-RL74JXVD-Djh5alV9.js → wardley-RL74JXVD-BFcIn2-Q.js} +1 -1
  69. package/dist/assets/{wardleyDiagram-NUSXRM2D-al50Jlxp.js → wardleyDiagram-NUSXRM2D-CYDWQLb6.js} +1 -1
  70. package/dist/assets/{xychartDiagram-5P7HB3ND-Bl969-3U.js → xychartDiagram-5P7HB3ND-zfusE3BC.js} +1 -1
  71. package/dist/index.html +6 -6
  72. package/package.json +1 -1
  73. package/server/lib/auth.js +20 -4
  74. package/server/lib/is-admin.js +27 -0
  75. package/server/lib/skills-api.js +48 -26
  76. package/server/routes/auth.js +11 -9
  77. package/server/routes/dingtalk.js +23 -8
  78. package/server/routes/files-fs.js +76 -1
  79. package/server/routes/im.js +36 -20
  80. package/server/routes/preferences.js +27 -15
  81. package/server/routes/project-prefs.js +6 -5
  82. package/server/routes/skills.js +50 -29
  83. package/server/routes/voice-pack.js +7 -6
  84. package/server/server.js +12 -2
  85. package/dist/assets/App-DuJsCzTH.js +0 -2
  86. package/dist/assets/classDiagram-6PBFFD2Q-DT8RQXlO.js +0 -1
  87. package/dist/assets/classDiagram-v2-HSJHXN6E-DT8RQXlO.js +0 -1
  88. package/dist/assets/clone-_dhVVwEt.js +0 -1
  89. package/dist/assets/index-D3GEPgde.css +0 -1
  90. package/dist/assets/seqResourceLoaders-76uKbVqf.js +0 -2
  91. package/dist/assets/stateDiagram-v2-QKLJ7IA2-CO0tv8rD.js +0 -1
@@ -24,43 +24,65 @@ export function validateSkillName(name) {
24
24
  return true;
25
25
  }
26
26
 
27
- // 解析 SKILL.md frontmatter 的 description。支持 3 种 YAML 形式:
28
- // 1) description: plain text ← 单行
29
- // 2) description: "quoted text" ← 带引号
30
- // 3) description: | ← block scalar(保留换行)
31
- // multi\n line\n content
32
- // 4) description: > ← folded scalar(换行折成空格)
33
- // 失败返 null,UI 兜底"无描述"。
34
- function parseSkillMdFrontmatter(skillDir) {
35
- const mdPath = join(skillDir, 'SKILL.md');
36
- if (!existsSync(mdPath)) return null;
37
- try {
38
- const text = readFileSync(mdPath, 'utf8');
39
- const m = /^---\s*\n([\s\S]*?)\n---/.exec(text);
40
- if (!m) return null;
41
- const lines = m[1].split('\n');
42
- let descIdx = -1, firstVal = '';
43
- for (let i = 0; i < lines.length; i++) {
44
- const lm = /^description\s*:\s*(.*)$/.exec(lines[i]);
45
- if (lm) { descIdx = i; firstVal = lm[1].trim(); break; }
46
- }
47
- if (descIdx < 0) return null;
27
+ // Parse SKILL.md frontmatter text → { name, description } (each string|null),
28
+ // or null when no parseable `---` frontmatter block exists.
29
+ // name: single-line `name: value` (surrounding quotes stripped).
30
+ // description supports 4 YAML forms:
31
+ // 1) description: plain text ← single line
32
+ // 2) description: "quoted text" ← quoted
33
+ // 3) description: | ← block scalar (keeps newlines)
34
+ // 4) description: > ← folded scalar (newlines → spaces)
35
+ // Values that are empty after trimming are returned as null (treated as absent).
36
+ export function parseSkillFrontmatter(text) {
37
+ // Tolerate CRLF line endings (Windows-authored SKILL.md) — otherwise the whole
38
+ // frontmatter block fails to match and valid skills are rejected as INVALID_FRONTMATTER.
39
+ const m = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---/.exec(text);
40
+ if (!m) return null;
41
+ const lines = m[1].split('\n').map((l) => l.replace(/\r$/, ''));
48
42
 
49
- // block scalar: | / |- / > / >-
43
+ let name = null;
44
+ const nm = /^name\s*:\s*(.*)$/m.exec(m[1]);
45
+ if (nm) {
46
+ const v = nm[1].trim().replace(/\r$/, '').replace(/^["']|["']$/g, '');
47
+ name = v || null;
48
+ }
49
+
50
+ let description = null;
51
+ let descIdx = -1, firstVal = '';
52
+ for (let i = 0; i < lines.length; i++) {
53
+ const lm = /^description\s*:\s*(.*)$/.exec(lines[i]);
54
+ if (lm) { descIdx = i; firstVal = lm[1].trim(); break; }
55
+ }
56
+ if (descIdx >= 0) {
50
57
  if (/^[|>][-+]?$/.test(firstVal)) {
58
+ // block scalar: | / |- / > / >-
51
59
  const fold = firstVal.startsWith('>');
52
60
  const collected = [];
53
61
  for (let i = descIdx + 1; i < lines.length; i++) {
54
62
  const line = lines[i];
55
- // 块结束:非空且不以空白字符开头(视为下一个顶级 key)
63
+ // Block ends at a non-empty line without leading whitespace (next top-level key).
56
64
  if (line !== '' && !/^\s/.test(line)) break;
57
65
  collected.push(line.replace(/^\s+/, ''));
58
66
  }
59
67
  while (collected.length && collected[collected.length - 1] === '') collected.pop();
60
- return fold ? collected.join(' ') : collected.join('\n');
68
+ description = fold ? collected.join(' ') : collected.join('\n');
69
+ } else {
70
+ description = firstVal.replace(/^["']|["']$/g, '');
61
71
  }
62
- // 单行 scalar(去首尾引号)
63
- return firstVal.replace(/^["']|["']$/g, '');
72
+ if (!description || !description.trim()) description = null;
73
+ }
74
+
75
+ return { name, description };
76
+ }
77
+
78
+ // Read <skillDir>/SKILL.md and return the frontmatter description (delegates to
79
+ // parseSkillFrontmatter). Returns null on any failure — the UI falls back to "no description".
80
+ function parseSkillMdFrontmatter(skillDir) {
81
+ const mdPath = join(skillDir, 'SKILL.md');
82
+ if (!existsSync(mdPath)) return null;
83
+ try {
84
+ const fm = parseSkillFrontmatter(readFileSync(mdPath, 'utf8'));
85
+ return fm ? fm.description : null;
64
86
  } catch { return null; }
65
87
  }
66
88
 
@@ -1,11 +1,12 @@
1
1
  // Password-auth API routes. See server/lib/auth.js for the underlying logic and
2
2
  // the plan for the overall design.
3
3
  //
4
- // GET /api/auth/state — public; reveals the plaintext password only to admin (isLocal).
5
- // POST /api/auth/config — admin-only (!isLocal → 403); enable/disable + set password.
4
+ // GET /api/auth/state — public; reveals the plaintext password only to a loopback admin.
5
+ // POST /api/auth/config — admin-only (loopback, or authenticated remote admin; else 403); enable/disable + set password.
6
6
  // POST /api/auth/login — public + IP rate-limited; on success Set-Cookie ccv_auth=ACCESS_TOKEN.
7
7
  import { createHash, timingSafeEqual } from 'node:crypto';
8
8
  import { generatePassword } from '../lib/auth.js';
9
+ import { isAdminReq } from '../lib/is-admin.js';
9
10
 
10
11
  const JSON_HEADERS = { 'Content-Type': 'application/json' };
11
12
 
@@ -28,14 +29,15 @@ function readBody(req, deps, cb) {
28
29
  req.on('end', () => cb(body));
29
30
  }
30
31
 
31
- function buildState(deps, isLocal) {
32
+ function buildState(deps, isLocal, isAdmin) {
32
33
  // Reports the EFFECTIVE config (gate enforces this) plus enough scope info for the
33
34
  // admin UI to manage both the global default and a per-project override.
34
- // Passwords (effective + global) are only revealed to the admin (local).
35
+ // Plaintext passwords stay loopback-only even for an authenticated remote admin
36
+ // (mirrors the IM status secret policy); `isAdmin` reflects loopback OR remote-auth.
35
37
  const s = deps.getAuthState();
36
38
  return {
37
39
  enabled: s.effective.enabled,
38
- isAdmin: isLocal,
40
+ isAdmin,
39
41
  password: isLocal ? s.effective.password : null,
40
42
  scope: s.scope, // 'project' | 'global' — which one is in effect
41
43
  hasProjectOverride: s.hasProjectOverride,
@@ -81,11 +83,11 @@ function recordFailedAttempt(ip) {
81
83
 
82
84
  function authState(req, res, parsedUrl, isLocal, deps) {
83
85
  res.writeHead(200, JSON_HEADERS);
84
- res.end(JSON.stringify(buildState(deps, isLocal)));
86
+ res.end(JSON.stringify(buildState(deps, isLocal, isAdminReq(req, isLocal))));
85
87
  }
86
88
 
87
89
  function authConfigPost(req, res, parsedUrl, isLocal, deps) {
88
- if (!isLocal) {
90
+ if (!isAdminReq(req, isLocal)) {
89
91
  res.writeHead(403, JSON_HEADERS);
90
92
  res.end(JSON.stringify({ error: 'admin-only' }));
91
93
  return;
@@ -104,7 +106,7 @@ function authConfigPost(req, res, parsedUrl, isLocal, deps) {
104
106
  if (incoming.clearOverride === true) {
105
107
  deps.clearAuthOverride();
106
108
  res.writeHead(200, JSON_HEADERS);
107
- res.end(JSON.stringify(buildState(deps, isLocal)));
109
+ res.end(JSON.stringify(buildState(deps, isLocal, isAdminReq(req, isLocal))));
108
110
  return;
109
111
  }
110
112
  // Target scope: explicit 'global' → global; otherwise project when this server has a
@@ -126,7 +128,7 @@ function authConfigPost(req, res, parsedUrl, isLocal, deps) {
126
128
  }
127
129
  deps.setAuthConfig(next, scope);
128
130
  res.writeHead(200, JSON_HEADERS);
129
- res.end(JSON.stringify(buildState(deps, isLocal)));
131
+ res.end(JSON.stringify(buildState(deps, isLocal, isAdminReq(req, isLocal))));
130
132
  });
131
133
  }
132
134
 
@@ -1,11 +1,13 @@
1
1
  // DingTalk bridge config API. See server/lib/im/dingtalk-config.js (storage) and
2
2
  // server/lib/im/dingtalk-bridge.js (the Stream client) for the underlying logic.
3
3
  //
4
- // GET /api/dingtalk/status — public; remote callers get only hasSecret, the local (admin)
5
- // caller additionally gets the plaintext appSecret to view/copy.
6
- // POST /api/dingtalk/config — loopback-only (!isLocal → 403); save creds, reload bridge.
7
- // POST /api/dingtalk/test — loopback-only; validate creds (fetch an access token).
4
+ // GET /api/dingtalk/status — public; remote callers get only enabled+hasSecret+connection; an
5
+ // authenticated remote admin additionally gets process+pid and lastError
6
+ // (error chip); only the plaintext appSecret stays loopback-only.
7
+ // POST /api/dingtalk/config — admin-only (loopback, or authenticated remote admin); save creds, reload bridge.
8
+ // POST /api/dingtalk/test — admin-only; validate creds (fetch an access token).
8
9
  import { loadDingTalkState, saveDingTalkConfig, loadDingTalkConfig } from '../lib/im/dingtalk-config.js';
10
+ import { isAdminReq } from '../lib/is-admin.js';
9
11
 
10
12
  const JSON_HEADERS = { 'Content-Type': 'application/json' };
11
13
 
@@ -36,7 +38,19 @@ async function dingtalkStatus(req, res, parsedUrl, isLocal, deps) {
36
38
  }
37
39
  res.writeHead(200, JSON_HEADERS);
38
40
  if (!isLocal) {
39
- // connectionState passes through as-is; lastError stays loopback-only (mirrors routes/im.js).
41
+ // Authenticated remote admin (container/cloud): full process/connection state (incl.
42
+ // `lastError` so the header chip can render the real error) — only the plaintext appSecret
43
+ // stays loopback-only (the frontend edit form only reads hasSecret).
44
+ if (isAdminReq(req, isLocal)) {
45
+ res.end(JSON.stringify({
46
+ ...loadDingTalkState(),
47
+ connection: conn,
48
+ process: processInfo,
49
+ pid: deps.dingtalk.isWorker ? process.pid : (processInfo?.pid ?? null),
50
+ }));
51
+ return;
52
+ }
53
+ // connectionState passes through as-is; lastError stays loopback-only (non-admin remote).
40
54
  res.end(JSON.stringify({
41
55
  enabled: loadDingTalkState().enabled,
42
56
  hasSecret: loadDingTalkState().hasSecret,
@@ -55,8 +69,9 @@ async function dingtalkStatus(req, res, parsedUrl, isLocal, deps) {
55
69
  }
56
70
 
57
71
  function dingtalkConfigPost(req, res, parsedUrl, isLocal, deps) {
58
- // Loopback-only: an app_secret must never be settable over the LAN even with a valid token.
59
- if (!isLocal) {
72
+ // Admin-only (loopback, or an authenticated remote admin): an app_secret must never be
73
+ // settable by an unauthenticated LAN client even with a valid token.
74
+ if (!isAdminReq(req, isLocal)) {
60
75
  res.writeHead(403, JSON_HEADERS);
61
76
  res.end(JSON.stringify({ error: 'Loopback only' }));
62
77
  return;
@@ -104,7 +119,7 @@ function dingtalkConfigPost(req, res, parsedUrl, isLocal, deps) {
104
119
  }
105
120
 
106
121
  function dingtalkTestPost(req, res, parsedUrl, isLocal, deps) {
107
- if (!isLocal) {
122
+ if (!isAdminReq(req, isLocal)) {
108
123
  res.writeHead(403, JSON_HEADERS);
109
124
  res.end(JSON.stringify({ error: 'Loopback only' }));
110
125
  return;
@@ -6,7 +6,7 @@ import {
6
6
  import { join, dirname, basename, resolve, sep } from 'node:path';
7
7
  import { homedir, tmpdir } from 'node:os';
8
8
  import { execFile, spawn } from 'node:child_process';
9
- import { bumpWorkspacesVersion } from '../lib/file-access-policy.js';
9
+ import { bumpWorkspacesVersion, isReadAllowed } from '../lib/file-access-policy.js';
10
10
  import { validateImportDir } from '../lib/file-api.js';
11
11
  import { PROFILE_PATH, _projectName, _logDir } from '../interceptor.js';
12
12
  import { LOG_DIR, getClaudeConfigDir } from '../../findcc.js';
@@ -659,6 +659,80 @@ function resolvePath(req, res, parsedUrl, isLocal, deps) {
659
659
  });
660
660
  }
661
661
 
662
+ // Lightweight existence probe for the chat markdown code-span feature: the web
663
+ // client batches path-looking inline code spans here and only renders them
664
+ // clickable when the file provably exists. Uniform {exists:false} for every
665
+ // not-readable-as-regular-file outcome (missing / outside allowlist / denied /
666
+ // directory) — never echoes policy reasons, so this is a strictly weaker oracle
667
+ // than /api/file-content's 404/403 split.
668
+ const FILES_EXISTS_MAX_BATCH = 50;
669
+ const FILES_EXISTS_MAX_PATH = 1024;
670
+ const FILES_EXISTS_MAX_BODY = 256 * 1024; // 50 × 1024 + JSON overhead ≪ this
671
+
672
+ function filesExists(req, res, parsedUrl, isLocal, deps) {
673
+ let body = '';
674
+ let aborted = false;
675
+ req.on('data', chunk => {
676
+ body += chunk;
677
+ if (body.length > FILES_EXISTS_MAX_BODY && !aborted) {
678
+ aborted = true;
679
+ res.writeHead(413, { 'Content-Type': 'application/json' });
680
+ res.end(JSON.stringify({ error: 'Request too large' }));
681
+ req.destroy();
682
+ }
683
+ });
684
+ req.on('end', () => {
685
+ if (aborted) return;
686
+ let parsed;
687
+ try { parsed = JSON.parse(body); } catch {
688
+ res.writeHead(400, { 'Content-Type': 'application/json' });
689
+ res.end(JSON.stringify({ error: 'Invalid request body' }));
690
+ return;
691
+ }
692
+ const paths = parsed && parsed.paths;
693
+ if (!Array.isArray(paths)) {
694
+ res.writeHead(400, { 'Content-Type': 'application/json' });
695
+ res.end(JSON.stringify({ error: 'Invalid paths' }));
696
+ return;
697
+ }
698
+ if (paths.length > FILES_EXISTS_MAX_BATCH) {
699
+ res.writeHead(400, { 'Content-Type': 'application/json' });
700
+ res.end(JSON.stringify({ error: 'Too many paths' }));
701
+ return;
702
+ }
703
+ for (const p of paths) {
704
+ if (typeof p !== 'string' || p.length > FILES_EXISTS_MAX_PATH) {
705
+ res.writeHead(400, { 'Content-Type': 'application/json' });
706
+ res.end(JSON.stringify({ error: 'Invalid path' }));
707
+ return;
708
+ }
709
+ }
710
+ const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
711
+ const results = paths.map(p => ({ path: p, exists: probeFileExists(p, cwd) }));
712
+ res.writeHead(200, { 'Content-Type': 'application/json' });
713
+ res.end(JSON.stringify({ results }));
714
+ });
715
+ }
716
+
717
+ function probeFileExists(p, cwd) {
718
+ try {
719
+ const isAbs = /^([a-zA-Z]:[\\/]|[\\/])/.test(p);
720
+ // Mirrors /api/file-content: a relative `..` segment can never be opened by
721
+ // the click path either, so report it as non-existent rather than 400.
722
+ // Segment-wise check — a legit filename like `foo..bar.md` must not match.
723
+ if (!isAbs && p.split(/[\\/]/).includes('..')) return false;
724
+ // Resolve BEFORE isReadAllowed: realpathSync inside the policy resolves
725
+ // relative input against process.cwd(), which is the app dir (not the
726
+ // project) in packaged Electron runs.
727
+ const absPath = isAbs ? p : resolve(cwd, p);
728
+ const policy = isReadAllowed(absPath);
729
+ if (!policy.ok) return false;
730
+ return statSync(policy.real).isFile();
731
+ } catch {
732
+ return false; // one bad path must not sink the whole batch
733
+ }
734
+ }
735
+
662
736
  function createFile(req, res, parsedUrl, isLocal, deps) {
663
737
  let body = '';
664
738
  req.on('data', chunk => { body += chunk; if (body.length > deps.MAX_POST_BODY) req.destroy(); });
@@ -964,6 +1038,7 @@ export const filesFsRoutes = [
964
1038
  { method: 'POST', match: 'exact', path: '/api/reveal-file', handler: revealFile },
965
1039
  { method: 'POST', match: 'exact', path: '/api/open-file', handler: openFile },
966
1040
  { method: 'POST', match: 'exact', path: '/api/resolve-path', handler: resolvePath },
1041
+ { method: 'POST', match: 'exact', path: '/api/files-exists', handler: filesExists },
967
1042
  { method: 'POST', match: 'exact', path: '/api/create-file', handler: createFile },
968
1043
  { method: 'POST', match: 'exact', path: '/api/open-terminal', handler: openTerminal },
969
1044
  { method: 'POST', match: 'exact', path: '/api/create-dir', handler: createDir },
@@ -1,13 +1,15 @@
1
1
  // Generic multi-IM bridge config + process-control API. Platform-parametric (keyed by descriptor id).
2
2
  //
3
- // GET /api/im/:platform/status — public; remote callers get only enabled+hasSecret+connection,
4
- // the local (admin) caller additionally gets plaintext secrets + process info.
5
- // POST /api/im/:platform/config — loopback-only; save creds, then drive the process manager
3
+ // GET /api/im/:platform/status — public; remote callers get only enabled+hasSecret+connection;
4
+ // an authenticated remote admin additionally gets process+pid and
5
+ // connection/process lastError (for the error chip); only the
6
+ // plaintext secrets stay loopback-only; the loopback admin gets all.
7
+ // POST /api/im/:platform/config — admin-only (loopback, or authenticated remote admin); save creds, then drive the process manager
6
8
  // (enable→stop+spawn worker, disable→stop). Allowlist is optional:
7
9
  // enabling with an empty one warns (not blocked) since the worker
8
10
  // runs with --dangerously-skip-permissions.
9
- // POST /api/im/:platform/test — loopback-only; validate creds (fetch an access token).
10
- // POST /api/im/:platform/process — loopback-only; {action:start|stop|restart} the detached worker.
11
+ // POST /api/im/:platform/test — admin-only; validate creds (fetch an access token).
12
+ // POST /api/im/:platform/process — admin-only; {action:start|stop|restart} the detached worker.
11
13
  // 'start' requires stored creds (400 `missing …` otherwise) and
12
14
  // also persists enabled:true (merged into the stored config):
13
15
  // a worker spawned while the config says disabled no-ops in
@@ -27,6 +29,7 @@ import { readImAppendSystem, writeImAppendSystem, buildImAppendSystemPreset, MAX
27
29
  import { imDir } from '../lib/im/im-lock.js';
28
30
  import { resolvePrefLang } from '../lib/im/im-lang.js';
29
31
  import { listSkills, moveSkill, deleteSkill } from '../lib/skills-api.js';
32
+ import { isAdminReq } from '../lib/is-admin.js';
30
33
  import { importSkillTo } from './skills.js';
31
34
  import { LOG_DIR } from '../../findcc.js';
32
35
  import { join, basename } from 'node:path';
@@ -116,7 +119,20 @@ async function imStatus(req, res, parsedUrl, isLocal, deps) {
116
119
 
117
120
  res.writeHead(200, JSON_HEADERS);
118
121
  if (!isLocal) {
119
- // Loopback gate: a token-authorized LAN client sees only what the header chip needs.
122
+ // Authenticated remote admin (container/cloud): full process/connection state so the settings
123
+ // UI start() poll sees `process.state === 'ready'` AND the header chip can render the real
124
+ // error (`connection.lastError` — the worker's own report, not a stored secret). Only the
125
+ // plaintext SECRETS stay loopback-only (the frontend edit form only ever reads hasSecret).
126
+ if (isAdminReq(req, isLocal)) {
127
+ res.end(JSON.stringify({
128
+ ...state,
129
+ connection,
130
+ process: processInfo,
131
+ pid: deps.im.isWorker ? process.pid : (processInfo?.pid ?? null),
132
+ }));
133
+ return;
134
+ }
135
+ // Unauthenticated/non-admin remote: only what the header chip needs.
120
136
  // connectionState is passed through as-is (no defaulting); lastError stays loopback-only.
121
137
  res.end(JSON.stringify({
122
138
  enabled: state.enabled,
@@ -141,7 +157,7 @@ async function imStatus(req, res, parsedUrl, isLocal, deps) {
141
157
  function imConfigPost(req, res, parsedUrl, isLocal, deps) {
142
158
  const id = platformOf(parsedUrl.pathname);
143
159
  if (!id) { notFound(res); return; }
144
- if (!isLocal) { loopbackOnly(res); return; }
160
+ if (!isAdminReq(req, isLocal)) { loopbackOnly(res); return; }
145
161
  readBody(req, deps, async (body) => {
146
162
  let incoming;
147
163
  try { incoming = JSON.parse(body); }
@@ -182,7 +198,7 @@ function imConfigPost(req, res, parsedUrl, isLocal, deps) {
182
198
  function imTestPost(req, res, parsedUrl, isLocal, deps) {
183
199
  const id = platformOf(parsedUrl.pathname);
184
200
  if (!id) { notFound(res); return; }
185
- if (!isLocal) { loopbackOnly(res); return; }
201
+ if (!isAdminReq(req, isLocal)) { loopbackOnly(res); return; }
186
202
  readBody(req, deps, async (body) => {
187
203
  let incoming = {};
188
204
  try { incoming = body ? JSON.parse(body) : {}; } catch { /* fall back to stored */ }
@@ -204,7 +220,7 @@ function imTestPost(req, res, parsedUrl, isLocal, deps) {
204
220
  function imProcessPost(req, res, parsedUrl, isLocal, deps) {
205
221
  const id = platformOf(parsedUrl.pathname);
206
222
  if (!id) { notFound(res); return; }
207
- if (!isLocal) { loopbackOnly(res); return; }
223
+ if (!isAdminReq(req, isLocal)) { loopbackOnly(res); return; }
208
224
  // 只有主进程负责管理 worker;worker 自身不应被要求 spawn/stop(避免嵌套)。
209
225
  if (deps.im.isWorker) {
210
226
  res.writeHead(409, JSON_HEADERS);
@@ -273,23 +289,23 @@ function imLogs(req, res, parsedUrl, isLocal, deps) {
273
289
  }
274
290
 
275
291
  // 发送者身份映射(senderId → {name, avatar, ts}):供「对话记录」按 senderId 显示真实姓名+头像。
276
- // loopback-only:姓名/头像属个人信息,不向局域网暴露(与 config/test/process 同级)。
292
+ // admin-only: names/avatars are personal info, not exposed to unauthenticated LAN clients (same tier as config/test/process).
277
293
  function imSenders(req, res, parsedUrl, isLocal, deps) {
278
294
  const id = platformOf(parsedUrl.pathname);
279
295
  if (!id) { notFound(res); return; }
280
- if (!isLocal) { loopbackOnly(res); return; }
296
+ if (!isAdminReq(req, isLocal)) { loopbackOnly(res); return; }
281
297
  res.writeHead(200, JSON_HEADERS);
282
298
  res.end(JSON.stringify({ platform: id, senders: readSenders(id) }));
283
299
  }
284
300
 
285
- // 「模型性格定义」= 该 IM worker 工作目录下的 CC_APPEND_SYSTEM.md(启动 claude 时注入为
286
- // --append-system-prompt-file)。loopback-only:本地文件内容、admin-only。
301
+ // "Model persona definition" = CC_APPEND_SYSTEM.md in that IM worker's working dir (injected at claude launch as
302
+ // --append-system-prompt-file). admin-only: local file content (loopback, or an authenticated remote admin).
287
303
  // 该文件仅在 worker 启动时读取一次,故保存后需重启该 IM worker 才生效(前端据此提示用户)。
288
304
  // ?default=1:返回当前语言的预置文本(绕过磁盘文件),供编辑器「恢复默认」按钮加载——不落盘,由用户保存才生效。
289
305
  function imAppendSystemGet(req, res, parsedUrl, isLocal, deps) {
290
306
  const id = platformOf(parsedUrl.pathname);
291
307
  if (!id) { notFound(res); return; }
292
- if (!isLocal) { loopbackOnly(res); return; }
308
+ if (!isAdminReq(req, isLocal)) { loopbackOnly(res); return; }
293
309
  try {
294
310
  const def = parsedUrl?.searchParams?.get('default');
295
311
  const lang = resolvePrefLang();
@@ -305,7 +321,7 @@ function imAppendSystemGet(req, res, parsedUrl, isLocal, deps) {
305
321
  function imAppendSystemPost(req, res, parsedUrl, isLocal, deps) {
306
322
  const id = platformOf(parsedUrl.pathname);
307
323
  if (!id) { notFound(res); return; }
308
- if (!isLocal) { loopbackOnly(res); return; }
324
+ if (!isAdminReq(req, isLocal)) { loopbackOnly(res); return; }
309
325
  readBody(req, deps, (body) => {
310
326
  let incoming;
311
327
  try { incoming = JSON.parse(body); }
@@ -327,13 +343,13 @@ function imAppendSystemPost(req, res, parsedUrl, isLocal, deps) {
327
343
  });
328
344
  }
329
345
 
330
- // 「${IM} SKILL 管理」= 该 IM worker 工作目录下的 .claude/skills/。loopback-only(本地文件操作、admin-only)。
346
+ // "${IM} SKILL management" = the .claude/skills/ under that IM worker's working dir. admin-only (local file ops; loopback, or an authenticated remote admin).
331
347
  // 复用 skills-api 的 listSkills/moveSkill(按 projectDir 参数化)+ skills.js 的 importSkillTo(按 skillsRoot 参数化)。
332
348
  // IM worker 仅在启动时读取 skills,故增删/启停后需重启该 IM worker 才生效(前端提示用户)。
333
349
  function imSkills(req, res, parsedUrl, isLocal, deps) {
334
350
  const id = platformOf(parsedUrl.pathname);
335
351
  if (!id) { notFound(res); return; }
336
- if (!isLocal) { loopbackOnly(res); return; }
352
+ if (!isAdminReq(req, isLocal)) { loopbackOnly(res); return; }
337
353
  try {
338
354
  const dir = imDir(id);
339
355
  // projectDir 与 homeDir 都指向 IM 目录:两次扫描命中同一 .claude/skills(user+project 重复),
@@ -350,7 +366,7 @@ function imSkills(req, res, parsedUrl, isLocal, deps) {
350
366
  function imSkillsToggle(req, res, parsedUrl, isLocal, deps) {
351
367
  const id = platformOf(parsedUrl.pathname);
352
368
  if (!id) { notFound(res); return; }
353
- if (!isLocal) { loopbackOnly(res); return; }
369
+ if (!isAdminReq(req, isLocal)) { loopbackOnly(res); return; }
354
370
  readBody(req, deps, (body) => {
355
371
  let incoming;
356
372
  try { incoming = JSON.parse(body); }
@@ -371,7 +387,7 @@ function imSkillsToggle(req, res, parsedUrl, isLocal, deps) {
371
387
  function imSkillsDelete(req, res, parsedUrl, isLocal, deps) {
372
388
  const id = platformOf(parsedUrl.pathname);
373
389
  if (!id) { notFound(res); return; }
374
- if (!isLocal) { loopbackOnly(res); return; }
390
+ if (!isAdminReq(req, isLocal)) { loopbackOnly(res); return; }
375
391
  readBody(req, deps, (body) => {
376
392
  let incoming;
377
393
  try { incoming = JSON.parse(body); }
@@ -392,7 +408,7 @@ function imSkillsDelete(req, res, parsedUrl, isLocal, deps) {
392
408
  function imSkillsImport(req, res, parsedUrl, isLocal, deps) {
393
409
  const id = platformOf(parsedUrl.pathname);
394
410
  if (!id) { notFound(res); return; }
395
- if (!isLocal) { loopbackOnly(res); return; }
411
+ if (!isAdminReq(req, isLocal)) { loopbackOnly(res); return; }
396
412
  importSkillTo(req, res, { skillsRoot: join(imDir(id), '.claude', 'skills'), windowsReserved: deps.WINDOWS_RESERVED_NAMES });
397
413
  }
398
414
 
@@ -14,6 +14,7 @@ import { readClaudeProjectModel } from '../lib/context-watcher.js';
14
14
  import { sendEventToClients } from '../lib/log-watcher.js';
15
15
  import { listPlatforms } from '../lib/im/im-config.js';
16
16
  import { mutatePrefs, applyPrefsPatch, readPrefsRaw } from '../lib/prefs-store.js';
17
+ import { isAdminReq } from '../lib/is-admin.js';
17
18
  import {
18
19
  getCurrentProjectKey, getCurrentProjectName, hasFork, listForks, resolveScoped,
19
20
  } from '../lib/project-prefs.js';
@@ -52,12 +53,16 @@ export function _resetThemeSyncForTests() { _themeSyncInFlight = false; }
52
53
  function preferencesGet(req, res, parsedUrl, isLocal, deps) {
53
54
  let prefs = {};
54
55
  try { if (existsSync(deps.getPrefsFile())) prefs = JSON.parse(readFileSync(deps.getPrefsFile(), 'utf-8')); } catch { }
56
+ // isAdmin = loopback OR authenticated remote (container/cloud admin); _isLocal stays the
57
+ // physical loopback fact. Computed once up-front so every admin gate below (fork keys,
58
+ // executable key, _isAdmin echo) uses the SAME value.
59
+ const isAdmin = isAdminReq(req, isLocal);
55
60
  // auth 配置(含明文密码)与偏好同存于 preferences.json,但绝不能从这里下发 ——
56
61
  // 密码仅由 admin-only 的 /api/auth/state 暴露给本机。否则远程密码登录用户也能读到明文。
57
62
  // 全局 auth 与每个项目的 authByProject 覆盖都要剥离(后者同样含明文密码)。
58
63
  delete prefs.auth;
59
64
  delete prefs.authByProject;
60
- if (!isLocal || !isSameOriginBrowserRequest(req, parsedUrl)) {
65
+ if (!isAdmin || !isSameOriginBrowserRequest(req, parsedUrl)) {
61
66
  delete prefs[CLAUDE_EXECUTABLE_PREF_KEY];
62
67
  }
63
68
  stripImConfigs(prefs); // dingtalk / feishu / … — admin-only, never to a LAN client
@@ -65,8 +70,10 @@ function preferencesGet(req, res, parsedUrl, isLocal, deps) {
65
70
  // 解析出该项目的有效偏好;本机(admin)始终看全局。forks blob 绝不下发,仅以 _projectPrefsKeys
66
71
  // 元信息告知本机管理入口该不该出现。元字段以 _ 前缀标记,POST 侧会剥离不落盘。
67
72
  const _projectKey = getCurrentProjectKey();
73
+ // _scoped keys on isLocal (physical loopback): a remote admin still gets the scoped project
74
+ // view like other remote users — only the MANAGE capability (_projectPrefsKeys) is admin-gated.
68
75
  const _scoped = !isLocal && hasFork(prefs, _projectKey);
69
- const _forkKeys = isLocal ? listForks(prefs) : null; // 计算需早于 delete prefsByProject
76
+ const _forkKeys = isAdmin ? listForks(prefs) : null; // 计算需早于 delete prefsByProject; admin(本机或远程)可见 fork 列表
70
77
  const _fork = _scoped ? prefs.prefsByProject[_projectKey] : null;
71
78
  delete prefs.prefsByProject;
72
79
  if (_scoped) {
@@ -90,11 +97,14 @@ function preferencesGet(req, res, parsedUrl, isLocal, deps) {
90
97
  prefs.approvalModal.voicePack = vpReconcile(LOG_DIR, prefs.approvalModal.voicePack);
91
98
  }
92
99
  // 项目独立配置元信息(_ 前缀 = 仅回包、不落盘;POST 侧统一剥离):前端据此决定
93
- // 非本机显示"启动项目独立配置"开关、本机在有 fork 时显示"配置管理"入口。
100
+ // 非本机显示"启动项目独立配置"开关、admin(本机或已鉴权远程)在有 fork 时显示"配置管理"入口。
101
+ // _isAdmin = loopback OR authenticated remote (container/cloud admin); _isLocal stays the
102
+ // physical loopback fact (drives remote-only affordances like "Download to local").
94
103
  prefs._isLocal = isLocal;
104
+ prefs._isAdmin = isAdmin;
95
105
  prefs._projectName = getCurrentProjectName();
96
106
  prefs._projectScoped = _scoped;
97
- if (isLocal) prefs._projectPrefsKeys = _forkKeys;
107
+ if (isAdmin) prefs._projectPrefsKeys = _forkKeys;
98
108
  res.writeHead(200, { 'Content-Type': 'application/json' });
99
109
  res.end(JSON.stringify(prefs));
100
110
  }
@@ -119,9 +129,9 @@ function preferencesPost(req, res, parsedUrl, isLocal, deps) {
119
129
  delete incoming.auth;
120
130
  delete incoming.authByProject;
121
131
  // Claude executable selection controls which local program CCV executes.
122
- // It is machine-global and therefore writable only by the loopback admin,
123
- // never by a remote/project-scoped preferences client.
124
- if (!isLocal) {
132
+ // It is machine-global and therefore writable only by an admin (loopback, or an
133
+ // authenticated remote admin) — never by an unauthenticated remote/project client.
134
+ if (!isAdminReq(req, isLocal)) {
125
135
  delete incoming[CLAUDE_EXECUTABLE_PREF_KEY];
126
136
  } else if (Object.prototype.hasOwnProperty.call(incoming, CLAUDE_EXECUTABLE_PREF_KEY)) {
127
137
  const value = incoming[CLAUDE_EXECUTABLE_PREF_KEY];
@@ -211,7 +221,7 @@ function preferencesPost(req, res, parsedUrl, isLocal, deps) {
211
221
  delete prefs.auth;
212
222
  delete prefs.authByProject;
213
223
  delete prefs.prefsByProject; // fork blob 绝不回显(与 GET 一致)
214
- if (!isLocal || !isSameOriginBrowserRequest(req, parsedUrl)) {
224
+ if (!isAdminReq(req, isLocal) || !isSameOriginBrowserRequest(req, parsedUrl)) {
215
225
  delete prefs[CLAUDE_EXECUTABLE_PREF_KEY];
216
226
  }
217
227
  stripImConfigs(prefs);
@@ -228,9 +238,10 @@ function preferencesPost(req, res, parsedUrl, isLocal, deps) {
228
238
  }
229
239
 
230
240
  function claudeExecutablesGet(req, res, parsedUrl, isLocal, deps) {
231
- if (!isLocal || !isSameOriginBrowserRequest(req, parsedUrl)) {
241
+ const isAdmin = isAdminReq(req, isLocal);
242
+ if (!isAdmin || !isSameOriginBrowserRequest(req, parsedUrl)) {
232
243
  res.writeHead(403, { 'Content-Type': 'application/json' });
233
- res.end(JSON.stringify({ error: isLocal ? 'Same-origin request required' : 'Loopback only' }));
244
+ res.end(JSON.stringify({ error: isAdmin ? 'Same-origin request required' : 'Loopback only' }));
234
245
  return;
235
246
  }
236
247
  let configuredPath = null;
@@ -406,11 +417,12 @@ function retryConfigGet(req, res, _parsedUrl, _isLocal, _deps) {
406
417
  }
407
418
 
408
419
  function retryConfigPost(req, res, _parsedUrl, isLocal, deps) {
409
- // Local-only, mirroring ccswitchImportPost: retry config is written to a
420
+ // Admin-only, mirroring ccswitchImportPost: retry config is written to a
410
421
  // cross-process file every ccv instance hot-reloads, and race/stagger with
411
- // high concurrency multiplies the HOST's paid upstream requests — a LAN
412
- // client must not be able to amplify the host's API spend machine-wide.
413
- if (!isLocal) {
422
+ // high concurrency multiplies the HOST's paid upstream requests — an unauthenticated
423
+ // LAN client must not be able to amplify the host's API spend machine-wide.
424
+ // An authenticated remote admin (container/cloud) is trusted like the loopback admin.
425
+ if (!isAdminReq(req, isLocal)) {
414
426
  res.writeHead(403, { 'Content-Type': 'application/json' });
415
427
  res.end(JSON.stringify({ ok: false, error: 'retry config is local-only' }));
416
428
  return;
@@ -474,7 +486,7 @@ async function ccswitchProvidersGet(req, res, _parsedUrl, isLocal, _deps) {
474
486
  // POST body 可选 { setActive: true } —— 是否把 cc-switch 的 current 设为 cc-viewer active。
475
487
  // 默认不设(避免覆盖用户当前选择)。导入只 merge 列表,凭证刷新幂等。
476
488
  async function ccswitchImportPost(req, res, _parsedUrl, isLocal, deps) {
477
- if (!isLocal) {
489
+ if (!isAdminReq(req, isLocal)) {
478
490
  res.writeHead(403, { 'Content-Type': 'application/json' });
479
491
  res.end(JSON.stringify({ ok: false, error: 'cc-switch import is local-only' }));
480
492
  return;
@@ -14,6 +14,7 @@ import { basename } from 'node:path';
14
14
  import { LOG_DIR } from '../../findcc.js';
15
15
  import { reconcileVoicePackPrefs as vpReconcile } from '../lib/voice-pack-manager.js';
16
16
  import { mutatePrefs, applyPrefsPatch, readPrefsRaw } from '../lib/prefs-store.js';
17
+ import { isAdminReq } from '../lib/is-admin.js';
17
18
  import {
18
19
  getCurrentProjectKey, hasActiveProject, snapshotForFork, stripSensitive,
19
20
  } from '../lib/project-prefs.js';
@@ -73,7 +74,7 @@ async function updateHandler(req, res, parsedUrl, isLocal, deps) {
73
74
  const hasProject = typeof body.project === 'string' && body.project;
74
75
  let key;
75
76
  if (hasProject) {
76
- if (!isLocal) return sendJson(res, 403, { error: 'forbidden' });
77
+ if (!isAdminReq(req, isLocal)) return sendJson(res, 403, { error: 'forbidden' });
77
78
  key = body.project;
78
79
  } else {
79
80
  if (!hasActiveProject()) return sendJson(res, 409, { error: 'no-active-project' });
@@ -91,9 +92,9 @@ async function updateHandler(req, res, parsedUrl, isLocal, deps) {
91
92
  sendJson(res, 200, { ok: true });
92
93
  }
93
94
 
94
- // POST /api/project-prefs/delete — remove a fork. Loopback admin only.
95
+ // POST /api/project-prefs/delete — remove a fork. Admin only (loopback or authenticated remote).
95
96
  async function deleteHandler(req, res, parsedUrl, isLocal, deps) {
96
- if (!isLocal) return sendJson(res, 403, { error: 'forbidden' });
97
+ if (!isAdminReq(req, isLocal)) return sendJson(res, 403, { error: 'forbidden' });
97
98
  let body;
98
99
  try { body = await readBody(req, deps); } catch { return sendJson(res, 400, { error: 'Invalid JSON' }); }
99
100
  const key = body.project;
@@ -107,9 +108,9 @@ async function deleteHandler(req, res, parsedUrl, isLocal, deps) {
107
108
  sendJson(res, 200, { ok: true });
108
109
  }
109
110
 
110
- // GET /api/project-prefs — every fork + contents (for the loopback management modal).
111
+ // GET /api/project-prefs — every fork + contents (for the admin management modal).
111
112
  function listHandler(req, res, parsedUrl, isLocal, deps) {
112
- if (!isLocal) return sendJson(res, 403, { error: 'forbidden' });
113
+ if (!isAdminReq(req, isLocal)) return sendJson(res, 403, { error: 'forbidden' });
113
114
  const prefs = readPrefsRaw();
114
115
  const forks = (prefs && prefs.prefsByProject && typeof prefs.prefsByProject === 'object') ? prefs.prefsByProject : {};
115
116
  const currentKey = getCurrentProjectKey();