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
@@ -2,7 +2,8 @@
2
2
  import { mkdirSync, writeFileSync, existsSync } from 'node:fs';
3
3
  import { join, resolve, sep, dirname } from 'node:path';
4
4
  import { getClaudeConfigDir } from '../../findcc.js';
5
- import { listSkills, moveSkill, deleteSkill, validateSkillName } from '../lib/skills-api.js';
5
+ import { listSkills, moveSkill, deleteSkill, validateSkillName, parseSkillFrontmatter } from '../lib/skills-api.js';
6
+ import { isAdminReq } from '../lib/is-admin.js';
6
7
 
7
8
  async function skillsList(req, res) {
8
9
  try {
@@ -44,10 +45,12 @@ function skillsToggle(req, res) {
44
45
  });
45
46
  }
46
47
 
47
- // Skill 永久删除 —— 把单个 skill 文件夹从 skills/ 或 skills-skip/ 彻底删除(不可恢复)
48
- // loopback-only:不可逆 rmSync 不向局域网暴露(toggle 是可逆 move,这里更严)。
48
+ // Permanently delete a skill — rmSync the skill folder from skills/ or skills-skip/ (irreversible).
49
+ // admin-only: an irreversible rmSync is not exposed to unauthenticated LAN clients (toggle is a
50
+ // reversible move — this is stricter); an authenticated remote admin (container/cloud) is allowed
51
+ // the same as the loopback admin.
49
52
  function skillsDelete(req, res, parsedUrl, isLocal) {
50
- if (!isLocal) {
53
+ if (!isAdminReq(req, isLocal)) {
51
54
  res.writeHead(403, { 'Content-Type': 'application/json' });
52
55
  res.end(JSON.stringify({ error: 'Loopback only' }));
53
56
  return;
@@ -78,22 +81,35 @@ function skillsDelete(req, res, parsedUrl, isLocal) {
78
81
  // 设计要点(与原实现一致):
79
82
  // · 扩展名白名单只放 zip / md(忽略大小写);其他类型 415;
80
83
  // · zip 内必须含 SKILL.md(任意子目录、忽略大小写),取最浅的那个所在目录作为 skill 根;
81
- // · skill 名优先取 SKILL.md frontmatter 的 name,回落 zip 根目录名 / 文件名(去扩展名);
84
+ // · Strict spec check (unified across all three entries): SKILL.md frontmatter must contain
85
+ // a name (must pass validateSkillName) and a description; violations reject with
86
+ // INVALID_FRONTMATTER / MISSING_NAME / INVALID_NAME / MISSING_DESCRIPTION — no more
87
+ // filename / zip-root-dir fallback;
82
88
  // · zip bomb 防护:单文件 ≤50MB,总解压 ≤200MB;拒绝 symlink entry。
83
89
 
84
- const parseNameFromMd = (text) => {
85
- const m = /^---\s*\n([\s\S]*?)\n---/.exec(text);
86
- if (!m) return null;
87
- const nm = /^name\s*:\s*(.*)$/m.exec(m[1]);
88
- if (!nm) return null;
89
- return nm[1].trim().replace(/^["']|["']$/g, '');
90
- };
90
+ // Shared size caps for the zip path (declared-size pass + actual-size re-check).
91
+ const MAX_SKILL_FILE = 50 * 1024 * 1024;
92
+ const MAX_SKILL_TOTAL = 200 * 1024 * 1024;
91
93
 
92
- const fallbackBaseName = (filename, stripExt) => {
93
- let n = filename.replace(/^.*[\\/]/, '');
94
- if (stripExt) n = n.replace(/\.[^.]+$/, '');
95
- return n;
96
- };
94
+ // Strict skill-spec validation shared by all three import entries (folder / zip / SKILL.md).
95
+ // Check order is fixed (tests depend on it): no frontmatter block → INVALID_FRONTMATTER;
96
+ // missing name → MISSING_NAME; invalid name → INVALID_NAME; missing description → MISSING_DESCRIPTION.
97
+ function requireSkillSpec(text) {
98
+ const fm = parseSkillFrontmatter(text);
99
+ if (!fm) {
100
+ throw Object.assign(new Error('SKILL.md is missing valid frontmatter'), { status: 400, code: 'INVALID_FRONTMATTER' });
101
+ }
102
+ if (!fm.name) {
103
+ throw Object.assign(new Error('SKILL.md frontmatter is missing a name'), { status: 400, code: 'MISSING_NAME' });
104
+ }
105
+ if (!validateSkillName(fm.name)) {
106
+ throw Object.assign(new Error(`Invalid skill name: ${fm.name}`), { status: 400, code: 'INVALID_NAME' });
107
+ }
108
+ if (!fm.description) {
109
+ throw Object.assign(new Error('SKILL.md frontmatter is missing a description'), { status: 400, code: 'MISSING_DESCRIPTION' });
110
+ }
111
+ return fm;
112
+ }
97
113
 
98
114
  /**
99
115
  * 解析一段 multipart body(已组装好的 Buffer)→ { skillName, files: [{relPath, data}] }。
@@ -133,7 +149,7 @@ export async function parseSkillUpload(buf, boundary, windowsReservedRe) {
133
149
 
134
150
  if (isMd) {
135
151
  const text = fileData.toString('utf8');
136
- skillName = parseNameFromMd(text) || fallbackBaseName(originalName, true);
152
+ skillName = requireSkillSpec(text).name;
137
153
  skillFiles = [{ relPath: 'SKILL.md', data: fileData }];
138
154
  } else {
139
155
  const AdmZip = (await import('adm-zip')).default;
@@ -144,8 +160,6 @@ export async function parseSkillUpload(buf, boundary, windowsReservedRe) {
144
160
  throw Object.assign(new Error('Invalid zip archive'), { status: 400, code: 'INVALID_ZIP' });
145
161
  }
146
162
  const entries = zip.getEntries();
147
- const MAX_PER_FILE = 50 * 1024 * 1024;
148
- const MAX_TOTAL_UNCOMPRESSED = 200 * 1024 * 1024;
149
163
  let totalUncompressed = 0;
150
164
  for (const e of entries) {
151
165
  if (e.isDirectory) continue;
@@ -154,11 +168,11 @@ export async function parseSkillUpload(buf, boundary, windowsReservedRe) {
154
168
  throw Object.assign(new Error('Symlinks not allowed in zip'), { status: 400, code: 'INVALID_ZIP' });
155
169
  }
156
170
  const sizeRaw = e.header?.size || 0;
157
- if (sizeRaw > MAX_PER_FILE) {
171
+ if (sizeRaw > MAX_SKILL_FILE) {
158
172
  throw Object.assign(new Error('File too large in archive'), { status: 400, code: 'ZIP_BOMB' });
159
173
  }
160
174
  totalUncompressed += sizeRaw;
161
- if (totalUncompressed > MAX_TOTAL_UNCOMPRESSED) {
175
+ if (totalUncompressed > MAX_SKILL_TOTAL) {
162
176
  throw Object.assign(new Error('Archive expands too large'), { status: 400, code: 'ZIP_BOMB' });
163
177
  }
164
178
  }
@@ -176,12 +190,16 @@ export async function parseSkillUpload(buf, boundary, windowsReservedRe) {
176
190
  if (!bestSkillEntry) {
177
191
  throw Object.assign(new Error('SKILL.md not found in zip'), { status: 400, code: 'MISSING_SKILL_MD' });
178
192
  }
193
+ // Guard BEFORE inflating: header.size is attacker-controlled, but a declared oversize at
194
+ // least lets us reject before spending memory on getData() (actual-size re-check below
195
+ // still catches a lying small header).
196
+ if ((bestSkillEntry.header?.size || 0) > MAX_SKILL_FILE) {
197
+ throw Object.assign(new Error('File too large in archive'), { status: 400, code: 'ZIP_BOMB' });
198
+ }
179
199
  const lastSlash = bestSkillEntry.entryName.lastIndexOf('/');
180
200
  const skillRootPrefix = lastSlash >= 0 ? bestSkillEntry.entryName.slice(0, lastSlash + 1) : '';
181
201
  const skillMdText = bestSkillEntry.getData().toString('utf8');
182
- skillName = parseNameFromMd(skillMdText)
183
- || (skillRootPrefix ? skillRootPrefix.replace(/\/$/, '').split('/').pop() : null)
184
- || fallbackBaseName(originalName, true);
202
+ skillName = requireSkillSpec(skillMdText).name;
185
203
 
186
204
  // 二次校验:header.size 来自 zip 中央目录是攻击者可控的(可谎报 size=0),用真实 data.length 复核。
187
205
  let actualTotal = 0;
@@ -191,11 +209,11 @@ export async function parseSkillUpload(buf, boundary, windowsReservedRe) {
191
209
  const rel = skillRootPrefix ? e.entryName.slice(skillRootPrefix.length) : e.entryName;
192
210
  if (!rel || rel.includes('..')) continue;
193
211
  const data = e.getData();
194
- if (data.length > MAX_PER_FILE) {
212
+ if (data.length > MAX_SKILL_FILE) {
195
213
  throw Object.assign(new Error('File actual size too large'), { status: 400, code: 'ZIP_BOMB' });
196
214
  }
197
215
  actualTotal += data.length;
198
- if (actualTotal > MAX_TOTAL_UNCOMPRESSED) {
216
+ if (actualTotal > MAX_SKILL_TOTAL) {
199
217
  throw Object.assign(new Error('Archive actual size too large'), { status: 400, code: 'ZIP_BOMB' });
200
218
  }
201
219
  const finalRel = rel.split('/').pop().toLowerCase() === 'skill.md'
@@ -205,6 +223,8 @@ export async function parseSkillUpload(buf, boundary, windowsReservedRe) {
205
223
  }
206
224
  }
207
225
 
226
+ // defense-in-depth: requireSkillSpec already validated the name (and would have
227
+ // thrown first); keep this re-check so future refactors can't silently skip it.
208
228
  if (!validateSkillName(skillName)) {
209
229
  throw Object.assign(new Error(`Invalid skill name: ${skillName}`), { status: 400, code: 'INVALID_NAME' });
210
230
  }
@@ -261,7 +281,8 @@ export function importSkillTo(req, res, { skillsRoot, windowsReserved }) {
261
281
  const contentLength = parseInt(req.headers['content-length'] || '0', 10);
262
282
  if (contentLength > MAX_UPLOAD) {
263
283
  res.writeHead(413, { 'Content-Type': 'application/json' });
264
- res.end(JSON.stringify({ error: 'File too large (max 100MB)' }));
284
+ // code 字段供前端 skillImportErrorKey 映射成 i18n 文案,而不是裸英文
285
+ res.end(JSON.stringify({ error: 'File too large (max 100MB)', code: 'TOO_LARGE' }));
265
286
  return;
266
287
  }
267
288
  const boundary = boundaryMatch[1].trim().replace(/^["']|["']$/g, '');
@@ -273,7 +294,7 @@ export function importSkillTo(req, res, { skillsRoot, windowsReserved }) {
273
294
  if (totalSize > MAX_UPLOAD) {
274
295
  aborted = true;
275
296
  res.writeHead(413, { 'Content-Type': 'application/json' });
276
- res.end(JSON.stringify({ error: 'File too large (max 100MB)' }));
297
+ res.end(JSON.stringify({ error: 'File too large (max 100MB)', code: 'TOO_LARGE' }));
277
298
  req.destroy();
278
299
  return;
279
300
  }
@@ -1,6 +1,6 @@
1
1
  // Voice-pack routes (moved verbatim from server.js handleRequest).
2
2
  // Manages user-uploaded audio + serves the bundled "皇上系列" default pack.
3
- // Uploads are loopback-only — LAN clients can play but not write.
3
+ // Uploads/deletes are admin-only (loopback, or authenticated remote admin) — other clients can play but not write.
4
4
  import { lstatSync, statSync, createReadStream } from 'node:fs';
5
5
  import { LOG_DIR } from '../../findcc.js';
6
6
  import {
@@ -18,6 +18,7 @@ import {
18
18
  EVENT_KEYS as VP_EVENT_KEYS,
19
19
  MAX_AUDIO_BYTES as VP_MAX_BYTES,
20
20
  } from '../lib/voice-pack-manager.js';
21
+ import { isAdminReq } from '../lib/is-admin.js';
21
22
 
22
23
  function voicePackList(req, res) {
23
24
  try {
@@ -45,10 +46,10 @@ function voicePackList(req, res) {
45
46
  }
46
47
 
47
48
  function voicePackUpload(req, res, parsedUrl, isLocal) {
48
- // Loopback-only — refuse LAN clients even if they hold a valid token.
49
- // The token already gates LAN access but voice-pack writes touch the local FS
50
- // and end up reachable from every client; keep the write side strictly local.
51
- if (!isLocal) {
49
+ // Admin-only — refuse unauthenticated LAN clients even if they hold a valid token.
50
+ // The token already gates LAN access but voice-pack writes touch the local FS and end up
51
+ // reachable from every client; an authenticated remote admin (container/cloud) is allowed.
52
+ if (!isAdminReq(req, isLocal)) {
52
53
  res.writeHead(403, { 'Content-Type': 'application/json' });
53
54
  res.end(JSON.stringify({ error: 'Upload allowed from loopback only' }));
54
55
  return;
@@ -106,7 +107,7 @@ function voicePackUpload(req, res, parsedUrl, isLocal) {
106
107
  }
107
108
 
108
109
  function voicePackDelete(req, res, parsedUrl, isLocal) {
109
- if (!isLocal) {
110
+ if (!isAdminReq(req, isLocal)) {
110
111
  res.writeHead(403, { 'Content-Type': 'application/json' });
111
112
  res.end(JSON.stringify({ error: 'Delete allowed from loopback only' }));
112
113
  return;
package/server/server.js CHANGED
@@ -757,17 +757,27 @@ async function handleRequest(req, res) {
757
757
  const isLocal = remoteIp === '127.0.0.1' || remoteIp === '::1' || remoteIp === '::ffff:127.0.0.1';
758
758
  const isStaticAsset = url.startsWith('/assets/') || url === '/favicon.ico';
759
759
  const wantsHtml = method === 'GET' && ((req.headers.accept || '').includes('text/html') || url === '/');
760
+ // Hoisted so decideAuth and the isAdmin computation share the SAME credential values.
761
+ const urlToken = parsedUrl.searchParams.get('token');
762
+ const cookieToken = parseCookies(req.headers.cookie).ccv_auth;
760
763
  const authDecision = decideAuth({
761
764
  isStaticAsset,
762
765
  pathname: url,
763
766
  isLocal,
764
- urlToken: parsedUrl.searchParams.get('token'),
765
- cookieToken: parseCookies(req.headers.cookie).ccv_auth,
767
+ urlToken,
768
+ cookieToken,
766
769
  accessToken: ACCESS_TOKEN,
767
770
  enabled: authConfig.enabled,
768
771
  password: authConfig.password,
769
772
  wantsHtml,
770
773
  });
774
+ // Remote-admin elevation (container/cloud deploy): an authenticated remote caller is
775
+ // treated as admin for management routes (isAdminReq in lib/is-admin.js). isAdmin comes
776
+ // straight from decideAuth (single source of truth — it mirrors the remote-allow branches
777
+ // and can never drift from them). Loopback is always admin. This MUST NOT relax the
778
+ // internal machine bridges (events.js hook notifies, ask-perm.js streamChunk) — those
779
+ // stay strictly loopback + INTERNAL_TOKEN.
780
+ req.ccvIsAdmin = authDecision.isAdmin === true;
771
781
  if (authDecision.action === 'login-page') {
772
782
  const lang = localeFromAcceptLanguage(req.headers['accept-language']) || getLang();
773
783
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });