biz-a-cli 2.3.80-15407 → 2.3.80-15419

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 (82) hide show
  1. package/bin/app.js +255 -20
  2. package/bin/hub.js +16 -0
  3. package/bin/hubEvent.js +98 -5
  4. package/bin/tunnelTarget.js +4 -0
  5. package/engine/attachments/store.js +195 -0
  6. package/engine/bpm/workflow.js +7 -6
  7. package/engine/cc/configValidate.js +53 -0
  8. package/engine/cc/drift.js +18 -2
  9. package/engine/cc/guides/V2-SCREEN-AUTHORING.md +531 -512
  10. package/engine/cc/layer3Behavior.js +2 -1
  11. package/engine/cc/layer3Schema.js +3 -1
  12. package/engine/cc/livePipelines.js +9 -3
  13. package/engine/cc/mapStore.js +4 -3
  14. package/engine/cc/publish.js +86 -105
  15. package/engine/domain/appConfig.js +108 -18
  16. package/engine/domain/applicationConfig.js +87 -12
  17. package/engine/domain/dialect.js +138 -0
  18. package/engine/domain/domainNavigation.js +86 -50
  19. package/engine/domain/domainRegistration.js +65 -0
  20. package/engine/domain/entityConfigLoader.js +16 -4
  21. package/engine/domain/numericScale.js +2 -0
  22. package/engine/domain/system.js +2 -0
  23. package/engine/ext/publicTheme.js +8 -1
  24. package/engine/fina/approval.js +141 -0
  25. package/engine/fina/approvalService.js +59 -0
  26. package/engine/fina/attachments.js +165 -0
  27. package/engine/fina/client.js +84 -0
  28. package/engine/fina/config.js +26 -0
  29. package/engine/fina/context.js +58 -0
  30. package/engine/fina/document.js +67 -0
  31. package/engine/fina/documentWrite.js +173 -0
  32. package/engine/fina/documents/index.js +28 -0
  33. package/engine/fina/documents/jv.js +105 -0
  34. package/engine/fina/documents/jvWrite.js +260 -0
  35. package/engine/fina/documents/si.js +40 -0
  36. package/engine/fina/errors.js +17 -0
  37. package/engine/fina/features.js +86 -0
  38. package/engine/fina/guard.js +102 -0
  39. package/engine/fina/identity.js +87 -0
  40. package/engine/fina/live.js +19 -0
  41. package/engine/fina/lock.js +35 -0
  42. package/engine/fina/master.js +331 -0
  43. package/engine/fina/masters/coa.js +104 -0
  44. package/engine/fina/masters/currency.js +39 -0
  45. package/engine/fina/masters/department.js +32 -0
  46. package/engine/fina/masters/index.js +21 -0
  47. package/engine/fina/masters/project.js +53 -0
  48. package/engine/fina/period.js +56 -0
  49. package/engine/fina/print.js +136 -0
  50. package/engine/fina/readPolicy.js +67 -0
  51. package/engine/fina/recurring.js +200 -0
  52. package/engine/fina/route.js +61 -0
  53. package/engine/fina/routes/attachment.js +41 -0
  54. package/engine/fina/routes/document.js +66 -0
  55. package/engine/fina/routes/master.js +26 -0
  56. package/engine/fina/routes/period.js +24 -0
  57. package/engine/fina/routes/print.js +19 -0
  58. package/engine/fina/routes/recurring.js +27 -0
  59. package/engine/fina/routes/session.js +23 -0
  60. package/engine/fina/routes/setup.js +58 -0
  61. package/engine/fina/routes/submission.js +46 -0
  62. package/engine/fina/routes/template.js +31 -0
  63. package/engine/fina/settings.js +159 -0
  64. package/engine/fina/sql.js +34 -0
  65. package/engine/fina/submission.js +290 -0
  66. package/engine/fina/submissionCompare.js +171 -0
  67. package/engine/fina/submissionRepo.js +114 -0
  68. package/engine/fina/template.js +118 -0
  69. package/engine/fina/userMap.js +79 -0
  70. package/engine/orm/apiRoute.js +187 -61
  71. package/engine/orm/companyLocale.js +74 -0
  72. package/engine/orm/entityConfig.js +32 -2
  73. package/engine/orm/finaAuth.js +0 -0
  74. package/engine/orm/finaEndpoint.js +19 -0
  75. package/engine/orm/imgQuery.js +92 -0
  76. package/engine/orm/listQuery.js +6 -1
  77. package/engine/orm/userApiDataset.js +153 -0
  78. package/migrations/postgres/1790600000000__fina.sql +33 -0
  79. package/migrations/postgres/1790700000000__attachment.sql +29 -0
  80. package/migrations/postgres/1790800000000__fina_jv_submission.sql +41 -0
  81. package/package.json +1 -1
  82. package/worker/cliScriptWorker.js +5 -0
package/bin/app.js CHANGED
@@ -27,6 +27,8 @@ import {
27
27
  } from "../engine/domain/themeConfig.js";
28
28
  import {
29
29
  APP_CONFIG_NAME,
30
+ appConfigNameFor,
31
+ secondaryAppNameOf,
30
32
  buildEnsureConfigTableSql,
31
33
  buildPostgresConfigUpsertSql,
32
34
  buildPostgresApplicationConfigLookupSql,
@@ -56,6 +58,7 @@ import { validateTenancyDeclaration } from "../engine/orm/tenancyDeclaration.js"
56
58
  import { logger } from "../logger.js";
57
59
  import { findRawColorsInSource } from "../engine/domain/styleColors.js";
58
60
  import { resolveMenuLayout } from "../engine/domain/domainNavigation.js";
61
+ import { bumpPatch, registerDomainVersions, unregisterDomains } from "../engine/domain/domainRegistration.js";
59
62
 
60
63
  const getKeyFolderPath = () => {
61
64
  const scriptPath =
@@ -162,6 +165,13 @@ const addCommandOptions = {
162
165
  demandOption: false,
163
166
  default: false,
164
167
  },
168
+ register: {
169
+ describe:
170
+ "Config mode for a directory of DOMAIN configs only (no application.js): upload them and ADD them to the tenant's application config, keeping everything else in it (other apps, Control Center's domains, menus). For a second application on a tenant. PostgreSQL only",
171
+ type: "boolean",
172
+ demandOption: false,
173
+ default: false,
174
+ },
165
175
  };
166
176
 
167
177
  const removeCommandOptions = {
@@ -395,7 +405,9 @@ const findApplicationConfigFile = (fileNames = []) =>
395
405
  gate exists to validate app scripts before they are bundled, and a config directory has nothing
396
406
  to test — worse, jest exits non-zero there ("Could not find a config file", after traversing up),
397
407
  which would abort the upload outright. */
398
- const isConfigModeDir = (workingDir, files = null) => {
408
+ const isConfigModeDir = (workingDir, files = null, { register = false } = {}) => {
409
+ /* Doc 8 — `--register` ships domain configs with no application.js: config mode by declaration. */
410
+ if (register) return true;
399
411
  try {
400
412
  return !!findApplicationConfigFile(
401
413
  getFileList({ workingDir: path.resolve(workingDir), files }),
@@ -525,7 +537,15 @@ const collectConfigUploads = ({ workingDir, fileNames = [], include = [] }) => {
525
537
  `${path.basename(applicationFile)}: metadata.version "${version}" must be semantic (x.y.z).`,
526
538
  );
527
539
  }
528
- application = { name: APP_CONFIG_NAME, version, content };
540
+ /* Doc 8 (2026-10-10): an application.js declaring `metadata.app` is ANOTHER application on the tenant, saved
541
+ as `APPLICATION_CONFIG.<app>` beside the host's (engine/domain/appConfig.js appConfigNameFor). */
542
+ let name = APP_CONFIG_NAME;
543
+ try {
544
+ name = appConfigNameFor(parseConfigForVersion(content)?.metadata);
545
+ } catch (error) {
546
+ problems.push(`${path.basename(applicationFile)}: ${error?.message ?? error}`);
547
+ }
548
+ application = { name, version, content };
529
549
  }
530
550
 
531
551
  /*
@@ -543,7 +563,12 @@ const collectConfigUploads = ({ workingDir, fileNames = [], include = [] }) => {
543
563
  const themeFile = fileNames.find(
544
564
  (f) => path.basename(String(f)).toLowerCase() === THEME_CONFIG_FILE,
545
565
  );
546
- if (themeFile) {
566
+ if (themeFile && secondaryAppNameOf(application?.name)) {
567
+ problems.push(
568
+ `${path.basename(String(themeFile))}: the theme is the tenant's, published with its host application — ` +
569
+ `not with ${application.name}.`,
570
+ );
571
+ } else if (themeFile) {
547
572
  const { theme: parsedTheme, problems: themeProblems } = validateThemeSource(
548
573
  readConfig(themeFile),
549
574
  );
@@ -688,6 +713,89 @@ const preserveApplicationAdditions = async (application, apiConfig) => {
688
713
  };
689
714
  };
690
715
 
716
+ /*
717
+ * Doc 8 — `--register`: what the domains are added to, read BEFORE anything is written.
718
+ *
719
+ * ⚠️ The tenant's NEWEST application config, the row every reader takes (buildPostgresApplicationConfigLookupSql).
720
+ * Refused when the directory carries its own application.js (that is the replacing mode), on Firebird (PostgreSQL
721
+ * only, as Control Center), and when the tenant has no application config yet (there is nothing to add to — the
722
+ * host application comes first).
723
+ */
724
+ const readRegistrationBase = async ({ application, dbIndex }) => {
725
+ if (application) {
726
+ throw new Error(
727
+ "--register adds domains to the tenant's application config, but this directory has application.js, which would " +
728
+ "REPLACE it. Upload the domains without application.js, or drop --register.",
729
+ );
730
+ }
731
+ if (!isPostgresIndex(dbIndex)) {
732
+ throw new Error(`--register needs a PostgreSQL database; index ${dbIndex} is not one.`);
733
+ }
734
+ const row = (await execPostgres(buildPostgresApplicationConfigLookupSql(), dbIndex))?.[0];
735
+ if (!row?.data) {
736
+ throw new Error(
737
+ "--register: this database has no application config to add the domains to. Upload the host application first.",
738
+ );
739
+ }
740
+ return { version: String(row.version ?? ""), config: parseConfigForVersion(String(row.data)) };
741
+ };
742
+
743
+ /*
744
+ * Doc 8 — `--register`: name the uploaded domains in the application config, as Control Center's publish does
745
+ * (engine/domain/domainRegistration.js). Answers the row the application publish must rebuild.
746
+ *
747
+ * Already named at these versions → nothing is written. Otherwise a NEW patch version, so the row every reader takes
748
+ * is the one that names them; every other key (other apps' domains, menus, theme) comes through unchanged.
749
+ */
750
+ const registerUploadedDomains = async ({ registration, domains, dbIndex, saveOne }) => {
751
+ const next = registerDomainVersions(registration.config, new Map(domains.map(({ name, version }) => [name, version])));
752
+ if (JSON.stringify(next) === JSON.stringify(registration.config)) {
753
+ logger.info(`${APP_CONFIG_NAME}@${registration.version} already names ${domains.map((d) => `${d.name}@${d.version}`).join(", ")}; not rewritten`);
754
+ return { name: APP_CONFIG_NAME, version: registration.version };
755
+ }
756
+
757
+ const version = bumpPatch(registration.version);
758
+ if (next.metadata && typeof next.metadata === "object" && next.metadata.version !== undefined) next.metadata.version = version;
759
+ await saveOne({ name: APP_CONFIG_NAME, version, content: renderApplicationConfig(next) });
760
+ logger.info(`registered ${domains.map((d) => d.name).join(", ")} in ${APP_CONFIG_NAME}@${version}`);
761
+
762
+ /* Control Center checks a draft against the row it started from, so an open application draft is now behind. */
763
+ try {
764
+ const open = await execPostgres("SELECT ID FROM SYS$CC_DRAFT WHERE KIND = 'APPLICATION' AND STATUS = 'OPEN'", dbIndex);
765
+ if (open?.length) {
766
+ logger.warn(
767
+ "Control Center has an open application draft, which started from the previous application config. " +
768
+ "Publishing it will be refused as stale until it is re-opened from the current one.",
769
+ );
770
+ }
771
+ } catch {
772
+ /* No Control Center tables on this tenant: nothing to warn about. */
773
+ }
774
+ return { name: APP_CONFIG_NAME, version };
775
+ };
776
+
777
+ /*
778
+ * Doc 8 (2026-10-10) — a SECONDARY application (`APPLICATION_CONFIG.<app>`), checked BEFORE anything is written.
779
+ *
780
+ * PostgreSQL only, as --register. And never the host's own SYS$APPS row: the host's row is keyed by its display name,
781
+ * so an app named like it would publish over the host's bundle.
782
+ */
783
+ const assertSecondaryApplication = async ({ application, dbIndex }) => {
784
+ const app = secondaryAppNameOf(application.name);
785
+ if (!isPostgresIndex(dbIndex)) {
786
+ throw new Error(`${application.name}: a second application on a tenant needs a PostgreSQL database; index ${dbIndex} is not one.`);
787
+ }
788
+ const host = (await execPostgres(buildPostgresApplicationConfigLookupSql(), dbIndex))?.[0];
789
+ const hostConfig = host?.data ? parseConfigForVersion(String(host.data)) : null;
790
+ const hostApp = normalizeAppName(hostConfig?.metadata?.name);
791
+ if (hostApp && hostApp === app) {
792
+ throw new Error(
793
+ `metadata.app "${app}" is the host application's own row (${APP_CONFIG_NAME} "${hostConfig.metadata.name}"); ` +
794
+ "name the second application something else.",
795
+ );
796
+ }
797
+ };
798
+
691
799
  async function uploadConfigs({
692
800
  workingDir,
693
801
  fileNames,
@@ -700,6 +808,7 @@ async function uploadConfigs({
700
808
  forceFull = false,
701
809
  releaseCc = false,
702
810
  serverMode = "prod",
811
+ register = false,
703
812
  }) {
704
813
  /* Phase B2: ask biz-a for this tenant's database coordinates before anything reads the
705
814
  dialect. A deployed CLI has no config/databases.json, so without this the registry
@@ -715,6 +824,11 @@ async function uploadConfigs({
715
824
  include,
716
825
  });
717
826
 
827
+ /* Doc 8 — `--register`: these domains are ADDED to the tenant's application config, never replace it. Every
828
+ refusal happens here, before anything is written. */
829
+ const registration = register ? await readRegistrationBase({ application, dbIndex }) : null;
830
+ if (secondaryAppNameOf(application?.name)) await assertSecondaryApplication({ application, dbIndex });
831
+
718
832
  const apiConfig = {
719
833
  url: `${server}:${apiPort}`,
720
834
  dbindex: dbIndex,
@@ -791,12 +905,20 @@ async function uploadConfigs({
791
905
  await saveOne(domain);
792
906
  }
793
907
 
908
+ /* The application config the publish below rebuilds: the uploaded one, or — with --register — the tenant's
909
+ newest one, now naming these domains. */
910
+ let applicationTarget = application ? { name: application.name, version: application.version } : null;
911
+ if (registration) {
912
+ applicationTarget = await registerUploadedDomains({ registration, domains, dbIndex, saveOne });
913
+ }
914
+
794
915
  if (!publish) {
795
916
  return {
796
917
  success: true,
797
918
  data: {
798
919
  applicationConfig: application,
799
920
  domains: domains.map(({ name, version }) => ({ name, version })),
921
+ ...(registration ? { registeredIn: applicationTarget } : {}),
800
922
  },
801
923
  };
802
924
  }
@@ -838,7 +960,7 @@ async function uploadConfigs({
838
960
  logger.info(`published ${domain.name}@${domain.version}`);
839
961
  }
840
962
 
841
- if (application) {
963
+ if (applicationTarget) {
842
964
  /* publishApplicationConfig -> publishApp -> addApp({ workingDir: process.cwd() }), and
843
965
  addApp folds getFileList(cwd) into the bundle. cwd is the CONFIG directory right now, so
844
966
  without this the configs themselves would be packed as app scripts. Run the publish from
@@ -851,8 +973,8 @@ async function uploadConfigs({
851
973
  process.chdir(scratchDir);
852
974
  const result = await publishApplicationConfig(
853
975
  {
854
- applicationConfigName: application.name,
855
- applicationConfigVersion: application.version,
976
+ applicationConfigName: applicationTarget.name,
977
+ applicationConfigVersion: applicationTarget.version,
856
978
  dbIndex,
857
979
  url: apiConfig.url,
858
980
  subdomain: sub,
@@ -861,10 +983,10 @@ async function uploadConfigs({
861
983
  );
862
984
  if (!result || result.success !== true) {
863
985
  throw new Error(
864
- `Publish ${application.name}@${application.version} failed: ${result?.error ?? "no error message from server"}`,
986
+ `Publish ${applicationTarget.name}@${applicationTarget.version} failed: ${result?.error ?? "no error message from server"}`,
865
987
  );
866
988
  }
867
- logger.info(`published ${application.name}@${application.version}`);
989
+ logger.info(`published ${applicationTarget.name}@${applicationTarget.version}`);
868
990
  } finally {
869
991
  process.chdir(previousCwd);
870
992
  fs.rmSync(scratchDir, { force: true, recursive: true });
@@ -881,6 +1003,79 @@ async function uploadConfigs({
881
1003
  };
882
1004
  }
883
1005
 
1006
+ /*
1007
+ * Doc 8 (2026-10-10) — `biza unregister --domains a,b [--publish]`: the inverse of --register. Takes the domains OUT of
1008
+ * the host APPLICATION_CONFIG (their entries and the menu leaves their publishes stamped; engine/domain/
1009
+ * domainRegistration.js unregisterDomains) as a new patch version, and with --publish rebuilds the host from it.
1010
+ *
1011
+ * The domain configs themselves stay in sys$config, and their tables stay in the database — only the host stops
1012
+ * naming them. How a domain moves out of the host into its own application.
1013
+ *
1014
+ * PostgreSQL only, as --register. A domain Control Center owns is refused: it is CC's to publish.
1015
+ */
1016
+ async function unregisterAppDomains({
1017
+ domains,
1018
+ server = "http://localhost",
1019
+ apiPort = 212,
1020
+ dbIndex = 2,
1021
+ sub,
1022
+ publish = false,
1023
+ serverMode = "prod",
1024
+ } = {}) {
1025
+ try {
1026
+ await primeRegistry({ subdomain: sub, serverLink: resolveServerLink(serverMode) });
1027
+
1028
+ const names = String(domains ?? "")
1029
+ .split(",")
1030
+ .map((name) => name.trim())
1031
+ .filter(Boolean);
1032
+ if (names.length === 0) throw new Error("unregister: name the domains to take out with --domains a,b.");
1033
+ if (!isPostgresIndex(dbIndex)) throw new Error(`unregister needs a PostgreSQL database; index ${dbIndex} is not one.`);
1034
+
1035
+ await guardCcOwnedDomains({ exec: (sql) => execPostgres(sql, dbIndex), domainNames: names, releaseCc: false });
1036
+
1037
+ const row = (await execPostgres(buildPostgresApplicationConfigLookupSql(), dbIndex))?.[0];
1038
+ if (!row?.data) throw new Error("unregister: this database has no application config.");
1039
+ const current = parseConfigForVersion(String(row.data));
1040
+ const { config: next, removed } = unregisterDomains(current, names);
1041
+ if (removed.length === 0) {
1042
+ logger.info(`${APP_CONFIG_NAME}@${row.version} names none of ${names.join(", ")}; not rewritten`);
1043
+ return { success: true, data: { removed } };
1044
+ }
1045
+
1046
+ const version = bumpPatch(String(row.version ?? ""));
1047
+ if (next.metadata && typeof next.metadata === "object" && next.metadata.version !== undefined) next.metadata.version = version;
1048
+ await execPostgres(buildEnsureConfigTableSql(), dbIndex);
1049
+ await execPostgres(buildPostgresConfigUpsertSql(APP_CONFIG_NAME, version, renderApplicationConfig(next), false), dbIndex);
1050
+ logger.info(`unregistered ${removed.join(", ")} from ${APP_CONFIG_NAME}@${version}`);
1051
+
1052
+ if (publish) {
1053
+ const { publishApplicationConfig } = await import("../engine/domain/applicationConfig.js");
1054
+ /* From an empty scratch dir, as uploadConfigs: addApp folds the working dir's own files into the bundle. */
1055
+ const scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), "biza-publish-"));
1056
+ const previousCwd = process.cwd();
1057
+ try {
1058
+ process.chdir(scratchDir);
1059
+ const result = await publishApplicationConfig(
1060
+ { applicationConfigName: APP_CONFIG_NAME, applicationConfigVersion: version, dbIndex, url: `${server}:${apiPort}`, subdomain: sub },
1061
+ { server, port: apiPort, dbindex: dbIndex, subdomain: sub, serverMode },
1062
+ );
1063
+ if (!result || result.success !== true) {
1064
+ throw new Error(`Publish ${APP_CONFIG_NAME}@${version} failed: ${result?.error ?? "no error message from server"}`);
1065
+ }
1066
+ logger.info(`published ${APP_CONFIG_NAME}@${version}`);
1067
+ } finally {
1068
+ process.chdir(previousCwd);
1069
+ fs.rmSync(scratchDir, { force: true, recursive: true });
1070
+ }
1071
+ }
1072
+ return { success: true, data: { removed, version, published: !!publish } };
1073
+ } catch (error) {
1074
+ logger.error(error?.message ?? error);
1075
+ return { success: false, error: error?.message ?? String(error) };
1076
+ }
1077
+ }
1078
+
884
1079
  const parseApplicationConfigData = (rawConfig) => {
885
1080
  if (rawConfig == null) {
886
1081
  return null;
@@ -1027,6 +1222,12 @@ async function addApp({
1027
1222
  serverMode = "prod",
1028
1223
  /* Extra directories whose DOMAIN configs upload alongside this one's (see --include). */
1029
1224
  include = [],
1225
+ /* Doc 8 — add the domains to the tenant's application config instead of replacing it (see --register). */
1226
+ register = false,
1227
+ /* Doc 8 (2026-10-10) — the SYS$APPS row this publish writes, from the config being published
1228
+ (applicationConfig.js appMetadataOf): { appName, displayName, menuLayout }. Without it the row is named
1229
+ from the host APPLICATION_CONFIG, as before. */
1230
+ appMeta = null,
1030
1231
  } = {}) {
1031
1232
  const oldCwd = process.cwd();
1032
1233
 
@@ -1069,7 +1270,7 @@ async function addApp({
1069
1270
  SYS$CONFIG rows, not libs — upload the configs instead of packing a bundle. Guarded on
1070
1271
  `body == null` so the publish path (publishApplicationConfig -> publishApp -> addApp with
1071
1272
  generated template scripts) can never recurse back into config mode. */
1072
- if (body == null && findApplicationConfigFile(mergedSourceFiles)) {
1273
+ if (body == null && (register || findApplicationConfigFile(mergedSourceFiles))) {
1073
1274
  return await uploadConfigs({
1074
1275
  workingDir: process.cwd(),
1075
1276
  fileNames: mergedSourceFiles,
@@ -1082,6 +1283,7 @@ async function addApp({
1082
1283
  forceFull,
1083
1284
  releaseCc,
1084
1285
  serverMode,
1286
+ register,
1085
1287
  });
1086
1288
  }
1087
1289
 
@@ -1171,13 +1373,14 @@ async function addApp({
1171
1373
  : `${server}:${apiPort}/${baseUrl}`;
1172
1374
 
1173
1375
  const headers = { "Content-Type": "text/plain" };
1174
- const { displayName: appDisplayName, menuLayout } =
1175
- await resolveAppMetadataFromApplicationConfig({
1176
- server,
1177
- apiPort,
1178
- dbIndex,
1179
- sub,
1180
- });
1376
+ const { displayName: appDisplayName, menuLayout } = appMeta?.appName
1377
+ ? { displayName: String(appMeta.displayName ?? "").trim(), menuLayout: appMeta.menuLayout ?? null }
1378
+ : await resolveAppMetadataFromApplicationConfig({
1379
+ server,
1380
+ apiPort,
1381
+ dbIndex,
1382
+ sub,
1383
+ });
1181
1384
  const useApplicationConfigName = body != null;
1182
1385
  if (useApplicationConfigName && appDisplayName.length > 0) {
1183
1386
  keys.metadata["name"] = appDisplayName;
@@ -1187,9 +1390,11 @@ async function addApp({
1187
1390
  if (useApplicationConfigName && menuLayout) {
1188
1391
  keys.metadata["menuLayout"] = menuLayout;
1189
1392
  }
1190
- const uploadAppName = normalizeAppName(
1191
- (useApplicationConfigName ? appDisplayName : "") || bundleName,
1192
- );
1393
+ const uploadAppName = appMeta?.appName
1394
+ ? normalizeAppName(appMeta.appName)
1395
+ : normalizeAppName(
1396
+ (useApplicationConfigName ? appDisplayName : "") || bundleName,
1397
+ );
1193
1398
  /* SYS$APPS is a Biz-A table, not a FINA one, so a PostgreSQL tenant is written
1194
1399
  directly — no FINA server in the path at all. On Firebird the FINA stored
1195
1400
  procedure TOrmMethod/"setApp" still owns the write. */
@@ -1312,7 +1517,7 @@ const buildCli = () =>
1312
1517
  addCommandOptions,
1313
1518
  async (commandOptions) => {
1314
1519
  /* Config mode uploads SYS$CONFIG rows, not app scripts — skip the jest gate. */
1315
- if (isConfigModeDir(commandOptions.workingDir, commandOptions.files)) {
1520
+ if (isConfigModeDir(commandOptions.workingDir, commandOptions.files, { register: commandOptions.register })) {
1316
1521
  await addApp({
1317
1522
  workingDir: path.resolve(commandOptions.workingDir),
1318
1523
  verbose: commandOptions.verbose,
@@ -1326,6 +1531,7 @@ const buildCli = () =>
1326
1531
  releaseCc: commandOptions.releaseCc,
1327
1532
  serverMode: commandOptions.serverMode,
1328
1533
  include: commandOptions.include,
1534
+ register: commandOptions.register,
1329
1535
  });
1330
1536
  return;
1331
1537
  }
@@ -1386,6 +1592,34 @@ const buildCli = () =>
1386
1592
  })();
1387
1593
  },
1388
1594
  )
1595
+ .command(
1596
+ "unregister",
1597
+ "Take domains out of the host application config (Doc 8), e.g. when they move to their own application",
1598
+ (yargs) =>
1599
+ yargs
1600
+ .option("domains", {
1601
+ describe: "Comma-separated domain names to take out",
1602
+ type: "string",
1603
+ demandOption: true,
1604
+ })
1605
+ .option("publish", {
1606
+ describe: "Republish the host application config afterwards",
1607
+ type: "boolean",
1608
+ default: false,
1609
+ }),
1610
+ async (commandOptions) => {
1611
+ const result = await unregisterAppDomains({
1612
+ domains: commandOptions.domains,
1613
+ server: commandOptions.server,
1614
+ apiPort: commandOptions.apiPort,
1615
+ dbIndex: Number(commandOptions.dbIndex),
1616
+ sub: commandOptions.sub,
1617
+ publish: commandOptions.publish,
1618
+ serverMode: commandOptions.serverMode,
1619
+ });
1620
+ if (!result.success) process.exitCode = 1;
1621
+ },
1622
+ )
1389
1623
  .command(
1390
1624
  "migrate:new",
1391
1625
  "Create a new blank migration file with an epoch timestamp",
@@ -1506,4 +1740,5 @@ export {
1506
1740
  resolveUpsertBlocks,
1507
1741
  uploadConfigs,
1508
1742
  isConfigModeDir,
1743
+ unregisterAppDomains,
1509
1744
  };
package/bin/hub.js CHANGED
@@ -136,6 +136,13 @@ const argv = yargs(process.argv.slice(2))
136
136
  "Doc 1 0b.6b: base URL of the central AI gateway (biz-a-server /ai) that ccai calls. Defaults to this hub's server (serverMode).",
137
137
  type: "string",
138
138
  })
139
+ .options("finaCloudDbindex", {
140
+ describe:
141
+ "Doc 8: the FINA API database FINA Cloud works on (FINA's own index, not Biz-A's). Absent = FINA Cloud off. When set, browser writes to that database are refused: they go through engine/fina.",
142
+ type: "number",
143
+ /* ⚠️ No default, and an operator setting rather than a console one: one FINA API can serve several
144
+ companies, and the index chooses whose books these are. */
145
+ })
139
146
  .options("services", {
140
147
  describe:
141
148
  "Comma-separated list of engine jobs this agent handles (e.g., 'BPM,EMAIL')",
@@ -203,6 +210,7 @@ import { createExtRouter } from "../engine/ext/routes.js";
203
210
  import { createLiveExtDeps } from "../engine/ext/live.js";
204
211
  import axios from "axios";
205
212
  import { createFinaRouter } from "../engine/orm/finaEndpoint.js";
213
+ import { createLiveFinaReadPolicy } from "../engine/fina/readPolicy.js";
206
214
  import { createSessionStore } from "../engine/orm/sessionStore.js";
207
215
  import { resolveUserTenancy } from "../engine/orm/tenantResolution.js";
208
216
  import { buildUserTenantSql } from "../engine/orm/finaAuth.js";
@@ -332,6 +340,14 @@ app.use(
332
340
  /* A v2 save that creates a record of an entity declaring its workflow starts it — the same
333
341
  starter the socket entrance (bin/hubEvent.js) hands the router. */
334
342
  startWorkflow: startWorkflowOnCreate,
343
+ /* Doc 8 D2 — the FINA Cloud write guard, the same one the socket entrance runs (bin/hubEvent.js). */
344
+ finaCloudDbIndex: argv.finaCloudDbindex,
345
+ /* Doc 8 M8 — and the read policy (a mapped FINA user), as the socket entrance runs it. */
346
+ finaReadPolicy: createLiveFinaReadPolicy({
347
+ argv,
348
+ isPostgresIndex,
349
+ readSession: hubSessionStore ? (sid) => hubSessionStore.read(sid) : null,
350
+ }),
335
351
  /* So a locally-served response still tells the client where to open its sockets — the
336
352
  tunnel's byte-level injection only fires for real FINA responses. */
337
353
  cliAddress: () => (argv.cliAddress ? argv.cliAddress() : {}),
package/bin/hubEvent.js CHANGED
@@ -21,6 +21,9 @@ import { tenantSqlFor } from "../engine/orm/tenantSql.js";
21
21
  import { ensureEntityConfig } from "../engine/domain/entityConfigPrimer.js";
22
22
  import { startWorkflowOnCreate } from "../engine/bpm/workflowOnCreate.js";
23
23
  import { bpmContextFromHeaders } from "../engine/bpm/bpmContext.js";
24
+ import { finaContext } from "../engine/fina/context.js";
25
+ import { refuseFinaCloudWrite } from "../engine/fina/guard.js";
26
+ import { createLiveFinaReadPolicy } from "../engine/fina/readPolicy.js";
24
27
  import { runAsPrincipal, runTrusted, ACTIVE_TENANT_NOT_HELD } from "../engine/orm/tenantContext.js";
25
28
  import { resolveRequestScope, SESSION_NOT_USABLE } from "../engine/orm/requestScope.js";
26
29
  import { createSessionStore } from "../engine/orm/sessionStore.js";
@@ -51,6 +54,7 @@ import {
51
54
  writeCompanyLogo,
52
55
  clearCompanyLogo,
53
56
  } from "../engine/orm/companyLogo.js";
57
+ import { readCompanyLocale, writeCompanyLocale } from "../engine/orm/companyLocale.js";
54
58
  import {
55
59
  execPostgres,
56
60
  execPostgresStatements,
@@ -92,6 +96,23 @@ const sessionStoreFor = (dbIndex) => {
92
96
  }
93
97
  return sessionStores.get(dbIndex);
94
98
  };
99
+ /* Doc 8 M8 — the FINA read policy (S-02: reading FINA takes a mapped FINA user), one per hub, built on first use from
100
+ the hub's own argv and session store: the same context the `fina/*` engine routes get. */
101
+ let finaReadPolicyOfHub = null;
102
+ const finaReadPolicyFor = (argv) => {
103
+ if (!finaReadPolicyOfHub) {
104
+ finaReadPolicyOfHub = createLiveFinaReadPolicy({
105
+ argv,
106
+ isPostgresIndex,
107
+ readSession: (sid) => {
108
+ const store = sessionStoreFor(Number(argv["dbindex"]));
109
+ return store ? store.read(sid) : null;
110
+ },
111
+ });
112
+ }
113
+ return finaReadPolicyOfHub;
114
+ };
115
+
95
116
  const openSessionFor = (dbIndex) => {
96
117
  const store = sessionStoreFor(dbIndex);
97
118
  return store ? (args) => store.open(args) : null;
@@ -500,6 +521,21 @@ const EngineRegistry = {
500
521
  "domain/appConfig": () => import("../engine/domain/appConfig.js"),
501
522
  "domain/applicationConfig": () =>
502
523
  import("../engine/domain/applicationConfig.js"),
524
+ /* Doc 8 — FINA Cloud. ⚠️ Route files only: every export of a registered module is callable from a browser. */
525
+ "fina/setup": () => import("../engine/fina/routes/setup.js"),
526
+ "fina/session": () => import("../engine/fina/routes/session.js"),
527
+ "fina/master": () => import("../engine/fina/routes/master.js"),
528
+ /* Doc 8 M4 — the template engine's server side. */
529
+ "fina/template": () => import("../engine/fina/routes/template.js"),
530
+ "fina/document": () => import("../engine/fina/routes/document.js"),
531
+ "fina/print": () => import("../engine/fina/routes/print.js"),
532
+ "fina/recurring": () => import("../engine/fina/routes/recurring.js"),
533
+ /* Doc 8 M5b — which months FINA keeps open, for the rows a list shows (L-08). */
534
+ "fina/period": () => import("../engine/fina/routes/period.js"),
535
+ /* Doc 8 M6 — a FINA document's attachments, kept in Biz-A (SYS$ATTACHMENT). */
536
+ "fina/attachment": () => import("../engine/fina/routes/attachment.js"),
537
+ /* Doc 8 M7 — JV submissions (pengajuan) with Need Approval on; the approval workflow posts them. */
538
+ "fina/submission": () => import("../engine/fina/routes/submission.js"),
503
539
  };
504
540
 
505
541
  /* Doc 1 step 3 — what a Phase B turn needs: the draft store, the gap list, a draft opener that applies the
@@ -665,6 +701,30 @@ const handleCliCommand = async (data, cb, argv) => {
665
701
  }
666
702
  break;
667
703
  }
704
+ /*
705
+ * Agung 2026-10-10 — the company number format (id / en-US), the theme's path: read by anyone signed
706
+ * in (every screen formats numbers by it), written only by a verified administrator
707
+ * (companyLocale.js), audited. A refused value is an answer with `problems`, not an error.
708
+ */
709
+ case "companylocale": {
710
+ const localePrincipal = principalForCliScript(data.headers, {
711
+ mode: argv["principalMode"] || "observe",
712
+ });
713
+ const localeDb = tenantSqlFor(argv["dbindex"]);
714
+ if (data.action === "write") {
715
+ const saved = await writeCompanyLocale({
716
+ exec: localeDb.exec,
717
+ execStatements: localeDb.atomic,
718
+ sql: localeDb,
719
+ principal: localePrincipal,
720
+ locale: data.locale,
721
+ });
722
+ cb(null, { success: saved.ok, problems: saved.problems, data: saved.locale });
723
+ } else {
724
+ cb(null, { success: true, data: await readCompanyLocale({ exec: localeDb.exec }) });
725
+ }
726
+ break;
727
+ }
668
728
  /*
669
729
  * Doc 6 §8.2 — the company logo, under its OWN sys$config key.
670
730
  *
@@ -846,12 +906,23 @@ const handleCliCommand = async (data, cb, argv) => {
846
906
  * These are engine operations invoked by an administrator, not tenant data reads, so
847
907
  * they are trusted for the same reason everything behind db/db.js is.
848
908
  */
849
- /* The BPM runtime alone gets who is asking — the signed principal in `data.headers`, never the
850
- caller-shaped payload — for transitions that ask for verified roles (bpm/bpmContext.js). */
909
+ /* Who is asking — the signed principal in `data.headers`, never the caller-shaped payload — for the
910
+ modules that act on it: the BPM runtime, for transitions that ask for verified roles
911
+ (bpm/bpmContext.js), and FINA Cloud, whose every route acts as the verified user (Doc 8 D2,
912
+ engine/fina/context.js). */
851
913
  const engineContext =
852
914
  modulePath === "bpm/workflowRT"
853
915
  ? bpmContextFromHeaders(data.headers, { mode: argv["principalMode"] || "observe" })
854
- : undefined;
916
+ : modulePath.startsWith("fina/")
917
+ ? await finaContext(data.headers, {
918
+ argv,
919
+ isPostgresIndex,
920
+ readSession: (() => {
921
+ const store = sessionStoreFor(Number(argv["dbindex"]));
922
+ return store ? (sid) => store.read(sid) : null;
923
+ })(),
924
+ })
925
+ : undefined;
855
926
  const result = await runTrusted(() =>
856
927
  engineContext
857
928
  ? engineModule[methodName](data.payload, argv, engineContext)
@@ -988,7 +1059,28 @@ export const clientListener = (socket, argv, via = "?") => {
988
1059
  : undefined,
989
1060
  });
990
1061
  if (argv["subdomain"].localeCompare(reqData.subDomain) == 0) {
991
- const forwardToApi = () =>
1062
+ /* Doc 8 D2 — a browser may not write to the FINA Cloud database directly: engine/fina does it,
1063
+ checking the user's FINA rights and the date lock. Checked HERE, the one place this transport
1064
+ hands a request to FINA; the HTTP entrance runs the same guard (finaEndpoint.js). */
1065
+ /* Doc 8 M8 — and READING FINA takes a Biz-A user mapped to a FINA user (S-02, engine/fina/readPolicy.js). */
1066
+ const finaReadPolicy = finaReadPolicyFor(argv);
1067
+ const forwardToApi = async () => {
1068
+ const refused =
1069
+ refuseFinaCloudWrite({ path: reqData.path, body: reqBody, finaCloudDbIndex: argv["finaCloudDbindex"] }) ??
1070
+ (await finaReadPolicy({ path: reqData.path, body: reqBody, headers: reqData.headers }));
1071
+ if (refused) {
1072
+ logger.warn(`[apiRequest] refused ${reqData.path}: ${refused.body.error}`);
1073
+ return resCB(null, {
1074
+ status: refused.status,
1075
+ statusText: "Forbidden",
1076
+ headers: { "content-type": "application/json", ...cliAddressHeaders() },
1077
+ body: refused.body,
1078
+ url: apiAddress + reqData.path,
1079
+ });
1080
+ }
1081
+ return forwardToFina();
1082
+ };
1083
+ const forwardToFina = () =>
992
1084
  axios
993
1085
  .request({
994
1086
  timeout:
@@ -1116,7 +1208,8 @@ export const clientListener = (socket, argv, via = "?") => {
1116
1208
  status: routed.status,
1117
1209
  statusText: routed.status === 200 ? "OK" : "Error",
1118
1210
  headers: {
1119
- "content-type": "application/json",
1211
+ /* An image read answers bytes with its own type (apiRoute.js `img`). */
1212
+ "content-type": routed.contentType ?? "application/json",
1120
1213
  ...cliAddressHeaders(),
1121
1214
  },
1122
1215
  body: routed.body,
@@ -48,6 +48,10 @@ export const resolveTunnelTarget = (argv, isPostgresIndex, firstChunk) => {
48
48
  };
49
49
 
50
50
  if (isLoginRequest(firstChunk)) return ownEndpoint(argv);
51
+ /* Doc 8 D2 — with FINA Cloud on, everything loops back: the FINA Cloud write guard sits on the CLI's own
52
+ endpoint (finaEndpoint.js), and a byte pipe straight to FINA would pass it by. */
53
+ const finaCloud = Number(argv.finaCloudDbindex);
54
+ if (Number.isInteger(finaCloud) && finaCloud > 0) return ownEndpoint(argv);
51
55
  if (!argv.dbindex) return fina;
52
56
 
53
57
  let servedHere = false;