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.
- package/bin/app.js +255 -20
- package/bin/hub.js +16 -0
- package/bin/hubEvent.js +98 -5
- package/bin/tunnelTarget.js +4 -0
- package/engine/attachments/store.js +195 -0
- package/engine/bpm/workflow.js +7 -6
- package/engine/cc/configValidate.js +53 -0
- package/engine/cc/drift.js +18 -2
- package/engine/cc/guides/V2-SCREEN-AUTHORING.md +531 -512
- package/engine/cc/layer3Behavior.js +2 -1
- package/engine/cc/layer3Schema.js +3 -1
- package/engine/cc/livePipelines.js +9 -3
- package/engine/cc/mapStore.js +4 -3
- package/engine/cc/publish.js +86 -105
- package/engine/domain/appConfig.js +108 -18
- package/engine/domain/applicationConfig.js +87 -12
- package/engine/domain/dialect.js +138 -0
- package/engine/domain/domainNavigation.js +86 -50
- package/engine/domain/domainRegistration.js +65 -0
- package/engine/domain/entityConfigLoader.js +16 -4
- package/engine/domain/numericScale.js +2 -0
- package/engine/domain/system.js +2 -0
- package/engine/ext/publicTheme.js +8 -1
- package/engine/fina/approval.js +141 -0
- package/engine/fina/approvalService.js +59 -0
- package/engine/fina/attachments.js +165 -0
- package/engine/fina/client.js +84 -0
- package/engine/fina/config.js +26 -0
- package/engine/fina/context.js +58 -0
- package/engine/fina/document.js +67 -0
- package/engine/fina/documentWrite.js +173 -0
- package/engine/fina/documents/index.js +28 -0
- package/engine/fina/documents/jv.js +105 -0
- package/engine/fina/documents/jvWrite.js +260 -0
- package/engine/fina/documents/si.js +40 -0
- package/engine/fina/errors.js +17 -0
- package/engine/fina/features.js +86 -0
- package/engine/fina/guard.js +102 -0
- package/engine/fina/identity.js +87 -0
- package/engine/fina/live.js +19 -0
- package/engine/fina/lock.js +35 -0
- package/engine/fina/master.js +331 -0
- package/engine/fina/masters/coa.js +104 -0
- package/engine/fina/masters/currency.js +39 -0
- package/engine/fina/masters/department.js +32 -0
- package/engine/fina/masters/index.js +21 -0
- package/engine/fina/masters/project.js +53 -0
- package/engine/fina/period.js +56 -0
- package/engine/fina/print.js +136 -0
- package/engine/fina/readPolicy.js +67 -0
- package/engine/fina/recurring.js +200 -0
- package/engine/fina/route.js +61 -0
- package/engine/fina/routes/attachment.js +41 -0
- package/engine/fina/routes/document.js +66 -0
- package/engine/fina/routes/master.js +26 -0
- package/engine/fina/routes/period.js +24 -0
- package/engine/fina/routes/print.js +19 -0
- package/engine/fina/routes/recurring.js +27 -0
- package/engine/fina/routes/session.js +23 -0
- package/engine/fina/routes/setup.js +58 -0
- package/engine/fina/routes/submission.js +46 -0
- package/engine/fina/routes/template.js +31 -0
- package/engine/fina/settings.js +159 -0
- package/engine/fina/sql.js +34 -0
- package/engine/fina/submission.js +290 -0
- package/engine/fina/submissionCompare.js +171 -0
- package/engine/fina/submissionRepo.js +114 -0
- package/engine/fina/template.js +118 -0
- package/engine/fina/userMap.js +79 -0
- package/engine/orm/apiRoute.js +187 -61
- package/engine/orm/companyLocale.js +74 -0
- package/engine/orm/entityConfig.js +32 -2
- package/engine/orm/finaAuth.js +0 -0
- package/engine/orm/finaEndpoint.js +19 -0
- package/engine/orm/imgQuery.js +92 -0
- package/engine/orm/listQuery.js +6 -1
- package/engine/orm/userApiDataset.js +153 -0
- package/migrations/postgres/1790600000000__fina.sql +33 -0
- package/migrations/postgres/1790700000000__attachment.sql +29 -0
- package/migrations/postgres/1790800000000__fina_jv_submission.sql +41 -0
- package/package.json +1 -1
- 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
|
-
|
|
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 (
|
|
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:
|
|
855
|
-
applicationConfigVersion:
|
|
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 ${
|
|
986
|
+
`Publish ${applicationTarget.name}@${applicationTarget.version} failed: ${result?.error ?? "no error message from server"}`,
|
|
865
987
|
);
|
|
866
988
|
}
|
|
867
|
-
logger.info(`published ${
|
|
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
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
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 =
|
|
1191
|
-
|
|
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
|
-
/*
|
|
850
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/bin/tunnelTarget.js
CHANGED
|
@@ -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;
|