smart-home-engine 0.10.4 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/web/assets/{index-DkhtWYJx.css → index-DZTaIKZS.css} +1 -1
- package/dist/web/assets/index-G6QfHETZ.js +212 -0
- package/dist/web/assets/{tsMode-THvwQw-l.js → tsMode-BU_qnlmu.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +4 -1
- package/src/config.js +3 -0
- package/src/index.js +9 -1
- package/src/web/ai-api.js +76 -7
- package/src/web/auth.js +186 -0
- package/src/web/log-ws.js +9 -2
- package/src/web/server.js +21 -12
- package/dist/web/assets/index-Bdf2J0nm.js +0 -140
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{gU as O}from"./monaco-langs-DZ6hB11b.js";import{t as I}from"./index-
|
|
1
|
+
import{gU as O}from"./monaco-langs-DZ6hB11b.js";import{t as I}from"./index-G6QfHETZ.js";/*!-----------------------------------------------------------------------------
|
|
2
2
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
3
3
|
* Version: 0.52.2(404545bded1df6ffa41ea0af4e8ddb219018c6c1)
|
|
4
4
|
* Released under the MIT license
|
package/dist/web/index.html
CHANGED
|
@@ -153,10 +153,10 @@
|
|
|
153
153
|
}
|
|
154
154
|
})();
|
|
155
155
|
</script>
|
|
156
|
-
<script type="module" crossorigin src="/assets/index-
|
|
156
|
+
<script type="module" crossorigin src="/assets/index-G6QfHETZ.js"></script>
|
|
157
157
|
<link rel="modulepreload" crossorigin href="/assets/monaco-langs-DZ6hB11b.js">
|
|
158
158
|
<link rel="stylesheet" crossorigin href="/assets/monaco-langs-DyX1CsEw.css">
|
|
159
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
159
|
+
<link rel="stylesheet" crossorigin href="/assets/index-DZTaIKZS.css">
|
|
160
160
|
</head>
|
|
161
161
|
<body>
|
|
162
162
|
<div id="app"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "smart-home-engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Node.js based script runner for use in MQTT based Smart Home environments",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"author": "Sebastian \u0027hobbyquaker\u0027 Raff \u003chobbyquaker@gmail.com\u003e",
|
|
27
27
|
"license": "MIT",
|
|
28
28
|
"dependencies": {
|
|
29
|
+
"bcryptjs": "^2.4.3",
|
|
29
30
|
"@elastic/elasticsearch": "^9.4.2",
|
|
30
31
|
"@influxdata/influxdb-client": "^1.35.0",
|
|
31
32
|
"@matter/main": "^0.17.0",
|
|
@@ -84,3 +85,5 @@
|
|
|
84
85
|
},
|
|
85
86
|
"homepage": "https://github.com/hobbyquaker/she"
|
|
86
87
|
}
|
|
88
|
+
|
|
89
|
+
|
package/src/config.js
CHANGED
package/src/index.js
CHANGED
|
@@ -53,9 +53,17 @@ log.info('she ' + pkg.version + ' starting');
|
|
|
53
53
|
log.debug('loaded config: ', config);
|
|
54
54
|
|
|
55
55
|
if (typeof config.port !== 'undefined') {
|
|
56
|
+
// Validate: password mode requires a password hash
|
|
57
|
+
if (config.auth === 'password' && !config.password) {
|
|
58
|
+
log.error('auth is set to "password" but no password is configured. Set a password via the web UI Config → Authentication section first.');
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
56
61
|
require('./web/server')
|
|
57
62
|
.startServer(config.port, {
|
|
58
|
-
|
|
63
|
+
auth: config.auth,
|
|
64
|
+
password: config.password || null,
|
|
65
|
+
proxyHeader: config.proxyHeader,
|
|
66
|
+
bindAddress: config.bindAddress,
|
|
59
67
|
configPath: config.config,
|
|
60
68
|
scriptDir: config.dir || null,
|
|
61
69
|
})
|
package/src/web/ai-api.js
CHANGED
|
@@ -127,7 +127,15 @@ function buildSystemPrompt(requestCtx, currentScript, store) {
|
|
|
127
127
|
`You are SHE Assistant, an expert AI pair programmer for she (smart-home-engine).
|
|
128
128
|
she is a Node.js daemon that runs user JavaScript scripts in a sandboxed VM for home automation.
|
|
129
129
|
When proposing changes to a script, always output the COMPLETE new file content in a single fenced \`\`\`javascript code block. Never output partial diffs or fragments — the user applies the full file at once.
|
|
130
|
-
Keep any existing header comments and the 'use strict'; directive
|
|
130
|
+
Keep any existing header comments and the 'use strict'; directive.
|
|
131
|
+
When the user asks you to CREATE a new script (not modify the current one), place a special hint as the very first line INSIDE the code block (right after the opening \`\`\`javascript fence line), like this:
|
|
132
|
+
\`\`\`javascript
|
|
133
|
+
// @new-file: descriptive-name.js
|
|
134
|
+
/* global she */
|
|
135
|
+
'use strict';
|
|
136
|
+
// ... rest of script
|
|
137
|
+
\`\`\`
|
|
138
|
+
Use a short kebab-case filename. Do NOT put the hint outside or before the code block. The UI will detect it and offer to save the file.`,
|
|
131
139
|
];
|
|
132
140
|
|
|
133
141
|
if (requestCtx.apiref) {
|
|
@@ -375,6 +383,64 @@ router.get('/config', (req, res) => {
|
|
|
375
383
|
});
|
|
376
384
|
});
|
|
377
385
|
|
|
386
|
+
// GET /she/ai/models — list available models for the configured provider
|
|
387
|
+
router.get('/models', async (req, res) => {
|
|
388
|
+
const ai = readAiConfig(req.app.locals.configPath);
|
|
389
|
+
if (!ai?.provider) return res.json({ models: [] });
|
|
390
|
+
|
|
391
|
+
const base = (ai.baseUrl || 'http://localhost:11434').replace(/\/$/, '');
|
|
392
|
+
|
|
393
|
+
try {
|
|
394
|
+
if (ai.provider === 'ollama') {
|
|
395
|
+
const r = await fetch(`${base}/api/tags`);
|
|
396
|
+
if (!r.ok) throw new Error(`Ollama /api/tags returned ${r.status}`);
|
|
397
|
+
const json = await r.json();
|
|
398
|
+
const models = (json.models || []).map((m) => m.name || m.model).filter(Boolean).sort();
|
|
399
|
+
return res.json({ models });
|
|
400
|
+
} else if (ai.provider === 'anthropic') {
|
|
401
|
+
return res.json({ models: [] }); // no public list endpoint
|
|
402
|
+
} else {
|
|
403
|
+
// OpenAI / LM Studio / etc. — try /v1/models
|
|
404
|
+
const h = { 'Content-Type': 'application/json' };
|
|
405
|
+
if (ai.apiKey) h['Authorization'] = `Bearer ${ai.apiKey}`;
|
|
406
|
+
const r = await fetch(`${base}/v1/models`, { headers: h });
|
|
407
|
+
if (!r.ok) throw new Error(`/v1/models returned ${r.status}`);
|
|
408
|
+
const json = await r.json();
|
|
409
|
+
const models = (json.data || []).map((m) => m.id).filter(Boolean).sort();
|
|
410
|
+
return res.json({ models });
|
|
411
|
+
}
|
|
412
|
+
} catch (e) {
|
|
413
|
+
res.status(500).json({ error: e.message, models: [] });
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// GET /she/ai/model-info — Ollama-specific: version, model details, running models
|
|
418
|
+
// Query param: ?model=<name> (defaults to configured model)
|
|
419
|
+
router.get('/model-info', async (req, res) => {
|
|
420
|
+
const ai = readAiConfig(req.app.locals.configPath);
|
|
421
|
+
if (!ai?.provider || !ai?.model) return res.status(400).json({ error: 'Not configured' });
|
|
422
|
+
if (ai.provider !== 'ollama') return res.status(400).json({ error: 'Model info is only available for Ollama' });
|
|
423
|
+
|
|
424
|
+
const base = (ai.baseUrl || 'http://localhost:11434').replace(/\/$/, '');
|
|
425
|
+
const model = (typeof req.query.model === 'string' && req.query.model) ? req.query.model : ai.model;
|
|
426
|
+
|
|
427
|
+
const [versionRes, showRes, psRes] = await Promise.allSettled([
|
|
428
|
+
fetch(`${base}/api/version`).then((r) => r.json()),
|
|
429
|
+
fetch(`${base}/api/show`, {
|
|
430
|
+
method: 'POST',
|
|
431
|
+
headers: { 'Content-Type': 'application/json' },
|
|
432
|
+
body: JSON.stringify({ name: model, model }),
|
|
433
|
+
}).then((r) => r.json()),
|
|
434
|
+
fetch(`${base}/api/ps`).then((r) => r.json()),
|
|
435
|
+
]);
|
|
436
|
+
|
|
437
|
+
res.json({
|
|
438
|
+
version: versionRes.status === 'fulfilled' ? versionRes.value.version : null,
|
|
439
|
+
details: showRes.status === 'fulfilled' ? showRes.value.details : null,
|
|
440
|
+
running: psRes.status === 'fulfilled' ? (psRes.value.models || []) : null,
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
|
|
378
444
|
// POST /she/ai/chat — non-streaming
|
|
379
445
|
router.post('/chat', async (req, res) => {
|
|
380
446
|
const ai = readAiConfig(req.app.locals.configPath);
|
|
@@ -382,18 +448,19 @@ router.post('/chat', async (req, res) => {
|
|
|
382
448
|
return res.status(400).json({ error: 'AI provider not configured. Set ai.provider and ai.model in Config.' });
|
|
383
449
|
}
|
|
384
450
|
|
|
385
|
-
const { messages = [], currentScript, context = {} } = req.body || {};
|
|
451
|
+
const { messages = [], currentScript, context = {}, modelOverride } = req.body || {};
|
|
386
452
|
if (!Array.isArray(messages)) return res.status(400).json({ error: 'messages must be an array' });
|
|
387
453
|
|
|
454
|
+
const aiWithModel = (modelOverride && typeof modelOverride === 'string') ? { ...ai, model: modelOverride } : ai;
|
|
388
455
|
const systemPrompt = buildSystemPrompt(context, currentScript ?? null, _store);
|
|
389
456
|
const fullMessages = [{ role: 'system', content: systemPrompt }, ...messages];
|
|
390
457
|
|
|
391
458
|
try {
|
|
392
459
|
let result;
|
|
393
460
|
if (ai.provider === 'anthropic') {
|
|
394
|
-
result = await callAnthropic(
|
|
461
|
+
result = await callAnthropic(aiWithModel, fullMessages);
|
|
395
462
|
} else {
|
|
396
|
-
result = await callOpenAICompat(
|
|
463
|
+
result = await callOpenAICompat(aiWithModel, fullMessages);
|
|
397
464
|
}
|
|
398
465
|
res.json(result);
|
|
399
466
|
} catch (e) {
|
|
@@ -408,9 +475,11 @@ router.post('/chat/stream', async (req, res) => {
|
|
|
408
475
|
return res.status(400).json({ error: 'AI provider not configured. Set ai.provider and ai.model in Config.' });
|
|
409
476
|
}
|
|
410
477
|
|
|
411
|
-
const { messages = [], currentScript, context = {} } = req.body || {};
|
|
478
|
+
const { messages = [], currentScript, context = {}, modelOverride } = req.body || {};
|
|
412
479
|
if (!Array.isArray(messages)) return res.status(400).json({ error: 'messages must be an array' });
|
|
413
480
|
|
|
481
|
+
const aiWithModel = (modelOverride && typeof modelOverride === 'string') ? { ...ai, model: modelOverride } : ai;
|
|
482
|
+
|
|
414
483
|
res.set({
|
|
415
484
|
'Content-Type': 'text/event-stream',
|
|
416
485
|
'Cache-Control': 'no-cache',
|
|
@@ -427,9 +496,9 @@ router.post('/chat/stream', async (req, res) => {
|
|
|
427
496
|
const onToken = (t) => send({ token: t });
|
|
428
497
|
|
|
429
498
|
if (ai.provider === 'anthropic') {
|
|
430
|
-
await streamAnthropic(
|
|
499
|
+
await streamAnthropic(aiWithModel, fullMessages, onToken);
|
|
431
500
|
} else {
|
|
432
|
-
await streamOpenAICompat(
|
|
501
|
+
await streamOpenAICompat(aiWithModel, fullMessages, onToken);
|
|
433
502
|
}
|
|
434
503
|
|
|
435
504
|
res.write('data: [DONE]\n\n');
|
package/src/web/auth.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Authentication module for the she web server.
|
|
5
|
+
*
|
|
6
|
+
* Supports three modes (configured via config.json `auth` field):
|
|
7
|
+
* 'none' — no auth, all /she/* routes are open (default)
|
|
8
|
+
* 'password' — single-password session auth with an HttpOnly cookie
|
|
9
|
+
* 'proxy' — trust a header set by nginx/authentik (e.g. X-Remote-User)
|
|
10
|
+
*
|
|
11
|
+
* Public endpoints (no auth required in any mode):
|
|
12
|
+
* GET /she/auth/mode — returns current mode
|
|
13
|
+
* POST /she/auth/login — password mode only; sets session cookie
|
|
14
|
+
* POST /she/auth/logout — clears session cookie
|
|
15
|
+
*
|
|
16
|
+
* Protected endpoint (auth required):
|
|
17
|
+
* POST /she/auth/setup — change auth mode / password / proxyHeader
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const crypto = require('crypto');
|
|
21
|
+
const bcrypt = require('bcryptjs');
|
|
22
|
+
const express = require('express');
|
|
23
|
+
const fs = require('fs');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
|
|
26
|
+
const BCRYPT_ROUNDS = 10;
|
|
27
|
+
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
28
|
+
|
|
29
|
+
// In-memory session store — intentionally cleared on restart
|
|
30
|
+
const _sessions = new Map(); // token (hex64) → { createdAt: number }
|
|
31
|
+
|
|
32
|
+
let _mode = 'none';
|
|
33
|
+
let _passwordHash = null; // bcrypt hash, only used in 'password' mode
|
|
34
|
+
let _proxyHeader = 'x-remote-user'; // lowercase for req.headers lookup
|
|
35
|
+
let _configPath = null;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Initialise auth state. Called once from startServer().
|
|
39
|
+
*/
|
|
40
|
+
function init({ auth = 'none', password = null, proxyHeader = 'X-Remote-User', configPath = null } = {}) {
|
|
41
|
+
_mode = auth;
|
|
42
|
+
_passwordHash = password || null;
|
|
43
|
+
_proxyHeader = proxyHeader.toLowerCase();
|
|
44
|
+
_configPath = configPath;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getMode() {
|
|
48
|
+
return _mode;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Session helpers ─────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
function _getSessionToken(req) {
|
|
54
|
+
const cookie = req.headers.cookie || '';
|
|
55
|
+
const m = cookie.match(/(?:^|;\s*)she_session=([a-f0-9]{64})/);
|
|
56
|
+
return m ? m[1] : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function _validateSession(token) {
|
|
60
|
+
if (!token) return false;
|
|
61
|
+
const s = _sessions.get(token);
|
|
62
|
+
if (!s) return false;
|
|
63
|
+
if (Date.now() - s.createdAt > SESSION_TTL_MS) {
|
|
64
|
+
_sessions.delete(token);
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Auth check (used by middleware and WS gate) ─────────────────────────────
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Returns true if the request is authenticated according to the current mode.
|
|
74
|
+
* @param {import('http').IncomingMessage} req
|
|
75
|
+
*/
|
|
76
|
+
function checkAuth(req) {
|
|
77
|
+
if (_mode === 'none') return true;
|
|
78
|
+
if (_mode === 'proxy') return !!req.headers[_proxyHeader];
|
|
79
|
+
if (_mode === 'password') return _validateSession(_getSessionToken(req));
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Express middleware that enforces auth on /she/* routes.
|
|
85
|
+
* Mount this AFTER the public auth router so login/mode/logout bypass it.
|
|
86
|
+
*/
|
|
87
|
+
function authMiddleware(req, res, next) {
|
|
88
|
+
if (checkAuth(req)) return next();
|
|
89
|
+
res.status(401).json({ error: 'Unauthorized' });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Auth API router ─────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
const router = express.Router();
|
|
95
|
+
|
|
96
|
+
/** GET /she/auth/mode — always public */
|
|
97
|
+
router.get('/mode', (req, res) => {
|
|
98
|
+
res.json({ mode: _mode });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
/** POST /she/auth/login — always public; only meaningful in password mode */
|
|
102
|
+
router.post('/login', async (req, res) => {
|
|
103
|
+
if (_mode !== 'password') return res.status(400).json({ error: 'Not in password mode' });
|
|
104
|
+
const { password } = req.body || {};
|
|
105
|
+
if (!password || !_passwordHash) return res.status(401).json({ error: 'Unauthorized' });
|
|
106
|
+
try {
|
|
107
|
+
const ok = await bcrypt.compare(password, _passwordHash);
|
|
108
|
+
if (!ok) return res.status(401).json({ error: 'Invalid password' });
|
|
109
|
+
const token = crypto.randomBytes(32).toString('hex');
|
|
110
|
+
_sessions.set(token, { createdAt: Date.now() });
|
|
111
|
+
res.setHeader('Set-Cookie', `she_session=${token}; HttpOnly; SameSite=Strict; Path=/`);
|
|
112
|
+
res.json({ ok: true });
|
|
113
|
+
} catch {
|
|
114
|
+
res.status(500).json({ error: 'Internal error' });
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
/** POST /she/auth/logout — always public; clears cookie */
|
|
119
|
+
router.post('/logout', (req, res) => {
|
|
120
|
+
const token = _getSessionToken(req);
|
|
121
|
+
if (token) _sessions.delete(token);
|
|
122
|
+
res.setHeader('Set-Cookie', 'she_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0');
|
|
123
|
+
res.json({ ok: true });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* POST /she/auth/setup — protected; change auth mode / password / proxyHeader.
|
|
128
|
+
* Body: { mode: 'none'|'password'|'proxy', password?: string, proxyHeader?: string }
|
|
129
|
+
*
|
|
130
|
+
* Self-guards when in password mode (requires valid session).
|
|
131
|
+
*/
|
|
132
|
+
router.post('/setup', async (req, res) => {
|
|
133
|
+
// Self-guard: in password mode the caller must be authenticated
|
|
134
|
+
if (_mode === 'password' && !_validateSession(_getSessionToken(req))) {
|
|
135
|
+
return res.status(401).json({ error: 'Unauthorized' });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const { mode, password, proxyHeader } = req.body || {};
|
|
139
|
+
|
|
140
|
+
if (!['none', 'password', 'proxy'].includes(mode)) {
|
|
141
|
+
return res.status(400).json({ error: 'Invalid auth mode. Must be none, password, or proxy.' });
|
|
142
|
+
}
|
|
143
|
+
if (mode === 'password' && !password) {
|
|
144
|
+
return res.status(400).json({ error: 'A non-empty password is required for password mode.' });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
// Read existing config
|
|
149
|
+
let cfg = {};
|
|
150
|
+
try {
|
|
151
|
+
cfg = JSON.parse(fs.readFileSync(_configPath, 'utf8'));
|
|
152
|
+
} catch {
|
|
153
|
+
// config file does not exist yet — start from empty
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Update auth fields, remove stale ones
|
|
157
|
+
cfg.auth = mode;
|
|
158
|
+
delete cfg.password;
|
|
159
|
+
delete cfg.proxyHeader;
|
|
160
|
+
|
|
161
|
+
if (mode === 'password') {
|
|
162
|
+
cfg.password = await bcrypt.hash(password, BCRYPT_ROUNDS);
|
|
163
|
+
}
|
|
164
|
+
if (mode === 'proxy') {
|
|
165
|
+
cfg.proxyHeader = proxyHeader || 'X-Remote-User';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Write back
|
|
169
|
+
fs.mkdirSync(path.dirname(_configPath), { recursive: true });
|
|
170
|
+
fs.writeFileSync(_configPath, JSON.stringify(cfg, null, 2), 'utf8');
|
|
171
|
+
|
|
172
|
+
// Apply in-memory (no restart needed)
|
|
173
|
+
_mode = mode;
|
|
174
|
+
_passwordHash = cfg.password || null;
|
|
175
|
+
_proxyHeader = (cfg.proxyHeader || 'X-Remote-User').toLowerCase();
|
|
176
|
+
|
|
177
|
+
// Invalidate all existing sessions when switching away from password mode
|
|
178
|
+
if (mode !== 'password') _sessions.clear();
|
|
179
|
+
|
|
180
|
+
res.json({ ok: true });
|
|
181
|
+
} catch (err) {
|
|
182
|
+
res.status(500).json({ error: err.message });
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
module.exports = { init, authMiddleware, checkAuth, getMode, router };
|
package/src/web/log-ws.js
CHANGED
|
@@ -12,11 +12,18 @@ const _clients = new Set();
|
|
|
12
12
|
* - { type: 'ping' } — keepalive every 30 s
|
|
13
13
|
*
|
|
14
14
|
* @param {import('http').Server} httpServer
|
|
15
|
+
* @param {(req: import('http').IncomingMessage) => boolean} [authCheck]
|
|
16
|
+
* Optional function that receives the upgrade request and returns true if
|
|
17
|
+
* the connection should be allowed. Defaults to always-allow.
|
|
15
18
|
*/
|
|
16
|
-
function attachWss(httpServer) {
|
|
19
|
+
function attachWss(httpServer, authCheck = () => true) {
|
|
17
20
|
_wss = new WebSocketServer({ server: httpServer, path: '/she/ws' });
|
|
18
21
|
|
|
19
|
-
_wss.on('connection', (ws) => {
|
|
22
|
+
_wss.on('connection', (ws, req) => {
|
|
23
|
+
if (!authCheck(req)) {
|
|
24
|
+
ws.close(1008, 'Unauthorized');
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
20
27
|
_clients.add(ws);
|
|
21
28
|
ws.on('close', () => _clients.delete(ws));
|
|
22
29
|
ws.on('error', () => _clients.delete(ws));
|
package/src/web/server.js
CHANGED
|
@@ -11,18 +11,21 @@ const { router: depsRouter } = require('./deps-api');
|
|
|
11
11
|
const { router: gitRouter } = require('./git-api');
|
|
12
12
|
const { router: aiRouter } = require('./ai-api');
|
|
13
13
|
const { attachWss, closeWss } = require('./log-ws');
|
|
14
|
+
const { init: initAuth, authMiddleware, checkAuth, router: authRouter } = require('./auth');
|
|
14
15
|
|
|
15
16
|
const app = express();
|
|
16
17
|
app.use(express.json());
|
|
17
18
|
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
19
|
+
// Public auth routes — always accessible regardless of auth mode.
|
|
20
|
+
// Must be mounted BEFORE the auth middleware.
|
|
21
|
+
app.use('/she/auth', authRouter);
|
|
22
|
+
|
|
23
|
+
// Auth middleware for all /she/* routes except the public auth endpoints above.
|
|
24
|
+
// /api/* is intentionally excluded — user scripts control their own auth.
|
|
25
|
+
const OPEN_SHE_PATHS = new Set(['/she/auth/mode', '/she/auth/login', '/she/auth/logout']);
|
|
26
|
+
app.use('/she', (req, res, next) => {
|
|
27
|
+
if (OPEN_SHE_PATHS.has(req.originalUrl.split('?')[0])) return next();
|
|
28
|
+
authMiddleware(req, res, next);
|
|
26
29
|
});
|
|
27
30
|
|
|
28
31
|
// Config REST endpoints: GET /she/config and PUT /she/config
|
|
@@ -90,20 +93,26 @@ let httpServer = null;
|
|
|
90
93
|
/**
|
|
91
94
|
* Start listening. Resolves with the actual port (useful when port 0 is given).
|
|
92
95
|
* @param {number} port
|
|
93
|
-
* @param {{
|
|
96
|
+
* @param {{ auth?: string, password?: string, proxyHeader?: string, bindAddress?: string, configPath?: string, scriptDir?: string }} [options]
|
|
94
97
|
* @returns {Promise<number>}
|
|
95
98
|
*/
|
|
96
99
|
function startServer(port, options = {}) {
|
|
97
|
-
|
|
100
|
+
initAuth({
|
|
101
|
+
auth: options.auth || 'none',
|
|
102
|
+
password: options.password || null,
|
|
103
|
+
proxyHeader: options.proxyHeader || 'X-Remote-User',
|
|
104
|
+
configPath: options.configPath || null,
|
|
105
|
+
});
|
|
98
106
|
if (options.configPath) {
|
|
99
107
|
app.locals.configPath = options.configPath;
|
|
100
108
|
}
|
|
101
109
|
if (options.scriptDir) {
|
|
102
110
|
app.locals.scriptDir = options.scriptDir;
|
|
103
111
|
}
|
|
112
|
+
const host = options.bindAddress || '0.0.0.0';
|
|
104
113
|
return new Promise((resolve, reject) => {
|
|
105
|
-
httpServer = app.listen(port, () => {
|
|
106
|
-
attachWss(httpServer);
|
|
114
|
+
httpServer = app.listen(port, host, () => {
|
|
115
|
+
attachWss(httpServer, checkAuth);
|
|
107
116
|
resolve(httpServer.address().port);
|
|
108
117
|
});
|
|
109
118
|
httpServer.on('error', reject);
|