biz-a-cli 2.3.80-15333 → 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/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, () =>
@@ -378,11 +378,98 @@ export const buildTemplateDomainConfigResolverSnippet = () => {
378
378
  this._ensureDomainConfig = ensureDomainConfig;`;
379
379
  };
380
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
+
381
467
  export const buildHardcodedTemplateContent = (
382
468
  entryPointName = "loginForm",
383
469
  domainKey = null,
384
470
  templateType = "form",
385
471
  domainSource = null,
472
+ libraryModules = [],
386
473
  ) => {
387
474
  const sharedDomainConfigResolverScript = buildTemplateDomainConfigResolverSnippet();
388
475
  const formTemplateContent = `get = function () {
@@ -390,7 +477,7 @@ export const buildHardcodedTemplateContent = (
390
477
  model: {},
391
478
  tableName: "",
392
479
  fields: [],
393
- functions: {
480
+ functions: {__USE_LIBRARY_BLOCK__
394
481
  beforeLoadData: function (id) {
395
482
  ${sharedDomainConfigResolverScript}
396
483
  return new Promise((resolve) => {
@@ -450,7 +537,7 @@ ${sharedDomainConfigResolverScript}
450
537
  filter: [],
451
538
  buttons: [],
452
539
  distinct: false,
453
- functions: {
540
+ functions: {__USE_LIBRARY_BLOCK__
454
541
  beforeLoadData: function () {
455
542
  ${sharedDomainConfigResolverScript}
456
543
  return new Promise((resolve) => {
@@ -543,7 +630,20 @@ ${sharedDomainConfigResolverScript}
543
630
  const safeDomainKey = typeof domainKey === "string" && domainKey.trim().length > 0 ? domainKey.trim() : "";
544
631
  const safeDomainSource = typeof domainSource === "string" && domainSource.trim().length > 0 ? domainSource.trim() : "";
545
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
+
546
645
  const resolvedTemplateContent = templateContent
646
+ .replace(/__USE_LIBRARY_BLOCK__/g, useLibraryBlock)
547
647
  .replace(/"__ENTRY_POINT_NAME__"/g, JSON.stringify(safeEntryPointName))
548
648
  .replace(/"__DOMAIN_SOURCE__"/g, JSON.stringify(safeDomainSource))
549
649
  .replace(
@@ -582,6 +682,11 @@ export const buildPublishBodyFromConfigs = (parsedApplicationConfig, parsedDomai
582
682
  (name) => String(name ?? "").trim().length > 0,
583
683
  ),
584
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
+
585
690
  let unresolvedTargetIndex = 0;
586
691
  const templateFiles = templateTargets.map((target, targetIndex) => {
587
692
  const explicitDomainReference = String(target.domainKey ?? "").trim();
@@ -612,7 +717,27 @@ export const buildPublishBodyFromConfigs = (parsedApplicationConfig, parsedDomai
612
717
  resolvedDomainKey && domainMap && typeof domainMap === "object" ? String(domainMap?.[resolvedDomainKey]?.source ?? "").trim() : "";
613
718
  return {
614
719
  name: target.fileName,
615
- 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
+ ),
616
741
  };
617
742
  });
618
743
  const fileList = [{ name: "menu.json", content: menuContent }, ...templateFiles];
@@ -663,9 +788,23 @@ export const publishApplicationConfig = async (payload = {}, argv = {}) => {
663
788
  /* Parsed once in the collect pass, reused in the build pass. */
664
789
  const scannedDomains = [];
665
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
+ */
666
797
  const addFilesToMergedList = (fileList) => {
667
798
  for (const file of fileList) {
668
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;
669
808
  continue;
670
809
  }
671
810
  seenFileNames.add(file.name);
@@ -735,7 +874,11 @@ export const publishApplicationConfig = async (payload = {}, argv = {}) => {
735
874
  }
736
875
  }
737
876
 
738
- 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
+ };
739
882
 
740
883
  const response = await publishApp(
741
884
  {
@@ -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))) {
@@ -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-15333",
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") {