biz-a-cli 2.3.80-15317 → 2.3.80-15330
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 +206 -7
- package/bin/directHubEvent.js +12 -1
- package/bin/hub.js +126 -29
- package/bin/hubEvent.js +135 -15
- package/bin/migrate.js +5 -2
- package/db/apiUrl.js +58 -0
- package/db/db.js +115 -7
- package/engine/domain/appConfig.js +228 -39
- package/engine/domain/entityConfigPrimer.js +97 -0
- package/engine/domain/firebird-ddl.js +27 -13
- package/engine/domain/naming.js +47 -0
- package/engine/domain/postgres-ddl.js +65 -5
- package/engine/domain/publishedAdditions.js +69 -0
- package/engine/domain/system.js +3 -0
- package/engine/ext/channel.js +3 -0
- package/engine/ext/dispatch.js +19 -2
- package/engine/ext/live.js +19 -0
- package/engine/ext/oidc.js +1 -1
- package/engine/ext/routes.js +2 -1
- package/engine/ext/token.js +1 -1
- package/engine/orm/apiRoute.js +323 -17
- package/engine/orm/changeInference.js +55 -7
- package/engine/orm/connection.js +50 -1
- package/engine/orm/crudQuery.js +122 -32
- package/engine/orm/dataSnapShape.js +20 -0
- package/engine/orm/entityConfig.js +506 -7
- package/engine/orm/executeBlock.js +237 -2
- package/engine/orm/executor.js +6 -0
- package/engine/orm/finaAuth.js +0 -0
- package/engine/orm/finaEndpoint.js +77 -13
- package/engine/orm/index.js +128 -10
- package/engine/orm/listQuery.js +74 -14
- package/engine/orm/masterDetail.js +173 -40
- package/engine/orm/requestScope.js +157 -0
- package/engine/orm/rlsApplier.js +74 -0
- package/engine/orm/rlsPolicy.js +370 -0
- package/engine/orm/sessionStore.js +337 -0
- package/engine/orm/tenancyDeclaration.js +268 -0
- package/engine/orm/tenantApi.js +416 -0
- package/engine/orm/tenantContext.js +216 -0
- package/engine/orm/tenantResolution.js +426 -0
- package/engine/orm/tenantScope.js +82 -0
- package/envs/env.dev.js +7 -2
- package/envs/env.js +7 -2
- package/envs/hosts.js +36 -0
- package/envs/serverEndpoints.js +50 -0
- package/migrations/postgres/1789040354328__tenancy.sql +33 -0
- package/migrations/postgres/1789200000000__rls_role.sql +46 -0
- package/migrations/postgres/1789350000000__user_tenant.sql +111 -0
- package/migrations/postgres/1789500000000__session.sql +73 -0
- package/package.json +1 -1
- package/readme.md +5 -5
- package/security/internalPrincipal.js +70 -3
- package/worker/cliScriptWorker.js +29 -0
package/bin/app.js
CHANGED
|
@@ -13,6 +13,10 @@ import {
|
|
|
13
13
|
import path, { basename } from "node:path";
|
|
14
14
|
import os from "node:os";
|
|
15
15
|
import { env } from "../envs/env.js";
|
|
16
|
+
import {
|
|
17
|
+
SERVER_MODES,
|
|
18
|
+
resolveServerLink,
|
|
19
|
+
} from "../envs/serverEndpoints.js";
|
|
16
20
|
import { prepareScript, encryptScript } from "./script.js";
|
|
17
21
|
import { spawn } from "node:child_process";
|
|
18
22
|
import { newMigration, runMigrations } from "./migrate.js";
|
|
@@ -27,6 +31,7 @@ import {
|
|
|
27
31
|
getUpsertBlock,
|
|
28
32
|
isSemanticVersion,
|
|
29
33
|
parseConfigForVersion,
|
|
34
|
+
readConfigRows,
|
|
30
35
|
} from "../engine/domain/appConfig.js";
|
|
31
36
|
import {
|
|
32
37
|
isPostgresIndex,
|
|
@@ -34,6 +39,12 @@ import {
|
|
|
34
39
|
primeRegistry,
|
|
35
40
|
} from "../engine/domain/dialect.js";
|
|
36
41
|
import { addUser } from "../engine/orm/userAdmin.js";
|
|
42
|
+
import {
|
|
43
|
+
preservePublishedAdditions,
|
|
44
|
+
renderApplicationConfig,
|
|
45
|
+
uploadSourceConfigName,
|
|
46
|
+
} from "../engine/domain/publishedAdditions.js";
|
|
47
|
+
import { validateTenancyDeclaration } from "../engine/orm/tenancyDeclaration.js";
|
|
37
48
|
import { logger } from "../logger.js";
|
|
38
49
|
|
|
39
50
|
const getKeyFolderPath = () => {
|
|
@@ -79,6 +90,23 @@ const options = {
|
|
|
79
90
|
type: "number",
|
|
80
91
|
demandOption: false,
|
|
81
92
|
},
|
|
93
|
+
/*
|
|
94
|
+
* ⚠️ NOT the same link as `-s/--server`. `-s` is the FINA/hub URL the bundle is POSTed to;
|
|
95
|
+
* this picks the Biz-A server that issues the bundle-encryption key, which `prepareKeys` must
|
|
96
|
+
* fetch BEFORE anything is uploaded. It was hard-coded to production, so `biza add` could not
|
|
97
|
+
* run against a local stack at all — it died on `ENOTFOUND server.biz-a.id` while every other
|
|
98
|
+
* local service was healthy. `choices` + the `.strict()` below are what stop a typo from
|
|
99
|
+
* silently falling back to production; see `envs/serverEndpoints.js`.
|
|
100
|
+
*/
|
|
101
|
+
serverMode: {
|
|
102
|
+
alias: "sMode",
|
|
103
|
+
describe:
|
|
104
|
+
"Which Biz-A backend issues the bundle encryption key: prod, dev, or local",
|
|
105
|
+
type: "string",
|
|
106
|
+
choices: SERVER_MODES,
|
|
107
|
+
default: "prod",
|
|
108
|
+
demandOption: false,
|
|
109
|
+
},
|
|
82
110
|
};
|
|
83
111
|
|
|
84
112
|
const addCommandOptions = {
|
|
@@ -96,6 +124,13 @@ const addCommandOptions = {
|
|
|
96
124
|
demandOption: false,
|
|
97
125
|
default: false,
|
|
98
126
|
},
|
|
127
|
+
include: {
|
|
128
|
+
describe:
|
|
129
|
+
"Config mode only: additional directories whose DOMAIN configs upload alongside this one's — for a Layer 3 domain package that lives in its own tree (repeatable). An application.js in an included directory is refused, never uploaded: that row belongs to -d.",
|
|
130
|
+
type: "array",
|
|
131
|
+
demandOption: false,
|
|
132
|
+
default: [],
|
|
133
|
+
},
|
|
99
134
|
publish: {
|
|
100
135
|
describe:
|
|
101
136
|
"Config mode only (directory contains application.js): after saving, publish the domain DDL then the application config — the same as the admin App Config Publish button",
|
|
@@ -122,7 +157,7 @@ const removeCommandOptions = {
|
|
|
122
157
|
},
|
|
123
158
|
};
|
|
124
159
|
|
|
125
|
-
const prepareKeys = async () => {
|
|
160
|
+
const prepareKeys = async (serverLink = env.BIZA_SERVER_LINK) => {
|
|
126
161
|
const data = Buffer.from(
|
|
127
162
|
JSON.stringify({ issuer: "CLI", acquirer: "Client" }),
|
|
128
163
|
).toString("base64");
|
|
@@ -132,7 +167,7 @@ const prepareKeys = async () => {
|
|
|
132
167
|
passphrase: "Biz-A@cli",
|
|
133
168
|
padding: cryptoConstants.RSA_PKCS1_PSS_PADDING,
|
|
134
169
|
}).toString("base64");
|
|
135
|
-
const res = await axios.get(
|
|
170
|
+
const res = await axios.get(serverLink + "/api/issuerKey", {
|
|
136
171
|
params: { data, signature },
|
|
137
172
|
});
|
|
138
173
|
if (
|
|
@@ -419,8 +454,64 @@ const resolveUpsertBlocks = (name, version, content, dbIndex) => {
|
|
|
419
454
|
WHOLE set before anything is uploaded, so a stray lib or a typo'd metadata can never leave a
|
|
420
455
|
half-applied config in SYS$CONFIG. Every problem is collected and reported at once, each
|
|
421
456
|
naming its file. */
|
|
422
|
-
|
|
457
|
+
/*
|
|
458
|
+
* ⚠️ ADDITIONAL CONFIG DIRECTORIES (`--include`), for a Layer 3 domain package that lives in its
|
|
459
|
+
* own tree rather than beside the host's `application.js`.
|
|
460
|
+
*
|
|
461
|
+
* Config mode triggers on an `application.js` in the ONE directory `-d` names, so before this a
|
|
462
|
+
* package like `/biz-a-template/hr/src/config/` had no upload path whatsoever — and registering it in
|
|
463
|
+
* the host's `domains` map was INERT rather than broken, because the runtime loader skips a named
|
|
464
|
+
* domain with no row (`if (!row?.data) continue`). A domain that silently is not there is worse than
|
|
465
|
+
* one that fails loudly.
|
|
466
|
+
*
|
|
467
|
+
* ⚠️ AN `application.js` IN AN INCLUDED DIRECTORY IS REFUSED, NOT SKIPPED. Uploading it would
|
|
468
|
+
* replace the HOST's APPLICATION_CONFIG — its navigation, its domains map — with a package's, and the
|
|
469
|
+
* only symptom would be a console that lost its menus. Pointing `--include` at a host directory is a
|
|
470
|
+
* mistake worth being told about.
|
|
471
|
+
*/
|
|
472
|
+
const collectIncludedConfigFiles = (include = []) => {
|
|
473
|
+
const problems = [];
|
|
474
|
+
const files = [];
|
|
475
|
+
|
|
476
|
+
for (const entry of Array.isArray(include) ? include : [include]) {
|
|
477
|
+
const dir = String(entry ?? "").trim();
|
|
478
|
+
if (!dir) continue;
|
|
479
|
+
const resolved = path.resolve(dir);
|
|
480
|
+
|
|
481
|
+
let names;
|
|
482
|
+
try {
|
|
483
|
+
if (!fs.statSync(resolved).isDirectory()) {
|
|
484
|
+
problems.push(`--include ${dir}: not a directory.`);
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
names = fs.readdirSync(resolved);
|
|
488
|
+
} catch (error) {
|
|
489
|
+
problems.push(`--include ${dir}: ${error?.message ?? error}`);
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const configs = names.filter((name) => name.toLowerCase().endsWith(".js"));
|
|
494
|
+
if (configs.some((name) => name.toLowerCase() === APPLICATION_CONFIG_FILE)) {
|
|
495
|
+
problems.push(
|
|
496
|
+
`--include ${dir}: contains ${APPLICATION_CONFIG_FILE}. An included directory supplies ` +
|
|
497
|
+
"DOMAIN configs only — the application config belongs to the directory named by -d.",
|
|
498
|
+
);
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
for (const name of configs) files.push(path.join(resolved, name));
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
return { files, problems };
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
/* PURE-ish, continued: `include` names extra directories whose DOMAIN configs join the set. They are
|
|
508
|
+
folded in BEFORE validation so an included file gets exactly the same checks — and the same
|
|
509
|
+
"nothing was uploaded" guarantee — as one sitting beside application.js. */
|
|
510
|
+
const collectConfigUploads = ({ workingDir, fileNames = [], include = [] }) => {
|
|
423
511
|
const problems = [];
|
|
512
|
+
const included = collectIncludedConfigFiles(include);
|
|
513
|
+
problems.push(...included.problems);
|
|
514
|
+
fileNames = [...fileNames, ...included.files];
|
|
424
515
|
const readConfig = (fileName) =>
|
|
425
516
|
fs
|
|
426
517
|
.readFileSync(
|
|
@@ -455,6 +546,10 @@ const collectConfigUploads = ({ workingDir, fileNames = [] }) => {
|
|
|
455
546
|
}
|
|
456
547
|
|
|
457
548
|
const domains = [];
|
|
549
|
+
/* ⚠️ Kept because the tenancy rule is CROSS-FILE: a scoped entity in one domain may be given its
|
|
550
|
+
foreign key by a relationship in another, and `buildTenancyMap` folds them all together at
|
|
551
|
+
runtime. Validating file-by-file would refuse a declaration the runtime accepts. */
|
|
552
|
+
const parsedConfigs = [];
|
|
458
553
|
const seenNames = new Map();
|
|
459
554
|
for (const fileName of fileNames) {
|
|
460
555
|
const baseName = path.basename(String(fileName));
|
|
@@ -496,9 +591,16 @@ const collectConfigUploads = ({ workingDir, fileNames = [] }) => {
|
|
|
496
591
|
continue;
|
|
497
592
|
}
|
|
498
593
|
seenNames.set(name, baseName);
|
|
594
|
+
parsedConfigs.push(parsed);
|
|
499
595
|
domains.push({ name, version, content, fileName: baseName });
|
|
500
596
|
}
|
|
501
597
|
|
|
598
|
+
/* Doc 3 §11 — refuse a tenancy declaration the schema does not actually back. This is the last
|
|
599
|
+
point where every config is in hand and NOTHING has been written to sys$config, which is what
|
|
600
|
+
makes "nothing was uploaded" true. See engine/orm/tenancyDeclaration.js for why it refuses
|
|
601
|
+
rather than warns. */
|
|
602
|
+
problems.push(...validateTenancyDeclaration(parsedConfigs));
|
|
603
|
+
|
|
502
604
|
if (problems.length > 0) {
|
|
503
605
|
throw new Error(
|
|
504
606
|
`Config upload aborted, nothing was uploaded:\n - ${problems.join("\n - ")}`,
|
|
@@ -512,24 +614,86 @@ const collectConfigUploads = ({ workingDir, fileNames = [] }) => {
|
|
|
512
614
|
matches appConfig.service.ts publishEditorConfigs: every domain DDL publish first (sequential),
|
|
513
615
|
then the application-config publish (which regenerates template scaffolding + rebuilds the
|
|
514
616
|
bundle). */
|
|
617
|
+
/*
|
|
618
|
+
* Fold the publisher's own additions back into the file about to be uploaded.
|
|
619
|
+
*
|
|
620
|
+
* Reads through `readConfigRows`, which is already dialect-aware, so this behaves identically on a
|
|
621
|
+
* Firebird tenant without a line of new SQL. Fails SOFT: if either row cannot be read we upload the
|
|
622
|
+
* file exactly as before, because preserving on a guess is what caused the earlier incident.
|
|
623
|
+
*/
|
|
624
|
+
const preserveApplicationAdditions = async (application, apiConfig) => {
|
|
625
|
+
const readParsed = async (name) => {
|
|
626
|
+
try {
|
|
627
|
+
const rows = await readConfigRows(name, application.version, apiConfig);
|
|
628
|
+
const data = rows?.[0]?.data;
|
|
629
|
+
return data ? parseConfigForVersion(String(data)) : null;
|
|
630
|
+
} catch {
|
|
631
|
+
return null;
|
|
632
|
+
}
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
let fileConfig;
|
|
636
|
+
try {
|
|
637
|
+
fileConfig = parseConfigForVersion(application.content);
|
|
638
|
+
} catch {
|
|
639
|
+
return { application };
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const [storedConfig, lastUploadedConfig] = await Promise.all([
|
|
643
|
+
readParsed(application.name),
|
|
644
|
+
readParsed(uploadSourceConfigName(application.name)),
|
|
645
|
+
]);
|
|
646
|
+
|
|
647
|
+
const kept = preservePublishedAdditions({ fileConfig, storedConfig, lastUploadedConfig });
|
|
648
|
+
const keptKeys = Object.keys(kept);
|
|
649
|
+
if (keptKeys.length === 0) {
|
|
650
|
+
/* Say WHY nothing was kept when there plainly was something to keep — a silent revert is how
|
|
651
|
+
this cost four round-trips before anyone worked out what was happening. */
|
|
652
|
+
if (storedConfig && !lastUploadedConfig) {
|
|
653
|
+
logger.info(
|
|
654
|
+
`[${application.name}] no record of a previous upload, so nothing is preserved; ` +
|
|
655
|
+
"re-run the application's own publish afterwards to regenerate what it adds.",
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
return { application };
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
logger.info(
|
|
662
|
+
`[${application.name}] keeping ${keptKeys.join(", ")} from the last application publish ` +
|
|
663
|
+
"(unchanged in application.js); every other key comes from the file.",
|
|
664
|
+
);
|
|
665
|
+
return {
|
|
666
|
+
application: {
|
|
667
|
+
...application,
|
|
668
|
+
content: renderApplicationConfig({ ...fileConfig, ...kept }),
|
|
669
|
+
},
|
|
670
|
+
};
|
|
671
|
+
};
|
|
672
|
+
|
|
515
673
|
async function uploadConfigs({
|
|
516
674
|
workingDir,
|
|
517
675
|
fileNames,
|
|
676
|
+
include = [],
|
|
518
677
|
server,
|
|
519
678
|
apiPort,
|
|
520
679
|
dbIndex,
|
|
521
680
|
sub,
|
|
522
681
|
publish = false,
|
|
523
682
|
forceFull = false,
|
|
683
|
+
serverMode = "prod",
|
|
524
684
|
}) {
|
|
525
685
|
/* Phase B2: ask biz-a for this tenant's database coordinates before anything reads the
|
|
526
686
|
dialect. A deployed CLI has no config/databases.json, so without this the registry
|
|
527
687
|
indexes would look like Firebird. No-op when --sub is absent or biz-a is unreachable. */
|
|
528
|
-
await primeRegistry({
|
|
688
|
+
await primeRegistry({
|
|
689
|
+
subdomain: sub,
|
|
690
|
+
serverLink: resolveServerLink(serverMode),
|
|
691
|
+
});
|
|
529
692
|
|
|
530
693
|
const { application, domains } = collectConfigUploads({
|
|
531
694
|
workingDir,
|
|
532
695
|
fileNames,
|
|
696
|
+
include,
|
|
533
697
|
});
|
|
534
698
|
|
|
535
699
|
const apiConfig = {
|
|
@@ -562,7 +726,28 @@ async function uploadConfigs({
|
|
|
562
726
|
};
|
|
563
727
|
|
|
564
728
|
if (application) {
|
|
565
|
-
|
|
729
|
+
/*
|
|
730
|
+
* ⚠️ APPLICATION_CONFIG HAS TWO WRITERS. This one uploads `application.js` verbatim — which
|
|
731
|
+
* deliberately ships only the BOOTSTRAP navigation and the hand-authored domains — while the
|
|
732
|
+
* APPLICATION's own publish rebuilds the same row with what it GENERATES (injected staff
|
|
733
|
+
* consoles, the generated runtime domain). Overwriting it wholesale wiped every app menu until
|
|
734
|
+
* someone re-ran the app publish by hand.
|
|
735
|
+
*
|
|
736
|
+
* ⚠️ The obvious fix is the one that already failed: "prefer whatever is stored" dropped the
|
|
737
|
+
* generated domain and made the damage permanent. The publisher owns SOME keys and the author
|
|
738
|
+
* owns the rest, so the question is not who wins but whether the AUTHOR touched this key —
|
|
739
|
+
* answerable only against the previously uploaded file. See engine/domain/publishedAdditions.js.
|
|
740
|
+
*/
|
|
741
|
+
const preserved = await preserveApplicationAdditions(application, apiConfig);
|
|
742
|
+
await saveOne(preserved.application);
|
|
743
|
+
/* Record what was uploaded, so the NEXT run can tell an author edit from an untouched key.
|
|
744
|
+
Written after the config itself: a failed upload must not leave a source row claiming the
|
|
745
|
+
author's file is already stored. */
|
|
746
|
+
await saveOne({
|
|
747
|
+
name: uploadSourceConfigName(application.name),
|
|
748
|
+
version: application.version,
|
|
749
|
+
content: application.content,
|
|
750
|
+
});
|
|
566
751
|
}
|
|
567
752
|
for (const domain of domains) {
|
|
568
753
|
await saveOne(domain);
|
|
@@ -590,6 +775,10 @@ async function uploadConfigs({
|
|
|
590
775
|
port: apiPort,
|
|
591
776
|
dbindex: dbIndex,
|
|
592
777
|
subdomain: sub,
|
|
778
|
+
/* publishApplicationConfig -> publishApp -> addApp re-enters bundle mode and fetches the
|
|
779
|
+
issuer key again, so the mode has to survive the round trip or the publish half of
|
|
780
|
+
`--publish` still dials production. */
|
|
781
|
+
serverMode,
|
|
593
782
|
};
|
|
594
783
|
|
|
595
784
|
for (const domain of domains) {
|
|
@@ -773,6 +962,9 @@ async function addApp({
|
|
|
773
962
|
body = null,
|
|
774
963
|
publish = false,
|
|
775
964
|
forceFull = false,
|
|
965
|
+
serverMode = "prod",
|
|
966
|
+
/* Extra directories whose DOMAIN configs upload alongside this one's (see --include). */
|
|
967
|
+
include = [],
|
|
776
968
|
} = {}) {
|
|
777
969
|
const oldCwd = process.cwd();
|
|
778
970
|
|
|
@@ -819,17 +1011,19 @@ async function addApp({
|
|
|
819
1011
|
return await uploadConfigs({
|
|
820
1012
|
workingDir: process.cwd(),
|
|
821
1013
|
fileNames: mergedSourceFiles,
|
|
1014
|
+
include,
|
|
822
1015
|
server,
|
|
823
1016
|
apiPort,
|
|
824
1017
|
dbIndex,
|
|
825
1018
|
sub,
|
|
826
1019
|
publish,
|
|
827
1020
|
forceFull,
|
|
1021
|
+
serverMode,
|
|
828
1022
|
});
|
|
829
1023
|
}
|
|
830
1024
|
|
|
831
1025
|
if (mergedSourceFiles.length > 0) {
|
|
832
|
-
const keys = await prepareKeys();
|
|
1026
|
+
const keys = await prepareKeys(resolveServerLink(serverMode));
|
|
833
1027
|
if (!keys) {
|
|
834
1028
|
const msg = "Can not prepare encryption keys";
|
|
835
1029
|
logger.error(msg);
|
|
@@ -997,6 +1191,7 @@ async function runUnitTests(options) {
|
|
|
997
1191
|
files: options.files,
|
|
998
1192
|
publish: options.publish,
|
|
999
1193
|
forceFull: options.forceFull,
|
|
1194
|
+
serverMode: options.serverMode,
|
|
1000
1195
|
});
|
|
1001
1196
|
} else {
|
|
1002
1197
|
logger.error("Biz-A Add aborted");
|
|
@@ -1029,6 +1224,8 @@ const buildCli = () =>
|
|
|
1029
1224
|
files: commandOptions.files,
|
|
1030
1225
|
publish: commandOptions.publish,
|
|
1031
1226
|
forceFull: commandOptions.forceFull,
|
|
1227
|
+
serverMode: commandOptions.serverMode,
|
|
1228
|
+
include: commandOptions.include,
|
|
1032
1229
|
});
|
|
1033
1230
|
return;
|
|
1034
1231
|
}
|
|
@@ -1146,7 +1343,9 @@ const buildCli = () =>
|
|
|
1146
1343
|
try {
|
|
1147
1344
|
await primeRegistry({
|
|
1148
1345
|
subdomain: commandOptions.sub,
|
|
1149
|
-
serverLink:
|
|
1346
|
+
serverLink: resolveServerLink(
|
|
1347
|
+
commandOptions.serverMode,
|
|
1348
|
+
),
|
|
1150
1349
|
});
|
|
1151
1350
|
const result = await addUser(
|
|
1152
1351
|
{
|
package/bin/directHubEvent.js
CHANGED
|
@@ -7,6 +7,7 @@ import { ClientEmitter } from "../message-broker/clientEmitter.js";
|
|
|
7
7
|
import { joinExtRooms } from "../engine/ext/esb.js";
|
|
8
8
|
import { extClientListener } from "../engine/ext/socketListener.js";
|
|
9
9
|
import { prefixedLogger } from "../logger.js";
|
|
10
|
+
import { APP_URL, TEST_URL, ADMIN_URL, TEST_ADMIN_URL } from "../envs/hosts.js";
|
|
10
11
|
|
|
11
12
|
const logger = prefixedLogger("[DirectHub]");
|
|
12
13
|
|
|
@@ -117,9 +118,19 @@ export function createSocketServer(httpServer, cliIpAddress = "127.0.0.1") {
|
|
|
117
118
|
return new ioServer(httpServer, {
|
|
118
119
|
cors: {
|
|
119
120
|
origin: [
|
|
121
|
+
// Browser apps only (client + admin). The server and hub reach the CLI
|
|
122
|
+
// server-to-server, not from a browser, so they need no entry here. Exact origins
|
|
123
|
+
// only — no wildcard: /\.biz-a\.id$/ trusted EVERY subdomain of the domain.
|
|
124
|
+
APP_URL,
|
|
125
|
+
TEST_URL,
|
|
126
|
+
ADMIN_URL,
|
|
127
|
+
TEST_ADMIN_URL,
|
|
128
|
+
// biz-a.id is still served alongside biz-a.app, so its browser origins stay allowed.
|
|
120
129
|
"https://biz-a.id",
|
|
121
130
|
"https://test.biz-a.id",
|
|
122
|
-
|
|
131
|
+
"https://admin.biz-a.id",
|
|
132
|
+
// NOT the wildcard (see above):
|
|
133
|
+
// /\.biz-a\.id$/,
|
|
123
134
|
"vscode-file://vscode-app",
|
|
124
135
|
/\.vscode-cdn\.net$/,
|
|
125
136
|
].concat(
|
package/bin/hub.js
CHANGED
|
@@ -19,6 +19,11 @@ import {
|
|
|
19
19
|
createSocketServer,
|
|
20
20
|
} from "./directHubEvent.js";
|
|
21
21
|
import { env } from "../envs/env.js";
|
|
22
|
+
import {
|
|
23
|
+
SERVER_MODES,
|
|
24
|
+
resolveHubLink,
|
|
25
|
+
resolveServerLink,
|
|
26
|
+
} from "../envs/serverEndpoints.js";
|
|
22
27
|
import { setGlobalConfig } from "../db/ds.js";
|
|
23
28
|
import { CLI_Agent } from "../message-broker/agent.js";
|
|
24
29
|
import { initBpmAgent } from "../engine/bpm/bpm-agent.js";
|
|
@@ -94,7 +99,7 @@ const argv = yargs(process.argv.slice(2))
|
|
|
94
99
|
describe: "Backend server mode: prod, dev, or backup",
|
|
95
100
|
type: "string",
|
|
96
101
|
// choices: ["prod", "dev", "backup"],
|
|
97
|
-
choices:
|
|
102
|
+
choices: SERVER_MODES,
|
|
98
103
|
default: "prod",
|
|
99
104
|
})
|
|
100
105
|
.options("mode", {
|
|
@@ -109,41 +114,42 @@ const argv = yargs(process.argv.slice(2))
|
|
|
109
114
|
type: "string",
|
|
110
115
|
default: "http://127.0.0.1:3002",
|
|
111
116
|
})
|
|
117
|
+
.options("principalMode", {
|
|
118
|
+
describe:
|
|
119
|
+
"Doc 3 §11: 'observe' logs what it sees and refuses nothing; 'enforce' refuses a data request without a valid principal (login is exempt).",
|
|
120
|
+
type: "string",
|
|
121
|
+
choices: ["observe", "enforce"],
|
|
122
|
+
/* ⚠️ The DEFAULT STAYS `observe`. This is platform code running for every tenant, and a
|
|
123
|
+
default of `enforce` would refuse every request from any tenant whose client has not been
|
|
124
|
+
rebuilt to send a principal. A tenant opts in explicitly. */
|
|
125
|
+
default: "observe",
|
|
126
|
+
})
|
|
112
127
|
.options("services", {
|
|
113
128
|
describe:
|
|
114
129
|
"Comma-separated list of engine jobs this agent handles (e.g., 'BPM,EMAIL')",
|
|
115
130
|
type: "string",
|
|
116
131
|
default: "ALL",
|
|
117
|
-
})
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
// server: "https://backupServer.biz-a.id",
|
|
131
|
-
// hub: "https://backupHub.biz-a.id",
|
|
132
|
-
// },
|
|
133
|
-
local: {
|
|
134
|
-
server: "http://localhost:3000",
|
|
135
|
-
hub: "http://localhost:3001",
|
|
136
|
-
},
|
|
137
|
-
};
|
|
132
|
+
})
|
|
133
|
+
/*
|
|
134
|
+
* ⚠️⚠️ REFUSE UNKNOWN FLAGS, because the fallback when one is ignored is PRODUCTION.
|
|
135
|
+
*
|
|
136
|
+
* yargs aliases are case-sensitive, so `--SMode local` is not `--sMode local`: without this the
|
|
137
|
+
* unknown key was silently dropped, `serverMode` kept its default `prod`, and the CLI quietly
|
|
138
|
+
* dialled server.biz-a.id / hub.biz-a.id instead of the local stack. Nothing logged that, the CLI
|
|
139
|
+
* looked healthy, and the only symptom was a 502 "<subdomain> is currently unregistered or
|
|
140
|
+
* offline" from a component three hops away. That typo cost two debugging rounds.
|
|
141
|
+
*
|
|
142
|
+
* `bin/app.js` has had `.strict()` all along; this file simply never did.
|
|
143
|
+
*/
|
|
144
|
+
.strict().argv;
|
|
138
145
|
|
|
146
|
+
/* The endpoint table moved to envs/serverEndpoints.js so `bin/app.js` resolves the SAME three
|
|
147
|
+
environments — it read env.BIZA_SERVER_LINK directly and so could only ever reach production. */
|
|
139
148
|
if (!argv.server) {
|
|
140
|
-
argv.server =
|
|
141
|
-
SERVER_ENDPOINTS[argv.serverMode]?.server ||
|
|
142
|
-
SERVER_ENDPOINTS.prod.server;
|
|
149
|
+
argv.server = resolveServerLink(argv.serverMode);
|
|
143
150
|
}
|
|
144
151
|
if (!argv.hubServer) {
|
|
145
|
-
argv.hubServer =
|
|
146
|
-
SERVER_ENDPOINTS[argv.serverMode]?.hub || SERVER_ENDPOINTS.prod.hub;
|
|
152
|
+
argv.hubServer = resolveHubLink(argv.serverMode);
|
|
147
153
|
}
|
|
148
154
|
|
|
149
155
|
// handle required options manually, not using yargs demandOption
|
|
@@ -184,13 +190,18 @@ import { createExtRouter } from "../engine/ext/routes.js";
|
|
|
184
190
|
import { createLiveExtDeps } from "../engine/ext/live.js";
|
|
185
191
|
import axios from "axios";
|
|
186
192
|
import { createFinaRouter } from "../engine/orm/finaEndpoint.js";
|
|
187
|
-
import {
|
|
193
|
+
import { createSessionStore } from "../engine/orm/sessionStore.js";
|
|
194
|
+
import { resolveUserTenancy } from "../engine/orm/tenantResolution.js";
|
|
195
|
+
import { buildUserTenantSql } from "../engine/orm/finaAuth.js";
|
|
196
|
+
import { primeEntityConfig, isTenancyDeclared } from "../engine/orm/entityConfig.js";
|
|
197
|
+
import { createTenantContextApplier, APP_ROLE } from "../engine/orm/rlsApplier.js";
|
|
188
198
|
import { createDomainConfigLoader } from "../engine/domain/entityConfigLoader.js";
|
|
189
199
|
import {
|
|
190
200
|
execPostgres,
|
|
191
201
|
execPostgresStatements,
|
|
192
202
|
execPostgresTransaction,
|
|
193
203
|
isPostgresIndex,
|
|
204
|
+
setTenantContextApplier,
|
|
194
205
|
} from "../engine/domain/dialect.js";
|
|
195
206
|
|
|
196
207
|
import { logger } from "../logger.js";
|
|
@@ -217,6 +228,41 @@ app.use("/bpm", bpmRoutes);
|
|
|
217
228
|
// socket-stream tunnel, which pipes bytes without reading them. bin/tunnelTarget.js points that
|
|
218
229
|
// tunnel here for a PostgreSQL tenant, so both transports land on the same routing. Whatever the
|
|
219
230
|
// ORM does not own is forwarded to the real FINA, so a part-migrated tenant keeps working.
|
|
231
|
+
/* Built ONCE: the store is stateless, but rebuilding it per request would make that a promise
|
|
232
|
+
nobody checks rather than a fact. Null on a Firebird index — there is no SYS$SESSION there. */
|
|
233
|
+
const hubSessionStore = isPostgresIndex(Number(argv.dbindex))
|
|
234
|
+
? createSessionStore({ exec: execPostgres, dbIndex: Number(argv.dbindex) })
|
|
235
|
+
: null;
|
|
236
|
+
|
|
237
|
+
/*
|
|
238
|
+
* Doc 4 §9.5 housekeeping — "a periodic job deletes rows past `expires_at` plus a retention margin".
|
|
239
|
+
*
|
|
240
|
+
* ⚠️ A PLATFORM TIMER, not a SYS$DAILY row. The scheduler here is application-config driven, and a
|
|
241
|
+
* platform table's housekeeping must not depend on an application remembering to declare it — an
|
|
242
|
+
* install with no such row would simply grow SYS$SESSION forever.
|
|
243
|
+
*
|
|
244
|
+
* ⚠️ `unref()` so this never holds the process open, and failures are logged and swallowed: a sweep
|
|
245
|
+
* that cannot run is a table that grows, which is a problem for later — not a reason to take the hub
|
|
246
|
+
* down. Safe to run on every instance: the DELETE is idempotent.
|
|
247
|
+
*
|
|
248
|
+
* ⚠️ The RETENTION MARGIN is Dok. 4 §14 open item #2 (audit may want these rows longer than
|
|
249
|
+
* operations does). `DEFAULT_SESSION_RETENTION` is a placeholder until that is settled, not a ruling.
|
|
250
|
+
*/
|
|
251
|
+
if (hubSessionStore) {
|
|
252
|
+
const SESSION_SWEEP_INTERVAL_MS = 24 * 3600 * 1000;
|
|
253
|
+
const sweepSessions = () =>
|
|
254
|
+
hubSessionStore
|
|
255
|
+
.purgeExpired()
|
|
256
|
+
.then(({ purged }) => {
|
|
257
|
+
if (purged > 0) logger.info(`[session] purged ${purged} expired session row(s)`);
|
|
258
|
+
})
|
|
259
|
+
.catch((error) =>
|
|
260
|
+
logger.warn(`[session] could not purge expired sessions: ${error?.message ?? error}`),
|
|
261
|
+
);
|
|
262
|
+
setTimeout(sweepSessions, 60 * 1000).unref();
|
|
263
|
+
setInterval(sweepSessions, SESSION_SWEEP_INTERVAL_MS).unref();
|
|
264
|
+
}
|
|
265
|
+
|
|
220
266
|
app.use(
|
|
221
267
|
"/fina",
|
|
222
268
|
createFinaRouter({
|
|
@@ -225,6 +271,30 @@ app.use(
|
|
|
225
271
|
execTransaction: execPostgresTransaction,
|
|
226
272
|
isPostgresIndex,
|
|
227
273
|
defaultDbIndex: Number(argv.dbindex),
|
|
274
|
+
/* ⚠️ ONE BOUNDARY, ONE POLICY. bin/hubEvent.js reads this from argv for the socket entrance;
|
|
275
|
+
without passing it here the HTTP entrance would stay on its `observe` default while the
|
|
276
|
+
socket path enforced — enforcement arriving on one transport ahead of the other is exactly
|
|
277
|
+
what finaEndpoint's own comment warns against. Login reaches the CLI through THIS door. */
|
|
278
|
+
principalMode: argv.principalMode,
|
|
279
|
+
/* Doc 4 §9.5 / B2 — the SAME session store both entrances use, for the same reason the
|
|
280
|
+
principal mode is shared: a session that is authoritative on one transport and ignored
|
|
281
|
+
on the other is worse than not having one. Null on a Firebird index, which puts the
|
|
282
|
+
request on the deprecated principal path rather than failing it. */
|
|
283
|
+
sessionStore: hubSessionStore,
|
|
284
|
+
openSession: hubSessionStore ? (args) => hubSessionStore.open(args) : null,
|
|
285
|
+
/* Doc 4 §8 — the switcher's choice, persisted on the session row. */
|
|
286
|
+
chooseActiveTenant: hubSessionStore
|
|
287
|
+
? (sid, tenantId) => hubSessionStore.chooseActiveTenant(sid, tenantId)
|
|
288
|
+
: null,
|
|
289
|
+
resolveTenancy: hubSessionStore
|
|
290
|
+
? ({ userId }) =>
|
|
291
|
+
resolveUserTenancy({
|
|
292
|
+
userId,
|
|
293
|
+
dbIndex: Number(argv.dbindex),
|
|
294
|
+
exec: execPostgres,
|
|
295
|
+
buildBindingSql: buildUserTenantSql,
|
|
296
|
+
})
|
|
297
|
+
: null,
|
|
228
298
|
/* So a locally-served response still tells the client where to open its sockets — the
|
|
229
299
|
tunnel's byte-level injection only fires for real FINA responses. */
|
|
230
300
|
cliAddress: () => (argv.cliAddress ? argv.cliAddress() : {}),
|
|
@@ -282,6 +352,9 @@ app.use(
|
|
|
282
352
|
oidc: extDeps.oidc,
|
|
283
353
|
identityStore: extDeps.identityStore,
|
|
284
354
|
resolveTenant: extDeps.resolveTenant,
|
|
355
|
+
/* Doc 3 section 11 -- without this the external surface has NO tenant context and the
|
|
356
|
+
fail-closed default hides every multi-tenant row from it. */
|
|
357
|
+
withTenantScope: extDeps.withTenantScope,
|
|
285
358
|
getChannelInfo: () => {
|
|
286
359
|
const a = argv.cliAddress ? argv.cliAddress() : {};
|
|
287
360
|
return { cliAddress: a.publicUrl, hubAddress: a.hubUrl };
|
|
@@ -349,7 +422,31 @@ if (isPostgresIndex(Number(argv.dbindex))) {
|
|
|
349
422
|
exec: execPostgres,
|
|
350
423
|
dbIndex: Number(argv.dbindex),
|
|
351
424
|
}),
|
|
352
|
-
})
|
|
425
|
+
})
|
|
426
|
+
.then(() => {
|
|
427
|
+
/*
|
|
428
|
+
* Doc 3 §11 layer 2 — install the RLS context applier, but ONLY once an application has
|
|
429
|
+
* actually declared tenancy.
|
|
430
|
+
*
|
|
431
|
+
* ⚠️ INSTALLING IT IS NOT FREE AND NOT INVISIBLE. `execPostgres` opens a transaction only
|
|
432
|
+
* when an applier is present; until then every read and every single-statement write is a
|
|
433
|
+
* bare `pool.query`. Installing it makes each one check out a pooled connection for a
|
|
434
|
+
* BEGIN/COMMIT. Gating on the declaration keeps that cost — and the whole SET LOCAL ROLE
|
|
435
|
+
* machinery — off every tenant that has not asked for it.
|
|
436
|
+
*
|
|
437
|
+
* ⚠️ ORDER: the applier must be installed BEFORE any policy exists. A policy whose
|
|
438
|
+
* predicate reads `app.current_tenant` while nothing sets it hides every row from
|
|
439
|
+
* everyone, which on a live POS is an outage rather than a security improvement.
|
|
440
|
+
*/
|
|
441
|
+
if (isTenancyDeclared(Number(argv.dbindex))) {
|
|
442
|
+
setTenantContextApplier(createTenantContextApplier());
|
|
443
|
+
logger.info(
|
|
444
|
+
`[RLS] tenant context applier installed for db ${argv.dbindex}; ` +
|
|
445
|
+
"request-path queries now run as " + APP_ROLE + " inside a transaction",
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
})
|
|
449
|
+
.catch(() => {});
|
|
353
450
|
}
|
|
354
451
|
|
|
355
452
|
if (argv.mode.trim().toLowerCase() === "agent") {
|