underpost 3.2.30 → 3.2.80

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 (46) hide show
  1. package/.github/workflows/ghpkg.ci.yml +87 -0
  2. package/.github/workflows/npmpkg.ci.yml +9 -6
  3. package/.github/workflows/publish.ci.yml +3 -3
  4. package/.github/workflows/pwa-microservices-template-page.cd.yml +0 -7
  5. package/.github/workflows/release.cd.yml +1 -1
  6. package/CHANGELOG.md +1230 -971
  7. package/CLI-HELP.md +14 -6
  8. package/README.md +3 -3
  9. package/bin/build.js +40 -4
  10. package/bin/deploy.js +2 -2
  11. package/bump.config.js +1 -0
  12. package/docker-compose.yml +26 -26
  13. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
  14. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
  15. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  16. package/package.json +15 -15
  17. package/scripts/disk-clean.sh +85 -60
  18. package/scripts/test-monitor.sh +1 -1
  19. package/src/api/core/core.controller.js +4 -65
  20. package/src/api/core/core.router.js +8 -14
  21. package/src/api/default/default.controller.js +2 -70
  22. package/src/api/default/default.router.js +7 -17
  23. package/src/api/document/document.controller.js +5 -77
  24. package/src/api/document/document.router.js +9 -13
  25. package/src/api/file/file.controller.js +9 -53
  26. package/src/api/file/file.router.js +14 -6
  27. package/src/api/test/test.controller.js +8 -53
  28. package/src/api/test/test.router.js +1 -4
  29. package/src/cli/cluster.js +104 -11
  30. package/src/cli/db.js +4 -2
  31. package/src/cli/deploy.js +60 -11
  32. package/src/cli/docker-compose.js +212 -1
  33. package/src/cli/fs.js +45 -25
  34. package/src/cli/image.js +147 -51
  35. package/src/cli/index.js +26 -6
  36. package/src/cli/release.js +50 -4
  37. package/src/cli/repository.js +98 -24
  38. package/src/cli/run.js +380 -178
  39. package/src/cli/secrets.js +75 -46
  40. package/src/cli/ssh.js +30 -11
  41. package/src/client/components/core/Modal.js +38 -4
  42. package/src/index.js +1 -1
  43. package/src/server/catalog.js +2 -0
  44. package/src/server/conf.js +163 -3
  45. package/src/server/downloader.js +3 -3
  46. package/src/server/middlewares.js +152 -0
@@ -13,6 +13,58 @@ import { loggerFactory } from '../server/logger.js';
13
13
 
14
14
  const logger = loggerFactory(import.meta);
15
15
 
16
+ // Shell/runtime-critical and Kubernetes-injected env keys that must never be persisted as
17
+ // application secrets nor injected into a pod via `envFrom`. An injected PATH (or HOME, etc.)
18
+ // overrides the container image's own and breaks coreutils/sudo resolution inside the pod
19
+ // ("rm: command not found"). Single source of truth for both container-env capture and the
20
+ // `underpost-config` secret built from an env file.
21
+ const RESERVED_ENV_KEYS = new Set([
22
+ 'HOME',
23
+ 'HOSTNAME',
24
+ 'PATH',
25
+ 'TERM',
26
+ 'SHLVL',
27
+ 'PWD',
28
+ '_',
29
+ 'LANG',
30
+ 'LANGUAGE',
31
+ 'LC_ALL',
32
+ 'container',
33
+ 'SHELL',
34
+ 'USER',
35
+ 'LOGNAME',
36
+ 'MAIL',
37
+ 'OLDPWD',
38
+ 'LESSOPEN',
39
+ 'LESSCLOSE',
40
+ 'LS_COLORS',
41
+ 'DISPLAY',
42
+ 'COLORTERM',
43
+ 'EDITOR',
44
+ 'VISUAL',
45
+ 'TERM_PROGRAM',
46
+ 'TERM_PROGRAM_VERSION',
47
+ 'SSH_AUTH_SOCK',
48
+ 'SSH_CLIENT',
49
+ 'SSH_CONNECTION',
50
+ 'SSH_TTY',
51
+ 'XDG_SESSION_ID',
52
+ 'XDG_RUNTIME_DIR',
53
+ 'XDG_DATA_DIRS',
54
+ 'XDG_CONFIG_DIRS',
55
+ 'DBUS_SESSION_BUS_ADDRESS',
56
+ 'GPG_AGENT_INFO',
57
+ 'WINDOWID',
58
+ 'DESKTOP_SESSION',
59
+ 'SESSION_MANAGER',
60
+ 'XAUTHORITY',
61
+ 'WAYLAND_DISPLAY',
62
+ 'which_declare',
63
+ ]);
64
+ const RESERVED_ENV_KEY_PREFIXES = ['KUBERNETES_', 'npm_', 'NODE_'];
65
+ const isReservedEnvKey = (key) =>
66
+ RESERVED_ENV_KEYS.has(key) || RESERVED_ENV_KEY_PREFIXES.some((prefix) => key.startsWith(prefix));
67
+
16
68
  /**
17
69
  * @class UnderpostSecret
18
70
  * @description Manages the secrets of the application.
@@ -49,58 +101,35 @@ class UnderpostSecret {
49
101
  */
50
102
  createFromContainerEnv() {
51
103
  Underpost.env.clean();
52
- const systemKeys = new Set([
53
- 'HOME',
54
- 'HOSTNAME',
55
- 'PATH',
56
- 'TERM',
57
- 'SHLVL',
58
- 'PWD',
59
- '_',
60
- 'LANG',
61
- 'LANGUAGE',
62
- 'LC_ALL',
63
- 'container',
64
- 'SHELL',
65
- 'USER',
66
- 'LOGNAME',
67
- 'MAIL',
68
- 'OLDPWD',
69
- 'LESSOPEN',
70
- 'LESSCLOSE',
71
- 'LS_COLORS',
72
- 'DISPLAY',
73
- 'COLORTERM',
74
- 'EDITOR',
75
- 'VISUAL',
76
- 'TERM_PROGRAM',
77
- 'TERM_PROGRAM_VERSION',
78
- 'SSH_AUTH_SOCK',
79
- 'SSH_CLIENT',
80
- 'SSH_CONNECTION',
81
- 'SSH_TTY',
82
- 'XDG_SESSION_ID',
83
- 'XDG_RUNTIME_DIR',
84
- 'XDG_DATA_DIRS',
85
- 'XDG_CONFIG_DIRS',
86
- 'DBUS_SESSION_BUS_ADDRESS',
87
- 'GPG_AGENT_INFO',
88
- 'WINDOWID',
89
- 'DESKTOP_SESSION',
90
- 'SESSION_MANAGER',
91
- 'XAUTHORITY',
92
- 'WAYLAND_DISPLAY',
93
- 'which_declare',
94
- ]);
95
- const systemKeyPrefixes = ['KUBERNETES_', 'npm_', 'NODE_'];
96
104
  for (const [key, value] of Object.entries(process.env)) {
97
- if (systemKeys.has(key)) continue;
98
- if (systemKeyPrefixes.some((prefix) => key.startsWith(prefix))) continue;
105
+ if (isReservedEnvKey(key)) continue;
99
106
  Underpost.env.set(key, value);
100
107
  }
101
108
  },
102
109
  },
103
110
 
111
+ /**
112
+ * @method sanitizeSecretEnvFile
113
+ * @description Strips shell/runtime-critical and Kubernetes-injected keys (PATH, HOME, …) from
114
+ * raw `.env` file content so the resulting `underpost-config` secret can be safely injected via
115
+ * `envFrom` without clobbering the container image's own PATH. Blank lines and comments are
116
+ * preserved. Uses the same {@link RESERVED_ENV_KEYS} blocklist as container-env capture.
117
+ * @param {string} envFileContent - Raw contents of a `.env.<env>` file.
118
+ * @returns {string} Filtered env-file content.
119
+ * @memberof UnderpostSecret
120
+ */
121
+ sanitizeSecretEnvFile(envFileContent) {
122
+ return envFileContent
123
+ .split('\n')
124
+ .filter((line) => {
125
+ const trimmed = line.trimStart();
126
+ if (!trimmed || trimmed.startsWith('#')) return true;
127
+ const key = line.slice(0, line.indexOf('=')).trim();
128
+ return !key || !isReservedEnvKey(key);
129
+ })
130
+ .join('\n');
131
+ },
132
+
104
133
  /**
105
134
  * Removes all filesystem traces of secrets after deployment startup.
106
135
  * Centralizes the defense-in-depth cleanup performed
package/src/cli/ssh.js CHANGED
@@ -532,12 +532,28 @@ EOF`);
532
532
  if (!host) throw new Error('copyDirToNode requires a host');
533
533
  if (!localDir || !fs.existsSync(localDir)) throw new Error(`copyDirToNode: local dir not found: ${localDir}`);
534
534
  if (!remoteDir) throw new Error('copyDirToNode requires a remoteDir');
535
- shellExec(`chmod 600 ${keyPath}`, { silent: true, silentOnError: true, disableLog: true });
536
- const sshOpts = `-i ${keyPath} -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p ${port}`;
537
- shellExec(`ssh ${sshOpts} ${user}@${host} 'mkdir -p ${remoteDir}'`);
538
- shellExec(`tar -C ${localDir} -c . | ssh ${sshOpts} ${user}@${host} 'tar -C ${remoteDir} -x'`);
539
- const fixups = `${owner ? `chown -R ${owner} ${remoteDir}; ` : ''}${mode ? `chmod -R ${mode} ${remoteDir}` : ''}`.trim();
540
- if (fixups) shellExec(`ssh ${sshOpts} ${user}@${host} '${fixups}'`);
535
+ try {
536
+ shellExec(`chmod 600 ${keyPath}`, { silent: true, silentOnError: true, disableLog: true });
537
+ const sshOpts = `-i ${keyPath} -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p ${port}`;
538
+ shellExec(`ssh ${sshOpts} ${user}@${host} 'mkdir -p ${remoteDir}'`, {
539
+ silent: true,
540
+ disableLog: true,
541
+ });
542
+ shellExec(`tar -C ${localDir} -c . | ssh ${sshOpts} ${user}@${host} 'tar -C ${remoteDir} -x'`, {
543
+ silent: true,
544
+ disableLog: true,
545
+ });
546
+ const fixups =
547
+ `${owner ? `chown -R ${owner} ${remoteDir}; ` : ''}${mode ? `chmod -R ${mode} ${remoteDir}` : ''}`.trim();
548
+ if (fixups)
549
+ shellExec(`ssh ${sshOpts} ${user}@${host} '${fixups}'`, {
550
+ silent: true,
551
+ disableLog: true,
552
+ });
553
+ } catch (err) {
554
+ logger.error(`copyDirToNode failed`);
555
+ process.exit(1);
556
+ }
541
557
  },
542
558
 
543
559
  /**
@@ -703,11 +719,14 @@ EOF
703
719
 
704
720
  let last = { ok: false, code: 255, stdout: '', stderr: '', attempts: 0 };
705
721
  for (let attempt = 1; attempt <= retries; attempt++) {
706
- const result = shellExec(`ssh ${sshOpts} ${user}@${host} bash -s <<'UNDERPOST_SSH_BATCH_EOF'\n${command}\nUNDERPOST_SSH_BATCH_EOF`, {
707
- stdout: false,
708
- silentOnError: true,
709
- disableLog: true,
710
- });
722
+ const result = shellExec(
723
+ `ssh ${sshOpts} ${user}@${host} bash -s <<'UNDERPOST_SSH_BATCH_EOF'\n${command}\nUNDERPOST_SSH_BATCH_EOF`,
724
+ {
725
+ stdout: false,
726
+ silentOnError: true,
727
+ disableLog: true,
728
+ },
729
+ );
711
730
  last = {
712
731
  ok: result.code === 0,
713
732
  code: result.code,
@@ -425,8 +425,8 @@ class Modal {
425
425
  s(`.main-body-btn-ui-menu-menu`).classList.add('hide');
426
426
  s(`.main-body-btn-ui-menu-close`).classList.remove('hide');
427
427
  if (s(`.btn-bar-center-icon-menu`)) {
428
- s(`.btn-bar-center-icon-close`).classList.remove('hide');
429
- s(`.btn-bar-center-icon-menu`).classList.add('hide');
428
+ sa(`.btn-bar-center-icon-close`).forEach((el) => el.classList.remove('hide'));
429
+ sa(`.btn-bar-center-icon-menu`).forEach((el) => el.classList.add('hide'));
430
430
  }
431
431
 
432
432
  s(`.main-body-btn-container`).style[
@@ -449,8 +449,8 @@ class Modal {
449
449
  s(`.main-body-btn-ui-menu-close`).classList.add('hide');
450
450
  s(`.main-body-btn-ui-menu-menu`).classList.remove('hide');
451
451
  if (s(`.btn-bar-center-icon-menu`)) {
452
- s(`.btn-bar-center-icon-menu`).classList.remove('hide');
453
- s(`.btn-bar-center-icon-close`).classList.add('hide');
452
+ sa(`.btn-bar-center-icon-menu`).forEach((el) => el.classList.remove('hide'));
453
+ sa(`.btn-bar-center-icon-close`).forEach((el) => el.classList.add('hide'));
454
454
  }
455
455
  s(`.main-body-btn-container`).style[
456
456
  true || (options.mode && options.mode.match('right')) ? 'right' : 'left'
@@ -683,6 +683,34 @@ class Modal {
683
683
  >
684
684
  </div>`
685
685
  : ''}
686
+ ${idModal === 'modal-menu' && options.mode !== 'slide-menu-right'
687
+ ? html`<div
688
+ class="abs main-btn-menu-top-container"
689
+ style="bottom: 0px; left: 0px; z-index: 10; height: ${originHeightTopBar}px; width: ${originHeightTopBar}px"
690
+ >
691
+ ${await BtnIcon.instance({
692
+ style: `height: 100%`,
693
+ class: `in fll main-btn-menu-top action-bar-box action-btn-center-top`,
694
+ label: html`<div class="abs center">
695
+ <i class="far fa-square btn-bar-center-icon-square hide"></i>
696
+ <span class="btn-bar-center-icon-close hide">${barConfig.buttons.close.label}</span>
697
+ <span class="btn-bar-center-icon-menu">${barConfig.buttons.menu.label}</span>
698
+ </div>`,
699
+ })}
700
+ </div>
701
+
702
+ <style>
703
+ .a-link-top-banner {
704
+ padding-left: 35px;
705
+ }
706
+ .main-body-btn-bar-custom {
707
+ top: 50px !important;
708
+ }
709
+ .main-body-btn-menu {
710
+ display: none;
711
+ }
712
+ </style>`
713
+ : ''}
686
714
  </div>`,
687
715
  );
688
716
  EventsUI.onClick(`.action-btn-profile-log-in`, () => {
@@ -697,6 +725,12 @@ class Modal {
697
725
  }
698
726
  s(`.main-btn-sign-up`).click();
699
727
  });
728
+ if (idModal === 'modal-menu' && options.mode !== 'slide-menu-right') {
729
+ EventsUI.onClick(`.action-btn-center-top`, (e) => {
730
+ e.preventDefault();
731
+ Modal.actionBtnCenter();
732
+ });
733
+ }
700
734
  s(`.input-info-${inputSearchBoxId}`).style.textAlign = 'left';
701
735
  htmls(`.input-info-${inputSearchBoxId}`, '');
702
736
  const inputInfoNode = s(`.input-info-${inputSearchBoxId}`).cloneNode(true);
package/src/index.js CHANGED
@@ -45,7 +45,7 @@ class Underpost {
45
45
  * @type {String}
46
46
  * @memberof Underpost
47
47
  */
48
- static version = 'v3.2.30';
48
+ static version = 'v3.2.80';
49
49
 
50
50
  /**
51
51
  * Required Node.js major version
@@ -24,6 +24,8 @@ const EMPTY_CATALOG = {
24
24
  privateConfPaths: [],
25
25
  templatePaths: [],
26
26
  stripPaths: [],
27
+ moves: [],
28
+ copies: [],
27
29
  keywords: [],
28
30
  description: '',
29
31
  };
@@ -476,9 +476,6 @@ const loadConf = (deployId = DEFAULT_DEPLOY_ID, subConf) => {
476
476
  fs.removeSync(`${path}/.env.production`);
477
477
  fs.removeSync(`${path}/.env.development`);
478
478
  fs.removeSync(`${path}/.env.test`);
479
- if (fs.existsSync(`${path}/typedoc.json`)) shellExec(`git checkout ${path}/typedoc.json`);
480
- shellExec(`git checkout ${path}/package.json`);
481
- shellExec(`git checkout ${path}/package-lock.json`);
482
479
  return;
483
480
  }
484
481
  const folder = getConfFolder(deployId);
@@ -1775,6 +1772,165 @@ const loadConfServerJson = (jsonPath, options) => {
1775
1772
  return options && options.resolve === true ? resolveConfSecrets(raw) : raw;
1776
1773
  };
1777
1774
 
1775
+ /**
1776
+ * @method deepReplaceToken
1777
+ * @description Recursively replaces every occurrence of `from` with `to` in all
1778
+ * string leaves of a JSON-shaped value, returning a new structure.
1779
+ * @param {*} value - Any JSON-serialisable value.
1780
+ * @param {string} from - Token to replace.
1781
+ * @param {string} to - Replacement token.
1782
+ * @returns {*} A structurally identical value with the token replaced.
1783
+ * @memberof ServerConfBuilder
1784
+ */
1785
+ const deepReplaceToken = (value, from, to) => {
1786
+ if (typeof value === 'string') return value.split(from).join(to);
1787
+ if (Array.isArray(value)) return value.map((entry) => deepReplaceToken(entry, from, to));
1788
+ if (value && typeof value === 'object')
1789
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, deepReplaceToken(v, from, to)]));
1790
+ return value;
1791
+ };
1792
+
1793
+ /**
1794
+ * @method readConfInstances
1795
+ * @description Reads `conf.instances.json` and returns the bare array of instance
1796
+ * entries. The canonical shape is a plain array; the historical `{ instances }`
1797
+ * object wrapper is still unwrapped for backward compatibility. Multi-instance is
1798
+ * declared per entry under `entry.multiInstance` (not deploy-wide).
1799
+ * @param {string} deployId - Deployment identifier (e.g. `dd-cyberia`).
1800
+ * @returns {Array<object>} Instance entries.
1801
+ * @memberof ServerConfBuilder
1802
+ */
1803
+ const readConfInstances = (deployId) => {
1804
+ const raw = JSON.parse(fs.readFileSync(`./engine-private/conf/${deployId}/conf.instances.json`, 'utf8'));
1805
+ return Array.isArray(raw) ? raw : raw.instances || [];
1806
+ };
1807
+
1808
+ /**
1809
+ * @method loadInstanceTopology
1810
+ * @description Returns `{ default, variants }` describing the deploy's instance
1811
+ * variants, or `null` when the deploy is single-instance. The topology is
1812
+ * declared per instance entry (`entry.multiInstance.default` / `.variants`); all
1813
+ * multi-instance entries share the same variant set, so this returns it from the
1814
+ * first entry that declares one.
1815
+ * @param {string} deployId - Deployment identifier (e.g. `dd-cyberia`).
1816
+ * @returns {?{default: string, variants: Array<object>}} The topology, or `null`.
1817
+ * @memberof ServerConfBuilder
1818
+ */
1819
+ const loadInstanceTopology = (deployId) => {
1820
+ for (const entry of readConfInstances(deployId)) {
1821
+ const mi = entry.multiInstance;
1822
+ if (mi && Array.isArray(mi.variants) && mi.variants.length > 0)
1823
+ return { default: mi.default || mi.variants[0].code, variants: mi.variants };
1824
+ }
1825
+ return null;
1826
+ };
1827
+
1828
+ /**
1829
+ * @method resolveInstanceEnvValue
1830
+ * @description Resolves one declared env value. A plain string is used verbatim;
1831
+ * an object is treated as env-scoped (`{ development, production }`), matching the
1832
+ * convention already used by `lifecycle` / `readinessProbe` / `livenessProbe`.
1833
+ * Placeholders are substituted from the variant tokens.
1834
+ * @param {string|object} value - Declared value.
1835
+ * @param {string} env - `development` | `production`.
1836
+ * @param {Object<string,string>} tokens - Placeholder name → replacement.
1837
+ * @returns {?string} Resolved value, or `null` when the env has no entry.
1838
+ * @memberof ServerConfBuilder
1839
+ */
1840
+ const resolveInstanceEnvValue = (value, env, tokens) => {
1841
+ const scoped = value && typeof value === 'object' && !Array.isArray(value) ? value[env] : value;
1842
+ if (scoped === undefined || scoped === null) return null;
1843
+ return `${scoped}`.replace(/\{\{(\w+)\}\}/g, (match, token) => (token in tokens ? tokens[token] : match));
1844
+ };
1845
+
1846
+ /**
1847
+ * @method loadConfInstances
1848
+ * @description Loads `conf.instances.json` and expands every entry carrying a
1849
+ * `multiInstance` block into one concrete instance per declared variant.
1850
+ *
1851
+ * A template entry is never deployed as-is once variants exist: each variant
1852
+ * produces its own entry whose id, env file path, volume mount and
1853
+ * container-status strings are derived by replacing the template id token
1854
+ * throughout. The variant whose `slug` is empty keeps the template id verbatim,
1855
+ * so pre-existing deployments, PVCs and env directories survive the move to
1856
+ * multi-instance untouched.
1857
+ *
1858
+ * Nothing here is application-specific: the variant set (`default` + `variants`),
1859
+ * which env keys an instance needs (`env`), and prefix-stripping (`stripPathPrefix`)
1860
+ * are all declared per entry under `entry.multiInstance`. Env values may use the
1861
+ * `{{code}}`, `{{slug}}`, `{{path}}`, `{{id}}` and `{{default}}` placeholders and
1862
+ * may be env-scoped objects.
1863
+ *
1864
+ * Every expanded entry carries three extra fields consumed by the deploy runners:
1865
+ * `instanceCode` (the variant this instance serves), `templateId` (so targeting
1866
+ * the template id acts on the whole family) and `instanceEnv` (the resolved env
1867
+ * keys to write, keyed by environment: `{ development, production }`, so a build
1868
+ * in either mode can emit a complete env directory).
1869
+ *
1870
+ * @param {string} deployId - Deployment identifier (e.g. `dd-cyberia`).
1871
+ * @returns {Array<object>} Expanded instance entries.
1872
+ * @memberof ServerConfBuilder
1873
+ */
1874
+ const INSTANCE_ENVS = ['development', 'production'];
1875
+
1876
+ const loadConfInstances = (deployId) => {
1877
+ const expanded = [];
1878
+ for (const entry of readConfInstances(deployId)) {
1879
+ const spec = entry.multiInstance;
1880
+ const variants = spec?.variants;
1881
+ if (!Array.isArray(variants) || 0 === variants.length) {
1882
+ expanded.push(entry);
1883
+ continue;
1884
+ }
1885
+ for (const variant of variants) {
1886
+ const id = variant.slug ? `${entry.id}-${variant.slug}` : entry.id;
1887
+ const instance = variant.slug ? deepReplaceToken(entry, entry.id, id) : JSON.parse(JSON.stringify(entry));
1888
+ delete instance.multiInstance;
1889
+ instance.id = id;
1890
+ instance.path = variant.path;
1891
+ instance.instanceCode = variant.code;
1892
+ instance.templateId = entry.id;
1893
+
1894
+ const tokens = {
1895
+ code: variant.code,
1896
+ slug: variant.slug || '',
1897
+ path: variant.path,
1898
+ id,
1899
+ default: spec.default || '',
1900
+ };
1901
+ // Resolve the declared env keys for both environments so a build in either
1902
+ // mode writes a complete env directory (development.env + production.env).
1903
+ instance.instanceEnv = Object.fromEntries(INSTANCE_ENVS.map((e) => [e, {}]));
1904
+ for (const [key, value] of Object.entries(spec.env || {}))
1905
+ for (const e of INSTANCE_ENVS) {
1906
+ const resolved = resolveInstanceEnvValue(value, e, tokens);
1907
+ if (resolved !== null) instance.instanceEnv[e][key] = resolved;
1908
+ }
1909
+
1910
+ // A backend that serves its routes at the root knows nothing about the
1911
+ // variant prefix — it is selected by env instead. Strip the prefix at the
1912
+ // proxy so the runtime stays instance-agnostic.
1913
+ if (spec.stripPathPrefix && variant.path !== '/')
1914
+ instance.pathRewritePolicy = [{ prefix: variant.path, replacement: '/' }];
1915
+ expanded.push(instance);
1916
+ }
1917
+ }
1918
+ return expanded;
1919
+ };
1920
+
1921
+ /**
1922
+ * @method selectConfInstances
1923
+ * @description Filters expanded instances for a deploy target. Targeting a
1924
+ * template id (`mmo-server`) selects the whole instance family; targeting a
1925
+ * concrete id (`mmo-server-forest`) selects just that one.
1926
+ * @param {Array<object>} instances - Expanded entries from {@link loadConfInstances}.
1927
+ * @param {string} id - Target instance id or template id.
1928
+ * @returns {Array<object>} Matching entries.
1929
+ * @memberof ServerConfBuilder
1930
+ */
1931
+ const selectConfInstances = (instances, id) =>
1932
+ instances.filter((instance) => instance.id === id || instance.templateId === id);
1933
+
1778
1934
  /**
1779
1935
  * Creates and writes the /etc/hosts file for a deployment.
1780
1936
  * @method etcHostFactory
@@ -2103,6 +2259,10 @@ git add .`);
2103
2259
  export {
2104
2260
  Config,
2105
2261
  loadConf,
2262
+ loadConfInstances,
2263
+ loadInstanceTopology,
2264
+ readConfInstances,
2265
+ selectConfInstances,
2106
2266
  loadReplicas,
2107
2267
  cloneConf,
2108
2268
  getCapVariableName,
@@ -41,18 +41,18 @@ class Downloader {
41
41
  const writer = fs.createWriteStream(fullPath);
42
42
  response.data.pipe(writer);
43
43
  writer.on('finish', () => {
44
- logger.info('Download complete. File saved at', fullPath);
44
+ logger.info('Download completet');
45
45
  return resolve(fullPath);
46
46
  });
47
47
  writer.on('error', (error) => {
48
- logger.error(error, 'Error downloading the file');
48
+ logger.error('Error downloading the file');
49
49
  // Cleanup incomplete file if possible
50
50
  if (fs.existsSync(fullPath)) fs.unlinkSync(fullPath);
51
51
  return reject(error);
52
52
  });
53
53
  })
54
54
  .catch((error) => {
55
- logger.error(error, 'Error in the request');
55
+ logger.error('Error in the request');
56
56
  return reject(error);
57
57
  }),
58
58
  );
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Express middleware and controller/router helpers for engine APIs.
3
+ *
4
+ * @module src/server/middlewares.js
5
+ */
6
+
7
+ import { loggerFactory } from './logger.js';
8
+ import { moderatorGuard, adminGuard } from './auth.js';
9
+
10
+ const logger = loggerFactory(import.meta);
11
+
12
+ /**
13
+ * The public-read CORS policy: reflect the request origin (or allow any)
14
+ * and mark the resource embeddable cross-origin.
15
+ * @param {import('express').Request} req
16
+ * @param {import('express').Response} res
17
+ */
18
+ const setCrossOriginHeaders = (req, res) => {
19
+ if (req && req.headers && req.headers.origin) res.set('Access-Control-Allow-Origin', req.headers.origin);
20
+ else res.setHeader('Access-Control-Allow-Origin', '*');
21
+ res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
22
+ };
23
+
24
+ /** Express middleware form of {@link setCrossOriginHeaders}. */
25
+ const crossOriginMiddleware = (req, res, next) => {
26
+ setCrossOriginHeaders(req, res);
27
+ next();
28
+ };
29
+
30
+ /**
31
+ * Shallow request copy with `page`/`limit` parsed to integers.
32
+ * `path` and `params` are copied explicitly because spreading an Express
33
+ * request drops prototype getters.
34
+ * @param {import('express').Request} req
35
+ */
36
+ const withParsedPagination = (req) => {
37
+ const { page, limit } = req.query;
38
+ return {
39
+ ...req,
40
+ path: req.path,
41
+ params: req.params,
42
+ query: { ...req.query, page: parseInt(page), limit: parseInt(limit) },
43
+ };
44
+ };
45
+
46
+ const sendSuccess = (res, data) => res.status(200).json({ status: 'success', data });
47
+
48
+ const sendError = (res, error, status = 400) => res.status(status).json({ status: 'error', message: error.message });
49
+
50
+ /**
51
+ * Binary response with cross-origin and content headers.
52
+ * @param {import('express').Request} req
53
+ * @param {import('express').Response} res
54
+ * @param {{ buffer: Buffer, mimetype: string, filename: string, disposition?: 'inline'|'attachment' }} blob
55
+ */
56
+ const sendBlob = (req, res, { buffer, mimetype, filename, disposition = 'inline' }) => {
57
+ setCrossOriginHeaders(req, res);
58
+ res.setHeader('Content-Type', mimetype);
59
+ res.setHeader('Content-Length', buffer.length);
60
+ res.setHeader('Content-Disposition', `${disposition}; filename="${filename}"`);
61
+ return res.status(200).end(buffer);
62
+ };
63
+
64
+ /**
65
+ * Wraps a controller body with error logging and the error response envelope.
66
+ * @param {(req, res, options) => Promise<any>} fn
67
+ * @param {{ errorStatus?: number }} [config]
68
+ */
69
+ const controllerHandler =
70
+ (fn, { errorStatus = 400 } = {}) =>
71
+ async (req, res, options) => {
72
+ try {
73
+ return await fn(req, res, options);
74
+ } catch (error) {
75
+ logger.error(error, error.stack);
76
+ return sendError(res, error, errorStatus);
77
+ }
78
+ };
79
+
80
+ /**
81
+ * Builds a controller method that delegates to a service method and wraps the
82
+ * result in the success envelope.
83
+ * @param {(req, res, options) => Promise<any>} serviceFn
84
+ * @param {{ errorStatus?: number, crossOrigin?: boolean, pagination?: boolean }} [config]
85
+ */
86
+ const serviceHandler = (serviceFn, { errorStatus = 400, crossOrigin = false, pagination = false } = {}) =>
87
+ controllerHandler(
88
+ async (req, res, options) => {
89
+ if (crossOrigin) setCrossOriginHeaders(req, res);
90
+ const result = await serviceFn(pagination ? withParsedPagination(req) : req, res, options);
91
+ return sendSuccess(res, result);
92
+ },
93
+ { errorStatus },
94
+ );
95
+
96
+ /**
97
+ * Builds a standard CRUD controller class (static post/get/put/delete) from a
98
+ * service exposing the same methods. `get` parses pagination.
99
+ * @param {{ post, get, put, delete }} service
100
+ * @param {Object<string, Function>} [extend] - Extra or overriding static handlers.
101
+ */
102
+ const buildCrudController = (service, extend = {}) => {
103
+ class CrudController {
104
+ static post = serviceHandler(service.post);
105
+ static get = serviceHandler(service.get, { pagination: true });
106
+ static put = serviceHandler(service.put);
107
+ static delete = serviceHandler(service.delete);
108
+ }
109
+ Object.assign(CrudController, extend);
110
+ return CrudController;
111
+ };
112
+
113
+ /**
114
+ * Registers the standard CRUD routes with the standard guard policy:
115
+ * public reads, moderator-guarded writes, admin-guarded collection delete.
116
+ * Custom routes must be registered before calling this (generic `/:id` routes
117
+ * capture everything).
118
+ * @param {import('express').Router} router
119
+ * @param {{ post, get, put, delete }} Controller
120
+ * @param {import('../../api/types.js').RouterOptions} options
121
+ * @param {{ readGuards?: Function[], writeGuards?: Function[], deleteAllGuards?: Function[] }} [config]
122
+ * Pass empty arrays for unguarded endpoints (e.g. player-written progress)
123
+ * or explicit guard chains (e.g. admin-only reads).
124
+ * @returns {import('express').Router}
125
+ */
126
+ const registerCrudRoutes = (router, Controller, options, { readGuards = [], writeGuards, deleteAllGuards } = {}) => {
127
+ const write = writeGuards ?? [options.authMiddleware, moderatorGuard];
128
+ const deleteAll = deleteAllGuards ?? [options.authMiddleware, adminGuard];
129
+ const handle = (method) => async (req, res) => await Controller[method](req, res, options);
130
+ router.post(`/:id`, ...write, handle('post'));
131
+ router.post(`/`, ...write, handle('post'));
132
+ router.get(`/:id`, ...readGuards, handle('get'));
133
+ router.get(`/`, ...readGuards, handle('get'));
134
+ router.put(`/:id`, ...write, handle('put'));
135
+ router.put(`/`, ...write, handle('put'));
136
+ router.delete(`/:id`, ...write, handle('delete'));
137
+ router.delete(`/`, ...deleteAll, handle('delete'));
138
+ return router;
139
+ };
140
+
141
+ export {
142
+ setCrossOriginHeaders,
143
+ crossOriginMiddleware,
144
+ withParsedPagination,
145
+ sendSuccess,
146
+ sendError,
147
+ sendBlob,
148
+ controllerHandler,
149
+ serviceHandler,
150
+ buildCrudController,
151
+ registerCrudRoutes,
152
+ };