node-red-contrib-room-tools 1.8.4 → 1.8.8
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/CONTROL_PANEL_AI_GUIDE.md +156 -39
- package/README.md +10 -0
- package/control-catalog.js +7 -6
- package/control-panel.js +5 -5
- package/control-policy.js +36 -478
- package/control-type-model.js +30 -31
- package/gateway.cjs +13 -261
- package/managed-bridge-admin.js +5 -11
- package/native-binding-plan.js +1 -1
- package/npm-shrinkwrap.json +433 -0
- package/package.json +8 -5
- package/panel-binding-editor.js +25 -36
- package/panel-editor-auth.js +3 -39
- package/panel-http.js +69 -0
- package/panel-visual-apply.js +19 -13
package/panel-http.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
function sameOrigin (req) {
|
|
2
|
+
if (!req.headers.origin) return false
|
|
3
|
+
try {
|
|
4
|
+
const origin = new URL(req.headers.origin)
|
|
5
|
+
return origin.host === req.headers.host &&
|
|
6
|
+
origin.protocol === (req.socket.encrypted ? 'https:' : 'http:')
|
|
7
|
+
} catch (_) { return false }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function replyJson (res, status, data, headers = {}) {
|
|
11
|
+
res.writeHead(status, {
|
|
12
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
13
|
+
'Cache-Control': 'no-store',
|
|
14
|
+
'X-Content-Type-Options': 'nosniff',
|
|
15
|
+
...headers
|
|
16
|
+
})
|
|
17
|
+
res.end(JSON.stringify(data))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function readJsonBody (req, maxBytes) {
|
|
21
|
+
if (String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase() !== 'application/json') {
|
|
22
|
+
throw new Error('JSON body required')
|
|
23
|
+
}
|
|
24
|
+
let raw
|
|
25
|
+
if (req.body !== undefined) {
|
|
26
|
+
// MUSE's middleware can expose an empty placeholder object while the raw
|
|
27
|
+
// JSON bytes are still on the request stream. Keep the legacy fallback so
|
|
28
|
+
// login and Apply receive the actual payload on that runtime.
|
|
29
|
+
const hasParsedObject = req.body && typeof req.body === 'object' &&
|
|
30
|
+
!Buffer.isBuffer(req.body) && !Array.isArray(req.body) &&
|
|
31
|
+
Object.keys(req.body).length > 0
|
|
32
|
+
if (Buffer.isBuffer(req.body) || typeof req.body === 'string' || hasParsedObject) {
|
|
33
|
+
raw = Buffer.isBuffer(req.body) ? req.body
|
|
34
|
+
: typeof req.body === 'string' ? Buffer.from(req.body) : Buffer.from(JSON.stringify(req.body))
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (raw === undefined && typeof req[Symbol.asyncIterator] === 'function') {
|
|
38
|
+
const chunks = []
|
|
39
|
+
let size = 0
|
|
40
|
+
for await (const chunk of req) {
|
|
41
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
42
|
+
size += bytes.length
|
|
43
|
+
if (size > maxBytes) throw new Error('Request too large')
|
|
44
|
+
chunks.push(bytes)
|
|
45
|
+
}
|
|
46
|
+
raw = Buffer.concat(chunks)
|
|
47
|
+
}
|
|
48
|
+
if (raw === undefined) {
|
|
49
|
+
const value = req.body === undefined ? {} : req.body
|
|
50
|
+
raw = Buffer.from(JSON.stringify(value))
|
|
51
|
+
}
|
|
52
|
+
if (raw.length > maxBytes) throw new Error('Request too large')
|
|
53
|
+
const value = JSON.parse(raw.toString('utf8'))
|
|
54
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('JSON object required')
|
|
55
|
+
return value
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Serialized into the Vue component, so keep this function browser-only and self-contained.
|
|
59
|
+
async function fetchWithTimeout (url, options = {}, timeoutMs = 12000) {
|
|
60
|
+
const controller = new AbortController()
|
|
61
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
62
|
+
try {
|
|
63
|
+
return await fetch(url, { ...options, signal: controller.signal })
|
|
64
|
+
} finally {
|
|
65
|
+
clearTimeout(timer)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { sameOrigin, replyJson, readJsonBody, fetchWithTimeout }
|
package/panel-visual-apply.js
CHANGED
|
@@ -4,7 +4,7 @@ const { validatedVisualBindings } = require('./panel-visual-bindings')
|
|
|
4
4
|
const { listCatalogDevices, listCatalogTargets } = require('./control-catalog')
|
|
5
5
|
const { configuredRoutes } = require('./control-bound-protocol')
|
|
6
6
|
const { provisionManagedBridges } = require('./managed-native-bridge')
|
|
7
|
-
const { backupFlow, flowLocation,
|
|
7
|
+
const { backupFlow, flowLocation, runtimeDirectory } = require('./managed-bridge-admin')
|
|
8
8
|
|
|
9
9
|
function conflict (message) {
|
|
10
10
|
const error = new Error(message)
|
|
@@ -12,15 +12,20 @@ function conflict (message) {
|
|
|
12
12
|
return error
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
function adminUrl (localPort) {
|
|
15
|
+
function adminUrl (localPort, adminRoot = '/red') {
|
|
16
16
|
if (!Number.isInteger(localPort) || localPort < 1 || localPort > 65535) {
|
|
17
|
-
throw new Error('
|
|
17
|
+
throw new Error('Node-RED runtime port unavailable')
|
|
18
18
|
}
|
|
19
|
-
|
|
19
|
+
if (typeof adminRoot !== 'string' || !adminRoot.startsWith('/') || /[?#]/.test(adminRoot)) {
|
|
20
|
+
throw new Error('Node-RED admin root unavailable')
|
|
21
|
+
}
|
|
22
|
+
const url = new URL(`http://127.0.0.1:${localPort}`)
|
|
23
|
+
url.pathname = adminRoot.replace(/\/$/, '') + '/flows'
|
|
24
|
+
return url.href
|
|
20
25
|
}
|
|
21
26
|
|
|
22
|
-
async function getSnapshot (localPort, fetcher = fetch) {
|
|
23
|
-
const response = await fetcher(adminUrl(localPort), {
|
|
27
|
+
async function getSnapshot (localPort, fetcher = fetch, adminRoot = '/red') {
|
|
28
|
+
const response = await fetcher(adminUrl(localPort, adminRoot), {
|
|
24
29
|
headers: { 'Node-RED-API-Version': 'v2' },
|
|
25
30
|
signal: AbortSignal.timeout(10000)
|
|
26
31
|
})
|
|
@@ -59,18 +64,19 @@ function editorConfig (snapshot, panelId) {
|
|
|
59
64
|
}
|
|
60
65
|
|
|
61
66
|
async function applyVisualBindings ({
|
|
62
|
-
panelId, rev, templateHash, entries, userDir,
|
|
67
|
+
panelId, rev, templateHash, entries, userDir, localPort, adminRoot = '/red',
|
|
63
68
|
catalogBase, fetcher = fetch
|
|
64
69
|
}) {
|
|
65
|
-
|
|
70
|
+
// The editor authenticates the browser; Flow access stays on the runtime's loopback interface.
|
|
71
|
+
const directory = runtimeDirectory(userDir)
|
|
66
72
|
if (!catalogBase) throw conflict('Device catalog is unavailable')
|
|
67
|
-
const snapshot = await getSnapshot(localPort, fetcher)
|
|
73
|
+
const snapshot = await getSnapshot(localPort, fetcher, adminRoot)
|
|
68
74
|
if (snapshot.rev !== rev) throw conflict('Flow changed; refresh the visual editor')
|
|
69
75
|
const panel = findPanel(snapshot, panelId)
|
|
70
76
|
if (inspectTemplate(panel.format).templateHash !== templateHash) {
|
|
71
77
|
throw conflict('Template changed; refresh the visual editor')
|
|
72
78
|
}
|
|
73
|
-
const location = flowLocation(
|
|
79
|
+
const location = flowLocation(directory)
|
|
74
80
|
if (JSON.stringify(JSON.parse(fs.readFileSync(location.flowFile, 'utf8'))) !== JSON.stringify(snapshot.flows)) {
|
|
75
81
|
throw conflict('Deployed Flow differs from the active project file')
|
|
76
82
|
}
|
|
@@ -92,13 +98,13 @@ async function applyVisualBindings ({
|
|
|
92
98
|
nextPanel.nativeBridge = configuredRoutes(nextPanel).size > 0
|
|
93
99
|
const provisioned = provisionManagedBridges(next, { panelId })
|
|
94
100
|
|
|
95
|
-
const latest = await getSnapshot(localPort, fetcher)
|
|
101
|
+
const latest = await getSnapshot(localPort, fetcher, adminRoot)
|
|
96
102
|
if (latest.rev !== snapshot.rev) throw conflict('Flow changed during validation; refresh and retry')
|
|
97
103
|
if (JSON.stringify(JSON.parse(fs.readFileSync(location.flowFile, 'utf8'))) !== JSON.stringify(snapshot.flows)) {
|
|
98
104
|
throw conflict('Active project changed during validation')
|
|
99
105
|
}
|
|
100
106
|
const backupDir = backupFlow(location, snapshot.rev, snapshot.flows)
|
|
101
|
-
const response = await fetcher(adminUrl(localPort), {
|
|
107
|
+
const response = await fetcher(adminUrl(localPort, adminRoot), {
|
|
102
108
|
method: 'POST',
|
|
103
109
|
headers: {
|
|
104
110
|
'Content-Type': 'application/json',
|
|
@@ -112,7 +118,7 @@ async function applyVisualBindings ({
|
|
|
112
118
|
const applied = await response.json().catch(() => ({}))
|
|
113
119
|
let confirmed
|
|
114
120
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
115
|
-
confirmed = await getSnapshot(localPort, fetcher)
|
|
121
|
+
confirmed = await getSnapshot(localPort, fetcher, adminRoot)
|
|
116
122
|
const confirmedPanel = confirmed.flows.find(node => node.id === panelId)
|
|
117
123
|
if (confirmedPanel?.visualBindings === nextPanel.visualBindings &&
|
|
118
124
|
confirmedPanel.bridgeRevisionApplied === provisioned.flows.find(node => node.id === panelId)?.bridgeRevisionApplied) {
|