cc-viewer 1.8.8 → 1.8.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/dist/assets/{App-CTaCMVVr.js → App-DJIVcbMa.js} +2 -2
- package/dist/assets/{MdxEditorPanel-C7CVP-2e.js → MdxEditorPanel-CTCAo0Gg.js} +1 -1
- package/dist/assets/{Mobile-RyXw8Gij.js → Mobile-Uo_qQRwl.js} +1 -1
- package/dist/assets/{ProxyStatsModal-DXeL8D0S.js → ProxyStatsModal-BnPXrvTb.js} +1 -1
- package/dist/assets/{index-TdQY_HcT.js → index-C4Jei4gi.js} +2 -2
- package/dist/assets/{seqResourceLoaders-C0YUbkfX.css → seqResourceLoaders-1EWxJwEP.css} +1 -1
- package/dist/assets/{seqResourceLoaders-DEu-YNy3.js → seqResourceLoaders-DfTPNrje.js} +2 -2
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/lib/auth.js +20 -4
- package/server/lib/is-admin.js +27 -0
- package/server/routes/auth.js +11 -9
- package/server/routes/dingtalk.js +23 -8
- package/server/routes/im.js +36 -20
- package/server/routes/preferences.js +27 -15
- package/server/routes/project-prefs.js +6 -5
- package/server/routes/skills.js +6 -3
- package/server/routes/voice-pack.js +7 -6
- package/server/server.js +12 -2
package/dist/index.html
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// 整体显示大小已弃用 CSS zoom:Electron 改用 webFrame.setZoomFactor(首屏抢占见
|
|
22
22
|
// electron/tab-content-preload.js),纯浏览器交由用户用浏览器自带快捷键缩放,故此处不再设 zoom。
|
|
23
23
|
</script>
|
|
24
|
-
<script type="module" crossorigin src="./assets/index-
|
|
24
|
+
<script type="module" crossorigin src="./assets/index-C4Jei4gi.js"></script>
|
|
25
25
|
<link rel="modulepreload" crossorigin href="./assets/vendor-antd-FhK5cQx7.js">
|
|
26
26
|
<link rel="modulepreload" crossorigin href="./assets/vendor-virtuoso-B_7lc2Ot.js">
|
|
27
27
|
<link rel="modulepreload" crossorigin href="./assets/vendor-codemirror-DL3yyKRQ.js">
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cc-viewer",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.10",
|
|
4
4
|
"description": "Claude Code logging, visualization, and management toolkit — launch a web viewer alongside Claude Code with full request/response tracing, proxy, and mobile support",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "server.js",
|
package/server/lib/auth.js
CHANGED
|
@@ -218,10 +218,18 @@ export function parseCookies(header) {
|
|
|
218
218
|
* urlToken, cookieToken, accessToken,
|
|
219
219
|
* enabled, password, wantsHtml
|
|
220
220
|
* }
|
|
221
|
-
* → { action: 'allow' | 'login-page' | 'unauthorized' | 'forbidden' }
|
|
221
|
+
* → { action: 'allow' | 'login-page' | 'unauthorized' | 'forbidden', isAdmin: boolean }
|
|
222
222
|
*
|
|
223
223
|
* Caller invariants (see plan): 'login-page' / 'unauthorized' / 'forbidden' MUST
|
|
224
224
|
* terminate the request; only 'allow' falls through to the Host allowlist + routing.
|
|
225
|
+
*
|
|
226
|
+
* `isAdmin` = loopback OR an authenticated remote (valid urlToken / cookieToken). It is computed
|
|
227
|
+
* from the credential-bearing allow branches, so the two can never drift apart — server.js uses
|
|
228
|
+
* it to elevate management routes (see lib/is-admin.js). Static-asset, /api/auth/login, AND the
|
|
229
|
+
* empty-password branch are allow-exempt but NOT admin: empty-password mode means "no read
|
|
230
|
+
* protection", but destructive/admin actions (delete skills, change the password, adjust
|
|
231
|
+
* retry/proxy config that multiplies the host's paid upstream spend) must NOT be opened to every
|
|
232
|
+
* unauthenticated LAN/internet client — those stay credential-or-loopback gated even in open mode.
|
|
225
233
|
*/
|
|
226
234
|
export function decideAuth(ctx) {
|
|
227
235
|
const {
|
|
@@ -230,6 +238,14 @@ export function decideAuth(ctx) {
|
|
|
230
238
|
enabled, password, wantsHtml,
|
|
231
239
|
} = ctx;
|
|
232
240
|
|
|
241
|
+
// Admin = the credential-bearing / trusted-peer allow branches. Deliberately EXCLUDES the
|
|
242
|
+
// empty-password allow branch: "no read protection" must not become "everyone is admin".
|
|
243
|
+
// Single source of truth for both gating and remote-admin elevation.
|
|
244
|
+
const isAdmin =
|
|
245
|
+
isLocal === true ||
|
|
246
|
+
urlToken === accessToken ||
|
|
247
|
+
cookieToken === accessToken;
|
|
248
|
+
|
|
233
249
|
if (
|
|
234
250
|
isStaticAsset ||
|
|
235
251
|
pathname === '/api/auth/login' || // login endpoint must be reachable unauthenticated
|
|
@@ -238,16 +254,16 @@ export function decideAuth(ctx) {
|
|
|
238
254
|
cookieToken === accessToken ||
|
|
239
255
|
(enabled && password === '') // empty password = explicitly no protection
|
|
240
256
|
) {
|
|
241
|
-
return { action: 'allow' };
|
|
257
|
+
return { action: 'allow', isAdmin };
|
|
242
258
|
}
|
|
243
259
|
|
|
244
260
|
if (enabled) {
|
|
245
261
|
// Password auth on but no valid credential: HTML navigations get the login page,
|
|
246
262
|
// XHR/asset/WS get a plain 401.
|
|
247
|
-
return { action: wantsHtml ? 'login-page' : 'unauthorized' };
|
|
263
|
+
return { action: wantsHtml ? 'login-page' : 'unauthorized', isAdmin };
|
|
248
264
|
}
|
|
249
265
|
// Password auth off → preserve original token-only behaviour (403).
|
|
250
|
-
return { action: 'forbidden' };
|
|
266
|
+
return { action: 'forbidden', isAdmin };
|
|
251
267
|
}
|
|
252
268
|
|
|
253
269
|
function escapeHtml(s) {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Remote-admin elevation for management routes (container / cloud deployment).
|
|
2
|
+
//
|
|
3
|
+
// cc-viewer's sensitive management endpoints are gated on `isLocal` (loopback socket
|
|
4
|
+
// peer) — a model that assumes the admin sits on the host machine. In a single-container
|
|
5
|
+
// or cloud deployment the admin's browser is remote, so `isLocal` is false and every
|
|
6
|
+
// management call 403s even after the caller has authenticated (password-login cookie or
|
|
7
|
+
// `?token=` URL).
|
|
8
|
+
//
|
|
9
|
+
// `server.js handleRequest` therefore computes `isAdmin = isLocal || authenticated-remote`
|
|
10
|
+
// (mirroring decideAuth's remote-allow branches) and attaches it as `req.ccvIsAdmin`
|
|
11
|
+
// before dispatch. Management handlers call `isAdminReq(req, isLocal)` instead of a bare
|
|
12
|
+
// `isLocal` check so an authenticated remote admin is treated like the loopback admin.
|
|
13
|
+
//
|
|
14
|
+
// IMPORTANT: this must NOT be used for the internal machine bridges (events.js hook
|
|
15
|
+
// notifies, ask-perm.js streamChunk) — those carry INTERNAL_TOKEN from the local claude
|
|
16
|
+
// child process and must stay strictly loopback. Only human-facing management routes use
|
|
17
|
+
// isAdminReq.
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Is this request from an admin — loopback, or an authenticated remote client?
|
|
21
|
+
* @param {object} req the incoming request (server.js sets req.ccvIsAdmin after auth)
|
|
22
|
+
* @param {boolean} isLocal loopback socket peer (the dispatch-threaded flag)
|
|
23
|
+
* @returns {boolean}
|
|
24
|
+
*/
|
|
25
|
+
export function isAdminReq(req, isLocal) {
|
|
26
|
+
return isLocal === true || req?.ccvIsAdmin === true;
|
|
27
|
+
}
|
package/server/routes/auth.js
CHANGED
|
@@ -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
|
|
5
|
-
// POST /api/auth/config — admin-only (
|
|
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
|
-
//
|
|
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
|
|
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
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// POST /api/dingtalk/
|
|
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
|
-
//
|
|
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
|
-
//
|
|
59
|
-
|
|
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;
|
package/server/routes/im.js
CHANGED
|
@@ -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
|
-
//
|
|
5
|
-
//
|
|
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 —
|
|
10
|
-
// POST /api/im/:platform/process —
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
286
|
-
// --append-system-prompt-file
|
|
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
|
-
//
|
|
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 (!
|
|
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 =
|
|
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
|
-
// 非本机显示"启动项目独立配置"
|
|
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 (
|
|
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
|
|
123
|
-
// never by
|
|
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
|
-
|
|
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:
|
|
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
|
-
//
|
|
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 —
|
|
412
|
-
// client must not be able to amplify the host's API spend machine-wide.
|
|
413
|
-
|
|
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.
|
|
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
|
|
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();
|
package/server/routes/skills.js
CHANGED
|
@@ -3,6 +3,7 @@ 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
5
|
import { listSkills, moveSkill, deleteSkill, validateSkillName } 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
|
-
//
|
|
48
|
-
//
|
|
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;
|
|
@@ -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
|
|
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
|
-
//
|
|
49
|
-
// The token already gates LAN access but voice-pack writes touch the local FS
|
|
50
|
-
//
|
|
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
|
|
765
|
-
cookieToken
|
|
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' });
|