biz-a-cli 2.3.80-15330 → 2.3.80-15339

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 CHANGED
@@ -36,8 +36,11 @@ import {
36
36
  import {
37
37
  isPostgresIndex,
38
38
  execPostgres,
39
+ execPostgresStatements,
39
40
  primeRegistry,
40
41
  } from "../engine/domain/dialect.js";
42
+
43
+ import { translateExecuteBlock } from "../engine/orm/executeBlock.js";
41
44
  import { addUser } from "../engine/orm/userAdmin.js";
42
45
  import {
43
46
  preservePublishedAdditions,
package/bin/hub.js CHANGED
@@ -193,9 +193,12 @@ import { createFinaRouter } from "../engine/orm/finaEndpoint.js";
193
193
  import { createSessionStore } from "../engine/orm/sessionStore.js";
194
194
  import { resolveUserTenancy } from "../engine/orm/tenantResolution.js";
195
195
  import { buildUserTenantSql } from "../engine/orm/finaAuth.js";
196
- import { primeEntityConfig, isTenancyDeclared } from "../engine/orm/entityConfig.js";
196
+ import { isTenancyDeclared } from "../engine/orm/entityConfig.js";
197
+ import {
198
+ ensureEntityConfig,
199
+ setEntityConfigRefreshHook,
200
+ } from "../engine/domain/entityConfigPrimer.js";
197
201
  import { createTenantContextApplier, APP_ROLE } from "../engine/orm/rlsApplier.js";
198
- import { createDomainConfigLoader } from "../engine/domain/entityConfigLoader.js";
199
202
  import {
200
203
  execPostgres,
201
204
  execPostgresStatements,
@@ -263,6 +266,19 @@ if (hubSessionStore) {
263
266
  setInterval(sweepSessions, SESSION_SWEEP_INTERVAL_MS).unref();
264
267
  }
265
268
 
269
+ /*
270
+ * Doc 6 Q5 — keep this thread's declarations current on the request path.
271
+ *
272
+ * ⚠️ The check is TTL'd inside `ensureEntityConfig`, so the overwhelming majority of requests pay
273
+ * nothing; roughly once every few seconds one request awaits a single digest query.
274
+ *
275
+ * ⚠️ `.finally(next)`, never `.then(next)`: a database hiccup while checking freshness must not stop
276
+ * the request. The declarations this thread already holds are the ones it would have used anyway.
277
+ */
278
+ app.use("/fina", (_req, _res, next) => {
279
+ ensureEntityConfig(Number(argv.dbindex)).finally(() => next());
280
+ });
281
+
266
282
  app.use(
267
283
  "/fina",
268
284
  createFinaRouter({
@@ -415,40 +431,57 @@ server.listen(argv.serverport, () => {
415
431
  * ⚠️ Deliberately NOT awaited and never fatal. It fails soft to "every entity is hard" — exactly what
416
432
  * the ORM did before §6.6 existed — so a config problem must not delay or prevent the hub coming up.
417
433
  */
434
+ /*
435
+ * Doc 3 §11 layer 2 — install the RLS context applier, but ONLY once an application has actually
436
+ * declared tenancy. Extracted and made IDEMPOTENT for Doc 6 Q5: declarations can now change while
437
+ * the hub is running, so this runs after the first prime AND after any refresh that moved them.
438
+ *
439
+ * ⚠️ IT NEVER UNINSTALLS. Un-declaring tenancy is rare, and dropping the applier mid-session would
440
+ * take RLS enforcement off in-flight requests — strictly worse than carrying a cost nobody asked for
441
+ * until the next restart.
442
+ */
443
+ let tenantApplierInstalled = false;
444
+ const installTenantApplierIfDeclared = () => {
445
+ if (tenantApplierInstalled) return;
446
+ if (!isTenancyDeclared(Number(argv.dbindex))) return;
447
+ setTenantContextApplier(createTenantContextApplier());
448
+ tenantApplierInstalled = true;
449
+ logger.info(
450
+ `[RLS] tenant context applier installed for db ${argv.dbindex}; ` +
451
+ "request-path queries now run as " + APP_ROLE + " inside a transaction",
452
+ );
453
+ };
454
+
418
455
  if (isPostgresIndex(Number(argv.dbindex))) {
419
- primeEntityConfig({
420
- dbIndex: Number(argv.dbindex),
421
- loadDomainConfigs: createDomainConfigLoader({
422
- exec: execPostgres,
423
- dbIndex: Number(argv.dbindex),
424
- }),
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
- })
456
+ /*
457
+ * Doc 6 Q5 — a publish that changes declarations must reach this thread without a restart, and
458
+ * the applier has to be installed if that publish is the one that DECLARES tenancy. Registering
459
+ * the hook before the first prime means even the startup read cannot outrun it.
460
+ */
461
+ setEntityConfigRefreshHook(() => installTenantApplierIfDeclared());
462
+
463
+ /*
464
+ * ⚠️ `ensureEntityConfig`, not `primeEntityConfig` directly: it is the same read, but it records
465
+ * this thread's fingerprint so the refresh check has a baseline. Priming behind its back would
466
+ * leave the first request to re-read the whole config for nothing.
467
+ */
468
+ ensureEntityConfig(Number(argv.dbindex))
469
+ /*
470
+ * ⚠️ INSTALLING IT IS NOT FREE AND NOT INVISIBLE. `execPostgres` opens a transaction only
471
+ * when an applier is present; until then every read and every single-statement write is a
472
+ * bare `pool.query`. Installing it makes each one check out a pooled connection for a
473
+ * BEGIN/COMMIT. Gating on the declaration keeps that cost — and the whole SET LOCAL ROLE
474
+ * machinery — off every tenant that has not asked for it.
475
+ *
476
+ * ⚠️ ORDER: the applier must be installed BEFORE any policy exists. A policy whose
477
+ * predicate reads `app.current_tenant` while nothing sets it hides every row from
478
+ * everyone, which on a live POS is an outage rather than a security improvement.
479
+ */
480
+ .then(() => installTenantApplierIfDeclared())
449
481
  .catch(() => {});
450
482
  }
451
483
 
484
+
452
485
  if (argv.mode.trim().toLowerCase() === "agent") {
453
486
  // Message broker agent not need connection to server, hubServer, directHub
454
487
  logger.info(`[Message Broker] Starting as isolated CLI Agent...`);
package/bin/hubEvent.js CHANGED
@@ -17,12 +17,13 @@ import { makeExtReqHandler } from "../engine/ext/hubProxy.js";
17
17
 
18
18
  import { logger } from "../logger.js";
19
19
  import { routeApiRequest } from "../engine/orm/apiRoute.js";
20
+ import { ensureEntityConfig } from "../engine/domain/entityConfigPrimer.js";
20
21
  import { runAsPrincipal, runTrusted, ACTIVE_TENANT_NOT_HELD } from "../engine/orm/tenantContext.js";
21
22
  import { resolveRequestScope, SESSION_NOT_USABLE } from "../engine/orm/requestScope.js";
22
23
  import { createSessionStore } from "../engine/orm/sessionStore.js";
23
24
  import { resolveUserTenancy } from "../engine/orm/tenantResolution.js";
24
25
  import { buildUserTenantSql } from "../engine/orm/finaAuth.js";
25
- import { authorizeRequest } from "../security/internalPrincipal.js";
26
+ import { authorizeRequest, principalForCliScript } from "../security/internalPrincipal.js";
26
27
  import {
27
28
  execPostgres,
28
29
  execPostgresStatements,
@@ -477,6 +478,22 @@ const handleCliCommand = async (data, cb, argv) => {
477
478
  query: { scriptName: data.scriptName },
478
479
  headers: { "user-agent": "biz-a-client-socket" },
479
480
  };
481
+ /*
482
+ * ⚠️⚠️ Doc 6 §8.3 — WHO IS ASKING. Until now this entrance carried no verified identity
483
+ * at all, which is why a CLI script could not gate anything on a right. The client sends
484
+ * the same signed principal it already attaches to HTTP; everything the script sees comes
485
+ * from inside that signature.
486
+ *
487
+ * ⚠️ OBSERVE-SHAPED, deliberately. An absent or invalid principal yields null and the script
488
+ * still RUNS — it is the script's job to refuse when it needs an identity it did not get.
489
+ * Refusing here would lock out every client that has not been rebuilt, which is exactly
490
+ * what `--principalMode` exists to avoid.
491
+ *
492
+ * ⚠️ `data.headers`, never `data.scriptData`: the payload around this is caller-shaped.
493
+ */
494
+ const cliScriptPrincipal = principalForCliScript(data.headers, {
495
+ mode: argv["principalMode"] || "observe",
496
+ });
480
497
  cb(
481
498
  null,
482
499
  await execCLIScriptWorker(
@@ -487,6 +504,7 @@ const handleCliCommand = async (data, cb, argv) => {
487
504
  },
488
505
  data.scriptName,
489
506
  liveTriggerPayload,
507
+ cliScriptPrincipal,
490
508
  ),
491
509
  );
492
510
  break;
@@ -735,12 +753,25 @@ export const clientListener = (socket, argv, via = "?") => {
735
753
  against the SIGNED claim, so it can only select among tenants already granted. */
736
754
  /* Doc 4 §9.5 / B2 — the session ROW is the authority for tenancy. A principal minted
737
755
  before sessions existed has no id and falls back to its own deprecated claims. */
738
- resolveRequestScope({
739
- claims: decision.claims,
740
- headers: reqData.headers,
741
- sessionStore: sessionStoreFor(Number(argv["dbindex"])),
742
- resolveTenancy: tenancyResolverFor(Number(argv["dbindex"])),
743
- })
756
+ /*
757
+ * Doc 6 Q5 — this is the OTHER entrance to the same ORM. `bin/hub.js` keeps the HTTP
758
+ * path's declarations current with a `/fina` middleware; the browser's data calls
759
+ * arrive here instead, so the same TTL'd check belongs in front of them or half the
760
+ * request traffic would keep reading whatever this thread learned at startup.
761
+ *
762
+ * ⚠️ Chained, never awaited-and-thrown: a freshness check that fails must not refuse a
763
+ * request the existing declarations could answer perfectly well.
764
+ */
765
+ ensureEntityConfig(Number(argv["dbindex"]))
766
+ .catch(() => {})
767
+ .then(() =>
768
+ resolveRequestScope({
769
+ claims: decision.claims,
770
+ headers: reqData.headers,
771
+ sessionStore: sessionStoreFor(Number(argv["dbindex"])),
772
+ resolveTenancy: tenancyResolverFor(Number(argv["dbindex"])),
773
+ }),
774
+ )
744
775
  .then((scope) =>
745
776
  /* ⚠️ The ROUTER is handed the resolved scope too — see finaEndpoint for why. */
746
777
  runAsPrincipalOr403(scope, () =>
@@ -2781,6 +2781,11 @@ const loadDdlHashes = async (domainName, domainVersion, config) => {
2781
2781
  }
2782
2782
  };
2783
2783
 
2784
+ /* Exported so a publish can store a config row it has just rewritten -- currently the navigation a
2785
+ domain contributed (Dok. 5 section 3.3). Same dialect-aware path every other config write uses. */
2786
+ export const saveConfigRow = (name, version, content, config) =>
2787
+ upsertConfigRow(name, version, content, config);
2788
+
2784
2789
  const writeDdlHashes = async (domainName, domainVersion, tables, config) => {
2785
2790
  const { name, version } = buildDdlHashConfigKey(domainName, domainVersion);
2786
2791
  const payload = JSON.stringify({ tables });
@@ -5,7 +5,12 @@ import {
5
5
  resolveConfigFromPayload,
6
6
  buildPublishStatusConfigKey,
7
7
  writePublishStatus,
8
+ saveConfigRow,
8
9
  } from "./appConfig.js";
10
+ import { execPostgres } from "./dialect.js";
11
+ /* Dok. 5 section 3.3 -- a Layer 3 domain contributing its own menus, gated by its own activation
12
+ setting. Read generically: no domain name and no domain code appear in this file. */
13
+ import { mergeDomainNavigation, selectActiveDomainMenus, readActiveModules } from "./domainNavigation.js";
9
14
 
10
15
  export const parseConfigData = (rawConfig) => {
11
16
  if (rawConfig == null) {
@@ -373,11 +378,98 @@ export const buildTemplateDomainConfigResolverSnippet = () => {
373
378
  this._ensureDomainConfig = ensureDomainConfig;`;
374
379
  };
375
380
 
381
+ /*
382
+ * ⚠️⚠️⚠️ THE GENERATED TEMPLATE MUST DECLARE ITS LIBRARIES.
383
+ *
384
+ * App + domain config publishing used to run in the CLIENT; moving it here dropped this step. The
385
+ * generated form then carried only beforeLoadData / afterLoadData / onInit / onDestroy / onPipe, so
386
+ * `form.component.ts` calling `doFunction('useLibrary')` got undefined and loaded nothing, and
387
+ * `db.service.loadScript` — which arms the template reloader from that SAME list — watched no
388
+ * libraries at all. A redeployed library was then never picked up by an open session, and nothing
389
+ * said so. See Doc 6 §7.12.
390
+ *
391
+ * ⚠️ THIS MUST MIRROR THE CLIENT EXACTLY — `resolveDomainLibraries` (render.domain.ts) and
392
+ * `normalizeDomainLibraryModules` (domain.service.ts). Any divergence means the form loads one set
393
+ * and the reloader watches another, which is a silent bug of precisely the kind this replaces.
394
+ *
395
+ * A module id is `<appName>-<fileName>`, e.g. `hrlib-hrpublishlib`: the app bundle that owns the
396
+ * file, then the file's own basename. A handler string carries both in its first two segments.
397
+ */
398
+ export const resolveDomainLibraryModules = (parsedDomainConfig) => {
399
+ const config = parsedDomainConfig && typeof parsedDomainConfig === "object" ? parsedDomainConfig : {};
400
+
401
+ const rawLibraries = Array.isArray(config?.libraries)
402
+ ? config.libraries
403
+ : Array.isArray(config?.ui?.libraries)
404
+ ? config.ui.libraries
405
+ : [];
406
+ const configuredModules = [
407
+ ...new Set(
408
+ rawLibraries
409
+ .map((item) => {
410
+ if (typeof item === "string") {
411
+ return item.trim();
412
+ }
413
+ if (item && typeof item === "object") {
414
+ if (typeof item.domain === "string") {
415
+ return item.domain.trim();
416
+ }
417
+ if (typeof item.module === "string") {
418
+ return item.module.trim();
419
+ }
420
+ }
421
+ return "";
422
+ })
423
+ .filter((moduleName) => moduleName.length > 0),
424
+ ),
425
+ ];
426
+
427
+ const events =
428
+ config?.events && typeof config.events === "object" && !Array.isArray(config.events)
429
+ ? config.events
430
+ : config?.ui?.events && typeof config.ui.events === "object" && !Array.isArray(config.ui.events)
431
+ ? config.ui.events
432
+ : {};
433
+ const derivedModules = new Set();
434
+ for (const eventDef of Object.values(events)) {
435
+ const handler = typeof eventDef?.handler === "string" ? eventDef.handler.trim() : "";
436
+ if (!handler) {
437
+ continue;
438
+ }
439
+ const segments = handler
440
+ .split(".")
441
+ .map((segment) => String(segment ?? "").trim())
442
+ .filter((segment) => segment.length > 0);
443
+ /* Fewer than 3 segments names a function in the template itself, not a library. */
444
+ if (segments.length >= 3) {
445
+ derivedModules.add(`${segments[0]}-${segments[1]}`);
446
+ }
447
+ }
448
+
449
+ if (configuredModules.length === 0) {
450
+ return [...derivedModules];
451
+ }
452
+
453
+ /* A bare domain name (`hrlib`) expands to the `hrlib-*` its handlers actually reference; if none
454
+ do, it is kept as-is so an explicitly declared library is never silently dropped. */
455
+ const derivedModuleList = [...derivedModules];
456
+ const normalizedConfigured = configuredModules.flatMap((moduleName) => {
457
+ if (moduleName.includes("-")) {
458
+ return [moduleName];
459
+ }
460
+ const matchedDerived = derivedModuleList.filter((candidate) => candidate.startsWith(`${moduleName}-`));
461
+ return matchedDerived.length > 0 ? matchedDerived : [moduleName];
462
+ });
463
+
464
+ return [...new Set([...normalizedConfigured, ...derivedModuleList])];
465
+ };
466
+
376
467
  export const buildHardcodedTemplateContent = (
377
468
  entryPointName = "loginForm",
378
469
  domainKey = null,
379
470
  templateType = "form",
380
471
  domainSource = null,
472
+ libraryModules = [],
381
473
  ) => {
382
474
  const sharedDomainConfigResolverScript = buildTemplateDomainConfigResolverSnippet();
383
475
  const formTemplateContent = `get = function () {
@@ -385,7 +477,7 @@ export const buildHardcodedTemplateContent = (
385
477
  model: {},
386
478
  tableName: "",
387
479
  fields: [],
388
- functions: {
480
+ functions: {__USE_LIBRARY_BLOCK__
389
481
  beforeLoadData: function (id) {
390
482
  ${sharedDomainConfigResolverScript}
391
483
  return new Promise((resolve) => {
@@ -445,7 +537,7 @@ ${sharedDomainConfigResolverScript}
445
537
  filter: [],
446
538
  buttons: [],
447
539
  distinct: false,
448
- functions: {
540
+ functions: {__USE_LIBRARY_BLOCK__
449
541
  beforeLoadData: function () {
450
542
  ${sharedDomainConfigResolverScript}
451
543
  return new Promise((resolve) => {
@@ -538,7 +630,20 @@ ${sharedDomainConfigResolverScript}
538
630
  const safeDomainKey = typeof domainKey === "string" && domainKey.trim().length > 0 ? domainKey.trim() : "";
539
631
  const safeDomainSource = typeof domainSource === "string" && domainSource.trim().length > 0 ? domainSource.trim() : "";
540
632
  const domainResolverExpression = `(() => { const domains = appConfig && appConfig.domains && typeof appConfig.domains === 'object' ? appConfig.domains : null; if (!domains) { return null; } const preferredDomainKey = ${JSON.stringify(safeDomainKey)}; if (preferredDomainKey && domains[preferredDomainKey]) { return domains[preferredDomainKey]; } if (domains.salesOrderDomain) { return domains.salesOrderDomain; } const domainKeys = Object.keys(domains); return domainKeys.length > 0 ? domains[domainKeys[0]] : null; })()`;
633
+ /*
634
+ * ⚠️ Emitted ONLY when there is something to declare, so a library-less domain publishes exactly
635
+ * the template it always did. The list is inlined as JSON rather than computed at runtime: the
636
+ * template is read back by `db.service.loadScript` with `JSON.parse`, which then calls this body
637
+ * through `new Function` — it has no access to the domain config.
638
+ */
639
+ const safeLibraryModules = Array.isArray(libraryModules)
640
+ ? libraryModules.filter((moduleName) => typeof moduleName === "string" && moduleName.trim().length > 0)
641
+ : [];
642
+ const useLibraryBlock =
643
+ safeLibraryModules.length > 0 ? `\n\t\t\tuseLibrary: function () { return ${JSON.stringify(safeLibraryModules)}; },` : "";
644
+
541
645
  const resolvedTemplateContent = templateContent
646
+ .replace(/__USE_LIBRARY_BLOCK__/g, useLibraryBlock)
542
647
  .replace(/"__ENTRY_POINT_NAME__"/g, JSON.stringify(safeEntryPointName))
543
648
  .replace(/"__DOMAIN_SOURCE__"/g, JSON.stringify(safeDomainSource))
544
649
  .replace(
@@ -577,6 +682,11 @@ export const buildPublishBodyFromConfigs = (parsedApplicationConfig, parsedDomai
577
682
  (name) => String(name ?? "").trim().length > 0,
578
683
  ),
579
684
  );
685
+ /* ⚠️ Derived ONCE per domain, from the domain config this call is publishing — every template it
686
+ generates belongs to that domain, so they all declare the same library set. See
687
+ resolveDomainLibraryModules for why the generated template has to declare it at all. */
688
+ const libraryModules = resolveDomainLibraryModules(safeDomainConfig);
689
+
580
690
  let unresolvedTargetIndex = 0;
581
691
  const templateFiles = templateTargets.map((target, targetIndex) => {
582
692
  const explicitDomainReference = String(target.domainKey ?? "").trim();
@@ -607,7 +717,27 @@ export const buildPublishBodyFromConfigs = (parsedApplicationConfig, parsedDomai
607
717
  resolvedDomainKey && domainMap && typeof domainMap === "object" ? String(domainMap?.[resolvedDomainKey]?.source ?? "").trim() : "";
608
718
  return {
609
719
  name: target.fileName,
610
- content: buildHardcodedTemplateContent(target.entryPointName, resolvedDomainKey, target.templateType ?? "form", resolvedDomainSource),
720
+ /*
721
+ * ⚠️⚠️ DID THIS DOMAIN ACTUALLY DECLARE THE ENTRY POINT, or did we guess an owner for it?
722
+ *
723
+ * The guess above walks the other domain keys POSITIONALLY, which is a coin toss once more
724
+ * than two domains are registered. `publishApplicationConfig` merges the passes first-wins,
725
+ * so whichever domain is scanned first imposed its guess on every template name — and a
726
+ * template naming the wrong domain makes the client throw E_ENTRYPOINT_NOT_FOUND, because
727
+ * that domain declares no such entry point. Live on db 3: Order-In and several other
728
+ * screens, owned by `supertailruntime`, were handed to `hr`.
729
+ *
730
+ * Every domain IS scanned, so the one that declares it claims it on its own pass. This flag
731
+ * is what lets the merge prefer that claim over a guess.
732
+ */
733
+ claimed: configuredEntryPointNames.has(target.entryPointName) && resolvedDomainKey === configuredDomainKey,
734
+ content: buildHardcodedTemplateContent(
735
+ target.entryPointName,
736
+ resolvedDomainKey,
737
+ target.templateType ?? "form",
738
+ resolvedDomainSource,
739
+ libraryModules,
740
+ ),
611
741
  };
612
742
  });
613
743
  const fileList = [{ name: "menu.json", content: menuContent }, ...templateFiles];
@@ -628,7 +758,7 @@ export const publishApplicationConfig = async (payload = {}, argv = {}) => {
628
758
  if (!appRow?.data) {
629
759
  throw new Error(`SYS$CONFIG record ${applicationConfigName}@${applicationConfigVersion} was not found.`);
630
760
  }
631
- const parsedApplicationConfig = parseConfigData(appRow.data) ?? {};
761
+ let parsedApplicationConfig = parseConfigData(appRow.data) ?? {};
632
762
 
633
763
  /* An app bundle publish is a full replace (addApp() rebuilds the whole bundle from scratch every call), so
634
764
  template.js scaffolding must be generated for EVERY domain registered in application.js's `domains` map, not
@@ -653,10 +783,28 @@ export const publishApplicationConfig = async (payload = {}, argv = {}) => {
653
783
  }
654
784
 
655
785
  const mergedFileList = [];
786
+ /* domainKey -> the menu entries that domain contributes this publish (§3.3). */
787
+ const domainNavigation = new Map();
788
+ /* Parsed once in the collect pass, reused in the build pass. */
789
+ const scannedDomains = [];
656
790
  const seenFileNames = new Set();
791
+ /*
792
+ * ⚠️ FIRST-WINS, EXCEPT THAT A CLAIM BEATS A GUESS. A domain that declares the entry point knows
793
+ * it owns the template; a domain that merely got handed it by the positional fallback does not.
794
+ * Without this, the domain scanned FIRST decided every template's owner and the screens it does
795
+ * not declare answered E_ENTRYPOINT_NOT_FOUND in the browser.
796
+ */
657
797
  const addFilesToMergedList = (fileList) => {
658
798
  for (const file of fileList) {
659
799
  if (seenFileNames.has(file.name)) {
800
+ if (!file.claimed) {
801
+ continue;
802
+ }
803
+ const existingIndex = mergedFileList.findIndex((entry) => entry.name === file.name);
804
+ if (existingIndex < 0 || mergedFileList[existingIndex].claimed) {
805
+ continue;
806
+ }
807
+ mergedFileList[existingIndex] = file;
660
808
  continue;
661
809
  }
662
810
  seenFileNames.add(file.name);
@@ -667,15 +815,70 @@ export const publishApplicationConfig = async (payload = {}, argv = {}) => {
667
815
  if (domainsToScan.size === 0) {
668
816
  addFilesToMergedList(buildPublishBodyFromConfigs(parsedApplicationConfig, {}, "").fileList);
669
817
  } else {
818
+ /*
819
+ * ⚠️ TWO PASSES, AND THE ORDER IS THE WHOLE POINT. The menu the CLIENT reads is generated
820
+ * INTO THE BUNDLE by buildPublishBodyFromConfigs, so any domain menu folded in after that is a
821
+ * publish behind — the row is right and the screen still has no way to be reached. Found live:
822
+ * `hr: 2` in the log, `HR_ROSTER` in APPLICATION_CONFIG, and no such item in the menu.
823
+ *
824
+ * So: collect first, merge, then build the files from the MERGED navigation. The template a
825
+ * leaf points at is generated in this same bundle, so a link can never outrun its screen.
826
+ */
670
827
  for (const [domainKey, { domainConfigName, domainConfigVersion }] of domainsToScan) {
671
828
  const domainRows = await readConfigRows(domainConfigName, domainConfigVersion, config);
672
829
  const domainRow = domainRows?.[0];
673
830
  const parsedDomainConfig = domainRow?.data ? (parseConfigData(domainRow.data) ?? {}) : {};
831
+ scannedDomains.push([domainKey, parsedDomainConfig]);
832
+
833
+ /*
834
+ * Dok. 5 §3.3 / §4.2 — a Layer 3 domain contributing its OWN menus, gated by its own
835
+ * activation setting. Without this a domain could declare screens and still have no way to
836
+ * be reached, and the activation setting drove nothing.
837
+ *
838
+ * ⚠️ READ, NEVER EXECUTED. The activating set lives in the domain's table, which this file
839
+ * must not know about — so the domain NAMES the table and column and the read is generic.
840
+ * No domain name and no domain code appear here, which is what keeps the golden rule.
841
+ *
842
+ * ⚠️ Fails SOFT: a domain that declares nothing, or whose setting cannot be read, simply
843
+ * contributes nothing. A publish must not fail because one domain's settings row is bad.
844
+ */
845
+ const declaredMenus = parsedDomainConfig?.navigation?.menus;
846
+ if (Array.isArray(declaredMenus) && declaredMenus.length > 0) {
847
+ try {
848
+ const activeModules = await readActiveModules({
849
+ activation: parsedDomainConfig?.navigation?.activation,
850
+ exec: execPostgres,
851
+ dbIndex: Number(config?.dbindex ?? config?.dbIndex),
852
+ });
853
+ domainNavigation.set(domainKey, selectActiveDomainMenus({ menus: declaredMenus, activeModules }));
854
+ } catch (error) {
855
+ logger.warn(
856
+ `[navigation] ${domainKey}: could not resolve its menus, so it contributes none: ` +
857
+ `${error?.message ?? error}`,
858
+ );
859
+ }
860
+ }
861
+ }
862
+
863
+ /* Fold the domains' menus in BEFORE the bundle is built from this config. */
864
+ if (domainNavigation.size > 0) {
865
+ let navigation = parsedApplicationConfig?.navigation ?? {};
866
+ for (const [domainKey, menus] of domainNavigation) {
867
+ navigation = mergeDomainNavigation({ applicationConfig: { navigation }, domainKey, menus });
868
+ }
869
+ parsedApplicationConfig = { ...parsedApplicationConfig, navigation };
870
+ }
871
+
872
+ for (const [domainKey, parsedDomainConfig] of scannedDomains) {
674
873
  addFilesToMergedList(buildPublishBodyFromConfigs(parsedApplicationConfig, parsedDomainConfig, domainKey).fileList);
675
874
  }
676
875
  }
677
876
 
678
- const publishBody = { verbose: false, fileList: mergedFileList };
877
+ /* `claimed` is merge bookkeeping, not part of the bundle contract — strip it before it ships. */
878
+ const publishBody = {
879
+ verbose: false,
880
+ fileList: mergedFileList.map(({ name, content }) => ({ name, content })),
881
+ };
679
882
 
680
883
  const response = await publishApp(
681
884
  {
@@ -687,6 +890,35 @@ export const publishApplicationConfig = async (payload = {}, argv = {}) => {
687
890
  if (!response || response.success !== true) {
688
891
  throw new Error(response?.error ? String(response.error) : "Application Config publish failed without an error message from the server.");
689
892
  }
893
+
894
+ /*
895
+ * ⚠️ AFTER the bundle publish, not before: a domain's leaf must not appear in the menu until the
896
+ * screen it opens is actually in the bundle. The other order produces a dead link for exactly as
897
+ * long as a publish takes, and permanently if the publish then fails.
898
+ *
899
+ * ⚠️ And the merge is additive + self-healing per domain (see domainNavigation.js), because
900
+ * APPLICATION_CONFIG has more than one writer and a wholesale replace has wiped the other one's
901
+ * work before.
902
+ */
903
+ if (domainNavigation.size > 0) {
904
+ try {
905
+ /* Already merged above, so the stored row and the bundle's menu are the same thing — which
906
+ is the only arrangement in which the two cannot drift. */
907
+ await saveConfigRow(
908
+ applicationConfigName,
909
+ applicationConfigVersion,
910
+ `get = function () { return ${JSON.stringify(parsedApplicationConfig)}; }`,
911
+ config,
912
+ );
913
+ const counts = [...domainNavigation].map(([key, menus]) => `${key}: ${menus.length}`).join(", ");
914
+ logger.info(`[navigation] domain menus published (${counts})`);
915
+ } catch (error) {
916
+ /* The bundle is already published and correct; a menu that did not update is a re-publish
917
+ away, so this must not turn a successful publish into a failure. */
918
+ logger.warn(`[navigation] domain menus could not be merged: ${error?.message ?? error}`);
919
+ }
920
+ }
921
+
690
922
  return response;
691
923
  };
692
924
 
@@ -0,0 +1,131 @@
1
+ /*
2
+ * A Layer 3 domain contributing its own navigation.
3
+ *
4
+ * ⚠️ WHY THIS EXISTS. `buildMenuStructureFromApplicationConfig` reads `APPLICATION_CONFIG.navigation`
5
+ * and nothing else, so a domain could declare entities, screens, settings and server code and still
6
+ * have no way to be REACHED — the host had to hand-declare every leaf. That also made Doc 5 §4.2's
7
+ * "runtime publish emits the menu tree from the activated set" impossible: the activation setting
8
+ * drove nothing at all.
9
+ *
10
+ * §3.3 names the mechanism: "Navigation entries carry `domain: 'hr'` and are emitted at runtime
11
+ * publish according to which submodules are active."
12
+ *
13
+ * ⚠️ THE ACTIVATION IS READ FROM DATA, NOT EXECUTED. The activating set lives in the DOMAIN's own
14
+ * table, which this file must not know about — so the domain NAMES the table and column in its config
15
+ * and the publish reads it generically. No domain name, no domain code, nothing app-specific here.
16
+ */
17
+
18
+ /* The route shape the client's menu component renders, identical to a hand-declared leaf. */
19
+ const linkFor = (entry) => ["./form", String(entry?.entryPoint ?? "").trim(), null];
20
+
21
+ /*
22
+ * Which of a domain's declared entries are live right now.
23
+ *
24
+ * ⚠️ AN ENTRY WITH NO `module` IS ALWAYS EMITTED — §4.2's design-time menu, which exists whatever is
25
+ * activated. An entry WITH one appears only while that module is active.
26
+ *
27
+ * ⚠️ AND AN UNREADABLE ACTIVATION LIST ACTIVATES NOTHING. Failing open would hand a client every
28
+ * screen the moment a settings row went bad, which is the opposite of what a gate is for.
29
+ */
30
+ export const selectActiveDomainMenus = ({ menus, activeModules } = {}) => {
31
+ const declared = Array.isArray(menus) ? menus : [];
32
+ const active = new Set(Array.isArray(activeModules) ? activeModules.map((m) => String(m)) : []);
33
+ return declared.filter((entry) => {
34
+ const gate = entry?.module;
35
+ if (gate === null || gate === undefined || String(gate).trim() === "") return true;
36
+ return active.has(String(gate));
37
+ });
38
+ };
39
+
40
+ /*
41
+ * Fold a domain's live entries into the host's navigation.
42
+ *
43
+ * ⚠️ ADDITIVE AND SELF-HEALING, which is the lesson APPLICATION_CONFIG's two writers already taught
44
+ * once: a publish that replaces the whole navigation wipes whatever the other writer added. So the
45
+ * host's own leaves are never touched, and neither are another domain's.
46
+ *
47
+ * ⚠️ AND IT STRIPS THIS DOMAIN'S PREVIOUS LEAVES FIRST. That is what the `domain` stamp is for, and
48
+ * it is what makes deactivation real: §4.2's stated failure is that "switching one off would leave
49
+ * its tile behind", which would make activation advisory — the setting would look like it did
50
+ * something while the tile still opened. Injecting without stripping would also accumulate duplicates
51
+ * that nothing could clear.
52
+ */
53
+ export const mergeDomainNavigation = ({ applicationConfig, domainKey, menus } = {}) => {
54
+ const key = String(domainKey ?? "").trim();
55
+ const navigation = applicationConfig?.navigation ?? {};
56
+ const sourceMenus = Array.isArray(navigation.menus) ? navigation.menus : [];
57
+ const entries = Array.isArray(menus) ? menus : [];
58
+
59
+ /* Everything that is NOT this domain's, group structure preserved. */
60
+ const kept = sourceMenus.map((group) => ({
61
+ ...group,
62
+ subMenu: (Array.isArray(group?.subMenu) ? group.subMenu : []).filter(
63
+ (leaf) => String(leaf?.domain ?? "") !== key,
64
+ ),
65
+ }));
66
+
67
+ for (const entry of entries) {
68
+ const groupCaption = String(entry?.group ?? "").trim();
69
+ if (!groupCaption || !entry?.entryPoint) continue;
70
+
71
+ let group = kept.find((g) => String(g?.caption ?? "") === groupCaption);
72
+ if (!group) {
73
+ group = { caption: groupCaption, link: [], subMenu: [] };
74
+ kept.push(group);
75
+ }
76
+ if (!Array.isArray(group.subMenu)) group.subMenu = [];
77
+
78
+ group.subMenu.push({
79
+ caption: String(entry.caption ?? entry.entryPoint),
80
+ menuKey: String(entry.menuKey ?? entry.entryPoint),
81
+ link: linkFor(entry),
82
+ /* §3.3's stamp: what the next publish uses to find exactly its own leaves. */
83
+ domain: key,
84
+ });
85
+ }
86
+
87
+ /* A group that existed only to hold this domain's leaves, and now holds none, goes with them —
88
+ an empty group renders as a dead parent nobody can open. The host's own empty groups are left
89
+ alone, because they were not this publish's to remove. */
90
+ const hadDomainLeafOnly = (group) =>
91
+ (group?.subMenu ?? []).length === 0 &&
92
+ !sourceMenus.some(
93
+ (original) =>
94
+ String(original?.caption ?? "") === String(group?.caption ?? "") &&
95
+ (original?.subMenu ?? []).some((leaf) => String(leaf?.domain ?? "") !== key),
96
+ );
97
+
98
+ return { ...navigation, menus: kept.filter((group) => !hadDomainLeafOnly(group)) };
99
+ };
100
+
101
+ /*
102
+ * Read a domain's activation setting through the coordinates IT declares.
103
+ *
104
+ * ⚠️ Returns an empty list for anything it cannot read — a missing declaration, a missing row, a value
105
+ * that is not a JSON array. See `selectActiveDomainMenus`: the gate fails closed.
106
+ */
107
+ export const readActiveModules = async ({ activation, exec, dbIndex } = {}) => {
108
+ const entity = String(activation?.entity ?? "").trim();
109
+ const keyColumn = String(activation?.keyColumn ?? "").trim();
110
+ const valueColumn = String(activation?.valueColumn ?? "").trim();
111
+ const setting = String(activation?.setting ?? "").trim();
112
+ if (!entity || !keyColumn || !valueColumn || !setting) return [];
113
+
114
+ /* Identifiers come from a published domain config, but they are still interpolated — so anything
115
+ that is not a plain identifier is refused rather than quoted into the statement. */
116
+ const identifier = /^[A-Za-z_][A-Za-z0-9_$]*$/;
117
+ if (![entity, keyColumn, valueColumn].every((name) => identifier.test(name))) return [];
118
+
119
+ try {
120
+ const rows = await exec(
121
+ `SELECT ${valueColumn} AS value FROM ${entity} WHERE ${keyColumn} = '${setting.replace(/'/g, "''")}'`,
122
+ dbIndex,
123
+ );
124
+ const raw = rows?.[0]?.value;
125
+ if (raw === null || raw === undefined) return [];
126
+ const parsed = JSON.parse(String(raw));
127
+ return Array.isArray(parsed) ? parsed : [];
128
+ } catch {
129
+ return [];
130
+ }
131
+ };
@@ -98,3 +98,31 @@ export const createDomainConfigLoader = ({ exec, dbIndex }) => async () => {
98
98
  }
99
99
  return parsed;
100
100
  };
101
+
102
+ /*
103
+ * ⚠️⚠️ Doc 6 Q5 — A CHEAP FINGERPRINT OF EVERYTHING THE DECLARATIONS ARE BUILT FROM.
104
+ *
105
+ * The entity-config cache is per THREAD and was read exactly once per thread, so a publish reached a
106
+ * running hub only by restarting it. `ensureEntityConfig` re-checks this fingerprint on a timer and
107
+ * re-primes when it moves.
108
+ *
109
+ * ⚠️ NOT `max(id)`. `sys$config` is UPDATED IN PLACE when name+version match, so republishing a
110
+ * domain at the SAME version — the commonest thing anyone does while iterating — leaves the highest
111
+ * id untouched. Verified on db 3: 12 rows, max id 14, `supertailsetup` present twice, one row per
112
+ * version. Only the content answers honestly.
113
+ *
114
+ * ⚠️ The hashing happens IN POSTGRES and only the 32-char digest crosses the wire; the configs
115
+ * themselves are hundreds of KB and must not be shipped every few seconds just to be compared.
116
+ *
117
+ * Returns null when the table cannot be read, which the caller treats as "do not change anything".
118
+ */
119
+ export const readConfigGeneration = async ({ exec, dbIndex }) => {
120
+ const rows = await exec(
121
+ `SELECT md5(string_agg(id || ':' || name || ':' || version || ':' || md5(coalesce(data, '')), '|' ORDER BY id)) AS generation FROM ${PG_CONFIG_TABLE}`,
122
+ dbIndex,
123
+ );
124
+ const generation = rows?.[0]?.generation ?? rows?.[0]?.GENERATION ?? null;
125
+ /* An empty sys$config aggregates to NULL; a stable sentinel keeps "empty" distinguishable from
126
+ "unreadable", which is the difference between leaving the cache alone and refreshing it. */
127
+ return generation == null ? "empty" : String(generation);
128
+ };
@@ -27,10 +27,37 @@
27
27
  * `entityConfig.js`, and closing that cycle inside the ORM once left `createExecutor` uninitialised.
28
28
  */
29
29
  import { primeEntityConfig } from "../orm/entityConfig.js";
30
- import { createDomainConfigLoader } from "./entityConfigLoader.js";
30
+ import { createDomainConfigLoader, readConfigGeneration } from "./entityConfigLoader.js";
31
31
  import { execPostgres, isPostgresIndex } from "./dialect.js";
32
32
  import { logger } from "../../logger.js";
33
33
 
34
+ /*
35
+ * ⚠️⚠️ Doc 6 Q5 — HOW OFTEN A THREAD ASKS WHETHER THE DECLARATIONS MOVED.
36
+ *
37
+ * `resetEntityConfig` had no caller outside tests, so each thread read the declarations once and
38
+ * kept them: the hub's main thread until restart, a worker for the life of the thread. A publish
39
+ * that added or changed a declaration simply did not arrive, and the staleness is SILENT — the same
40
+ * five fallbacks described above, taken because the cache is OLD rather than empty. A newly
41
+ * soft-delete entity was PHYSICALLY deleted; a newly tenanted table was neither filtered nor stamped.
42
+ *
43
+ * The check is one small digest query, so this bounds staleness to seconds rather than to a restart.
44
+ * It is not zero-cost, which is why it is a TTL and not a per-request read.
45
+ */
46
+ export const CONFIG_GENERATION_TTL_MS = 5000;
47
+
48
+ /*
49
+ * Called with the dbIndex after a refresh ACTUALLY changed the declarations.
50
+ *
51
+ * ⚠️ It exists for one specific reason. `bin/hub.js` installs the RLS context applier ONCE, right
52
+ * after the first prime, gated on `isTenancyDeclared`. An app that declares tenancy for the first
53
+ * time in a publish would refresh this cache and still have no applier — tenancy declared and NOT
54
+ * enforced, which is quieter and worse than the stale cache this fixes.
55
+ */
56
+ let onRefresh = null;
57
+ export const setEntityConfigRefreshHook = (hook) => {
58
+ onRefresh = typeof hook === "function" ? hook : null;
59
+ };
60
+
34
61
  /*
35
62
  * ⚠️ PROMISES, not booleans. A BPM agent runs effects continuously and they all enter through one
36
63
  * `run`; several can be in flight inside a single worker at once. Recording "started" as a promise
@@ -51,8 +78,29 @@ const degrade = (dbIndex, reason) => {
51
78
  );
52
79
  };
53
80
 
81
+ /* dbIndex -> { generation, checkedAt } for the copy this thread currently holds. */
82
+ const freshness = new Map();
83
+ /* dbIndex -> in-flight freshness check, so concurrent callers share one digest query. */
84
+ const checking = new Map();
85
+
54
86
  const primeOnce = async (dbIndex) => {
55
87
  try {
88
+ /*
89
+ * ⚠️ THE FINGERPRINT IS READ BEFORE THE CONFIGS, NEVER AFTER. A publish landing between the
90
+ * two reads must cost an EXTRA refresh, never a missed one — taking it afterwards would
91
+ * record a fingerprint newer than the data just read, and that change would stay invisible
92
+ * until something else happened to publish.
93
+ *
94
+ * ⚠️ And it must not be fatal: a digest we cannot read is no reason to skip priming. Recording
95
+ * `null` simply means the next check has nothing to compare against and refreshes.
96
+ */
97
+ let generation = null;
98
+ try {
99
+ generation = await readConfigGeneration({ exec: execPostgres, dbIndex });
100
+ } catch {
101
+ generation = null;
102
+ }
103
+
56
104
  const loadDomainConfigs = createDomainConfigLoader({
57
105
  exec: execPostgres,
58
106
  dbIndex,
@@ -63,7 +111,10 @@ const primeOnce = async (dbIndex) => {
63
111
  * try/catch therefore treats a FAILED read as a successful one and memoises the failure
64
112
  * forever, which is worse than not caching at all. The boolean is the only honest signal.
65
113
  */
66
- if (await primeEntityConfig({ dbIndex, loadDomainConfigs })) return;
114
+ if (await primeEntityConfig({ dbIndex, loadDomainConfigs })) {
115
+ freshness.set(dbIndex, { generation, checkedAt: Date.now() });
116
+ return;
117
+ }
67
118
  degrade(dbIndex, "declarations could not be read");
68
119
  } catch (error) {
69
120
  /*
@@ -75,6 +126,59 @@ const primeOnce = async (dbIndex) => {
75
126
  }
76
127
  };
77
128
 
129
+ /*
130
+ * Doc 6 Q5 — has anything been published since this thread last looked?
131
+ *
132
+ * ⚠️ FAILS SOFT IN BOTH DIRECTIONS. A digest we cannot read leaves the declarations exactly as they
133
+ * are (blinding the thread is the state this module exists to end), and a re-prime that fails leaves
134
+ * the previous copy in place because `primeEntityConfig` only replaces the maps on success.
135
+ */
136
+ const refreshIfStale = async (dbIndex) => {
137
+ const current = freshness.get(dbIndex);
138
+ const now = Date.now();
139
+ if (current && now - current.checkedAt < CONFIG_GENERATION_TTL_MS) return;
140
+
141
+ let generation;
142
+ try {
143
+ generation = await readConfigGeneration({ exec: execPostgres, dbIndex });
144
+ } catch (error) {
145
+ /* Checked, not read: wait out another TTL rather than hammering a database in trouble. */
146
+ if (current) {
147
+ freshness.set(dbIndex, { ...current, checkedAt: now });
148
+ }
149
+ logger.warn(
150
+ `[entityConfig] could not read the config fingerprint for db ${dbIndex} ` +
151
+ `(${error?.message ?? error}); keeping the declarations this thread already has.`,
152
+ );
153
+ return;
154
+ }
155
+
156
+ if (current && generation === current.generation) {
157
+ freshness.set(dbIndex, { ...current, checkedAt: now });
158
+ return;
159
+ }
160
+
161
+ const loadDomainConfigs = createDomainConfigLoader({ exec: execPostgres, dbIndex });
162
+ if (await primeEntityConfig({ dbIndex, loadDomainConfigs })) {
163
+ freshness.set(dbIndex, { generation, checkedAt: Date.now() });
164
+ logger.info(`[entityConfig] db ${dbIndex}: declarations changed since this thread last read them; refreshed.`);
165
+ if (onRefresh) {
166
+ /* ⚠️ A broken hook must not cost the refresh that already succeeded. */
167
+ try {
168
+ await onRefresh(dbIndex);
169
+ } catch (error) {
170
+ logger.warn(`[entityConfig] refresh hook failed for db ${dbIndex}: ${error?.message ?? error}`);
171
+ }
172
+ }
173
+ return;
174
+ }
175
+ /* Re-prime failed: the previous declarations stand, and the fingerprint is NOT recorded so the
176
+ next check tries again rather than treating the failure as the new truth. */
177
+ if (current) {
178
+ freshness.set(dbIndex, { ...current, checkedAt: now });
179
+ }
180
+ };
181
+
78
182
  /*
79
183
  * Prime this thread for `dbIndex` if it has not been primed already. Safe to await on every entry to
80
184
  * a worker: after the first call it resolves immediately.
@@ -88,10 +192,32 @@ export const ensureEntityConfig = async (dbIndex) => {
88
192
  if (!Number.isFinite(index) || !isPostgresIndex(index)) return;
89
193
  if (!priming.has(index)) {
90
194
  priming.set(index, primeOnce(index));
195
+ await priming.get(index);
196
+ return;
91
197
  }
92
198
  await priming.get(index);
199
+
200
+ /*
201
+ * Doc 6 Q5 — already primed, so ask (at most once per TTL) whether it is still true.
202
+ *
203
+ * ⚠️ Held as a PROMISE for the same reason the priming above is: several effects run concurrently
204
+ * inside one worker and a whole console-load's worth of requests arrive together on the hub, so
205
+ * they share ONE digest query instead of each issuing their own.
206
+ */
207
+ if (!checking.has(index)) {
208
+ checking.set(
209
+ index,
210
+ refreshIfStale(index).finally(() => checking.delete(index)),
211
+ );
212
+ }
213
+ await checking.get(index);
93
214
  };
94
215
 
95
216
  /* Test hook. The cache it fronts has `resetEntityConfig`; this clears the "already started" record so
96
- a test can prime again. */
97
- export const resetEntityConfigPrimer = () => priming.clear();
217
+ a test can prime again — and the freshness record with it, or the next prime would be judged
218
+ against a fingerprint belonging to declarations that are no longer there. */
219
+ export const resetEntityConfigPrimer = () => {
220
+ priming.clear();
221
+ freshness.clear();
222
+ checking.clear();
223
+ };
@@ -276,7 +276,18 @@ export const buildRlsSyncPlan = (table, rlsRows, policyRows) => {
276
276
  }
277
277
 
278
278
  const live = (Array.isArray(policyRows) ? policyRows : []).map(readPolicyRow);
279
- const find = (policy) => live.find((row) => row.name === policy);
279
+ /*
280
+ * ⚠️⚠️ CASE-INSENSITIVE, because PostgreSQL folds unquoted identifiers. A policy created on
281
+ * `ST_OUTLET` comes back from pg_policies as `st_outlet_tenant_write`, while `writePolicyName`
282
+ * keeps the DECLARATION's case. Comparing them verbatim never matched, so every policy looked
283
+ * MISSING on every publish, took the create branch, and the guarded CREATE swallowed
284
+ * `duplicate_object` — which meant the live policy was never compared and drift was never
285
+ * corrected. The whole replace-on-drift path below was unreachable.
286
+ *
287
+ * Found live on db 3: a changed write predicate survived `--publish` and `--publish --forceFull`
288
+ * untouched, while the log reported the policy as missing each time.
289
+ */
290
+ const find = (policy) => live.find((row) => row.name.toLowerCase() === String(policy).toLowerCase());
280
291
 
281
292
  /*
282
293
  * Create, or replace when what is live no longer says what it must.
@@ -307,7 +318,25 @@ export const buildRlsSyncPlan = (table, rlsRows, policyRows) => {
307
318
  };
308
319
 
309
320
  const readScope = buildTenantPredicate(tenantColumn);
310
- const writeCheck = buildActiveTenantCheck(tenantColumn);
321
+ /*
322
+ * ⚠️⚠️ THE TENANT TABLE IS NOT A TENANT-SCOPED TABLE, and its write scope is not the same question.
323
+ *
324
+ * §9.2 makes this check SCALAR on purpose, and for a scoped table that stands: its tenant column
325
+ * says which outlet a row BELONGS TO, so checking against the whole read set would let a screen
326
+ * opened in one context file a row into another. That is the data-corruption vector.
327
+ *
328
+ * On the TENANT TABLE the tenant column is the row's own `id`. Writing there renames the outlet;
329
+ * it cannot misfile anything into it, because the row IS the outlet. Holding it to the scalar
330
+ * active tenant meant an administrator bound to two outlets could only ever edit whichever one
331
+ * their DEVICE was assigned to -- and the console that sets that assignment is itself an outlet
332
+ * list. Live on db 3: user 3 holds both outlets, and editing either answered 403 until the active
333
+ * tenant was set, after which the OTHER one answered 403.
334
+ *
335
+ * So the relaxation is scoped to the tenant table and nowhere else.
336
+ */
337
+ const isTenantTable = table?.isTenantTable === true;
338
+ const writeCheck = isTenantTable ? readScope : buildActiveTenantCheck(tenantColumn);
339
+ const writeCheckSetting = isTenantTable ? TENANT_SETTING : ACTIVE_TENANT_SETTING;
311
340
 
312
341
  ensure(
313
342
  readPolicyName(name),
@@ -325,12 +354,16 @@ export const buildRlsSyncPlan = (table, rlsRows, policyRows) => {
325
354
  */
326
355
  ensure(
327
356
  writePolicyName(name),
328
- `write predicate (${tenantColumn} against ${TENANT_SETTING}, checked against ${ACTIVE_TENANT_SETTING})`,
357
+ `write predicate (${tenantColumn} against ${TENANT_SETTING}, checked against ${writeCheckSetting})`,
329
358
  `CREATE POLICY ${writePolicyName(name)} ON ${name} FOR ALL USING (${readScope}) WITH CHECK (${writeCheck})`,
359
+ /* ⚠️ A check naming the OTHER setting is drift in both directions: a scoped table carrying the
360
+ tenant table's relaxed check reopens §9.2, and a tenant table still carrying the scalar one
361
+ keeps an administrator locked to one outlet. */
330
362
  (row) =>
331
363
  row.cmd === "ALL" &&
332
364
  mentions(row.qual, tenantColumn, TENANT_SETTING) &&
333
- mentions(row.check, tenantColumn, ACTIVE_TENANT_SETTING),
365
+ mentions(row.check, tenantColumn, writeCheckSetting) &&
366
+ !mentions(row.check, tenantColumn, isTenantTable ? ACTIVE_TENANT_SETTING : TENANT_SETTING),
334
367
  );
335
368
 
336
369
  /* See insertPolicyName: without this a new tenant row could never be created. */
@@ -326,11 +326,29 @@ export const createTenantApi = ({
326
326
  * §7.3 lists only `revoke`. Without this the correction is inexpressible through the API, which
327
327
  * leaves a direct table write as the only option — the exact thing §7.3 exists to prevent.
328
328
  */
329
- const deactivate = async ({ id }) => {
329
+ const deactivate = async ({ id = null, sourceRef = null }) => {
330
+ /*
331
+ * ⚠️ Addressable by sourceRef as `revoke` is, and for a reason found live: a swap or a cancel
332
+ * of a FUTURE-dated duty revokes nothing (revoke matches only rows in force, since stamping
333
+ * `expired_at = now()` on a future row would violate `expired_at > issued_at`), leaving the
334
+ * previous holder a grant for a shift they no longer have. §7.2's correction is the right one
335
+ * — the row never took effect — but the bridge knows only the duty it created, not the
336
+ * assignment id. Without this the correction was inexpressible through the API.
337
+ */
338
+ if (id === null && sourceRef === null) {
339
+ throw new Error(
340
+ "deactivate needs an id or a sourceRef — refusing to deactivate every assignment (Doc 4 §7.3).",
341
+ );
342
+ }
343
+ const target =
344
+ id !== null
345
+ ? `ut.ID = ${literal(assertUuid(id, "id"))}::uuid`
346
+ : `ut.SOURCE_REF = ${literal(assertUuid(sourceRef, "sourceRef"))}::uuid`;
347
+
330
348
  const rows =
331
349
  (await run(
332
350
  `UPDATE SYS$USER_TENANT ut SET ACTIVE = false ` +
333
- `WHERE ut.ID = ${literal(assertUuid(id, "id"))}::uuid AND ut.ACTIVE ` +
351
+ `WHERE ${target} AND ut.ACTIVE ` +
334
352
  "RETURNING ut.USER_ID",
335
353
  )) ?? [];
336
354
  for (const user of new Set(rows.map((row) => row?.user_id ?? row?.USER_ID))) {
@@ -50,6 +50,29 @@ export const assertInterval = (value, what = "grace") => {
50
50
 
51
51
  /* An instant, as a literal. Refused unless it actually parses — same reasoning as the interval. */
52
52
  export const assertInstant = (value, what = "at") => {
53
+ /*
54
+ * ⚠⚠ A DATE OBJECT IS WHAT THE DRIVER HANDS BACK, and it used to pass this guard and then
55
+ * break the statement. `Date.parse` accepts a JS Date's own toString() — "Tue Sep 15 2026
56
+ * 08:00:00 GMT+0700 (Western Indonesia Time)" — which was then interpolated verbatim, and
57
+ * PostgreSQL refused it with `time zone "gmt+0700" not recognized`.
58
+ *
59
+ * So the value was CERTIFIED and still unusable, which is the worst thing a validator can do.
60
+ * Found the first time the §10 bridge ran against real rows: every timestamp it carries comes
61
+ * straight out of a `timestamp` column, so every assignment it issued would have failed.
62
+ *
63
+ * ⚠️ Only a Date is converted. A naive STRING keeps its passthrough, because parsing it here
64
+ * would move its interpretation from the SERVER's timezone to this process's — a silent shift in
65
+ * meaning for every existing caller.
66
+ */
67
+ if (value instanceof Date) {
68
+ if (Number.isNaN(value.getTime())) {
69
+ throw new Error(
70
+ `${what} must be a timestamp — refused an Invalid Date rather than interpolating it ` +
71
+ "into a statement (Doc 4 §7.1).",
72
+ );
73
+ }
74
+ return value.toISOString();
75
+ }
53
76
  const text = String(value ?? "").trim();
54
77
  if (text === "" || Number.isNaN(Date.parse(text))) {
55
78
  throw new Error(
@@ -0,0 +1,62 @@
1
+ /*
2
+ * Doc 6 §8.3 — which usergroups a VERIFIED caller holds.
3
+ *
4
+ * ⚠️ WHY THIS IS PLATFORM CODE. `hrextensionlib.maySupervise` compares `hr_config.rights.dutyExtend`
5
+ * against the groups its actor holds, and Doc 5 §1–2 forbids a Layer 3 domain from touching any
6
+ * `sys$` table — its own tests enforce that. So the domain states the right it needs and the platform
7
+ * answers who holds what; the script receives names and never learns where they came from.
8
+ *
9
+ * ⚠️ IT ANSWERS ONLY FOR AN ALREADY-VERIFIED USER ID. Nothing here authenticates anybody: the id
10
+ * comes from inside a signature (security/internalPrincipal.js), and handing this a caller-supplied
11
+ * id would let anyone enumerate somebody else's rights.
12
+ */
13
+ import { logger } from "../../logger.js";
14
+
15
+ const MEMBERSHIP_TABLE = "sys$ugmember";
16
+ const GROUP_TABLE = "sys$usergroup";
17
+
18
+ /*
19
+ * The statement, or null when the id is not a plain number.
20
+ *
21
+ * ⚠️ NUMERIC OR NOTHING. This id is interpolated, so a value that is not a number must never reach
22
+ * the statement — returning null is the fail-closed answer, and the caller turns it into "no groups".
23
+ */
24
+ export const buildUserGroupsSql = (userId) => {
25
+ /* ⚠️ Reject the empties BEFORE coercing: Number(null) and Number([]) are both 0, a perfectly
26
+ valid-looking integer that would build a statement asking about user zero. */
27
+ if (userId === null || userId === undefined || userId === "") return null;
28
+ if (typeof userId === "object" || typeof userId === "boolean") return null;
29
+ const id = Number(userId);
30
+ if (!Number.isInteger(id)) return null;
31
+ return (
32
+ `SELECT g.group_name FROM ${MEMBERSHIP_TABLE} m ` +
33
+ `INNER JOIN ${GROUP_TABLE} g ON g.id = m."sys$usergroup_id" ` +
34
+ `WHERE m.user_id = ${id}`
35
+ );
36
+ };
37
+
38
+ /*
39
+ * The group names this user holds, or [].
40
+ *
41
+ * ⚠️ FAIL-CLOSED IN EVERY DIRECTION. An unreadable table, an odd row shape, a missing id — all yield
42
+ * NO groups. A rights check reading an empty list refuses, which is the safe outcome; a read that
43
+ * failed open would hand out the supervisor right on a database hiccup.
44
+ */
45
+ export const resolveUserGroups = async ({ exec, dbIndex, userId }) => {
46
+ const sql = buildUserGroupsSql(userId);
47
+ if (!sql || typeof exec !== "function") return [];
48
+
49
+ try {
50
+ const rows = await exec(sql, dbIndex);
51
+ if (!Array.isArray(rows)) return [];
52
+ return rows
53
+ .map((row) => String(row?.group_name ?? row?.GROUP_NAME ?? "").trim())
54
+ .filter((name) => name.length > 0);
55
+ } catch (error) {
56
+ logger.warn(
57
+ `[principal] could not read usergroups for user ${userId}: ${error?.message ?? error}. ` +
58
+ "Treating the caller as holding none, so any right checked against them is refused.",
59
+ );
60
+ return [];
61
+ }
62
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "biz-a-cli",
3
- "version": "2.3.80-15330",
3
+ "version": "2.3.80-15339",
4
4
  "description": "",
5
5
  "main": "bin/index.js",
6
6
  "type": "module",
@@ -160,10 +160,44 @@ export async function setLibrary(config, libraries, defaultLib) {
160
160
  return libs;
161
161
  }
162
162
 
163
+ /*
164
+ * ⚠️⚠️ WHO THE SERVER VERIFIED IS ASKING — normalised, or null.
165
+ *
166
+ * Doc 6 §8.3 needed a right (`rights.dutyExtend`) checked inside a CLI script, and a script had only
167
+ * caller-shaped data to go on: gating on that would be security theatre, so the supervisor path was
168
+ * shut by construction rather than half-built.
169
+ *
170
+ * ⚠️ NULL IS THE FAIL-CLOSED ANSWER and it is the common one: `/cb` is an unauthenticated webhook
171
+ * surface and cron has no user at all. A script that gates on identity must refuse a null principal,
172
+ * never assume one.
173
+ *
174
+ * ⚠️ Anything it does not positively recognise becomes null. A principal is an authorisation input;
175
+ * "some object arrived" is not the same as "a caller was verified".
176
+ */
177
+ const normalisePrincipal = (principal) => {
178
+ if (!principal || typeof principal !== "object" || Array.isArray(principal)) return null;
179
+ const actorType = String(principal.actorType ?? "").trim();
180
+ if (!actorType) return null;
181
+ return Object.freeze({
182
+ actorType,
183
+ userId: principal.userId ?? null,
184
+ sessionId: principal.sessionId ?? null,
185
+ dbIndex: principal.dbIndex ?? null,
186
+ subdomain: principal.subdomain ?? null,
187
+ /* Frozen too: a script that could push onto it would widen its own tenant scope. */
188
+ tenantIds: Object.freeze(Array.isArray(principal.tenantIds) ? [...principal.tenantIds] : []),
189
+ /* Doc 6 §8.3 — the usergroups this caller holds, resolved by the platform (a Layer 3 domain
190
+ may not read sys$ tables). Frozen for the same reason: a script that could push onto it
191
+ would grant itself the very right it is about to check. */
192
+ groups: Object.freeze(Array.isArray(principal.groups) ? [...principal.groups] : []),
193
+ });
194
+ };
195
+
163
196
  export async function extractFunctionScript(
164
197
  selectedConfig,
165
198
  data,
166
199
  extraLib = {},
200
+ principal = null,
167
201
  ) {
168
202
  const config = getConfig(selectedConfig);
169
203
 
@@ -212,6 +246,19 @@ export async function extractFunctionScript(
212
246
  );
213
247
  }
214
248
 
249
+ /*
250
+ * ⚠️ DEFINED LAST, AND NOT WRITABLE. It has to survive both `...extraLib` above and the loaded
251
+ * libraries merged in just now — a value whose entire purpose is that the script cannot choose it
252
+ * must not be shadowable by anything the script can influence, including a library it names in
253
+ * its own useLibrary.
254
+ */
255
+ Object.defineProperty(lib, "principal", {
256
+ value: normalisePrincipal(principal),
257
+ writable: false,
258
+ configurable: false,
259
+ enumerable: true,
260
+ });
261
+
215
262
  return scriptFn(lib).functions;
216
263
  }
217
264
 
@@ -199,3 +199,36 @@ export function authorizeRequest(headers, { mode = "observe", exempt = false, no
199
199
  allowed: exempt || mode === "observe" ? true : valid,
200
200
  };
201
201
  }
202
+
203
+ /*
204
+ * ⚠️⚠️ Doc 6 §8.3 — WHO IS ASKING, for a CLI script.
205
+ *
206
+ * A CLI script had no identity: the internal cliCommand dispatch "trusts caller-supplied
207
+ * identity/roles" (engine/ext/cliInvoke.js says so in its own header), so §8.3's supervisor path was
208
+ * shut by construction rather than half-built. This turns the SIGNED principal the client already
209
+ * sends on HTTP into one a script can trust.
210
+ *
211
+ * ⚠️ EVERYTHING IT REPORTS COMES FROM INSIDE THE SIGNATURE. `dbIndex`, `subdomain` and `tenantIds`
212
+ * are taken from the verified claims and never from the surrounding request, because a caller that
213
+ * could nominate its own tenant list would be choosing its own authority. The socket payload around
214
+ * this is entirely caller-shaped — `engine/ext/esb.js` already had to learn to ignore a handshake
215
+ * `userId` for the same reason.
216
+ *
217
+ * ⚠️ NULL IS THE NORMAL ANSWER and it is fail-closed: `/cb` is an unauthenticated webhook surface and
218
+ * cron has no user. `mode` decides whether a REQUEST is refused elsewhere; it never fabricates an
219
+ * identity here, so observe and enforce agree on who the caller is.
220
+ */
221
+ export function principalForCliScript(headers, { mode = "observe", now = Date.now() } = {}) {
222
+ const decision = authorizeRequest(headers, { mode, now });
223
+ const claims = decision.claims;
224
+ if (!claims) return null;
225
+
226
+ return {
227
+ actorType: "USER",
228
+ userId: claims.userId ?? null,
229
+ sessionId: claims.sid ?? null,
230
+ dbIndex: claims.dbIndex ?? null,
231
+ subdomain: claims.subdomain ?? null,
232
+ tenantIds: Array.isArray(claims.tenantIds) ? claims.tenantIds : [],
233
+ };
234
+ }
@@ -2,6 +2,8 @@ import workerpool from "workerpool";
2
2
  import { loadCliScript, extractFunctionScript } from "../scheduler/datalib.js";
3
3
  import * as db from "../db/db.js";
4
4
  import { ensureEntityConfig } from "../engine/domain/entityConfigPrimer.js";
5
+ import { resolveUserGroups } from "../engine/orm/userGroups.js";
6
+ import { execPostgres } from "../engine/domain/dialect.js";
5
7
  import { logger } from "../logger.js";
6
8
 
7
9
  // export const run = async (apiConfig, scriptIdentifier, scriptData) => {
@@ -35,7 +37,15 @@ import { logger } from "../logger.js";
35
37
  // }
36
38
  // };
37
39
 
38
- export const run = async (apiConfig, scriptIdentifier, scriptData) => {
40
+ /*
41
+ * ⚠️⚠️ `principal` IS A SEPARATE ARGUMENT, NOT A FIELD ON `scriptData`.
42
+ *
43
+ * Every entrance builds `scriptData` out of what the caller sent, and script authors are trained
44
+ * to unwrap `scriptData.body` (the BPM effect wrapper). A principal living in there would be both
45
+ * forgeable by the caller and easy to read by mistake. Kept apart, it can only come from the code
46
+ * that actually verified it — see scheduler/datalib.js normalisePrincipal.
47
+ */
48
+ export const run = async (apiConfig, scriptIdentifier, scriptData, principal = null) => {
39
49
  const searchBy =
40
50
  typeof scriptIdentifier === "object"
41
51
  ? scriptIdentifier.searchBy
@@ -196,10 +206,32 @@ export const run = async (apiConfig, scriptIdentifier, scriptData) => {
196
206
  injectESBMethods(extraLib);
197
207
  injectDBMethods(extraLib, apiConfig);
198
208
 
209
+ /*
210
+ * Doc 6 §8.3 — the groups a VERIFIED caller holds, resolved HERE rather than by the script.
211
+ *
212
+ * ⚠️ A Layer 3 domain may not read `sys$` tables (Doc 5 §1–2, enforced by its own tests), so it
213
+ * states the right it needs and the platform answers who holds what. Fail-closed: an
214
+ * unreadable membership table yields none, and a rights check against an empty list refuses.
215
+ *
216
+ * ⚠️ Only for a principal that was actually verified — the id comes from inside a signature,
217
+ * never from the request.
218
+ */
219
+ const principalWithGroups = principal
220
+ ? {
221
+ ...principal,
222
+ groups: await resolveUserGroups({
223
+ exec: execPostgres,
224
+ dbIndex: db.resolveDbIndex(apiConfig),
225
+ userId: principal.userId,
226
+ }),
227
+ }
228
+ : null;
229
+
199
230
  const functions = await extractFunctionScript(
200
231
  apiConfig,
201
232
  await loadCliScript(apiConfig, searchBy, searchValue),
202
233
  extraLib,
234
+ principalWithGroups,
203
235
  );
204
236
 
205
237
  const result =
@@ -36,6 +36,12 @@ export const execCLIScriptWorker = async (
36
36
  apiConfig,
37
37
  scriptName,
38
38
  scriptData,
39
+ /*
40
+ * ⚠️ WHO THE ENTRANCE VERIFIED, or null. Each caller passes only what it has actually
41
+ * verified: `/cb` is an unauthenticated webhook surface and cron has no user, so both pass
42
+ * nothing. Kept OUT of `scriptData` on purpose — that is the caller's own payload.
43
+ */
44
+ principal = null,
39
45
  ) => {
40
46
  // callback
41
47
  // {
@@ -78,7 +84,10 @@ export const execCLIScriptWorker = async (
78
84
  }
79
85
  const safeConfig = JSON.parse(JSON.stringify(apiConfig || {}));
80
86
 
81
- return cliScriptWorkerPool.exec("run", [safeConfig, scriptName, safeData], {
87
+ /* Serialised like everything else crossing the thread boundary; normalised on arrival. */
88
+ const safePrincipal = principal ? JSON.parse(JSON.stringify(principal)) : null;
89
+
90
+ return cliScriptWorkerPool.exec("run", [safeConfig, scriptName, safeData, safePrincipal], {
82
91
  on: (message) => {
83
92
  // Catch the relay and bounce it to the main thread
84
93
  if (message && message.type === "IPC_RELAY") {