node-red-contrib-tdn-eventsub 0.1.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/lib/client.js ADDED
@@ -0,0 +1,322 @@
1
+ 'use strict';
2
+ /**
3
+ * Minimal REST Server API client.
4
+ *
5
+ * - Auth: "Authorization: Basic base64(':' + key)" by default (server v9.0+),
6
+ * or "Authorization: <scheme> <key>" when a custom scheme token is configured
7
+ * - HATEOAS: everything is discovered from GET /api (features.*.href)
8
+ * - Optional server certificate pinning by SHA-1 or SHA-256 fingerprint
9
+ * (lets you talk to a self-signed REST Server without disabling verification)
10
+ * - Optional client certificate (server "pinned client certificate" / thumbprint)
11
+ * - No external dependencies
12
+ */
13
+ const tls = require('tls');
14
+ const net = require('net');
15
+ const http = require('http');
16
+ const https = require('https');
17
+ const fs = require('fs');
18
+ const { URL } = require('url');
19
+
20
+ const DEFAULT_PORT = 8904;
21
+ const TOKEN_RE = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/; // RFC 7230 token: a valid auth-scheme name
22
+
23
+ function normFp(fp) {
24
+ return String(fp || '').replace(/[^0-9a-f]/gi, '').toUpperCase();
25
+ }
26
+
27
+ function fmtFp(hex) {
28
+ return (hex.match(/.{1,2}/g) || []).join(':');
29
+ }
30
+
31
+ class RestError extends Error {
32
+ constructor(message, extra) {
33
+ super(message);
34
+ this.name = 'RestError';
35
+ Object.assign(this, extra || {});
36
+ }
37
+ }
38
+
39
+ function abortError() {
40
+ return new RestError('Request aborted', { code: 'ABORTED', aborted: true });
41
+ }
42
+
43
+ class RestClient {
44
+ /**
45
+ * @param {object} o
46
+ * @param {string} o.url host, IP or URL (https:// assumed)
47
+ * @param {number} [o.port] default 8904
48
+ * @param {string} o.apiKey
49
+ * @param {string} [o.authScheme] '' = Basic with ':<key>'; otherwise sent as '<scheme> <key>'
50
+ * @param {string} [o.fingerprint] server cert SHA-1 (40 hex) or SHA-256 (64 hex), any separators
51
+ * @param {boolean} [o.rejectUnauthorized] used only when no fingerprint is set
52
+ * @param {string} [o.certPath] [o.keyPath] [o.pfxPath] [o.passphrase] client certificate
53
+ * @param {boolean} [o.rewriteHrefs] force followed hrefs back onto the configured host:port
54
+ * @param {number} [o.timeoutMs] default request timeout
55
+ */
56
+ constructor(o) {
57
+ o = o || {};
58
+ let raw = String(o.url || '').trim();
59
+ if (!raw) throw new RestError('No server URL configured');
60
+ if (!/^https?:\/\//i.test(raw)) raw = 'https://' + raw;
61
+ const u = new URL(raw);
62
+ const port = parseInt(o.port, 10);
63
+ if (port > 0) u.port = String(port);
64
+ else if (!u.port) u.port = String(DEFAULT_PORT);
65
+
66
+ this.base = u;
67
+ const path = u.pathname && u.pathname !== '/' ? u.pathname.replace(/\/+$/, '') : '/api';
68
+ this.apiRoot = u.origin + path;
69
+ this.apiKey = String(o.apiKey || '').trim();
70
+ const scheme = String(o.authScheme || '').trim();
71
+ if (scheme && !TOKEN_RE.test(scheme)) throw new RestError('Auth scheme must be a single token (letters, digits, - _ . etc., no spaces)');
72
+ if (/[\r\n]/.test(this.apiKey)) throw new RestError('API key must not contain line breaks');
73
+ this.authHeader = scheme
74
+ ? scheme + ' ' + this.apiKey
75
+ : 'Basic ' + Buffer.from(':' + this.apiKey, 'utf8').toString('base64');
76
+ this.fingerprint = normFp(o.fingerprint);
77
+ if (this.fingerprint && this.fingerprint.length !== 40 && this.fingerprint.length !== 64) {
78
+ throw new RestError('Certificate fingerprint must be SHA-1 (40 hex) or SHA-256 (64 hex)');
79
+ }
80
+ this.rejectUnauthorized = o.rejectUnauthorized !== false;
81
+ this.rewriteHrefs = o.rewriteHrefs !== false;
82
+ this.timeoutMs = parseInt(o.timeoutMs, 10) > 0 ? parseInt(o.timeoutMs, 10) : 30000;
83
+
84
+ this.tlsExtra = {};
85
+ try {
86
+ if (o.pfxPath) this.tlsExtra.pfx = fs.readFileSync(o.pfxPath);
87
+ if (o.certPath) this.tlsExtra.cert = fs.readFileSync(o.certPath);
88
+ if (o.keyPath) this.tlsExtra.key = fs.readFileSync(o.keyPath);
89
+ } catch (e) {
90
+ throw new RestError('Cannot read client certificate: ' + e.message);
91
+ }
92
+ if (o.passphrase) this.tlsExtra.passphrase = o.passphrase;
93
+
94
+ this._discovery = null;
95
+ this._discoveryAt = 0;
96
+ }
97
+
98
+ /** Resolve an href (absolute or relative) and optionally pin it to the configured host:port. */
99
+ resolve(href) {
100
+ const t = new URL(href, this.base.origin);
101
+ if (this.rewriteHrefs) {
102
+ t.protocol = this.base.protocol;
103
+ t.hostname = this.base.hostname;
104
+ t.port = this.base.port;
105
+ } else if (this.base.protocol === 'https:' && t.protocol !== 'https:') {
106
+ // never let a server-supplied link downgrade us to cleartext (the API key would follow)
107
+ throw new RestError(`Refusing to follow non-HTTPS link ${t.protocol}//${t.host}${t.pathname}`, { code: 'EDOWNGRADE' });
108
+ }
109
+ return t;
110
+ }
111
+
112
+ /** Append query params to an href that may already carry its own. Skips empty values. */
113
+ static addQuery(href, params) {
114
+ const t = new URL(href);
115
+ for (const [k, v] of Object.entries(params || {})) {
116
+ if (v === undefined || v === null || v === '') continue;
117
+ t.searchParams.set(k, String(v));
118
+ }
119
+ return t.toString();
120
+ }
121
+
122
+ /** Parse "a=1&b=two" into an object. */
123
+ static parseQueryString(qs) {
124
+ const out = {};
125
+ if (!qs) return out;
126
+ const sp = new URLSearchParams(String(qs).replace(/^[?&]/, ''));
127
+ for (const [k, v] of sp) out[k] = v;
128
+ return out;
129
+ }
130
+
131
+ _connect(target, signal, timeoutMs) {
132
+ return new Promise((resolve, reject) => {
133
+ const host = target.hostname.replace(/^\[|\]$/g, '');
134
+ const port = parseInt(target.port, 10) || (target.protocol === 'http:' ? 80 : 443);
135
+ let sock;
136
+ let done = false;
137
+ const finish = (err, s) => {
138
+ if (done) return;
139
+ done = true;
140
+ clearTimeout(timer);
141
+ if (signal) signal.removeEventListener('abort', onAbort);
142
+ if (err) {
143
+ if (sock) sock.destroy();
144
+ reject(err);
145
+ } else resolve(s);
146
+ };
147
+ const onAbort = () => finish(abortError());
148
+ const timer = setTimeout(() => finish(new RestError(`Connect timeout to ${host}:${port}`, { code: 'ETIMEDOUT' })), Math.min(timeoutMs, 15000));
149
+ if (signal) {
150
+ if (signal.aborted) return finish(abortError());
151
+ signal.addEventListener('abort', onAbort);
152
+ }
153
+
154
+ if (target.protocol === 'http:') {
155
+ sock = net.connect({ host, port }, () => finish(null, sock));
156
+ sock.once('error', (e) => finish(e));
157
+ return;
158
+ }
159
+
160
+ const pinning = !!this.fingerprint;
161
+ const opts = Object.assign({
162
+ host,
163
+ port,
164
+ rejectUnauthorized: pinning ? false : this.rejectUnauthorized,
165
+ ALPNProtocols: ['http/1.1']
166
+ }, this.tlsExtra);
167
+ if (!net.isIP(host)) opts.servername = host;
168
+
169
+ sock = tls.connect(opts);
170
+ sock.once('error', (e) => finish(e));
171
+ sock.once('secureConnect', () => {
172
+ if (pinning) {
173
+ const c = sock.getPeerCertificate();
174
+ const actual = this.fingerprint.length === 40 ? normFp(c.fingerprint) : normFp(c.fingerprint256);
175
+ if (!actual || actual !== this.fingerprint) {
176
+ return finish(new RestError(
177
+ `Server certificate fingerprint mismatch. Expected ${fmtFp(this.fingerprint)}, got ${c.fingerprint256 || '(none)'} (SHA-256) / ${c.fingerprint || '(none)'} (SHA-1)`,
178
+ { code: 'ECERTPIN', actualFingerprint256: c.fingerprint256, actualFingerprint: c.fingerprint }
179
+ ));
180
+ }
181
+ }
182
+ finish(null, sock);
183
+ });
184
+ });
185
+ }
186
+
187
+ /**
188
+ * Make a request. Resolves { statusCode, headers, body, raw, url }.
189
+ * Rejects RestError for transport errors and HTTP >= 400 (statusCode/body attached).
190
+ */
191
+ async request(method, href, body, opts) {
192
+ opts = opts || {};
193
+ const target = this.resolve(href || this.apiRoot);
194
+ const timeoutMs = opts.timeoutMs || this.timeoutMs;
195
+ const signal = opts.signal;
196
+ // The socket is opened (and the server cert checked) before any header leaves,
197
+ // so the API key is never sent to a server that fails the fingerprint pin.
198
+ const sock = await this._connect(target, signal, timeoutMs);
199
+ const payload = body === undefined || body === null ? null : Buffer.from(JSON.stringify(body));
200
+ const headers = {
201
+ Authorization: this.authHeader,
202
+ Accept: 'application/json',
203
+ Connection: 'close'
204
+ };
205
+ if (payload) {
206
+ headers['Content-Type'] = 'application/json';
207
+ headers['Content-Length'] = payload.length;
208
+ }
209
+ const lib = target.protocol === 'http:' ? http : https;
210
+
211
+ return new Promise((resolve, reject) => {
212
+ let settled = false;
213
+ const finish = (err, res) => {
214
+ if (settled) return;
215
+ settled = true;
216
+ if (signal) signal.removeEventListener('abort', onAbort);
217
+ if (err) reject(err); else resolve(res);
218
+ };
219
+ let req;
220
+ const onAbort = () => { if (req) req.destroy(); finish(abortError()); };
221
+ try {
222
+ req = lib.request({
223
+ method,
224
+ host: target.hostname,
225
+ port: target.port,
226
+ path: target.pathname + target.search,
227
+ headers,
228
+ createConnection: () => sock
229
+ }, onResponse);
230
+ } catch (e) {
231
+ // e.g. invalid header characters: don't leak the already-open socket
232
+ sock.destroy();
233
+ return finish(e);
234
+ }
235
+ function onResponse(res) {
236
+ const chunks = [];
237
+ res.on('data', (c) => chunks.push(c));
238
+ res.on('error', (e) => finish(e));
239
+ res.on('end', () => {
240
+ const raw = Buffer.concat(chunks).toString('utf8');
241
+ let parsed = raw;
242
+ if (raw.length) {
243
+ try { parsed = JSON.parse(raw); } catch (e) { /* leave as text */ }
244
+ } else parsed = null;
245
+ const out = { statusCode: res.statusCode, headers: res.headers, body: parsed, raw, url: target.toString(), method };
246
+ if (res.statusCode >= 400) {
247
+ const detail = parsed && typeof parsed === 'object' && parsed.message ? `: ${parsed.message}` : (typeof parsed === 'string' && parsed ? `: ${parsed.slice(0, 200)}` : '');
248
+ return finish(new RestError(`HTTP ${res.statusCode} ${method} ${target.pathname}${detail}`, out));
249
+ }
250
+ finish(null, out);
251
+ });
252
+ }
253
+ if (signal) {
254
+ if (signal.aborted) return onAbort();
255
+ signal.addEventListener('abort', onAbort);
256
+ }
257
+ req.setTimeout(timeoutMs, () => {
258
+ req.destroy();
259
+ finish(new RestError(`Request timeout after ${timeoutMs} ms: ${method} ${target.pathname}`, { code: 'ETIMEDOUT', timeout: true }));
260
+ });
261
+ req.on('error', (e) => finish(e));
262
+ if (payload) req.write(payload);
263
+ req.end();
264
+ });
265
+ }
266
+
267
+ /** GET /api, cached for cacheMs (default 10 min). */
268
+ async discover(opts) {
269
+ opts = opts || {};
270
+ const cacheMs = opts.cacheMs === undefined ? 600000 : opts.cacheMs;
271
+ if (!opts.force && this._discovery && Date.now() - this._discoveryAt < cacheMs) return this._discovery;
272
+ const res = await this.request('GET', this.apiRoot, null, opts);
273
+ if (!res.body || typeof res.body !== 'object' || !res.body.features) {
274
+ throw new RestError('GET /api did not return a features block (is this a REST Server API endpoint?)', res);
275
+ }
276
+ this._discovery = res.body;
277
+ this._discoveryAt = Date.now();
278
+ return this._discovery;
279
+ }
280
+
281
+ /** Look up features.<a>.<b>.href, e.g. feature('accessGroups.accessGroups'). */
282
+ async feature(path, opts) {
283
+ const d = await this.discover(opts);
284
+ let node = d.features;
285
+ for (const part of path.split('.')) node = node && node[part];
286
+ if (!node || !node.href) {
287
+ // licences may have changed; re-discover once before giving up
288
+ if (!(opts && opts.force)) return this.feature(path, Object.assign({}, opts, { force: true }));
289
+ throw new RestError(`Feature "features.${path}.href" not present in /api (not licensed, or operator lacks privilege)`, { code: 'ENOFEATURE' });
290
+ }
291
+ return node.href;
292
+ }
293
+
294
+ /**
295
+ * Follow next.href until exhausted. Resolves { results, pages, pageCount }.
296
+ * @param {string} href first page
297
+ * @param {object} [opts] { maxPages, signal, timeoutMs, onPage(body, index) }
298
+ */
299
+ async getAll(href, opts) {
300
+ opts = opts || {};
301
+ const maxPages = parseInt(opts.maxPages, 10) > 0 ? parseInt(opts.maxPages, 10) : Infinity;
302
+ const results = [];
303
+ const pages = [];
304
+ const seen = new Set();
305
+ let url = href;
306
+ while (url && pages.length < maxPages) {
307
+ if (seen.has(url)) break; // defensive: never loop on the same link
308
+ seen.add(url);
309
+ const res = await this.request('GET', url, null, opts);
310
+ const b = res.body || {};
311
+ pages.push(b);
312
+ const batch = Array.isArray(b.results) ? b.results : [];
313
+ for (const r of batch) results.push(r);
314
+ if (opts.onPage) opts.onPage(b, pages.length - 1);
315
+ if (!batch.length) break;
316
+ url = b.next && b.next.href ? b.next.href : null;
317
+ }
318
+ return { results, pages, pageCount: pages.length };
319
+ }
320
+ }
321
+
322
+ module.exports = { RestClient, RestError, normFp, fmtFp, DEFAULT_PORT };
package/lib/outputs.js ADDED
@@ -0,0 +1,76 @@
1
+ 'use strict';
2
+ /**
3
+ * Shared helpers: every functional node has 3 outputs
4
+ * [0] response - full server response
5
+ * [1] status - state changes, for listing elsewhere (dashboards, logs)
6
+ * [2] error - errors, for listing elsewhere
7
+ */
8
+
9
+ const OUT_RESPONSE = 0;
10
+ const OUT_STATUS = 1;
11
+ const OUT_ERROR = 2;
12
+
13
+ const COLOURS = {
14
+ idle: { fill: 'grey', shape: 'ring' },
15
+ requesting: { fill: 'blue', shape: 'dot' },
16
+ paging: { fill: 'blue', shape: 'dot' },
17
+ done: { fill: 'green', shape: 'dot' },
18
+ subscribing: { fill: 'blue', shape: 'ring' },
19
+ subscribed: { fill: 'green', shape: 'ring' },
20
+ waiting: { fill: 'green', shape: 'ring' },
21
+ update: { fill: 'green', shape: 'dot' },
22
+ timeout: { fill: 'green', shape: 'ring' },
23
+ resubscribing: { fill: 'yellow', shape: 'ring' },
24
+ retrying: { fill: 'yellow', shape: 'dot' },
25
+ busy: { fill: 'yellow', shape: 'ring' },
26
+ stopped: { fill: 'grey', shape: 'ring' },
27
+ error: { fill: 'red', shape: 'dot' }
28
+ };
29
+
30
+ function nowIso() { return new Date().toISOString(); }
31
+
32
+ function send(node, index, msg) {
33
+ const arr = [null, null, null];
34
+ arr[index] = msg;
35
+ node.send(arr);
36
+ }
37
+
38
+ function setStatus(node, state, text, extra) {
39
+ const c = COLOURS[state] || COLOURS.idle;
40
+ const shortText = text.length > 60 ? text.slice(0, 57) + '...' : text;
41
+ node.status({ fill: c.fill, shape: c.shape, text: shortText });
42
+ send(node, OUT_STATUS, {
43
+ topic: 'status',
44
+ payload: Object.assign({
45
+ state,
46
+ text,
47
+ node: node.id,
48
+ name: node.name || '',
49
+ type: node.type,
50
+ time: nowIso()
51
+ }, extra || {})
52
+ });
53
+ }
54
+
55
+ function reportError(node, err, extra) {
56
+ const payload = Object.assign({
57
+ message: err && err.message ? err.message : String(err),
58
+ code: err && err.code,
59
+ statusCode: err && err.statusCode,
60
+ url: err && err.url,
61
+ body: err && err.body,
62
+ node: node.id,
63
+ name: node.name || '',
64
+ type: node.type,
65
+ time: nowIso()
66
+ }, extra || {});
67
+ node.status({ fill: 'red', shape: 'dot', text: payload.message.length > 60 ? payload.message.slice(0, 57) + '...' : payload.message });
68
+ // log only (no msg arg), so Catch nodes don't double-report what output 3 already carries
69
+ node.error(payload.message);
70
+ send(node, OUT_ERROR, { topic: 'error', payload, error: { message: payload.message, source: { id: node.id, type: node.type, name: node.name } } });
71
+ }
72
+
73
+ /** Resolve the global-context store name ('' -> default store). */
74
+ function storeName(s) { return s && s !== 'default' ? s : undefined; }
75
+
76
+ module.exports = { OUT_RESPONSE, OUT_STATUS, OUT_ERROR, send, setStatus, reportError, storeName, nowIso };
@@ -0,0 +1,101 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType('tdn-rest-item-status', {
3
+ category: 'TDN REST',
4
+ color: '#E2D96E',
5
+ defaults: {
6
+ name: { value: '' },
7
+ server: { value: '', type: 'tdn-rest-server', required: true },
8
+ pollDelay: { value: 1000, validate: function (v) { var n = Number(v); return !isNaN(n) && n >= 0 && n <= 20000; } },
9
+ retryDelay: { value: 5000, validate: RED.validators.number() },
10
+ maxRetryDelay: { value: 60000, validate: RED.validators.number() },
11
+ emitTimeouts: { value: false },
12
+ contextKey: { value: '' },
13
+ contextStore: { value: '' }
14
+ },
15
+ inputs: 1,
16
+ outputs: 3,
17
+ outputLabels: ['status updates', 'node status', 'error'],
18
+ icon: 'font-awesome/fa-heartbeat',
19
+ label: function () { return this.name || 'item status updates'; },
20
+ paletteLabel: 'item status',
21
+ oneditprepare: function () {
22
+ var sel = $('#node-input-contextStore');
23
+ var stores = (RED.settings.context && RED.settings.context.stores) || ['default'];
24
+ sel.empty();
25
+ sel.append($('<option>').val('').text('default'));
26
+ stores.forEach(function (s) { if (s !== 'default') sel.append($('<option>').val(s).text(s)); });
27
+ sel.val(this.contextStore || '');
28
+ }
29
+ });
30
+ </script>
31
+
32
+ <script type="text/html" data-template-name="tdn-rest-item-status">
33
+ <div class="form-row">
34
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
35
+ <input type="text" id="node-input-name" placeholder="item status updates">
36
+ </div>
37
+ <div class="form-row">
38
+ <label for="node-input-server"><i class="fa fa-server"></i> Server</label>
39
+ <input type="text" id="node-input-server">
40
+ </div>
41
+ <div class="form-row">
42
+ <label for="node-input-pollDelay">Delay between waits (ms)</label>
43
+ <input type="text" id="node-input-pollDelay" style="width:100px"> <span>0&ndash;20000</span>
44
+ </div>
45
+ <div class="form-row">
46
+ <label for="node-input-retryDelay">Retry delay (ms)</label>
47
+ <input type="text" id="node-input-retryDelay" style="width:100px">
48
+ </div>
49
+ <div class="form-row">
50
+ <label for="node-input-maxRetryDelay">Max retry delay (ms)</label>
51
+ <input type="text" id="node-input-maxRetryDelay" style="width:100px">
52
+ </div>
53
+ <div class="form-row">
54
+ <label>&nbsp;</label>
55
+ <input type="checkbox" id="node-input-emitTimeouts" style="display:inline-block;width:auto;vertical-align:top">
56
+ <label for="node-input-emitTimeouts" style="width:auto">Also output empty (timed-out) wait responses</label>
57
+ </div>
58
+ <div class="form-row">
59
+ <label for="node-input-contextKey"><i class="fa fa-globe"></i> Global key</label>
60
+ <input type="text" id="node-input-contextKey" placeholder="optional, e.g. rest_itemStatus">
61
+ </div>
62
+ <div class="form-row">
63
+ <label for="node-input-contextStore"><i class="fa fa-database"></i> Context store</label>
64
+ <select id="node-input-contextStore"></select>
65
+ </div>
66
+ </script>
67
+
68
+ <script type="text/html" data-help-name="tdn-rest-item-status">
69
+ <p>Subscribes to status changes for a set of REST Server items and keeps listening indefinitely.
70
+ Needs REST Server v8.30+ and the RESTStatus licence.</p>
71
+ <p>Sequence: <code>POST features.items.updates.href</code> with <code>{"itemIds":[...]}</code> (returns current state),
72
+ then an update-wait <code>GET next.href</code>. When an update arrives it is output and a new update-wait is sent.
73
+ When an update-wait times out (~50&nbsp;s, empty <code>updates</code>) another one is sent.
74
+ A <code>404</code> means the server dropped the subscription (more than 30&nbsp;s between calls, or server restart), so it re-subscribes.
75
+ Any other error is reported, then retried with exponential back-off.</p>
76
+ <h3>Input</h3>
77
+ <dl class="message-properties">
78
+ <dt>payload <span class="property-type">string | number | array | object</span></dt>
79
+ <dd>The source id(s): <code>"508"</code>, <code>"508,526"</code>, <code>["508","526"]</code>,
80
+ <code>{"itemIds":["508","526"]}</code>, or an array of item objects with an <code>id</code> (e.g. straight from <i>get items</i> results).
81
+ <code>msg.itemIds</code> takes precedence if present.</dd>
82
+ <dd>A new trigger replaces the running subscription. <code>payload</code> = <code>"stop"</code> or <code>msg.stop = true</code> stops it.</dd>
83
+ </dl>
84
+ <h3>Outputs</h3>
85
+ <ol class="node-ports">
86
+ <li>Status updates
87
+ <dl class="message-properties">
88
+ <dt>payload <span class="property-type">object</span></dt><dd>Full server response: <code>{updates:[{id,status,statusText,statusFlags}], next:{href}}</code>.</dd>
89
+ <dt>updates <span class="property-type">array</span></dt><dd>Shortcut to <code>payload.updates</code>.</dd>
90
+ <dt>phase <span class="property-type">string</span></dt><dd><code>subscribe</code> (initial state from the POST), <code>update</code>, or <code>timeout</code> (only if enabled).</dd>
91
+ <dt>itemIds / seq / statusCode / url</dt><dd></dd>
92
+ </dl>
93
+ </li>
94
+ <li>Node status &ndash; <code>payload.state</code>: <code>subscribing</code>, <code>subscribed</code>, <code>update</code>, <code>resubscribing</code>, <code>retrying</code>, <code>stopped</code>. Each long-poll wait and timeout shows on the node badge only, so this output isn't flooded every ~50&nbsp;s.</li>
95
+ <li>Error &ndash; <code>payload.message</code>, <code>statusCode</code>, <code>body</code> (on 9.60+ a 403 lists the ids you may monitor), <code>attempt</code>, <code>retryInMs</code>.</li>
96
+ </ol>
97
+ <h3>Global context (optional)</h3>
98
+ <p>If a key is set, <code>global.&lt;key&gt;</code> is kept as a map of item id &rarr; latest status (with an <code>updated</code> time).</p>
99
+ <h3>Notes</h3>
100
+ <p>Before 9.50 the first update-wait repeats the POST's results; that is the server's behaviour and is passed through as-is.</p>
101
+ </script>