@aaqu/fromcubes-react 0.0.0-stage → 0.2.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.
Files changed (37) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +435 -2
  3. package/THIRD-PARTY-NOTICES.md +164 -0
  4. package/examples/D3 Poland Map.json +90 -0
  5. package/examples/Live Chart.json +101 -0
  6. package/examples/PixiJS Sprites.json +114 -0
  7. package/examples/Sensor Portal.json +93 -0
  8. package/examples/Shared Components.json +68 -0
  9. package/examples/Three.js Scene.json +118 -0
  10. package/examples/Utility Hooks.json +94 -0
  11. package/examples/WebGPU Shader.json +110 -0
  12. package/nodes/editor/ark-pixel.LICENSE.txt +96 -0
  13. package/nodes/editor/ark-pixel.woff2 +0 -0
  14. package/nodes/editor/file-tree.js +403 -0
  15. package/nodes/editor/format.js +182 -0
  16. package/nodes/editor/icons.svg +46 -0
  17. package/nodes/editor/mark.svg +5 -0
  18. package/nodes/editor/provider-settings.js +192 -0
  19. package/nodes/editor/workspace.css +660 -0
  20. package/nodes/editor/workspace.js +1565 -0
  21. package/nodes/icons/fromcubes-component.svg +27 -0
  22. package/nodes/icons/fromcubes-react.svg +25 -0
  23. package/nodes/icons/fromcubes-utility.svg +21 -0
  24. package/nodes/lib/admin-api.js +238 -0
  25. package/nodes/lib/admin-security.js +161 -0
  26. package/nodes/lib/api.js +29 -0
  27. package/nodes/lib/helpers.js +990 -0
  28. package/nodes/lib/hooks.js +145 -0
  29. package/nodes/lib/page-builder.js +499 -0
  30. package/nodes/lib/portal-page-route.js +131 -0
  31. package/nodes/lib/providers.js +581 -0
  32. package/nodes/lib/registry-nodes.js +331 -0
  33. package/nodes/lib/router.js +103 -0
  34. package/nodes/lib/ws-heartbeat.js +58 -0
  35. package/nodes/portal-react.html +2539 -0
  36. package/nodes/portal-react.js +1652 -0
  37. package/package.json +73 -4
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Public portal page route factory.
3
+ *
4
+ * Owns only the HTTP response branching for a portal endpoint:
5
+ * building/teardown, hard build error, degraded last-good build, and fresh
6
+ * successful build. Runtime lifecycle, WebSocket handling, and pageState
7
+ * mutation stay in portal-react.js.
8
+ */
9
+
10
+ function buildBuildingPage(wsPath) {
11
+ return `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Building\u2026</title><style>@keyframes __sp{to{transform:rotate(360deg)}}body{font-family:monospace;background:#111;color:#888;margin:0;min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center}</style></head><body><div style="font-size:24px;margin-bottom:16px">Building\u2026</div><div style="width:40px;height:40px;border:3px solid #333;border-top-color:#888;border-radius:50%;animation:__sp .8s linear infinite"></div><script>(function(){let r=0;function c(){const p=location.protocol==='https:'?'wss:':'ws:';const ws=new WebSocket(p+'//'+location.host+'${wsPath}');ws.onmessage=function(e){try{const m=JSON.parse(e.data);if((m.type==='version'&&m.hash)||m.type==='error')location.reload();}catch(_){}};ws.onclose=function(){const d=Math.min(500*Math.pow(2,r),8000);r++;setTimeout(c,d);};ws.onerror=function(){ws.close();};}c();})()</script></body></html>`;
12
+ }
13
+
14
+ function withNoStore(res) {
15
+ return res.set("Cache-Control", "no-store");
16
+ }
17
+
18
+ /**
19
+ * @param {Object} opts
20
+ * @param {string} opts.endpoint
21
+ * @param {Object<string, Object>} opts.pageState
22
+ * @param {string} opts.wsPath
23
+ * @param {string} opts.pageTitle
24
+ * @param {string} opts.nodeRoot
25
+ * @param {Function} opts.buildPage
26
+ * @param {Function} opts.buildErrorPage
27
+ * @param {function(Object): Promise<Object|null>} opts.resolveUser Identity provider (lib/providers).
28
+ * @returns {Function}
29
+ */
30
+ function createPortalPageHandler(opts) {
31
+ const {
32
+ endpoint,
33
+ pageState,
34
+ wsPath,
35
+ pageTitle,
36
+ nodeRoot,
37
+ buildPage,
38
+ buildErrorPage,
39
+ resolveUser,
40
+ } = opts;
41
+
42
+ return async function portalPageHandler(req, res) {
43
+ let cssTimer = null;
44
+ try {
45
+ const state = pageState[endpoint];
46
+ if (!state || state.building || !state.compiled) {
47
+ const bWsPath = state?.wsPath || wsPath;
48
+ withNoStore(res)
49
+ .type("text/html")
50
+ .send(buildBuildingPage(bWsPath));
51
+ return;
52
+ }
53
+
54
+ withNoStore(res);
55
+ if (state.compiled.error) {
56
+ if (state.lastGood) {
57
+ const user = state.portalAuth ? await resolveUser(req) : null;
58
+ res
59
+ .type("text/html")
60
+ .send(
61
+ buildPage(
62
+ state.lastGood.pageTitle,
63
+ state.lastGood.compiledJs,
64
+ state.wsPath,
65
+ state.lastGood.customHead,
66
+ state.lastGood.cssHash,
67
+ user,
68
+ state.showWsStatus,
69
+ nodeRoot,
70
+ ),
71
+ );
72
+ return;
73
+ }
74
+
75
+ res
76
+ .status(500)
77
+ .type("text/html")
78
+ .send(
79
+ buildErrorPage(
80
+ state.pageTitle,
81
+ state.compiled.error,
82
+ state.wsPath,
83
+ ),
84
+ );
85
+ return;
86
+ }
87
+
88
+ const { cssHash } = await Promise.race([
89
+ state.cssReady,
90
+ new Promise((_, reject) => {
91
+ cssTimer = setTimeout(
92
+ () => reject(new Error("CSS generation timeout")),
93
+ 15000,
94
+ );
95
+ }),
96
+ ]);
97
+ // Clear the losing timer — without this every request leaves a 15 s
98
+ // timer pending after the CSS promise wins the race.
99
+ clearTimeout(cssTimer);
100
+ const user = state.portalAuth ? await resolveUser(req) : null;
101
+ res
102
+ .type("text/html")
103
+ .send(
104
+ buildPage(
105
+ state.pageTitle,
106
+ state.compiled.js,
107
+ state.wsPath,
108
+ state.customHead,
109
+ cssHash,
110
+ user,
111
+ state.showWsStatus,
112
+ nodeRoot,
113
+ ),
114
+ );
115
+ } catch (e) {
116
+ clearTimeout(cssTimer);
117
+ res
118
+ .status(500)
119
+ .type("text/html")
120
+ .send(
121
+ buildErrorPage(
122
+ pageTitle,
123
+ "Page build failed: " + e.message,
124
+ wsPath,
125
+ ),
126
+ );
127
+ }
128
+ };
129
+ }
130
+
131
+ module.exports = { createPortalPageHandler };
@@ -0,0 +1,581 @@
1
+ /** @module nodes/lib/providers */
2
+
3
+ /**
4
+ * Pluggable providers for @aaqu/fromcubes-react.
5
+ *
6
+ * Two extension points, each a Node-RED plugin type:
7
+ *
8
+ * RED.plugins.registerPlugin("my-sso", {
9
+ * type: "fromcubes-identity",
10
+ * apiVersion: 1,
11
+ * resolveUser(req) { return { userId, login, displayName, email, role, groups } | null },
12
+ * })
13
+ *
14
+ * RED.plugins.registerPlugin("my-css", {
15
+ * type: "fromcubes-css",
16
+ * apiVersion: 1,
17
+ * generate(source) { return { css } },
18
+ * editor: { classes() { return ["p-4", …] } }, // optional: completion
19
+ * })
20
+ *
21
+ * The core builds in only `none` for each kind. Everything that does real
22
+ * work — `headers` (@aaqu/fromcubes-auth-headers) and
23
+ * `tailwind` (@aaqu/fromcubes-tailwind) — is a Node-RED plugin like any third-party one,
24
+ * found through RED.plugins and held to the same apiVersion and validation.
25
+ * Two plugins registering the same provider id of one type are ambiguous and
26
+ * neither is used.
27
+ *
28
+ * Selection is explicit, exactly one provider per kind, first match wins:
29
+ * 1. `RED.settings.portalReact.identityProvider` / `.cssEngine` (settings.js)
30
+ * — the administrator's word; the editor shows it locked.
31
+ * 2. The choice made in the editor (Settings → Fromcubes), persisted in
32
+ * `<userDir>/fromcubes/providers.json`.
33
+ * 3. The default: the official add-on when it is installed — `headers`
34
+ * from @aaqu/fromcubes-auth-headers, `tailwind` from
35
+ * @aaqu/fromcubes-tailwind — and `none` when it is not. The core works
36
+ * on its own; installing the add-on is what turns the feature on. Only
37
+ * the official package counts here: another package registering a
38
+ * plugin called `headers` never becomes the default identity provider
39
+ * (it can still be chosen explicitly, in 1 or 2).
40
+ * Installing a plugin never switches anything on its own — for identity that
41
+ * would let a package change who counts as logged in.
42
+ *
43
+ * Failure semantics:
44
+ * - identity: unknown provider, wrong apiVersion, throw, timeout or a result
45
+ * that does not validate ⇒ anonymous (`null`). Fail closed: anonymous is the
46
+ * least privileged answer.
47
+ * - css: unknown provider or wrong apiVersion ⇒ `generate` rejects, which the
48
+ * build turns into the yellow `css-fail` status (page still served).
49
+ */
50
+
51
+ const IDENTITY_TYPE = "fromcubes-identity";
52
+ const CSS_TYPE = "fromcubes-css";
53
+ const API_VERSION = 1;
54
+
55
+ const DEFAULT_IDENTITY = "headers";
56
+ const DEFAULT_CSS = "tailwind";
57
+ const NONE = "none";
58
+
59
+ // Bounds on what a provider may hand us. Generous enough that anything the
60
+ // built-in header parser produces passes untouched (its groups header is
61
+ // capped at 8 KB, so at most ~4096 one-char groups); anything above is
62
+ // dropped rather than cut, same reasoning as the groups header: a shortened
63
+ // value could match a different, real one.
64
+ const MAX_USER_STRING = 8192;
65
+ const MAX_GROUPS = 4096;
66
+ const IDENTITY_TIMEOUT_MS = 5000;
67
+ // Completion list sent to the editor. Tailwind's own list is a few thousand
68
+ // entries; the caps only stop a plugin from shipping something absurd to
69
+ // every editor that opens a dialog.
70
+ const MAX_EDITOR_CLASSES = 50000;
71
+ const MAX_EDITOR_CLASS_LEN = 200;
72
+
73
+ const USER_STRING_FIELDS = ["login", "displayName", "email", "role"];
74
+
75
+ const KINDS = {
76
+ identity: {
77
+ type: IDENTITY_TYPE,
78
+ setting: "identityProvider",
79
+ fallback: DEFAULT_IDENTITY,
80
+ fallbackModule: "@aaqu/fromcubes-auth-headers",
81
+ },
82
+ css: {
83
+ type: CSS_TYPE,
84
+ setting: "cssEngine",
85
+ fallback: DEFAULT_CSS,
86
+ fallbackModule: "@aaqu/fromcubes-tailwind",
87
+ },
88
+ };
89
+
90
+ // Provider ids travel through the settings file, the admin API and the
91
+ // editor's DOM. Plugin ids are free-form in Node-RED; anything outside this
92
+ // shape can be registered but not selected.
93
+ const PROVIDER_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
94
+
95
+ /**
96
+ * Error from `select()`, with a code the admin API maps to an HTTP status.
97
+ */
98
+ class ProviderSelectError extends Error {
99
+ /**
100
+ * @param {"bad-kind"|"bad-id"|"locked"|"unknown"} code
101
+ * @param {string} message
102
+ */
103
+ constructor(code, message) {
104
+ super(message);
105
+ this.code = code;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * @param {*} v
111
+ * @returns {boolean}
112
+ */
113
+ function isBoundedString(v) {
114
+ return typeof v === "string" && v.length > 0 && v.length <= MAX_USER_STRING;
115
+ }
116
+
117
+ /**
118
+ * Normalize a provider's answer into a PortalUser, or null. Only the known
119
+ * fields are copied — never the provider's object itself — so a provider
120
+ * cannot smuggle extra keys (or a getter, or a prototype) into `msg._client`
121
+ * and `useNodeRed().user`.
122
+ *
123
+ * @param {*} raw
124
+ * @returns {import("./helpers").PortalUser|null}
125
+ */
126
+ function sanitizeUser(raw) {
127
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
128
+ if (!isBoundedString(raw.userId)) return null;
129
+ const user = { userId: raw.userId };
130
+ for (const field of USER_STRING_FIELDS) {
131
+ if (isBoundedString(raw[field])) user[field] = raw[field];
132
+ }
133
+ const groups = [];
134
+ if (Array.isArray(raw.groups) && raw.groups.length <= MAX_GROUPS) {
135
+ for (const g of raw.groups) if (isBoundedString(g)) groups.push(g);
136
+ }
137
+ user.groups = groups;
138
+ return user;
139
+ }
140
+
141
+ /**
142
+ * The only providers built into the core: one per kind that does nothing, so
143
+ * "no identity" and "no stylesheet" are always choosable. Their ids are
144
+ * reserved.
145
+ *
146
+ * @returns {{ identity: Object<string,Object>, css: Object<string,Object> }}
147
+ */
148
+ function builtinProviders() {
149
+ return {
150
+ identity: {
151
+ none: {
152
+ id: "none",
153
+ type: IDENTITY_TYPE,
154
+ apiVersion: API_VERSION,
155
+ label: "Nobody is identified — every session is anonymous",
156
+ resolveUser: () => null,
157
+ },
158
+ },
159
+ css: {
160
+ none: {
161
+ id: "none",
162
+ type: CSS_TYPE,
163
+ apiVersion: API_VERSION,
164
+ label: "No stylesheet",
165
+ generate: () => ({ css: "" }),
166
+ },
167
+ },
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Create the provider registry bound to `RED`.
173
+ *
174
+ * @param {Object} RED
175
+ * @param {{ identityTimeoutMs?: number, storeFile?: string }} [opts]
176
+ * `storeFile`: where the editor's choice is persisted; without it the
177
+ * choice lives in memory only (tests, embedded use).
178
+ * @returns {{
179
+ * resolveUser: function(Object): Promise<Object|null>,
180
+ * generateCss: function(string): Promise<{css: string}>,
181
+ * identityId: function(): string,
182
+ * cssEngineId: function(): string,
183
+ * describe: function(): Object,
184
+ * select: function(string, string): boolean,
185
+ * editorClasses: function(): Promise<{engine: string, classes: Array<string>}>,
186
+ * }}
187
+ */
188
+ function createProviders(RED, opts = {}) {
189
+ const fs = require("fs");
190
+ const path = require("path");
191
+ const builtins = builtinProviders();
192
+ const tables = { identity: builtins.identity, css: builtins.css };
193
+ const identityTimeoutMs = opts.identityTimeoutMs ?? IDENTITY_TIMEOUT_MS;
194
+ const storeFile = opts.storeFile || null;
195
+ const logged = new Set();
196
+ /** @type {{identity?: string, css?: string}} */
197
+ const chosen = loadChoice();
198
+ // provider object → its validated class list. Keyed by the object, not the
199
+ // id, so a plugin reinstalled at runtime (a new definition) is asked again.
200
+ const classCache = new WeakMap();
201
+
202
+ /**
203
+ * Log a configuration problem once per process — these are evaluated per
204
+ * request, and a misconfigured provider must not flood the log.
205
+ * @param {string} msg
206
+ * @returns {void}
207
+ */
208
+ function logOnce(msg) {
209
+ if (logged.has(msg)) return;
210
+ logged.add(msg);
211
+ RED.log.error("[portal-react] " + msg);
212
+ }
213
+
214
+ /** @returns {Object} */
215
+ function cfg() {
216
+ return (RED.settings && RED.settings.portalReact) || {};
217
+ }
218
+
219
+ /**
220
+ * Read the editor's persisted choice. A missing file is the normal first
221
+ * run; a malformed one is reported and ignored (defaults apply) rather
222
+ * than stopping Node-RED.
223
+ * @returns {{identity?: string, css?: string}}
224
+ */
225
+ function loadChoice() {
226
+ if (!storeFile) return {};
227
+ let raw;
228
+ try {
229
+ raw = fs.readFileSync(storeFile, "utf8");
230
+ } catch (e) {
231
+ if (e.code !== "ENOENT") {
232
+ RED.log.warn(`[portal-react] cannot read ${storeFile}: ${e.message}`);
233
+ }
234
+ return {};
235
+ }
236
+ let data;
237
+ try {
238
+ data = JSON.parse(raw);
239
+ } catch (e) {
240
+ RED.log.warn(`[portal-react] ignoring malformed ${storeFile}: ${e.message}`);
241
+ return {};
242
+ }
243
+ const out = {};
244
+ if (data && typeof data === "object") {
245
+ for (const kind of Object.keys(KINDS)) {
246
+ if (typeof data[kind] === "string" && PROVIDER_ID_RE.test(data[kind])) {
247
+ out[kind] = data[kind];
248
+ }
249
+ }
250
+ }
251
+ return out;
252
+ }
253
+
254
+ /**
255
+ * Persist the choice atomically: a crash mid-write must not leave a
256
+ * half-written file that silently resets identity to the default.
257
+ * @returns {void}
258
+ */
259
+ function saveChoice() {
260
+ if (!storeFile) return;
261
+ fs.mkdirSync(path.dirname(storeFile), { recursive: true });
262
+ const tmp = storeFile + ".tmp";
263
+ fs.writeFileSync(tmp, JSON.stringify(chosen, null, 2) + "\n", "utf8");
264
+ fs.renameSync(tmp, storeFile);
265
+ }
266
+
267
+ /**
268
+ * The provider id in force for `kind`, and where it came from.
269
+ * @param {"identity"|"css"} kind
270
+ * @returns {{ id: string, source: "settings"|"editor"|"default" }}
271
+ */
272
+ function current(kind) {
273
+ const k = KINDS[kind];
274
+ const fromSettings = cfg()[k.setting];
275
+ if (typeof fromSettings === "string" && fromSettings) {
276
+ return { id: fromSettings, source: "settings" };
277
+ }
278
+ if (chosen[kind]) return { id: chosen[kind], source: "editor" };
279
+ const official = pluginsOf(k.type).some(
280
+ (p) => p.id === k.fallback && p.module === k.fallbackModule,
281
+ );
282
+ return { id: official ? k.fallback : NONE, source: "default", addonMissing: !official };
283
+ }
284
+
285
+ /**
286
+ * Log once, at info level, why a kind runs on `none` by default — so a
287
+ * core installed on its own explains itself instead of failing quietly.
288
+ * @param {string} kind
289
+ * @param {string} msg
290
+ * @returns {void}
291
+ */
292
+ function explainNone(kind, msg) {
293
+ const key = "none:" + kind;
294
+ if (logged.has(key)) return;
295
+ logged.add(key);
296
+ if (RED.log.info) RED.log.info("[portal-react] " + msg);
297
+ }
298
+
299
+ /**
300
+ * Plugins registered with Node-RED for `type`. Never throws.
301
+ * @param {string} type
302
+ * @returns {Array<Object>}
303
+ */
304
+ function pluginsOf(type) {
305
+ try {
306
+ return ((RED.plugins && RED.plugins.getByType(type)) || []).filter(Boolean);
307
+ } catch (_) {
308
+ return [];
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Find the provider `id` of `type`: built-ins first (their ids are
314
+ * reserved), then plugins registered with Node-RED. An id registered by
315
+ * more than one plugin is refused rather than resolved by load order — for
316
+ * identity, "whichever package loaded first" must never decide who is
317
+ * logged in.
318
+ *
319
+ * @param {string} type
320
+ * @param {Object<string,Object>} table
321
+ * @param {string} id
322
+ * @returns {{ provider: Object|null, error: string|null }}
323
+ */
324
+ function lookup(type, table, id) {
325
+ const plugins = pluginsOf(type);
326
+ // Scanned before the built-in short-circuit: a plugin shadowing a
327
+ // built-in id must be reported even while the built-in is in use.
328
+ for (const p of plugins) {
329
+ if (p && Object.prototype.hasOwnProperty.call(table, p.id)) {
330
+ logOnce(`${type} plugin "${p.id}" ignored: id is reserved for a built-in`);
331
+ }
332
+ }
333
+ if (Object.prototype.hasOwnProperty.call(table, id)) {
334
+ return { provider: table[id], error: null };
335
+ }
336
+ const matches = plugins.filter((p) => p && p.id === id);
337
+ if (matches.length === 0) {
338
+ return { provider: null, error: `${type} provider "${id}" is not registered` };
339
+ }
340
+ if (matches.length > 1) {
341
+ const mods = matches.map((p) => p.module || "?").join(", ");
342
+ return {
343
+ provider: null,
344
+ error: `${type} provider "${id}" is registered by more than one package (${mods})`,
345
+ };
346
+ }
347
+ const found = matches[0];
348
+ if (found.apiVersion !== API_VERSION) {
349
+ return {
350
+ provider: null,
351
+ error: `${type} provider "${id}" has apiVersion ${JSON.stringify(found.apiVersion)}, expected ${API_VERSION}`,
352
+ };
353
+ }
354
+ return { provider: found, error: null };
355
+ }
356
+
357
+ /** @returns {string} */
358
+ function identityId() {
359
+ return current("identity").id;
360
+ }
361
+
362
+ /** @returns {string} */
363
+ function cssEngineId() {
364
+ return current("css").id;
365
+ }
366
+
367
+ /**
368
+ * Everything the editor's settings pane needs: per kind, what is in force,
369
+ * where it came from, whether it can be changed, and every provider that
370
+ * could be chosen. A selected id that is no longer registered stays in
371
+ * the list, flagged `missing`, so the editor can say so instead of
372
+ * silently showing nothing selected.
373
+ *
374
+ * @returns {Object<string, {selected: string, source: string, locked: boolean, missing: boolean,
375
+ * options: Array<{id: string, label: string, module: string, builtin: boolean, missing?: boolean, problem?: string}>}>}
376
+ */
377
+ function describe() {
378
+ const out = {};
379
+ for (const [kind, k] of Object.entries(KINDS)) {
380
+ const table = tables[kind];
381
+ const options = Object.values(table).map((p) => ({
382
+ id: p.id,
383
+ label: p.label || "",
384
+ module: "built-in",
385
+ builtin: true,
386
+ }));
387
+ const seen = new Set(options.map((o) => o.id));
388
+ const plugins = pluginsOf(k.type);
389
+ const count = {};
390
+ for (const p of plugins) count[p.id] = (count[p.id] || 0) + 1;
391
+ for (const p of plugins) {
392
+ if (typeof p.id !== "string" || seen.has(p.id)) continue;
393
+ seen.add(p.id);
394
+ const opt = {
395
+ id: p.id,
396
+ label: typeof p.label === "string" ? p.label : "",
397
+ module: typeof p.module === "string" ? p.module : "",
398
+ builtin: false,
399
+ };
400
+ if (count[p.id] > 1) {
401
+ opt.problem = "registered by more than one package — neither is used";
402
+ } else if (p.apiVersion !== API_VERSION) {
403
+ opt.problem = `apiVersion ${JSON.stringify(p.apiVersion)}, expected ${API_VERSION}`;
404
+ } else if (!PROVIDER_ID_RE.test(p.id)) {
405
+ opt.problem = "id cannot be selected (letters, digits, . _ - only)";
406
+ }
407
+ options.push(opt);
408
+ }
409
+ const { id, source } = current(kind);
410
+ const missing = !seen.has(id);
411
+ if (missing) {
412
+ options.push({ id, label: "", module: "", builtin: false, missing: true });
413
+ }
414
+ out[kind] = { selected: id, source, locked: source === "settings", missing, options };
415
+ }
416
+ return out;
417
+ }
418
+
419
+ /**
420
+ * Choose the provider for `kind` from the editor and persist it. Only a
421
+ * provider that is registered and usable right now can be chosen.
422
+ *
423
+ * @param {string} kind "identity" | "css"
424
+ * @param {string} id
425
+ * @returns {boolean} true when the provider in force changed.
426
+ * @throws {ProviderSelectError}
427
+ */
428
+ function select(kind, id) {
429
+ if (!Object.prototype.hasOwnProperty.call(KINDS, kind)) {
430
+ throw new ProviderSelectError("bad-kind", `unknown provider kind "${kind}"`);
431
+ }
432
+ if (typeof id !== "string" || !PROVIDER_ID_RE.test(id)) {
433
+ throw new ProviderSelectError("bad-id", "invalid provider id");
434
+ }
435
+ const before = current(kind);
436
+ if (before.source === "settings") {
437
+ throw new ProviderSelectError(
438
+ "locked",
439
+ `${kind} provider is set in settings.js (portalReact.${KINDS[kind].setting})`,
440
+ );
441
+ }
442
+ const { provider, error } = lookup(KINDS[kind].type, tables[kind], id);
443
+ if (!provider) throw new ProviderSelectError("unknown", error);
444
+ const prev = chosen[kind];
445
+ chosen[kind] = id;
446
+ try {
447
+ saveChoice();
448
+ } catch (e) {
449
+ if (prev === undefined) delete chosen[kind];
450
+ else chosen[kind] = prev;
451
+ throw e;
452
+ }
453
+ return before.id !== id;
454
+ }
455
+
456
+ /**
457
+ * Who is behind this request. Never rejects: every failure is anonymous.
458
+ *
459
+ * @param {Object} req HTTP request or WS upgrade request (Node IncomingMessage).
460
+ * @returns {Promise<Object|null>}
461
+ */
462
+ async function resolveUser(req) {
463
+ const cur = current("identity");
464
+ const id = cur.id;
465
+ // Called only for portals with Portal Auth on — so this is someone who
466
+ // expects users and gets none.
467
+ if (cur.addonMissing) {
468
+ explainNone("identity", "Portal Auth is on but no identity provider is installed, so every " +
469
+ "session is anonymous. For users from x-portal-user-* headers install " +
470
+ "@aaqu/fromcubes-auth-headers, or choose a provider in Settings → Fromcubes.");
471
+ }
472
+ const { provider, error } = lookup(IDENTITY_TYPE, builtins.identity, id);
473
+ if (!provider) {
474
+ logOnce(error + " — every session is anonymous");
475
+ return null;
476
+ }
477
+ if (typeof provider.resolveUser !== "function") {
478
+ logOnce(`${IDENTITY_TYPE} provider "${id}" has no resolveUser() — every session is anonymous`);
479
+ return null;
480
+ }
481
+ let timer = null;
482
+ try {
483
+ const raw = await Promise.race([
484
+ Promise.resolve().then(() => provider.resolveUser(req)),
485
+ new Promise((_, reject) => {
486
+ timer = setTimeout(
487
+ () => reject(new Error(`timed out after ${identityTimeoutMs} ms`)),
488
+ identityTimeoutMs,
489
+ );
490
+ }),
491
+ ]);
492
+ return sanitizeUser(raw);
493
+ } catch (e) {
494
+ RED.log.warn(`[portal-react] identity provider "${id}" failed: ${e.message}`);
495
+ return null;
496
+ } finally {
497
+ clearTimeout(timer);
498
+ }
499
+ }
500
+
501
+ /**
502
+ * Generate the stylesheet for a portal's source. Rejects on failure — the
503
+ * caller owns the `css-fail` status.
504
+ *
505
+ * @param {string} source
506
+ * @returns {Promise<{css: string}>}
507
+ */
508
+ async function generateCss(source) {
509
+ const cur = current("css");
510
+ const id = cur.id;
511
+ if (cur.addonMissing) {
512
+ explainNone("css", "No CSS engine is installed, so portals are served without a stylesheet. " +
513
+ "For Tailwind CSS install @aaqu/fromcubes-tailwind.");
514
+ }
515
+ const { provider, error } = lookup(CSS_TYPE, builtins.css, id);
516
+ if (!provider) throw new Error(error);
517
+ if (typeof provider.generate !== "function") {
518
+ throw new Error(`${CSS_TYPE} provider "${id}" has no generate()`);
519
+ }
520
+ const out = await provider.generate(source);
521
+ if (!out || typeof out.css !== "string") {
522
+ throw new Error(`${CSS_TYPE} provider "${id}" returned no css string`);
523
+ }
524
+ return { css: out.css };
525
+ }
526
+
527
+ /**
528
+ * Class names the editor offers inside `className="…"` for the engine in
529
+ * force. Optional for plugins (`editor.classes()`); an engine without it,
530
+ * an unusable engine, or one that fails yields an empty list — completion
531
+ * just stays quiet, nothing else depends on it.
532
+ *
533
+ * @returns {Promise<{engine: string, classes: Array<string>}>}
534
+ */
535
+ async function editorClasses() {
536
+ const engine = cssEngineId();
537
+ const { provider } = lookup(CSS_TYPE, builtins.css, engine);
538
+ const fn = provider && provider.editor && provider.editor.classes;
539
+ if (typeof fn !== "function") return { engine, classes: [] };
540
+ if (classCache.has(provider)) return { engine, classes: classCache.get(provider) };
541
+ let raw;
542
+ try {
543
+ raw = await fn.call(provider.editor);
544
+ } catch (e) {
545
+ RED.log.warn(`[portal-react] css engine "${engine}" editor.classes() failed: ${e.message}`);
546
+ return { engine, classes: [] };
547
+ }
548
+ const classes = [];
549
+ if (Array.isArray(raw)) {
550
+ const seen = new Set();
551
+ for (const c of raw) {
552
+ if (classes.length >= MAX_EDITOR_CLASSES) break;
553
+ if (typeof c !== "string" || !c || c.length > MAX_EDITOR_CLASS_LEN || /\s/.test(c)) continue;
554
+ if (seen.has(c)) continue;
555
+ seen.add(c);
556
+ classes.push(c);
557
+ }
558
+ } else {
559
+ RED.log.warn(`[portal-react] css engine "${engine}" editor.classes() did not return an array`);
560
+ }
561
+ classCache.set(provider, classes);
562
+ return { engine, classes };
563
+ }
564
+
565
+ return { resolveUser, generateCss, identityId, cssEngineId, describe, select, editorClasses };
566
+ }
567
+
568
+ module.exports = {
569
+ createProviders,
570
+ sanitizeUser,
571
+ ProviderSelectError,
572
+ PROVIDER_ID_RE,
573
+ IDENTITY_TYPE,
574
+ CSS_TYPE,
575
+ API_VERSION,
576
+ DEFAULT_IDENTITY,
577
+ DEFAULT_CSS,
578
+ MAX_USER_STRING,
579
+ MAX_GROUPS,
580
+ MAX_EDITOR_CLASSES,
581
+ };