biz-a-cli 2.3.80-15333 → 2.3.80-15357

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
@@ -20,6 +20,10 @@ import {
20
20
  import { prepareScript, encryptScript } from "./script.js";
21
21
  import { spawn } from "node:child_process";
22
22
  import { newMigration, runMigrations } from "./migrate.js";
23
+ import {
24
+ validateThemeSource,
25
+ APPLICATION_THEME_NAME,
26
+ } from "../engine/domain/themeConfig.js";
23
27
  import {
24
28
  APP_CONFIG_NAME,
25
29
  buildEnsureConfigTableSql,
@@ -367,6 +371,9 @@ const getFileList = ({ workingDir = process.cwd(), files = null } = {}) => {
367
371
  ============================================================================ */
368
372
 
369
373
  const APPLICATION_CONFIG_FILE = "application.js";
374
+ /* Doc 6 §8.1 — the application theme source. JSON, because it is DATA that reaches the browser
375
+ via sys$config and must never be evaluated (AC 26). */
376
+ const THEME_CONFIG_FILE = "theme.json";
370
377
  /* Parity with the admin editor (appConfig.component.ts executeBlockBodyLimit): the DataSnap
371
378
  `block` call body must stay under 64KB, so an oversized config is upserted in chunks. */
372
379
  const EXECUTE_BLOCK_BODY_LIMIT = 2 ** 16;
@@ -548,6 +555,41 @@ const collectConfigUploads = ({ workingDir, fileNames = [], include = [] }) => {
548
555
  application = { name: APP_CONFIG_NAME, version, content };
549
556
  }
550
557
 
558
+ /*
559
+ * Doc 6 §8.1 — the application's theme, published beside its config.
560
+ *
561
+ * ⚠️ A SEPARATE FILE, AND JSON, for the reason §8.1 points 1-2 give: this ends up in
562
+ * `sys$config` and is read by every user's browser, so if it were executable anyone who can
563
+ * write `sys$config` could run code in all of them. It is parsed, never evaluated (AC 26).
564
+ *
565
+ * ⚠️ Validated at PUBLISH, not at runtime. A mistyped token name is silent at runtime — the
566
+ * token is simply never applied — and the author is left believing the theme works. Refusing the
567
+ * publish puts the error where somebody can act on it (§8.1 point 4).
568
+ */
569
+ let theme = null;
570
+ const themeFile = fileNames.find(
571
+ (f) => path.basename(String(f)).toLowerCase() === THEME_CONFIG_FILE,
572
+ );
573
+ if (themeFile) {
574
+ const { theme: parsedTheme, problems: themeProblems } = validateThemeSource(
575
+ readConfig(themeFile),
576
+ );
577
+ if (themeProblems.length) {
578
+ problems.push(
579
+ ...themeProblems.map((m) => `${path.basename(String(themeFile))}: ${m}`),
580
+ );
581
+ } else {
582
+ /* Versioned with the application: the theme belongs to that release of the app, and a
583
+ rollback of one without the other would pair a config with a theme it never shipped
584
+ with. */
585
+ theme = {
586
+ name: APPLICATION_THEME_NAME,
587
+ version: application?.version ?? "1.0.0",
588
+ content: JSON.stringify(parsedTheme),
589
+ };
590
+ }
591
+ }
592
+
551
593
  const domains = [];
552
594
  /* ⚠️ Kept because the tenancy rule is CROSS-FILE: a scoped entity in one domain may be given its
553
595
  foreign key by a relationship in another, and `buildTenancyMap` folds them all together at
@@ -610,7 +652,7 @@ const collectConfigUploads = ({ workingDir, fileNames = [], include = [] }) => {
610
652
  );
611
653
  }
612
654
 
613
- return { application, domains };
655
+ return { application, domains, theme };
614
656
  };
615
657
 
616
658
  /* Upsert the configs, then optionally run the same publish the admin button does. Publish order
@@ -693,7 +735,7 @@ async function uploadConfigs({
693
735
  serverLink: resolveServerLink(serverMode),
694
736
  });
695
737
 
696
- const { application, domains } = collectConfigUploads({
738
+ const { application, domains, theme } = collectConfigUploads({
697
739
  workingDir,
698
740
  fileNames,
699
741
  include,
@@ -752,6 +794,12 @@ async function uploadConfigs({
752
794
  content: application.content,
753
795
  });
754
796
  }
797
+
798
+ /* Doc 6 §8.1 — level 2. ⚠️ A DIFFERENT KEY from the company's own theme (level 3): sharing one
799
+ would mean publishing an app silently overwrote a company's branding. */
800
+ if (theme) {
801
+ await saveOne(theme);
802
+ }
755
803
  for (const domain of domains) {
756
804
  await saveOne(domain);
757
805
  }
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,26 @@ 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";
27
+ import {
28
+ readUserPreferences,
29
+ writeUserPreferences,
30
+ } from "../engine/orm/userPreferences.js";
31
+ import {
32
+ readCompanyTheme,
33
+ writeCompanyTheme,
34
+ } from "../engine/orm/companyTheme.js";
35
+ import {
36
+ readCompanyLogo,
37
+ writeCompanyLogo,
38
+ clearCompanyLogo,
39
+ } from "../engine/orm/companyLogo.js";
26
40
  import {
27
41
  execPostgres,
28
42
  execPostgresStatements,
@@ -477,6 +491,22 @@ const handleCliCommand = async (data, cb, argv) => {
477
491
  query: { scriptName: data.scriptName },
478
492
  headers: { "user-agent": "biz-a-client-socket" },
479
493
  };
494
+ /*
495
+ * ⚠️⚠️ Doc 6 §8.3 — WHO IS ASKING. Until now this entrance carried no verified identity
496
+ * at all, which is why a CLI script could not gate anything on a right. The client sends
497
+ * the same signed principal it already attaches to HTTP; everything the script sees comes
498
+ * from inside that signature.
499
+ *
500
+ * ⚠️ OBSERVE-SHAPED, deliberately. An absent or invalid principal yields null and the script
501
+ * still RUNS — it is the script's job to refuse when it needs an identity it did not get.
502
+ * Refusing here would lock out every client that has not been rebuilt, which is exactly
503
+ * what `--principalMode` exists to avoid.
504
+ *
505
+ * ⚠️ `data.headers`, never `data.scriptData`: the payload around this is caller-shaped.
506
+ */
507
+ const cliScriptPrincipal = principalForCliScript(data.headers, {
508
+ mode: argv["principalMode"] || "observe",
509
+ });
480
510
  cb(
481
511
  null,
482
512
  await execCLIScriptWorker(
@@ -487,9 +517,134 @@ const handleCliCommand = async (data, cb, argv) => {
487
517
  },
488
518
  data.scriptName,
489
519
  liveTriggerPayload,
520
+ cliScriptPrincipal,
490
521
  ),
491
522
  );
492
523
  break;
524
+ /*
525
+ * Doc 6 §4.2 / AC 19 — read or merge the SIGNED-IN USER'S preferences.
526
+ *
527
+ * ⚠️ A DEDICATED COMMAND, NOT `engine`. The `engine` case below runs `runTrusted` with no
528
+ * principal at all — by design, because those are administrator operations. Routing user
529
+ * preferences through it would take the user id from `data.payload`, i.e. from the
530
+ * browser, and a user could then write somebody else's.
531
+ *
532
+ * ⚠️ AND NOT `runcliscript` EITHER. That resolves an app-authored script by name; a
533
+ * platform operation would have to be seeded into SYS$CLI_SCRIPT per tenant and re-seeded
534
+ * to change.
535
+ *
536
+ * ⚠️ REQUIRED, not observe-shaped. `runcliscript` deliberately lets a null principal
537
+ * through so an un-rebuilt client still works — the script decides. Here there is nothing
538
+ * to decide: without an identity there is no row to read or write, so userPreferences.js
539
+ * throws and the client falls back to its localStorage mirror (§4.2 point 2b).
540
+ */
541
+ case "userpreferences": {
542
+ const prefPrincipal = principalForCliScript(data.headers, {
543
+ mode: argv["principalMode"] || "observe",
544
+ });
545
+ const prefExec = (sql) => execPostgres(sql, argv["dbindex"]);
546
+ const prefs =
547
+ data.action === "write"
548
+ ? await writeUserPreferences({
549
+ exec: prefExec,
550
+ principal: prefPrincipal,
551
+ patch: data.patch,
552
+ })
553
+ : await readUserPreferences({
554
+ exec: prefExec,
555
+ principal: prefPrincipal,
556
+ });
557
+ cb(null, { success: true, data: prefs });
558
+ break;
559
+ }
560
+ /*
561
+ * Doc 6 §7.1.1 step 7 + §13 point 3 — save the COMPANY theme, with its audit trail.
562
+ *
563
+ * ⚠️ THIS EXISTS SO THE AUDIT TRAIL CAN NAME SOMEBODY. The client could write SYS$CONFIG
564
+ * directly through executeBlock, as the App Config screen does — but then the audit row
565
+ * would be authored by the thing being audited, with the user id read out of
566
+ * localStorage. principalForCliScript is what makes "siapa" mean anything.
567
+ *
568
+ * ⚠️ Validation (token names, ranges, WCAG AA) happens inside writeCompanyTheme via the
569
+ * SAME validateThemeSource that gates an application theme at publish — §7.1's one rule
570
+ * for Jalur A and Jalur B.
571
+ */
572
+ case "companytheme": {
573
+ const themePrincipal = principalForCliScript(data.headers, {
574
+ mode: argv["principalMode"] || "observe",
575
+ });
576
+ const themeExec = (sql) => execPostgres(sql, argv["dbindex"]);
577
+ if (data.action === "write") {
578
+ const saved = await writeCompanyTheme({
579
+ exec: themeExec,
580
+ execStatements: (statements) =>
581
+ execPostgresStatements(statements, argv["dbindex"]),
582
+ principal: themePrincipal,
583
+ theme: data.theme,
584
+ });
585
+ /*
586
+ * ⚠️ A REFUSAL IS NOT AN ERROR. `problems` is what the settings screen shows the
587
+ * admin so they can fix the colour — reporting it as a failed call would lose
588
+ * the explanation and leave them with "save failed".
589
+ */
590
+ cb(null, {
591
+ success: saved.ok,
592
+ problems: saved.problems,
593
+ data: saved.theme,
594
+ });
595
+ } else {
596
+ cb(null, {
597
+ success: true,
598
+ data: await readCompanyTheme({ exec: themeExec }),
599
+ });
600
+ }
601
+ break;
602
+ }
603
+ /*
604
+ * Doc 6 §8.2 — the company logo, under its OWN sys$config key.
605
+ *
606
+ * ⚠️ A SEPARATE COMMAND FROM THE THEME, deliberately. §8.2 point 3 keeps the image out
607
+ * of the theme JSON because the theme is read on every application start and a logo is
608
+ * megabytes; folding it into `companytheme` would undo that at the transport layer even
609
+ * if the storage stayed split.
610
+ */
611
+ case "companylogo": {
612
+ const logoPrincipal = principalForCliScript(data.headers, {
613
+ mode: argv["principalMode"] || "observe",
614
+ });
615
+ const logoExec = (sql) => execPostgres(sql, argv["dbindex"]);
616
+ const logoStatements = (statements) =>
617
+ execPostgresStatements(statements, argv["dbindex"]);
618
+
619
+ if (data.action === "write") {
620
+ const saved = await writeCompanyLogo({
621
+ exec: logoExec,
622
+ execStatements: logoStatements,
623
+ principal: logoPrincipal,
624
+ dataUrl: data.dataUrl,
625
+ });
626
+ /* ⚠️ A refusal is NOT an error: `problems` is what the settings screen shows the
627
+ admin so they can pick a different file. */
628
+ cb(null, {
629
+ success: saved.ok,
630
+ problems: saved.problems,
631
+ data: saved.logo ?? null,
632
+ });
633
+ } else if (data.action === "clear") {
634
+ const cleared = await clearCompanyLogo({
635
+ exec: logoExec,
636
+ execStatements: logoStatements,
637
+ principal: logoPrincipal,
638
+ });
639
+ cb(null, { success: cleared.ok, problems: cleared.problems, data: null });
640
+ } else {
641
+ cb(null, {
642
+ success: true,
643
+ data: await readCompanyLogo({ exec: logoExec }),
644
+ });
645
+ }
646
+ break;
647
+ }
493
648
  case "runextcliscript": {
494
649
  // External-access op invoker — enforces the capability boundary (dispatch.js),
495
650
  // unlike raw `runcliscript`. Safe to expose: no valid token -> UNAUTHENTICATED.
@@ -735,12 +890,25 @@ export const clientListener = (socket, argv, via = "?") => {
735
890
  against the SIGNED claim, so it can only select among tenants already granted. */
736
891
  /* Doc 4 §9.5 / B2 — the session ROW is the authority for tenancy. A principal minted
737
892
  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
- })
893
+ /*
894
+ * Doc 6 Q5 — this is the OTHER entrance to the same ORM. `bin/hub.js` keeps the HTTP
895
+ * path's declarations current with a `/fina` middleware; the browser's data calls
896
+ * arrive here instead, so the same TTL'd check belongs in front of them or half the
897
+ * request traffic would keep reading whatever this thread learned at startup.
898
+ *
899
+ * ⚠️ Chained, never awaited-and-thrown: a freshness check that fails must not refuse a
900
+ * request the existing declarations could answer perfectly well.
901
+ */
902
+ ensureEntityConfig(Number(argv["dbindex"]))
903
+ .catch(() => {})
904
+ .then(() =>
905
+ resolveRequestScope({
906
+ claims: decision.claims,
907
+ headers: reqData.headers,
908
+ sessionStore: sessionStoreFor(Number(argv["dbindex"])),
909
+ resolveTenancy: tenancyResolverFor(Number(argv["dbindex"])),
910
+ }),
911
+ )
744
912
  .then((scope) =>
745
913
  /* ⚠️ The ROUTER is handed the resolved scope too — see finaEndpoint for why. */
746
914
  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
  {