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 +50 -2
- package/bin/hub.js +65 -32
- package/bin/hubEvent.js +175 -7
- package/engine/domain/applicationConfig.js +147 -4
- package/engine/domain/contrast.js +224 -0
- package/engine/domain/entityConfigLoader.js +28 -0
- package/engine/domain/entityConfigPrimer.js +130 -4
- package/engine/domain/syncThemePresets.js +33 -0
- package/engine/domain/theme-presets.json +190 -0
- package/engine/domain/themeConfig.js +201 -0
- package/engine/domain/themePresets.js +80 -0
- package/engine/orm/companyLogo.js +206 -0
- package/engine/orm/companyTheme.js +169 -0
- package/engine/orm/principalGuards.js +51 -0
- package/engine/orm/rlsPolicy.js +37 -4
- package/engine/orm/tenantApi.js +20 -2
- package/engine/orm/userGroups.js +62 -0
- package/engine/orm/userPreferences.js +113 -0
- package/migrations/1789600000000__user_preferences.sql +38 -0
- package/migrations/1789700000000__theme_audit.sql +46 -0
- package/migrations/postgres/1789600000000__user_preferences.sql +36 -0
- package/migrations/postgres/1789700000000__theme_audit.sql +44 -0
- package/package.json +1 -1
- package/scheduler/datalib.js +47 -0
- package/security/internalPrincipal.js +33 -0
- package/worker/cliScriptWorker.js +33 -1
- package/worker/cliWorkerPool.js +10 -1
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 {
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
{
|