@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,27 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="44" height="60" viewBox="0 0 44 60">
2
+ <!-- The fromcubes mark, traced from the project artwork by tools/build-icon.mjs.
3
+ The badge is cut out of the mark with a mask rather than hidden behind a
4
+ halo painted in the background colour: an icon cannot know what is behind
5
+ it, and masking keeps the traced edges exact instead of re-rasterising them.
6
+ The badge is the "component" icon from Lucide (https://lucide.dev), ISC —
7
+ see THIRD-PARTY-NOTICES.md. Worn by fc-portal-component. React's own logo is a
8
+ Meta trademark and is deliberately not used. -->
9
+ <mask id="fc-cut" maskUnits="userSpaceOnUse" x="0" y="0" width="44" height="60">
10
+ <rect width="44" height="60" fill="#ffffff"/>
11
+ <g transform="translate(25.216003874368653 34.26600387436865) scale(0.7903)" fill="none" stroke="#000000"
12
+ stroke-width="6.83" stroke-linecap="round" stroke-linejoin="round">
13
+ <path d="M15.536 11.293a1 1 0 0 0 0 1.414l2.376 2.377a1 1 0 0 0 1.414 0l2.377-2.377a1 1 0 0 0 0-1.414l-2.377-2.377a1 1 0 0 0-1.414 0z"/>
14
+ <path d="M2.297 11.293a1 1 0 0 0 0 1.414l2.377 2.377a1 1 0 0 0 1.414 0l2.377-2.377a1 1 0 0 0 0-1.414L6.088 8.916a1 1 0 0 0-1.414 0z"/>
15
+ <path d="M8.916 17.912a1 1 0 0 0 0 1.415l2.377 2.376a1 1 0 0 0 1.414 0l2.377-2.376a1 1 0 0 0 0-1.415l-2.377-2.376a1 1 0 0 0-1.414 0z"/>
16
+ <path d="M8.916 4.674a1 1 0 0 0 0 1.414l2.377 2.376a1 1 0 0 0 1.414 0l2.377-2.376a1 1 0 0 0 0-1.414l-2.377-2.377a1 1 0 0 0-1.414 0z"/>
17
+ </g>
18
+ </mask>
19
+ <path fill="#10181d" fill-rule="evenodd" mask="url(#fc-cut)" d="M20.79 6.25L22.01 6.25L23.23 6.86L32.17 12.34L37.65 15.19L38.46 16.00L38.46 35.50L37.65 36.31L32.17 39.16L23.23 44.64L22.01 45.25L20.38 45.05L12.06 39.97L6.57 37.13L4.34 35.50L4.34 16.00L9.42 12.75L20.79 6.45ZM21.20 8.08L6.98 16.20L7.18 16.61L7.99 16.61L10.84 14.78L14.29 13.97L15.92 13.97L22.42 17.63L24.65 19.05L24.85 20.67L24.04 21.48L23.43 22.91L20.99 24.13L21.20 24.73L35.82 16.41L35.01 16.00L31.96 18.03L27.09 18.84L20.59 15.19L19.37 14.17L18.15 13.77L17.95 12.14L19.57 9.70L22.01 8.48L21.40 8.08ZM23.63 9.50L20.99 10.92L19.98 12.75L27.49 17.02L30.95 16.41L33.38 14.78L23.84 9.50ZM14.49 15.80L11.45 16.41L9.42 17.83L15.71 21.28L16.93 22.30L18.56 22.91L18.76 23.31L22.21 21.48L22.82 20.06L17.13 16.61L15.51 15.80L14.70 15.80ZM6.17 18.03L5.96 18.84L9.21 20.47L12.46 24.13L12.67 34.69L11.85 35.50L8.81 35.50L6.57 34.08L5.96 34.08L5.96 34.69L9.62 36.52L20.38 43.02L20.38 41.80L17.34 40.38L14.29 37.13L13.88 26.16L14.70 25.34L17.95 25.34L20.18 26.77L20.59 26.77L20.59 26.16L6.37 18.03ZM36.63 18.03L35.82 18.44L35.82 22.70L34.40 26.77L26.07 31.84L25.06 32.05L24.04 31.64L22.82 29.41L22.62 26.16L22.21 26.36L22.21 42.81L23.02 42.61L22.82 38.95L24.24 34.48L33.18 29.00L34.81 29.61L36.02 31.84L36.02 34.89L36.84 34.69L36.84 18.03ZM33.99 19.45L24.45 24.94L24.65 29.00L25.06 29.81L25.67 30.02L27.49 28.59L32.98 25.55L33.99 22.50L33.99 19.66ZM6.17 20.88L5.96 31.84L8.81 33.27L9.01 33.67L10.84 33.67L10.63 24.53L8.81 22.91L8.40 22.09L6.17 21.08ZM15.92 27.17L15.92 36.11L18.56 38.95L20.38 39.97L20.59 29.00L17.54 27.17L16.12 27.17ZM33.18 31.23L25.67 35.70L24.65 38.95L24.65 41.59L34.20 36.11L34.20 32.25L33.38 31.23Z"/>
20
+ <g transform="translate(25.216003874368653 34.26600387436865) scale(0.7903)" fill="none" stroke="#0f6f8c"
21
+ stroke-width="3.04" stroke-linecap="round" stroke-linejoin="round">
22
+ <path d="M15.536 11.293a1 1 0 0 0 0 1.414l2.376 2.377a1 1 0 0 0 1.414 0l2.377-2.377a1 1 0 0 0 0-1.414l-2.377-2.377a1 1 0 0 0-1.414 0z"/>
23
+ <path d="M2.297 11.293a1 1 0 0 0 0 1.414l2.377 2.377a1 1 0 0 0 1.414 0l2.377-2.377a1 1 0 0 0 0-1.414L6.088 8.916a1 1 0 0 0-1.414 0z"/>
24
+ <path d="M8.916 17.912a1 1 0 0 0 0 1.415l2.377 2.376a1 1 0 0 0 1.414 0l2.377-2.376a1 1 0 0 0 0-1.415l-2.377-2.376a1 1 0 0 0-1.414 0z"/>
25
+ <path d="M8.916 4.674a1 1 0 0 0 0 1.414l2.377 2.376a1 1 0 0 0 1.414 0l2.377-2.376a1 1 0 0 0 0-1.414l-2.377-2.377a1 1 0 0 0-1.414 0z"/>
26
+ </g>
27
+ </svg>
@@ -0,0 +1,25 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="44" height="60" viewBox="0 0 44 60">
2
+ <!-- The fromcubes mark, traced from the project artwork by tools/build-icon.mjs.
3
+ The badge is cut out of the mark with a mask rather than hidden behind a
4
+ halo painted in the background colour: an icon cannot know what is behind
5
+ it, and masking keeps the traced edges exact instead of re-rasterising them.
6
+ The badge is the "atom" icon from Lucide (https://lucide.dev), ISC —
7
+ see THIRD-PARTY-NOTICES.md. Worn by portal-react. React's own logo is a
8
+ Meta trademark and is deliberately not used. -->
9
+ <mask id="fc-cut" maskUnits="userSpaceOnUse" x="0" y="0" width="44" height="60">
10
+ <rect width="44" height="60" fill="#ffffff"/>
11
+ <g transform="translate(24.158614219195222 33.20861421919522) scale(0.8784)" fill="none" stroke="#000000"
12
+ stroke-width="6.15" stroke-linecap="round" stroke-linejoin="round">
13
+ <circle cx="12" cy="12" r="1"/>
14
+ <path d="M20.2 20.2c2.04-2.03.02-7.36-4.5-11.9-4.54-4.52-9.87-6.54-11.9-4.5-2.04 2.03-.02 7.36 4.5 11.9 4.54 4.52 9.87 6.54 11.9 4.5Z"/>
15
+ <path d="M15.7 15.7c4.52-4.54 6.54-9.87 4.5-11.9-2.03-2.04-7.36-.02-11.9 4.5-4.52 4.54-6.54 9.87-4.5 11.9 2.03 2.04 7.36.02 11.9-4.5Z"/>
16
+ </g>
17
+ </mask>
18
+ <path fill="#10181d" fill-rule="evenodd" mask="url(#fc-cut)" d="M20.79 6.25L22.01 6.25L23.23 6.86L32.17 12.34L37.65 15.19L38.46 16.00L38.46 35.50L37.65 36.31L32.17 39.16L23.23 44.64L22.01 45.25L20.38 45.05L12.06 39.97L6.57 37.13L4.34 35.50L4.34 16.00L9.42 12.75L20.79 6.45ZM21.20 8.08L6.98 16.20L7.18 16.61L7.99 16.61L10.84 14.78L14.29 13.97L15.92 13.97L22.42 17.63L24.65 19.05L24.85 20.67L24.04 21.48L23.43 22.91L20.99 24.13L21.20 24.73L35.82 16.41L35.01 16.00L31.96 18.03L27.09 18.84L20.59 15.19L19.37 14.17L18.15 13.77L17.95 12.14L19.57 9.70L22.01 8.48L21.40 8.08ZM23.63 9.50L20.99 10.92L19.98 12.75L27.49 17.02L30.95 16.41L33.38 14.78L23.84 9.50ZM14.49 15.80L11.45 16.41L9.42 17.83L15.71 21.28L16.93 22.30L18.56 22.91L18.76 23.31L22.21 21.48L22.82 20.06L17.13 16.61L15.51 15.80L14.70 15.80ZM6.17 18.03L5.96 18.84L9.21 20.47L12.46 24.13L12.67 34.69L11.85 35.50L8.81 35.50L6.57 34.08L5.96 34.08L5.96 34.69L9.62 36.52L20.38 43.02L20.38 41.80L17.34 40.38L14.29 37.13L13.88 26.16L14.70 25.34L17.95 25.34L20.18 26.77L20.59 26.77L20.59 26.16L6.37 18.03ZM36.63 18.03L35.82 18.44L35.82 22.70L34.40 26.77L26.07 31.84L25.06 32.05L24.04 31.64L22.82 29.41L22.62 26.16L22.21 26.36L22.21 42.81L23.02 42.61L22.82 38.95L24.24 34.48L33.18 29.00L34.81 29.61L36.02 31.84L36.02 34.89L36.84 34.69L36.84 18.03ZM33.99 19.45L24.45 24.94L24.65 29.00L25.06 29.81L25.67 30.02L27.49 28.59L32.98 25.55L33.99 22.50L33.99 19.66ZM6.17 20.88L5.96 31.84L8.81 33.27L9.01 33.67L10.84 33.67L10.63 24.53L8.81 22.91L8.40 22.09L6.17 21.08ZM15.92 27.17L15.92 36.11L18.56 38.95L20.38 39.97L20.59 29.00L17.54 27.17L16.12 27.17ZM33.18 31.23L25.67 35.70L24.65 38.95L24.65 41.59L34.20 36.11L34.20 32.25L33.38 31.23Z"/>
19
+ <g transform="translate(24.158614219195222 33.20861421919522) scale(0.8784)" fill="none" stroke="#0f6f8c"
20
+ stroke-width="2.73" stroke-linecap="round" stroke-linejoin="round">
21
+ <circle cx="12" cy="12" r="1"/>
22
+ <path d="M20.2 20.2c2.04-2.03.02-7.36-4.5-11.9-4.54-4.52-9.87-6.54-11.9-4.5-2.04 2.03-.02 7.36 4.5 11.9 4.54 4.52 9.87 6.54 11.9 4.5Z"/>
23
+ <path d="M15.7 15.7c4.52-4.54 6.54-9.87 4.5-11.9-2.03-2.04-7.36-.02-11.9 4.5-4.52 4.54-6.54 9.87-4.5 11.9 2.03 2.04 7.36.02 11.9-4.5Z"/>
24
+ </g>
25
+ </svg>
@@ -0,0 +1,21 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="44" height="60" viewBox="0 0 44 60">
2
+ <!-- The fromcubes mark, traced from the project artwork by tools/build-icon.mjs.
3
+ The badge is cut out of the mark with a mask rather than hidden behind a
4
+ halo painted in the background colour: an icon cannot know what is behind
5
+ it, and masking keeps the traced edges exact instead of re-rasterising them.
6
+ The badge is the "wrench" icon from Lucide (https://lucide.dev), ISC —
7
+ see THIRD-PARTY-NOTICES.md. Worn by fc-portal-utility. React's own logo is a
8
+ Meta trademark and is deliberately not used. -->
9
+ <mask id="fc-cut" maskUnits="userSpaceOnUse" x="0" y="0" width="44" height="60">
10
+ <rect width="44" height="60" fill="#ffffff"/>
11
+ <g transform="translate(25.219431250695457 34.25674619515118) scale(0.7906)" fill="none" stroke="#000000"
12
+ stroke-width="6.83" stroke-linecap="round" stroke-linejoin="round">
13
+ <path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z"/>
14
+ </g>
15
+ </mask>
16
+ <path fill="#10181d" fill-rule="evenodd" mask="url(#fc-cut)" d="M20.79 6.25L22.01 6.25L23.23 6.86L32.17 12.34L37.65 15.19L38.46 16.00L38.46 35.50L37.65 36.31L32.17 39.16L23.23 44.64L22.01 45.25L20.38 45.05L12.06 39.97L6.57 37.13L4.34 35.50L4.34 16.00L9.42 12.75L20.79 6.45ZM21.20 8.08L6.98 16.20L7.18 16.61L7.99 16.61L10.84 14.78L14.29 13.97L15.92 13.97L22.42 17.63L24.65 19.05L24.85 20.67L24.04 21.48L23.43 22.91L20.99 24.13L21.20 24.73L35.82 16.41L35.01 16.00L31.96 18.03L27.09 18.84L20.59 15.19L19.37 14.17L18.15 13.77L17.95 12.14L19.57 9.70L22.01 8.48L21.40 8.08ZM23.63 9.50L20.99 10.92L19.98 12.75L27.49 17.02L30.95 16.41L33.38 14.78L23.84 9.50ZM14.49 15.80L11.45 16.41L9.42 17.83L15.71 21.28L16.93 22.30L18.56 22.91L18.76 23.31L22.21 21.48L22.82 20.06L17.13 16.61L15.51 15.80L14.70 15.80ZM6.17 18.03L5.96 18.84L9.21 20.47L12.46 24.13L12.67 34.69L11.85 35.50L8.81 35.50L6.57 34.08L5.96 34.08L5.96 34.69L9.62 36.52L20.38 43.02L20.38 41.80L17.34 40.38L14.29 37.13L13.88 26.16L14.70 25.34L17.95 25.34L20.18 26.77L20.59 26.77L20.59 26.16L6.37 18.03ZM36.63 18.03L35.82 18.44L35.82 22.70L34.40 26.77L26.07 31.84L25.06 32.05L24.04 31.64L22.82 29.41L22.62 26.16L22.21 26.36L22.21 42.81L23.02 42.61L22.82 38.95L24.24 34.48L33.18 29.00L34.81 29.61L36.02 31.84L36.02 34.89L36.84 34.69L36.84 18.03ZM33.99 19.45L24.45 24.94L24.65 29.00L25.06 29.81L25.67 30.02L27.49 28.59L32.98 25.55L33.99 22.50L33.99 19.66ZM6.17 20.88L5.96 31.84L8.81 33.27L9.01 33.67L10.84 33.67L10.63 24.53L8.81 22.91L8.40 22.09L6.17 21.08ZM15.92 27.17L15.92 36.11L18.56 38.95L20.38 39.97L20.59 29.00L17.54 27.17L16.12 27.17ZM33.18 31.23L25.67 35.70L24.65 38.95L24.65 41.59L34.20 36.11L34.20 32.25L33.38 31.23Z"/>
17
+ <g transform="translate(25.219431250695457 34.25674619515118) scale(0.7906)" fill="none" stroke="#0f6f8c"
18
+ stroke-width="3.04" stroke-linecap="round" stroke-linejoin="round">
19
+ <path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z"/>
20
+ </g>
21
+ </svg>
@@ -0,0 +1,238 @@
1
+ const path = require("path");
2
+
3
+ /**
4
+ * Register editor/admin/public support routes for portal-react.
5
+ *
6
+ * This module intentionally owns only HTTP route registration. Runtime page
7
+ * state and registries remain owned by portal-react.js and are passed in so
8
+ * deploy/rebuild lifecycle stays in one place.
9
+ *
10
+ * @param {Object} RED
11
+ * @param {Object} deps
12
+ * @param {import("express")} deps.express
13
+ * @param {Function} deps.permRead
14
+ * @param {Function} deps.permWrite
15
+ * @param {Function} deps.csrfGuard
16
+ * @param {Function} deps.rateLimit
17
+ * @param {Object<string, Object>} deps.pageState
18
+ * @param {Object<string, Object>} deps.registry
19
+ * @param {Object<string, Object>} deps.utilities
20
+ * @param {(code: string) => Set<string>} deps.extractUtilitySymbols
21
+ * @param {Object} [deps.providers] lib/providers registry (describe/select).
22
+ * @param {Function} [deps.onProviderChange] Called with the kind after a choice changed.
23
+ * @returns {void}
24
+ */
25
+ function registerAdminApi(RED, deps) {
26
+ const {
27
+ express,
28
+ permRead,
29
+ permWrite,
30
+ csrfGuard,
31
+ rateLimit,
32
+ pageState,
33
+ registry,
34
+ utilities,
35
+ extractUtilitySymbols,
36
+ providers,
37
+ onProviderChange,
38
+ } = deps;
39
+
40
+ // Since 0.56 monaco-editor has an `exports` map that hides package.json;
41
+ // its CommonJS entry is min/vs/index.js, so the AMD build (loader.js and
42
+ // editor/editor.main.js) sits in the same directory.
43
+ const monacoVsPath = path.dirname(require.resolve("monaco-editor"));
44
+ RED.httpAdmin.use(
45
+ "/fromcubes-react/vs",
46
+ permRead,
47
+ express.static(monacoVsPath),
48
+ );
49
+
50
+ // Editor-side workspace plugin (tree + full-screen Monaco overlay). Served
51
+ // as static files instead of being inlined into portal-react.html so the
52
+ // node's editor template stays readable.
53
+ RED.httpAdmin.use(
54
+ "/fromcubes-react/editor",
55
+ permRead,
56
+ express.static(path.join(__dirname, "..", "editor")),
57
+ );
58
+
59
+ // The node icon doubles as the editor's status-bar mark. Serving the icons
60
+ // directory here keeps one copy on disk and spares the client from having to
61
+ // know the package name that Node-RED's own /icons route is keyed by.
62
+ RED.httpAdmin.use(
63
+ "/fromcubes-react/icons",
64
+ permRead,
65
+ express.static(path.join(__dirname, "..", "icons")),
66
+ );
67
+
68
+ // Prettier's browser builds, straight from node_modules — same arrangement
69
+ // as Monaco at /fromcubes-react/vs. Nothing is fetched until someone formats.
70
+ RED.httpAdmin.use(
71
+ "/fromcubes-react/prettier",
72
+ permRead,
73
+ express.static(path.dirname(require.resolve("prettier/package.json"))),
74
+ );
75
+
76
+ const CSS_HASH_RE = /^[a-f0-9]{1,64}$/;
77
+ function findCssByHash(reqHash) {
78
+ for (const ep in pageState) {
79
+ if (pageState[ep]?.cssHash === reqHash) return pageState[ep].css;
80
+ }
81
+ return null;
82
+ }
83
+ function serveCss(req, res) {
84
+ const reqHash = req.params.hash;
85
+ if (!CSS_HASH_RE.test(reqHash)) {
86
+ res.status(400).send("Bad request");
87
+ return;
88
+ }
89
+ const css = findCssByHash(reqHash);
90
+ if (!css) {
91
+ res.status(404).send("Not found");
92
+ return;
93
+ }
94
+ res.set({
95
+ "Content-Type": "text/css",
96
+ "Cache-Control": "public, max-age=31536000, immutable",
97
+ });
98
+ res.send(css);
99
+ }
100
+
101
+ RED.httpNode.get("/fromcubes/css/:hash.css", serveCss);
102
+
103
+
104
+ RED.httpAdmin.get("/fromcubes-react/registry", permRead, (_req, res) => {
105
+ res.json(registry);
106
+ });
107
+
108
+ RED.httpAdmin.post(
109
+ "/fromcubes-react/registry",
110
+ permWrite,
111
+ csrfGuard,
112
+ rateLimit,
113
+ (_req, res) => {
114
+ res.status(410).json({
115
+ error: "registry writes are deprecated; use fc-portal-component nodes",
116
+ });
117
+ },
118
+ );
119
+
120
+ RED.httpAdmin.delete(
121
+ "/fromcubes-react/registry/:name",
122
+ permWrite,
123
+ csrfGuard,
124
+ rateLimit,
125
+ (_req, res) => {
126
+ res.status(410).json({
127
+ error: "registry writes are deprecated; use fc-portal-component nodes",
128
+ });
129
+ },
130
+ );
131
+
132
+ RED.httpAdmin.get("/fromcubes-react/utilities", permRead, (_req, res) => {
133
+ const out = {};
134
+ for (const [name, u] of Object.entries(utilities)) {
135
+ out[name] = {
136
+ code: u.code,
137
+ error: u.error || null,
138
+ symbols: [...extractUtilitySymbols(u.code || "")],
139
+ };
140
+ }
141
+ res.json(out);
142
+ });
143
+
144
+ RED.httpAdmin.post(
145
+ "/fromcubes-react/utilities",
146
+ permWrite,
147
+ csrfGuard,
148
+ rateLimit,
149
+ (_req, res) => {
150
+ res.status(410).json({
151
+ error: "utility writes are deprecated; use fc-portal-utility nodes",
152
+ });
153
+ },
154
+ );
155
+
156
+ RED.httpAdmin.delete(
157
+ "/fromcubes-react/utilities/:name",
158
+ permWrite,
159
+ csrfGuard,
160
+ rateLimit,
161
+ (_req, res) => {
162
+ res.status(410).json({
163
+ error: "utility writes are deprecated; use fc-portal-utility nodes",
164
+ });
165
+ },
166
+ );
167
+
168
+ if (providers) registerProviderRoutes(RED, deps);
169
+ }
170
+
171
+ // select() error code → HTTP status
172
+ const SELECT_STATUS = { "bad-kind": 400, "bad-id": 400, unknown: 400, locked: 409 };
173
+
174
+ /**
175
+ * Settings → Fromcubes: read and change the identity provider and CSS engine.
176
+ * Changing identity decides who counts as logged in, so the write sits
177
+ * behind the same permission, CSRF and rate-limit gates as every other
178
+ * admin write, and is logged with the editor user who made it.
179
+ *
180
+ * @param {Object} RED
181
+ * @param {Object} deps See registerAdminApi.
182
+ * @returns {void}
183
+ * @private
184
+ */
185
+ function registerProviderRoutes(RED, deps) {
186
+ const { express, permRead, permWrite, csrfGuard, rateLimit, providers, onProviderChange } = deps;
187
+ // Node-RED's admin app parses JSON already (capped by apiMaxLength); parse
188
+ // only where nothing upstream did — the same arrangement as lib/assets.js.
189
+ const parseJson = express.json({ limit: (RED.settings && RED.settings.apiMaxLength) || "5mb" });
190
+ const jsonBody = (req, res, next) => (req._body ? next() : parseJson(req, res, next));
191
+
192
+ RED.httpAdmin.get("/fromcubes-react/providers", permRead, (_req, res) => {
193
+ res.json(providers.describe());
194
+ });
195
+
196
+ // Completion list for className="…", from whichever CSS engine is in force.
197
+ // `engine` lets the editor tell a stale list from a fresh one.
198
+ RED.httpAdmin.get("/fromcubes-react/css-classes", permRead, async (_req, res) => {
199
+ try {
200
+ res.json(await providers.editorClasses());
201
+ } catch (e) {
202
+ RED.log.error("[portal-react] css-classes: " + e.message);
203
+ res.status(500).json({ error: "could not list classes" });
204
+ }
205
+ });
206
+
207
+ RED.httpAdmin.put(
208
+ "/fromcubes-react/providers",
209
+ permWrite,
210
+ csrfGuard,
211
+ rateLimit,
212
+ jsonBody,
213
+ (req, res) => {
214
+ const { kind, id } = req.body || {};
215
+ let changed;
216
+ try {
217
+ changed = providers.select(kind, id);
218
+ } catch (e) {
219
+ const status = SELECT_STATUS[e.code];
220
+ if (!status) {
221
+ RED.log.error("[portal-react] saving provider choice failed: " + e.message);
222
+ res.status(500).json({ error: "could not save the provider choice" });
223
+ return;
224
+ }
225
+ res.status(status).json({ error: e.message, code: e.code });
226
+ return;
227
+ }
228
+ if (changed) {
229
+ const who = (req.user && req.user.username) || "anonymous editor";
230
+ RED.log.warn(`[portal-react] ${kind} provider set to "${id}" by ${who}`);
231
+ if (onProviderChange) onProviderChange(kind);
232
+ }
233
+ res.json(providers.describe());
234
+ },
235
+ );
236
+ }
237
+
238
+ module.exports = { registerAdminApi };
@@ -0,0 +1,161 @@
1
+ /** @module nodes/lib/admin-security */
2
+
3
+ /**
4
+ * The gates every fromcubes admin route goes through — permission scopes,
5
+ * the CSRF header check and the per-IP rate limit — plus the express that
6
+ * Node-RED itself ships. Shared by the core and by the add-on packages
7
+ * (through the `api` export, lib/api.js), so an add-on's routes get exactly
8
+ * the same protection and the same rate-limit buckets as the core's.
9
+ */
10
+
11
+ /**
12
+ * Borrow the express that the running Node-RED ships with instead of
13
+ * declaring our own copy. We only need its static/json/raw middleware; the
14
+ * server itself is RED.httpAdmin/RED.httpNode. This package lives in
15
+ * `<userDir>/node_modules`, from where a plain require cannot see Node-RED's
16
+ * install directory, so resolve from the entry script that started the
17
+ * process (node-red's red.js). Embedded Node-RED (an app that requires
18
+ * node-red itself) falls back to a plain require — such an app has express.
19
+ * @returns {typeof import("express")}
20
+ */
21
+ function loadExpress() {
22
+ const entry = require.main && require.main.filename;
23
+ if (entry) {
24
+ try {
25
+ return require("module").createRequire(entry)("express");
26
+ } catch (_) {
27
+ // not launched through node-red's own entry script
28
+ }
29
+ }
30
+ return require("express");
31
+ }
32
+
33
+ /**
34
+ * Build the admin gates bound to `RED`. Safe to call more than once (core
35
+ * and each add-on do): the rate-limit buckets and their prune timer live on
36
+ * `RED.settings`, so every caller shares one bucket per client IP.
37
+ *
38
+ * @param {Object} RED
39
+ * @returns {{
40
+ * permRead: import("express").RequestHandler,
41
+ * permWrite: import("express").RequestHandler,
42
+ * csrfGuard: import("express").RequestHandler,
43
+ * rateLimit: import("express").RequestHandler,
44
+ * }}
45
+ */
46
+ function createAdminSecurity(RED) {
47
+ // ── Admin auth gate (Node-RED 4.x adminAuth) ─────────────────
48
+ // When the user configures `adminAuth` in settings.js, RED.auth.needsPermission
49
+ // returns an Express middleware that enforces the named permission scope.
50
+ // Without this gate, any endpoint mounted on RED.httpAdmin is reachable
51
+ // without auth and can read/modify the registry, utilities, and assets.
52
+ // Fallback no-op for runtimes that do not expose RED.auth.
53
+ /**
54
+ * Resolve a Node-RED permission middleware. Returns a no-op when the runtime
55
+ * does not expose `RED.auth.needsPermission` (test harnesses, Node-RED <1.0)
56
+ * so unit tests don't have to mock auth.
57
+ *
58
+ * @param {string} scope Permission scope (e.g. `"portal-react.write"`).
59
+ * @returns {import("express").RequestHandler}
60
+ * @private
61
+ */
62
+ function needsPerm(scope) {
63
+ if (RED.auth && typeof RED.auth.needsPermission === "function") {
64
+ return RED.auth.needsPermission(scope);
65
+ }
66
+ return function (_req, _res, next) { next(); };
67
+ }
68
+ const PERM_READ = needsPerm("portal-react.read");
69
+ const PERM_WRITE = needsPerm("portal-react.write");
70
+
71
+ // ── CSRF guard for admin write endpoints ──────────────────────
72
+ // The Node-RED editor sends `Node-RED-API-Version` on every XHR. Browsers
73
+ // refuse to attach custom headers on cross-origin form/fetch submissions
74
+ // without a CORS preflight, so requiring this header on write endpoints is
75
+ // sufficient CSRF protection without per-session tokens. Same trick the
76
+ // Node-RED core admin API uses for its own POST/PUT/DELETE routes.
77
+ /**
78
+ * Express middleware enforcing the `Node-RED-API-Version` header on write
79
+ * endpoints. The header is sent by the Node-RED editor XHR layer on every
80
+ * request; browser cross-origin form/fetch POSTs cannot attach custom
81
+ * headers without a CORS preflight, so requiring this header gives CSRF
82
+ * protection without per-session tokens.
83
+ *
84
+ * @param {import("express").Request} req
85
+ * @param {import("express").Response} res
86
+ * @param {Function} next
87
+ * @returns {void}
88
+ * @private
89
+ */
90
+ function csrfGuard(req, res, next) {
91
+ if (!req.get("Node-RED-API-Version")) {
92
+ return res.status(403).json({ error: "missing Node-RED-API-Version header" });
93
+ }
94
+ next();
95
+ }
96
+
97
+ // ── Token-bucket rate limiter keyed by req.ip ─────────────────
98
+ // Steady-state: RATE_LIMIT_TOKENS tokens refilled over RATE_LIMIT_WINDOW_MS.
99
+ // Default = 60 tokens / 60 s → 1 req/s sustained, 60 burst. Tunable via
100
+ // RED.settings.portalReact.rateLimit = { tokens, windowMs }. The buckets
101
+ // are pruned every 5 minutes to bound memory usage.
102
+ const rateLimitCfg = (RED.settings.portalReact &&
103
+ RED.settings.portalReact.rateLimit) || {};
104
+ const RATE_LIMIT_TOKENS = Number.isFinite(rateLimitCfg.tokens)
105
+ ? rateLimitCfg.tokens
106
+ : 60;
107
+ const RATE_LIMIT_WINDOW_MS = Number.isFinite(rateLimitCfg.windowMs)
108
+ ? rateLimitCfg.windowMs
109
+ : 60_000;
110
+ if (!RED.settings.portalReactRateBuckets) RED.settings.portalReactRateBuckets = new Map();
111
+ const rateBuckets = RED.settings.portalReactRateBuckets;
112
+
113
+ /**
114
+ * Token-bucket rate limit middleware keyed by `req.ip`. Each bucket refills
115
+ * `RATE_LIMIT_TOKENS` over `RATE_LIMIT_WINDOW_MS`. Steady-state default is
116
+ * 1 request/second with a 60-token burst. On exhaustion: HTTP 429 with
117
+ * `Retry-After`. Buckets idle > 10 minutes get pruned by a separate
118
+ * interval (see below).
119
+ *
120
+ * @param {import("express").Request} req
121
+ * @param {import("express").Response} res
122
+ * @param {Function} next
123
+ * @returns {void}
124
+ * @private
125
+ */
126
+ function rateLimit(req, res, next) {
127
+ const ip = req.ip || req.socket?.remoteAddress || "unknown";
128
+ const now = Date.now();
129
+ let b = rateBuckets.get(ip);
130
+ if (!b) {
131
+ b = { tokens: RATE_LIMIT_TOKENS, last: now };
132
+ rateBuckets.set(ip, b);
133
+ }
134
+ const elapsed = now - b.last;
135
+ b.tokens = Math.min(
136
+ RATE_LIMIT_TOKENS,
137
+ b.tokens + (elapsed * RATE_LIMIT_TOKENS) / RATE_LIMIT_WINDOW_MS,
138
+ );
139
+ b.last = now;
140
+ if (b.tokens < 1) {
141
+ res.set("Retry-After", Math.ceil(RATE_LIMIT_WINDOW_MS / 1000));
142
+ return res.status(429).json({ error: "rate limit exceeded" });
143
+ }
144
+ b.tokens -= 1;
145
+ next();
146
+ }
147
+
148
+ if (!RED.settings.portalReactRateBucketPruneIv) {
149
+ RED.settings.portalReactRateBucketPruneIv = setInterval(() => {
150
+ const now = Date.now();
151
+ for (const [ip, b] of rateBuckets) {
152
+ if (now - b.last > 10 * 60_000) rateBuckets.delete(ip);
153
+ }
154
+ }, 5 * 60_000);
155
+ RED.settings.portalReactRateBucketPruneIv.unref?.();
156
+ }
157
+
158
+ return { permRead: PERM_READ, permWrite: PERM_WRITE, csrfGuard, rateLimit };
159
+ }
160
+
161
+ module.exports = { createAdminSecurity, loadExpress };
@@ -0,0 +1,29 @@
1
+ /** @module nodes/lib/api */
2
+
3
+ /**
4
+ * The core's public surface for add-ons. Add-on packages
5
+ * (@aaqu/fromcubes-auth-headers, -tailwind, -assets, and third-party ones)
6
+ * require `@aaqu/fromcubes-react/api` — package.json `exports` maps it here —
7
+ * and nothing else from the core (tests/addon-boundary.test.js).
8
+ *
9
+ * Keep it small and stable — anything exported here is a promise to every
10
+ * add-on, ours and third-party.
11
+ */
12
+
13
+ const { createAdminSecurity, loadExpress } = require("./admin-security");
14
+ const {
15
+ IDENTITY_TYPE,
16
+ CSS_TYPE,
17
+ API_VERSION,
18
+ } = require("./providers");
19
+
20
+ module.exports = {
21
+ // Plugin contract (see lib/providers.js)
22
+ IDENTITY_TYPE,
23
+ CSS_TYPE,
24
+ API_VERSION,
25
+ // Admin route gates shared with the core: permission, CSRF, rate limit
26
+ createAdminSecurity,
27
+ // The express Node-RED itself ships (static/json/raw middleware)
28
+ loadExpress,
29
+ };