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.
Files changed (54) hide show
  1. package/bin/app.js +206 -7
  2. package/bin/directHubEvent.js +12 -1
  3. package/bin/hub.js +126 -29
  4. package/bin/hubEvent.js +135 -15
  5. package/bin/migrate.js +5 -2
  6. package/db/apiUrl.js +58 -0
  7. package/db/db.js +115 -7
  8. package/engine/domain/appConfig.js +228 -39
  9. package/engine/domain/entityConfigPrimer.js +97 -0
  10. package/engine/domain/firebird-ddl.js +27 -13
  11. package/engine/domain/naming.js +47 -0
  12. package/engine/domain/postgres-ddl.js +65 -5
  13. package/engine/domain/publishedAdditions.js +69 -0
  14. package/engine/domain/system.js +3 -0
  15. package/engine/ext/channel.js +3 -0
  16. package/engine/ext/dispatch.js +19 -2
  17. package/engine/ext/live.js +19 -0
  18. package/engine/ext/oidc.js +1 -1
  19. package/engine/ext/routes.js +2 -1
  20. package/engine/ext/token.js +1 -1
  21. package/engine/orm/apiRoute.js +323 -17
  22. package/engine/orm/changeInference.js +55 -7
  23. package/engine/orm/connection.js +50 -1
  24. package/engine/orm/crudQuery.js +122 -32
  25. package/engine/orm/dataSnapShape.js +20 -0
  26. package/engine/orm/entityConfig.js +506 -7
  27. package/engine/orm/executeBlock.js +237 -2
  28. package/engine/orm/executor.js +6 -0
  29. package/engine/orm/finaAuth.js +0 -0
  30. package/engine/orm/finaEndpoint.js +77 -13
  31. package/engine/orm/index.js +128 -10
  32. package/engine/orm/listQuery.js +74 -14
  33. package/engine/orm/masterDetail.js +173 -40
  34. package/engine/orm/requestScope.js +157 -0
  35. package/engine/orm/rlsApplier.js +74 -0
  36. package/engine/orm/rlsPolicy.js +370 -0
  37. package/engine/orm/sessionStore.js +337 -0
  38. package/engine/orm/tenancyDeclaration.js +268 -0
  39. package/engine/orm/tenantApi.js +416 -0
  40. package/engine/orm/tenantContext.js +216 -0
  41. package/engine/orm/tenantResolution.js +426 -0
  42. package/engine/orm/tenantScope.js +82 -0
  43. package/envs/env.dev.js +7 -2
  44. package/envs/env.js +7 -2
  45. package/envs/hosts.js +36 -0
  46. package/envs/serverEndpoints.js +50 -0
  47. package/migrations/postgres/1789040354328__tenancy.sql +33 -0
  48. package/migrations/postgres/1789200000000__rls_role.sql +46 -0
  49. package/migrations/postgres/1789350000000__user_tenant.sql +111 -0
  50. package/migrations/postgres/1789500000000__session.sql +73 -0
  51. package/package.json +1 -1
  52. package/readme.md +5 -5
  53. package/security/internalPrincipal.js +70 -3
  54. 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(env.BIZA_SERVER_LINK + "/api/issuerKey", {
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
- const collectConfigUploads = ({ workingDir, fileNames = [] }) => {
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({ subdomain: sub, serverLink: env.BIZA_SERVER_LINK });
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
- await saveOne(application);
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: env.BIZA_SERVER_LINK,
1346
+ serverLink: resolveServerLink(
1347
+ commandOptions.serverMode,
1348
+ ),
1150
1349
  });
1151
1350
  const result = await addUser(
1152
1351
  {
@@ -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
- /\.biz-a\.id$/,
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: ["prod", "dev", "local"],
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
- }).argv;
118
-
119
- // Server endpoint mapping by mode
120
- const SERVER_ENDPOINTS = {
121
- prod: {
122
- server: env.BIZA_SERVER_LINK,
123
- hub: env.BIZA_HUB_SERVER_LINK,
124
- },
125
- dev: {
126
- server: "https://devServer.biz-a.id",
127
- hub: "https://devHub.biz-a.id",
128
- },
129
- // backup: {
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 { primeEntityConfig } from "../engine/orm/entityConfig.js";
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
- }).catch(() => {});
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") {