biz-a-cli 2.3.80-15330 → 2.3.80-15339
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/app.js +3 -0
- package/bin/hub.js +65 -32
- package/bin/hubEvent.js +38 -7
- package/engine/domain/appConfig.js +5 -0
- package/engine/domain/applicationConfig.js +237 -5
- package/engine/domain/domainNavigation.js +131 -0
- package/engine/domain/entityConfigLoader.js +28 -0
- package/engine/domain/entityConfigPrimer.js +130 -4
- package/engine/orm/rlsPolicy.js +37 -4
- package/engine/orm/tenantApi.js +20 -2
- package/engine/orm/tenantResolution.js +23 -0
- package/engine/orm/userGroups.js +62 -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
|
@@ -36,8 +36,11 @@ import {
|
|
|
36
36
|
import {
|
|
37
37
|
isPostgresIndex,
|
|
38
38
|
execPostgres,
|
|
39
|
+
execPostgresStatements,
|
|
39
40
|
primeRegistry,
|
|
40
41
|
} from "../engine/domain/dialect.js";
|
|
42
|
+
|
|
43
|
+
import { translateExecuteBlock } from "../engine/orm/executeBlock.js";
|
|
41
44
|
import { addUser } from "../engine/orm/userAdmin.js";
|
|
42
45
|
import {
|
|
43
46
|
preservePublishedAdditions,
|
package/bin/hub.js
CHANGED
|
@@ -193,9 +193,12 @@ import { createFinaRouter } from "../engine/orm/finaEndpoint.js";
|
|
|
193
193
|
import { createSessionStore } from "../engine/orm/sessionStore.js";
|
|
194
194
|
import { resolveUserTenancy } from "../engine/orm/tenantResolution.js";
|
|
195
195
|
import { buildUserTenantSql } from "../engine/orm/finaAuth.js";
|
|
196
|
-
import {
|
|
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,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
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
756
|
+
/*
|
|
757
|
+
* Doc 6 Q5 — this is the OTHER entrance to the same ORM. `bin/hub.js` keeps the HTTP
|
|
758
|
+
* path's declarations current with a `/fina` middleware; the browser's data calls
|
|
759
|
+
* arrive here instead, so the same TTL'd check belongs in front of them or half the
|
|
760
|
+
* request traffic would keep reading whatever this thread learned at startup.
|
|
761
|
+
*
|
|
762
|
+
* ⚠️ Chained, never awaited-and-thrown: a freshness check that fails must not refuse a
|
|
763
|
+
* request the existing declarations could answer perfectly well.
|
|
764
|
+
*/
|
|
765
|
+
ensureEntityConfig(Number(argv["dbindex"]))
|
|
766
|
+
.catch(() => {})
|
|
767
|
+
.then(() =>
|
|
768
|
+
resolveRequestScope({
|
|
769
|
+
claims: decision.claims,
|
|
770
|
+
headers: reqData.headers,
|
|
771
|
+
sessionStore: sessionStoreFor(Number(argv["dbindex"])),
|
|
772
|
+
resolveTenancy: tenancyResolverFor(Number(argv["dbindex"])),
|
|
773
|
+
}),
|
|
774
|
+
)
|
|
744
775
|
.then((scope) =>
|
|
745
776
|
/* ⚠️ The ROUTER is handed the resolved scope too — see finaEndpoint for why. */
|
|
746
777
|
runAsPrincipalOr403(scope, () =>
|
|
@@ -2781,6 +2781,11 @@ const loadDdlHashes = async (domainName, domainVersion, config) => {
|
|
|
2781
2781
|
}
|
|
2782
2782
|
};
|
|
2783
2783
|
|
|
2784
|
+
/* Exported so a publish can store a config row it has just rewritten -- currently the navigation a
|
|
2785
|
+
domain contributed (Dok. 5 section 3.3). Same dialect-aware path every other config write uses. */
|
|
2786
|
+
export const saveConfigRow = (name, version, content, config) =>
|
|
2787
|
+
upsertConfigRow(name, version, content, config);
|
|
2788
|
+
|
|
2784
2789
|
const writeDdlHashes = async (domainName, domainVersion, tables, config) => {
|
|
2785
2790
|
const { name, version } = buildDdlHashConfigKey(domainName, domainVersion);
|
|
2786
2791
|
const payload = JSON.stringify({ tables });
|
|
@@ -5,7 +5,12 @@ import {
|
|
|
5
5
|
resolveConfigFromPayload,
|
|
6
6
|
buildPublishStatusConfigKey,
|
|
7
7
|
writePublishStatus,
|
|
8
|
+
saveConfigRow,
|
|
8
9
|
} from "./appConfig.js";
|
|
10
|
+
import { execPostgres } from "./dialect.js";
|
|
11
|
+
/* Dok. 5 section 3.3 -- a Layer 3 domain contributing its own menus, gated by its own activation
|
|
12
|
+
setting. Read generically: no domain name and no domain code appear in this file. */
|
|
13
|
+
import { mergeDomainNavigation, selectActiveDomainMenus, readActiveModules } from "./domainNavigation.js";
|
|
9
14
|
|
|
10
15
|
export const parseConfigData = (rawConfig) => {
|
|
11
16
|
if (rawConfig == null) {
|
|
@@ -373,11 +378,98 @@ export const buildTemplateDomainConfigResolverSnippet = () => {
|
|
|
373
378
|
this._ensureDomainConfig = ensureDomainConfig;`;
|
|
374
379
|
};
|
|
375
380
|
|
|
381
|
+
/*
|
|
382
|
+
* ⚠️⚠️⚠️ THE GENERATED TEMPLATE MUST DECLARE ITS LIBRARIES.
|
|
383
|
+
*
|
|
384
|
+
* App + domain config publishing used to run in the CLIENT; moving it here dropped this step. The
|
|
385
|
+
* generated form then carried only beforeLoadData / afterLoadData / onInit / onDestroy / onPipe, so
|
|
386
|
+
* `form.component.ts` calling `doFunction('useLibrary')` got undefined and loaded nothing, and
|
|
387
|
+
* `db.service.loadScript` — which arms the template reloader from that SAME list — watched no
|
|
388
|
+
* libraries at all. A redeployed library was then never picked up by an open session, and nothing
|
|
389
|
+
* said so. See Doc 6 §7.12.
|
|
390
|
+
*
|
|
391
|
+
* ⚠️ THIS MUST MIRROR THE CLIENT EXACTLY — `resolveDomainLibraries` (render.domain.ts) and
|
|
392
|
+
* `normalizeDomainLibraryModules` (domain.service.ts). Any divergence means the form loads one set
|
|
393
|
+
* and the reloader watches another, which is a silent bug of precisely the kind this replaces.
|
|
394
|
+
*
|
|
395
|
+
* A module id is `<appName>-<fileName>`, e.g. `hrlib-hrpublishlib`: the app bundle that owns the
|
|
396
|
+
* file, then the file's own basename. A handler string carries both in its first two segments.
|
|
397
|
+
*/
|
|
398
|
+
export const resolveDomainLibraryModules = (parsedDomainConfig) => {
|
|
399
|
+
const config = parsedDomainConfig && typeof parsedDomainConfig === "object" ? parsedDomainConfig : {};
|
|
400
|
+
|
|
401
|
+
const rawLibraries = Array.isArray(config?.libraries)
|
|
402
|
+
? config.libraries
|
|
403
|
+
: Array.isArray(config?.ui?.libraries)
|
|
404
|
+
? config.ui.libraries
|
|
405
|
+
: [];
|
|
406
|
+
const configuredModules = [
|
|
407
|
+
...new Set(
|
|
408
|
+
rawLibraries
|
|
409
|
+
.map((item) => {
|
|
410
|
+
if (typeof item === "string") {
|
|
411
|
+
return item.trim();
|
|
412
|
+
}
|
|
413
|
+
if (item && typeof item === "object") {
|
|
414
|
+
if (typeof item.domain === "string") {
|
|
415
|
+
return item.domain.trim();
|
|
416
|
+
}
|
|
417
|
+
if (typeof item.module === "string") {
|
|
418
|
+
return item.module.trim();
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return "";
|
|
422
|
+
})
|
|
423
|
+
.filter((moduleName) => moduleName.length > 0),
|
|
424
|
+
),
|
|
425
|
+
];
|
|
426
|
+
|
|
427
|
+
const events =
|
|
428
|
+
config?.events && typeof config.events === "object" && !Array.isArray(config.events)
|
|
429
|
+
? config.events
|
|
430
|
+
: config?.ui?.events && typeof config.ui.events === "object" && !Array.isArray(config.ui.events)
|
|
431
|
+
? config.ui.events
|
|
432
|
+
: {};
|
|
433
|
+
const derivedModules = new Set();
|
|
434
|
+
for (const eventDef of Object.values(events)) {
|
|
435
|
+
const handler = typeof eventDef?.handler === "string" ? eventDef.handler.trim() : "";
|
|
436
|
+
if (!handler) {
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
const segments = handler
|
|
440
|
+
.split(".")
|
|
441
|
+
.map((segment) => String(segment ?? "").trim())
|
|
442
|
+
.filter((segment) => segment.length > 0);
|
|
443
|
+
/* Fewer than 3 segments names a function in the template itself, not a library. */
|
|
444
|
+
if (segments.length >= 3) {
|
|
445
|
+
derivedModules.add(`${segments[0]}-${segments[1]}`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (configuredModules.length === 0) {
|
|
450
|
+
return [...derivedModules];
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/* A bare domain name (`hrlib`) expands to the `hrlib-*` its handlers actually reference; if none
|
|
454
|
+
do, it is kept as-is so an explicitly declared library is never silently dropped. */
|
|
455
|
+
const derivedModuleList = [...derivedModules];
|
|
456
|
+
const normalizedConfigured = configuredModules.flatMap((moduleName) => {
|
|
457
|
+
if (moduleName.includes("-")) {
|
|
458
|
+
return [moduleName];
|
|
459
|
+
}
|
|
460
|
+
const matchedDerived = derivedModuleList.filter((candidate) => candidate.startsWith(`${moduleName}-`));
|
|
461
|
+
return matchedDerived.length > 0 ? matchedDerived : [moduleName];
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
return [...new Set([...normalizedConfigured, ...derivedModuleList])];
|
|
465
|
+
};
|
|
466
|
+
|
|
376
467
|
export const buildHardcodedTemplateContent = (
|
|
377
468
|
entryPointName = "loginForm",
|
|
378
469
|
domainKey = null,
|
|
379
470
|
templateType = "form",
|
|
380
471
|
domainSource = null,
|
|
472
|
+
libraryModules = [],
|
|
381
473
|
) => {
|
|
382
474
|
const sharedDomainConfigResolverScript = buildTemplateDomainConfigResolverSnippet();
|
|
383
475
|
const formTemplateContent = `get = function () {
|
|
@@ -385,7 +477,7 @@ export const buildHardcodedTemplateContent = (
|
|
|
385
477
|
model: {},
|
|
386
478
|
tableName: "",
|
|
387
479
|
fields: [],
|
|
388
|
-
functions: {
|
|
480
|
+
functions: {__USE_LIBRARY_BLOCK__
|
|
389
481
|
beforeLoadData: function (id) {
|
|
390
482
|
${sharedDomainConfigResolverScript}
|
|
391
483
|
return new Promise((resolve) => {
|
|
@@ -445,7 +537,7 @@ ${sharedDomainConfigResolverScript}
|
|
|
445
537
|
filter: [],
|
|
446
538
|
buttons: [],
|
|
447
539
|
distinct: false,
|
|
448
|
-
functions: {
|
|
540
|
+
functions: {__USE_LIBRARY_BLOCK__
|
|
449
541
|
beforeLoadData: function () {
|
|
450
542
|
${sharedDomainConfigResolverScript}
|
|
451
543
|
return new Promise((resolve) => {
|
|
@@ -538,7 +630,20 @@ ${sharedDomainConfigResolverScript}
|
|
|
538
630
|
const safeDomainKey = typeof domainKey === "string" && domainKey.trim().length > 0 ? domainKey.trim() : "";
|
|
539
631
|
const safeDomainSource = typeof domainSource === "string" && domainSource.trim().length > 0 ? domainSource.trim() : "";
|
|
540
632
|
const domainResolverExpression = `(() => { const domains = appConfig && appConfig.domains && typeof appConfig.domains === 'object' ? appConfig.domains : null; if (!domains) { return null; } const preferredDomainKey = ${JSON.stringify(safeDomainKey)}; if (preferredDomainKey && domains[preferredDomainKey]) { return domains[preferredDomainKey]; } if (domains.salesOrderDomain) { return domains.salesOrderDomain; } const domainKeys = Object.keys(domains); return domainKeys.length > 0 ? domains[domainKeys[0]] : null; })()`;
|
|
633
|
+
/*
|
|
634
|
+
* ⚠️ Emitted ONLY when there is something to declare, so a library-less domain publishes exactly
|
|
635
|
+
* the template it always did. The list is inlined as JSON rather than computed at runtime: the
|
|
636
|
+
* template is read back by `db.service.loadScript` with `JSON.parse`, which then calls this body
|
|
637
|
+
* through `new Function` — it has no access to the domain config.
|
|
638
|
+
*/
|
|
639
|
+
const safeLibraryModules = Array.isArray(libraryModules)
|
|
640
|
+
? libraryModules.filter((moduleName) => typeof moduleName === "string" && moduleName.trim().length > 0)
|
|
641
|
+
: [];
|
|
642
|
+
const useLibraryBlock =
|
|
643
|
+
safeLibraryModules.length > 0 ? `\n\t\t\tuseLibrary: function () { return ${JSON.stringify(safeLibraryModules)}; },` : "";
|
|
644
|
+
|
|
541
645
|
const resolvedTemplateContent = templateContent
|
|
646
|
+
.replace(/__USE_LIBRARY_BLOCK__/g, useLibraryBlock)
|
|
542
647
|
.replace(/"__ENTRY_POINT_NAME__"/g, JSON.stringify(safeEntryPointName))
|
|
543
648
|
.replace(/"__DOMAIN_SOURCE__"/g, JSON.stringify(safeDomainSource))
|
|
544
649
|
.replace(
|
|
@@ -577,6 +682,11 @@ export const buildPublishBodyFromConfigs = (parsedApplicationConfig, parsedDomai
|
|
|
577
682
|
(name) => String(name ?? "").trim().length > 0,
|
|
578
683
|
),
|
|
579
684
|
);
|
|
685
|
+
/* ⚠️ Derived ONCE per domain, from the domain config this call is publishing — every template it
|
|
686
|
+
generates belongs to that domain, so they all declare the same library set. See
|
|
687
|
+
resolveDomainLibraryModules for why the generated template has to declare it at all. */
|
|
688
|
+
const libraryModules = resolveDomainLibraryModules(safeDomainConfig);
|
|
689
|
+
|
|
580
690
|
let unresolvedTargetIndex = 0;
|
|
581
691
|
const templateFiles = templateTargets.map((target, targetIndex) => {
|
|
582
692
|
const explicitDomainReference = String(target.domainKey ?? "").trim();
|
|
@@ -607,7 +717,27 @@ export const buildPublishBodyFromConfigs = (parsedApplicationConfig, parsedDomai
|
|
|
607
717
|
resolvedDomainKey && domainMap && typeof domainMap === "object" ? String(domainMap?.[resolvedDomainKey]?.source ?? "").trim() : "";
|
|
608
718
|
return {
|
|
609
719
|
name: target.fileName,
|
|
610
|
-
|
|
720
|
+
/*
|
|
721
|
+
* ⚠️⚠️ DID THIS DOMAIN ACTUALLY DECLARE THE ENTRY POINT, or did we guess an owner for it?
|
|
722
|
+
*
|
|
723
|
+
* The guess above walks the other domain keys POSITIONALLY, which is a coin toss once more
|
|
724
|
+
* than two domains are registered. `publishApplicationConfig` merges the passes first-wins,
|
|
725
|
+
* so whichever domain is scanned first imposed its guess on every template name — and a
|
|
726
|
+
* template naming the wrong domain makes the client throw E_ENTRYPOINT_NOT_FOUND, because
|
|
727
|
+
* that domain declares no such entry point. Live on db 3: Order-In and several other
|
|
728
|
+
* screens, owned by `supertailruntime`, were handed to `hr`.
|
|
729
|
+
*
|
|
730
|
+
* Every domain IS scanned, so the one that declares it claims it on its own pass. This flag
|
|
731
|
+
* is what lets the merge prefer that claim over a guess.
|
|
732
|
+
*/
|
|
733
|
+
claimed: configuredEntryPointNames.has(target.entryPointName) && resolvedDomainKey === configuredDomainKey,
|
|
734
|
+
content: buildHardcodedTemplateContent(
|
|
735
|
+
target.entryPointName,
|
|
736
|
+
resolvedDomainKey,
|
|
737
|
+
target.templateType ?? "form",
|
|
738
|
+
resolvedDomainSource,
|
|
739
|
+
libraryModules,
|
|
740
|
+
),
|
|
611
741
|
};
|
|
612
742
|
});
|
|
613
743
|
const fileList = [{ name: "menu.json", content: menuContent }, ...templateFiles];
|
|
@@ -628,7 +758,7 @@ export const publishApplicationConfig = async (payload = {}, argv = {}) => {
|
|
|
628
758
|
if (!appRow?.data) {
|
|
629
759
|
throw new Error(`SYS$CONFIG record ${applicationConfigName}@${applicationConfigVersion} was not found.`);
|
|
630
760
|
}
|
|
631
|
-
|
|
761
|
+
let parsedApplicationConfig = parseConfigData(appRow.data) ?? {};
|
|
632
762
|
|
|
633
763
|
/* An app bundle publish is a full replace (addApp() rebuilds the whole bundle from scratch every call), so
|
|
634
764
|
template.js scaffolding must be generated for EVERY domain registered in application.js's `domains` map, not
|
|
@@ -653,10 +783,28 @@ export const publishApplicationConfig = async (payload = {}, argv = {}) => {
|
|
|
653
783
|
}
|
|
654
784
|
|
|
655
785
|
const mergedFileList = [];
|
|
786
|
+
/* domainKey -> the menu entries that domain contributes this publish (§3.3). */
|
|
787
|
+
const domainNavigation = new Map();
|
|
788
|
+
/* Parsed once in the collect pass, reused in the build pass. */
|
|
789
|
+
const scannedDomains = [];
|
|
656
790
|
const seenFileNames = new Set();
|
|
791
|
+
/*
|
|
792
|
+
* ⚠️ FIRST-WINS, EXCEPT THAT A CLAIM BEATS A GUESS. A domain that declares the entry point knows
|
|
793
|
+
* it owns the template; a domain that merely got handed it by the positional fallback does not.
|
|
794
|
+
* Without this, the domain scanned FIRST decided every template's owner and the screens it does
|
|
795
|
+
* not declare answered E_ENTRYPOINT_NOT_FOUND in the browser.
|
|
796
|
+
*/
|
|
657
797
|
const addFilesToMergedList = (fileList) => {
|
|
658
798
|
for (const file of fileList) {
|
|
659
799
|
if (seenFileNames.has(file.name)) {
|
|
800
|
+
if (!file.claimed) {
|
|
801
|
+
continue;
|
|
802
|
+
}
|
|
803
|
+
const existingIndex = mergedFileList.findIndex((entry) => entry.name === file.name);
|
|
804
|
+
if (existingIndex < 0 || mergedFileList[existingIndex].claimed) {
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
807
|
+
mergedFileList[existingIndex] = file;
|
|
660
808
|
continue;
|
|
661
809
|
}
|
|
662
810
|
seenFileNames.add(file.name);
|
|
@@ -667,15 +815,70 @@ export const publishApplicationConfig = async (payload = {}, argv = {}) => {
|
|
|
667
815
|
if (domainsToScan.size === 0) {
|
|
668
816
|
addFilesToMergedList(buildPublishBodyFromConfigs(parsedApplicationConfig, {}, "").fileList);
|
|
669
817
|
} else {
|
|
818
|
+
/*
|
|
819
|
+
* ⚠️ TWO PASSES, AND THE ORDER IS THE WHOLE POINT. The menu the CLIENT reads is generated
|
|
820
|
+
* INTO THE BUNDLE by buildPublishBodyFromConfigs, so any domain menu folded in after that is a
|
|
821
|
+
* publish behind — the row is right and the screen still has no way to be reached. Found live:
|
|
822
|
+
* `hr: 2` in the log, `HR_ROSTER` in APPLICATION_CONFIG, and no such item in the menu.
|
|
823
|
+
*
|
|
824
|
+
* So: collect first, merge, then build the files from the MERGED navigation. The template a
|
|
825
|
+
* leaf points at is generated in this same bundle, so a link can never outrun its screen.
|
|
826
|
+
*/
|
|
670
827
|
for (const [domainKey, { domainConfigName, domainConfigVersion }] of domainsToScan) {
|
|
671
828
|
const domainRows = await readConfigRows(domainConfigName, domainConfigVersion, config);
|
|
672
829
|
const domainRow = domainRows?.[0];
|
|
673
830
|
const parsedDomainConfig = domainRow?.data ? (parseConfigData(domainRow.data) ?? {}) : {};
|
|
831
|
+
scannedDomains.push([domainKey, parsedDomainConfig]);
|
|
832
|
+
|
|
833
|
+
/*
|
|
834
|
+
* Dok. 5 §3.3 / §4.2 — a Layer 3 domain contributing its OWN menus, gated by its own
|
|
835
|
+
* activation setting. Without this a domain could declare screens and still have no way to
|
|
836
|
+
* be reached, and the activation setting drove nothing.
|
|
837
|
+
*
|
|
838
|
+
* ⚠️ READ, NEVER EXECUTED. The activating set lives in the domain's table, which this file
|
|
839
|
+
* must not know about — so the domain NAMES the table and column and the read is generic.
|
|
840
|
+
* No domain name and no domain code appear here, which is what keeps the golden rule.
|
|
841
|
+
*
|
|
842
|
+
* ⚠️ Fails SOFT: a domain that declares nothing, or whose setting cannot be read, simply
|
|
843
|
+
* contributes nothing. A publish must not fail because one domain's settings row is bad.
|
|
844
|
+
*/
|
|
845
|
+
const declaredMenus = parsedDomainConfig?.navigation?.menus;
|
|
846
|
+
if (Array.isArray(declaredMenus) && declaredMenus.length > 0) {
|
|
847
|
+
try {
|
|
848
|
+
const activeModules = await readActiveModules({
|
|
849
|
+
activation: parsedDomainConfig?.navigation?.activation,
|
|
850
|
+
exec: execPostgres,
|
|
851
|
+
dbIndex: Number(config?.dbindex ?? config?.dbIndex),
|
|
852
|
+
});
|
|
853
|
+
domainNavigation.set(domainKey, selectActiveDomainMenus({ menus: declaredMenus, activeModules }));
|
|
854
|
+
} catch (error) {
|
|
855
|
+
logger.warn(
|
|
856
|
+
`[navigation] ${domainKey}: could not resolve its menus, so it contributes none: ` +
|
|
857
|
+
`${error?.message ?? error}`,
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/* Fold the domains' menus in BEFORE the bundle is built from this config. */
|
|
864
|
+
if (domainNavigation.size > 0) {
|
|
865
|
+
let navigation = parsedApplicationConfig?.navigation ?? {};
|
|
866
|
+
for (const [domainKey, menus] of domainNavigation) {
|
|
867
|
+
navigation = mergeDomainNavigation({ applicationConfig: { navigation }, domainKey, menus });
|
|
868
|
+
}
|
|
869
|
+
parsedApplicationConfig = { ...parsedApplicationConfig, navigation };
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
for (const [domainKey, parsedDomainConfig] of scannedDomains) {
|
|
674
873
|
addFilesToMergedList(buildPublishBodyFromConfigs(parsedApplicationConfig, parsedDomainConfig, domainKey).fileList);
|
|
675
874
|
}
|
|
676
875
|
}
|
|
677
876
|
|
|
678
|
-
|
|
877
|
+
/* `claimed` is merge bookkeeping, not part of the bundle contract — strip it before it ships. */
|
|
878
|
+
const publishBody = {
|
|
879
|
+
verbose: false,
|
|
880
|
+
fileList: mergedFileList.map(({ name, content }) => ({ name, content })),
|
|
881
|
+
};
|
|
679
882
|
|
|
680
883
|
const response = await publishApp(
|
|
681
884
|
{
|
|
@@ -687,6 +890,35 @@ export const publishApplicationConfig = async (payload = {}, argv = {}) => {
|
|
|
687
890
|
if (!response || response.success !== true) {
|
|
688
891
|
throw new Error(response?.error ? String(response.error) : "Application Config publish failed without an error message from the server.");
|
|
689
892
|
}
|
|
893
|
+
|
|
894
|
+
/*
|
|
895
|
+
* ⚠️ AFTER the bundle publish, not before: a domain's leaf must not appear in the menu until the
|
|
896
|
+
* screen it opens is actually in the bundle. The other order produces a dead link for exactly as
|
|
897
|
+
* long as a publish takes, and permanently if the publish then fails.
|
|
898
|
+
*
|
|
899
|
+
* ⚠️ And the merge is additive + self-healing per domain (see domainNavigation.js), because
|
|
900
|
+
* APPLICATION_CONFIG has more than one writer and a wholesale replace has wiped the other one's
|
|
901
|
+
* work before.
|
|
902
|
+
*/
|
|
903
|
+
if (domainNavigation.size > 0) {
|
|
904
|
+
try {
|
|
905
|
+
/* Already merged above, so the stored row and the bundle's menu are the same thing — which
|
|
906
|
+
is the only arrangement in which the two cannot drift. */
|
|
907
|
+
await saveConfigRow(
|
|
908
|
+
applicationConfigName,
|
|
909
|
+
applicationConfigVersion,
|
|
910
|
+
`get = function () { return ${JSON.stringify(parsedApplicationConfig)}; }`,
|
|
911
|
+
config,
|
|
912
|
+
);
|
|
913
|
+
const counts = [...domainNavigation].map(([key, menus]) => `${key}: ${menus.length}`).join(", ");
|
|
914
|
+
logger.info(`[navigation] domain menus published (${counts})`);
|
|
915
|
+
} catch (error) {
|
|
916
|
+
/* The bundle is already published and correct; a menu that did not update is a re-publish
|
|
917
|
+
away, so this must not turn a successful publish into a failure. */
|
|
918
|
+
logger.warn(`[navigation] domain menus could not be merged: ${error?.message ?? error}`);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
690
922
|
return response;
|
|
691
923
|
};
|
|
692
924
|
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* A Layer 3 domain contributing its own navigation.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ WHY THIS EXISTS. `buildMenuStructureFromApplicationConfig` reads `APPLICATION_CONFIG.navigation`
|
|
5
|
+
* and nothing else, so a domain could declare entities, screens, settings and server code and still
|
|
6
|
+
* have no way to be REACHED — the host had to hand-declare every leaf. That also made Doc 5 §4.2's
|
|
7
|
+
* "runtime publish emits the menu tree from the activated set" impossible: the activation setting
|
|
8
|
+
* drove nothing at all.
|
|
9
|
+
*
|
|
10
|
+
* §3.3 names the mechanism: "Navigation entries carry `domain: 'hr'` and are emitted at runtime
|
|
11
|
+
* publish according to which submodules are active."
|
|
12
|
+
*
|
|
13
|
+
* ⚠️ THE ACTIVATION IS READ FROM DATA, NOT EXECUTED. The activating set lives in the DOMAIN's own
|
|
14
|
+
* table, which this file must not know about — so the domain NAMES the table and column in its config
|
|
15
|
+
* and the publish reads it generically. No domain name, no domain code, nothing app-specific here.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/* The route shape the client's menu component renders, identical to a hand-declared leaf. */
|
|
19
|
+
const linkFor = (entry) => ["./form", String(entry?.entryPoint ?? "").trim(), null];
|
|
20
|
+
|
|
21
|
+
/*
|
|
22
|
+
* Which of a domain's declared entries are live right now.
|
|
23
|
+
*
|
|
24
|
+
* ⚠️ AN ENTRY WITH NO `module` IS ALWAYS EMITTED — §4.2's design-time menu, which exists whatever is
|
|
25
|
+
* activated. An entry WITH one appears only while that module is active.
|
|
26
|
+
*
|
|
27
|
+
* ⚠️ AND AN UNREADABLE ACTIVATION LIST ACTIVATES NOTHING. Failing open would hand a client every
|
|
28
|
+
* screen the moment a settings row went bad, which is the opposite of what a gate is for.
|
|
29
|
+
*/
|
|
30
|
+
export const selectActiveDomainMenus = ({ menus, activeModules } = {}) => {
|
|
31
|
+
const declared = Array.isArray(menus) ? menus : [];
|
|
32
|
+
const active = new Set(Array.isArray(activeModules) ? activeModules.map((m) => String(m)) : []);
|
|
33
|
+
return declared.filter((entry) => {
|
|
34
|
+
const gate = entry?.module;
|
|
35
|
+
if (gate === null || gate === undefined || String(gate).trim() === "") return true;
|
|
36
|
+
return active.has(String(gate));
|
|
37
|
+
});
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/*
|
|
41
|
+
* Fold a domain's live entries into the host's navigation.
|
|
42
|
+
*
|
|
43
|
+
* ⚠️ ADDITIVE AND SELF-HEALING, which is the lesson APPLICATION_CONFIG's two writers already taught
|
|
44
|
+
* once: a publish that replaces the whole navigation wipes whatever the other writer added. So the
|
|
45
|
+
* host's own leaves are never touched, and neither are another domain's.
|
|
46
|
+
*
|
|
47
|
+
* ⚠️ AND IT STRIPS THIS DOMAIN'S PREVIOUS LEAVES FIRST. That is what the `domain` stamp is for, and
|
|
48
|
+
* it is what makes deactivation real: §4.2's stated failure is that "switching one off would leave
|
|
49
|
+
* its tile behind", which would make activation advisory — the setting would look like it did
|
|
50
|
+
* something while the tile still opened. Injecting without stripping would also accumulate duplicates
|
|
51
|
+
* that nothing could clear.
|
|
52
|
+
*/
|
|
53
|
+
export const mergeDomainNavigation = ({ applicationConfig, domainKey, menus } = {}) => {
|
|
54
|
+
const key = String(domainKey ?? "").trim();
|
|
55
|
+
const navigation = applicationConfig?.navigation ?? {};
|
|
56
|
+
const sourceMenus = Array.isArray(navigation.menus) ? navigation.menus : [];
|
|
57
|
+
const entries = Array.isArray(menus) ? menus : [];
|
|
58
|
+
|
|
59
|
+
/* Everything that is NOT this domain's, group structure preserved. */
|
|
60
|
+
const kept = sourceMenus.map((group) => ({
|
|
61
|
+
...group,
|
|
62
|
+
subMenu: (Array.isArray(group?.subMenu) ? group.subMenu : []).filter(
|
|
63
|
+
(leaf) => String(leaf?.domain ?? "") !== key,
|
|
64
|
+
),
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
for (const entry of entries) {
|
|
68
|
+
const groupCaption = String(entry?.group ?? "").trim();
|
|
69
|
+
if (!groupCaption || !entry?.entryPoint) continue;
|
|
70
|
+
|
|
71
|
+
let group = kept.find((g) => String(g?.caption ?? "") === groupCaption);
|
|
72
|
+
if (!group) {
|
|
73
|
+
group = { caption: groupCaption, link: [], subMenu: [] };
|
|
74
|
+
kept.push(group);
|
|
75
|
+
}
|
|
76
|
+
if (!Array.isArray(group.subMenu)) group.subMenu = [];
|
|
77
|
+
|
|
78
|
+
group.subMenu.push({
|
|
79
|
+
caption: String(entry.caption ?? entry.entryPoint),
|
|
80
|
+
menuKey: String(entry.menuKey ?? entry.entryPoint),
|
|
81
|
+
link: linkFor(entry),
|
|
82
|
+
/* §3.3's stamp: what the next publish uses to find exactly its own leaves. */
|
|
83
|
+
domain: key,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/* A group that existed only to hold this domain's leaves, and now holds none, goes with them —
|
|
88
|
+
an empty group renders as a dead parent nobody can open. The host's own empty groups are left
|
|
89
|
+
alone, because they were not this publish's to remove. */
|
|
90
|
+
const hadDomainLeafOnly = (group) =>
|
|
91
|
+
(group?.subMenu ?? []).length === 0 &&
|
|
92
|
+
!sourceMenus.some(
|
|
93
|
+
(original) =>
|
|
94
|
+
String(original?.caption ?? "") === String(group?.caption ?? "") &&
|
|
95
|
+
(original?.subMenu ?? []).some((leaf) => String(leaf?.domain ?? "") !== key),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
return { ...navigation, menus: kept.filter((group) => !hadDomainLeafOnly(group)) };
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/*
|
|
102
|
+
* Read a domain's activation setting through the coordinates IT declares.
|
|
103
|
+
*
|
|
104
|
+
* ⚠️ Returns an empty list for anything it cannot read — a missing declaration, a missing row, a value
|
|
105
|
+
* that is not a JSON array. See `selectActiveDomainMenus`: the gate fails closed.
|
|
106
|
+
*/
|
|
107
|
+
export const readActiveModules = async ({ activation, exec, dbIndex } = {}) => {
|
|
108
|
+
const entity = String(activation?.entity ?? "").trim();
|
|
109
|
+
const keyColumn = String(activation?.keyColumn ?? "").trim();
|
|
110
|
+
const valueColumn = String(activation?.valueColumn ?? "").trim();
|
|
111
|
+
const setting = String(activation?.setting ?? "").trim();
|
|
112
|
+
if (!entity || !keyColumn || !valueColumn || !setting) return [];
|
|
113
|
+
|
|
114
|
+
/* Identifiers come from a published domain config, but they are still interpolated — so anything
|
|
115
|
+
that is not a plain identifier is refused rather than quoted into the statement. */
|
|
116
|
+
const identifier = /^[A-Za-z_][A-Za-z0-9_$]*$/;
|
|
117
|
+
if (![entity, keyColumn, valueColumn].every((name) => identifier.test(name))) return [];
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const rows = await exec(
|
|
121
|
+
`SELECT ${valueColumn} AS value FROM ${entity} WHERE ${keyColumn} = '${setting.replace(/'/g, "''")}'`,
|
|
122
|
+
dbIndex,
|
|
123
|
+
);
|
|
124
|
+
const raw = rows?.[0]?.value;
|
|
125
|
+
if (raw === null || raw === undefined) return [];
|
|
126
|
+
const parsed = JSON.parse(String(raw));
|
|
127
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
128
|
+
} catch {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
};
|
|
@@ -98,3 +98,31 @@ export const createDomainConfigLoader = ({ exec, dbIndex }) => async () => {
|
|
|
98
98
|
}
|
|
99
99
|
return parsed;
|
|
100
100
|
};
|
|
101
|
+
|
|
102
|
+
/*
|
|
103
|
+
* ⚠️⚠️ Doc 6 Q5 — A CHEAP FINGERPRINT OF EVERYTHING THE DECLARATIONS ARE BUILT FROM.
|
|
104
|
+
*
|
|
105
|
+
* The entity-config cache is per THREAD and was read exactly once per thread, so a publish reached a
|
|
106
|
+
* running hub only by restarting it. `ensureEntityConfig` re-checks this fingerprint on a timer and
|
|
107
|
+
* re-primes when it moves.
|
|
108
|
+
*
|
|
109
|
+
* ⚠️ NOT `max(id)`. `sys$config` is UPDATED IN PLACE when name+version match, so republishing a
|
|
110
|
+
* domain at the SAME version — the commonest thing anyone does while iterating — leaves the highest
|
|
111
|
+
* id untouched. Verified on db 3: 12 rows, max id 14, `supertailsetup` present twice, one row per
|
|
112
|
+
* version. Only the content answers honestly.
|
|
113
|
+
*
|
|
114
|
+
* ⚠️ The hashing happens IN POSTGRES and only the 32-char digest crosses the wire; the configs
|
|
115
|
+
* themselves are hundreds of KB and must not be shipped every few seconds just to be compared.
|
|
116
|
+
*
|
|
117
|
+
* Returns null when the table cannot be read, which the caller treats as "do not change anything".
|
|
118
|
+
*/
|
|
119
|
+
export const readConfigGeneration = async ({ exec, dbIndex }) => {
|
|
120
|
+
const rows = await exec(
|
|
121
|
+
`SELECT md5(string_agg(id || ':' || name || ':' || version || ':' || md5(coalesce(data, '')), '|' ORDER BY id)) AS generation FROM ${PG_CONFIG_TABLE}`,
|
|
122
|
+
dbIndex,
|
|
123
|
+
);
|
|
124
|
+
const generation = rows?.[0]?.generation ?? rows?.[0]?.GENERATION ?? null;
|
|
125
|
+
/* An empty sys$config aggregates to NULL; a stable sentinel keeps "empty" distinguishable from
|
|
126
|
+
"unreadable", which is the difference between leaving the cache alone and refreshing it. */
|
|
127
|
+
return generation == null ? "empty" : String(generation);
|
|
128
|
+
};
|
|
@@ -27,10 +27,37 @@
|
|
|
27
27
|
* `entityConfig.js`, and closing that cycle inside the ORM once left `createExecutor` uninitialised.
|
|
28
28
|
*/
|
|
29
29
|
import { primeEntityConfig } from "../orm/entityConfig.js";
|
|
30
|
-
import { createDomainConfigLoader } from "./entityConfigLoader.js";
|
|
30
|
+
import { createDomainConfigLoader, readConfigGeneration } from "./entityConfigLoader.js";
|
|
31
31
|
import { execPostgres, isPostgresIndex } from "./dialect.js";
|
|
32
32
|
import { logger } from "../../logger.js";
|
|
33
33
|
|
|
34
|
+
/*
|
|
35
|
+
* ⚠️⚠️ Doc 6 Q5 — HOW OFTEN A THREAD ASKS WHETHER THE DECLARATIONS MOVED.
|
|
36
|
+
*
|
|
37
|
+
* `resetEntityConfig` had no caller outside tests, so each thread read the declarations once and
|
|
38
|
+
* kept them: the hub's main thread until restart, a worker for the life of the thread. A publish
|
|
39
|
+
* that added or changed a declaration simply did not arrive, and the staleness is SILENT — the same
|
|
40
|
+
* five fallbacks described above, taken because the cache is OLD rather than empty. A newly
|
|
41
|
+
* soft-delete entity was PHYSICALLY deleted; a newly tenanted table was neither filtered nor stamped.
|
|
42
|
+
*
|
|
43
|
+
* The check is one small digest query, so this bounds staleness to seconds rather than to a restart.
|
|
44
|
+
* It is not zero-cost, which is why it is a TTL and not a per-request read.
|
|
45
|
+
*/
|
|
46
|
+
export const CONFIG_GENERATION_TTL_MS = 5000;
|
|
47
|
+
|
|
48
|
+
/*
|
|
49
|
+
* Called with the dbIndex after a refresh ACTUALLY changed the declarations.
|
|
50
|
+
*
|
|
51
|
+
* ⚠️ It exists for one specific reason. `bin/hub.js` installs the RLS context applier ONCE, right
|
|
52
|
+
* after the first prime, gated on `isTenancyDeclared`. An app that declares tenancy for the first
|
|
53
|
+
* time in a publish would refresh this cache and still have no applier — tenancy declared and NOT
|
|
54
|
+
* enforced, which is quieter and worse than the stale cache this fixes.
|
|
55
|
+
*/
|
|
56
|
+
let onRefresh = null;
|
|
57
|
+
export const setEntityConfigRefreshHook = (hook) => {
|
|
58
|
+
onRefresh = typeof hook === "function" ? hook : null;
|
|
59
|
+
};
|
|
60
|
+
|
|
34
61
|
/*
|
|
35
62
|
* ⚠️ PROMISES, not booleans. A BPM agent runs effects continuously and they all enter through one
|
|
36
63
|
* `run`; several can be in flight inside a single worker at once. Recording "started" as a promise
|
|
@@ -51,8 +78,29 @@ const degrade = (dbIndex, reason) => {
|
|
|
51
78
|
);
|
|
52
79
|
};
|
|
53
80
|
|
|
81
|
+
/* dbIndex -> { generation, checkedAt } for the copy this thread currently holds. */
|
|
82
|
+
const freshness = new Map();
|
|
83
|
+
/* dbIndex -> in-flight freshness check, so concurrent callers share one digest query. */
|
|
84
|
+
const checking = new Map();
|
|
85
|
+
|
|
54
86
|
const primeOnce = async (dbIndex) => {
|
|
55
87
|
try {
|
|
88
|
+
/*
|
|
89
|
+
* ⚠️ THE FINGERPRINT IS READ BEFORE THE CONFIGS, NEVER AFTER. A publish landing between the
|
|
90
|
+
* two reads must cost an EXTRA refresh, never a missed one — taking it afterwards would
|
|
91
|
+
* record a fingerprint newer than the data just read, and that change would stay invisible
|
|
92
|
+
* until something else happened to publish.
|
|
93
|
+
*
|
|
94
|
+
* ⚠️ And it must not be fatal: a digest we cannot read is no reason to skip priming. Recording
|
|
95
|
+
* `null` simply means the next check has nothing to compare against and refreshes.
|
|
96
|
+
*/
|
|
97
|
+
let generation = null;
|
|
98
|
+
try {
|
|
99
|
+
generation = await readConfigGeneration({ exec: execPostgres, dbIndex });
|
|
100
|
+
} catch {
|
|
101
|
+
generation = null;
|
|
102
|
+
}
|
|
103
|
+
|
|
56
104
|
const loadDomainConfigs = createDomainConfigLoader({
|
|
57
105
|
exec: execPostgres,
|
|
58
106
|
dbIndex,
|
|
@@ -63,7 +111,10 @@ const primeOnce = async (dbIndex) => {
|
|
|
63
111
|
* try/catch therefore treats a FAILED read as a successful one and memoises the failure
|
|
64
112
|
* forever, which is worse than not caching at all. The boolean is the only honest signal.
|
|
65
113
|
*/
|
|
66
|
-
if (await primeEntityConfig({ dbIndex, loadDomainConfigs }))
|
|
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
|
-
|
|
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
|
+
};
|
package/engine/orm/rlsPolicy.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 ${
|
|
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,
|
|
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. */
|
package/engine/orm/tenantApi.js
CHANGED
|
@@ -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
|
|
351
|
+
`WHERE ${target} AND ut.ACTIVE ` +
|
|
334
352
|
"RETURNING ut.USER_ID",
|
|
335
353
|
)) ?? [];
|
|
336
354
|
for (const user of new Set(rows.map((row) => row?.user_id ?? row?.USER_ID))) {
|
|
@@ -50,6 +50,29 @@ export const assertInterval = (value, what = "grace") => {
|
|
|
50
50
|
|
|
51
51
|
/* An instant, as a literal. Refused unless it actually parses — same reasoning as the interval. */
|
|
52
52
|
export const assertInstant = (value, what = "at") => {
|
|
53
|
+
/*
|
|
54
|
+
* ⚠⚠ A DATE OBJECT IS WHAT THE DRIVER HANDS BACK, and it used to pass this guard and then
|
|
55
|
+
* break the statement. `Date.parse` accepts a JS Date's own toString() — "Tue Sep 15 2026
|
|
56
|
+
* 08:00:00 GMT+0700 (Western Indonesia Time)" — which was then interpolated verbatim, and
|
|
57
|
+
* PostgreSQL refused it with `time zone "gmt+0700" not recognized`.
|
|
58
|
+
*
|
|
59
|
+
* So the value was CERTIFIED and still unusable, which is the worst thing a validator can do.
|
|
60
|
+
* Found the first time the §10 bridge ran against real rows: every timestamp it carries comes
|
|
61
|
+
* straight out of a `timestamp` column, so every assignment it issued would have failed.
|
|
62
|
+
*
|
|
63
|
+
* ⚠️ Only a Date is converted. A naive STRING keeps its passthrough, because parsing it here
|
|
64
|
+
* would move its interpretation from the SERVER's timezone to this process's — a silent shift in
|
|
65
|
+
* meaning for every existing caller.
|
|
66
|
+
*/
|
|
67
|
+
if (value instanceof Date) {
|
|
68
|
+
if (Number.isNaN(value.getTime())) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`${what} must be a timestamp — refused an Invalid Date rather than interpolating it ` +
|
|
71
|
+
"into a statement (Doc 4 §7.1).",
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return value.toISOString();
|
|
75
|
+
}
|
|
53
76
|
const text = String(value ?? "").trim();
|
|
54
77
|
if (text === "" || Number.isNaN(Date.parse(text))) {
|
|
55
78
|
throw new Error(
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Doc 6 §8.3 — which usergroups a VERIFIED caller holds.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ WHY THIS IS PLATFORM CODE. `hrextensionlib.maySupervise` compares `hr_config.rights.dutyExtend`
|
|
5
|
+
* against the groups its actor holds, and Doc 5 §1–2 forbids a Layer 3 domain from touching any
|
|
6
|
+
* `sys$` table — its own tests enforce that. So the domain states the right it needs and the platform
|
|
7
|
+
* answers who holds what; the script receives names and never learns where they came from.
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ IT ANSWERS ONLY FOR AN ALREADY-VERIFIED USER ID. Nothing here authenticates anybody: the id
|
|
10
|
+
* comes from inside a signature (security/internalPrincipal.js), and handing this a caller-supplied
|
|
11
|
+
* id would let anyone enumerate somebody else's rights.
|
|
12
|
+
*/
|
|
13
|
+
import { logger } from "../../logger.js";
|
|
14
|
+
|
|
15
|
+
const MEMBERSHIP_TABLE = "sys$ugmember";
|
|
16
|
+
const GROUP_TABLE = "sys$usergroup";
|
|
17
|
+
|
|
18
|
+
/*
|
|
19
|
+
* The statement, or null when the id is not a plain number.
|
|
20
|
+
*
|
|
21
|
+
* ⚠️ NUMERIC OR NOTHING. This id is interpolated, so a value that is not a number must never reach
|
|
22
|
+
* the statement — returning null is the fail-closed answer, and the caller turns it into "no groups".
|
|
23
|
+
*/
|
|
24
|
+
export const buildUserGroupsSql = (userId) => {
|
|
25
|
+
/* ⚠️ Reject the empties BEFORE coercing: Number(null) and Number([]) are both 0, a perfectly
|
|
26
|
+
valid-looking integer that would build a statement asking about user zero. */
|
|
27
|
+
if (userId === null || userId === undefined || userId === "") return null;
|
|
28
|
+
if (typeof userId === "object" || typeof userId === "boolean") return null;
|
|
29
|
+
const id = Number(userId);
|
|
30
|
+
if (!Number.isInteger(id)) return null;
|
|
31
|
+
return (
|
|
32
|
+
`SELECT g.group_name FROM ${MEMBERSHIP_TABLE} m ` +
|
|
33
|
+
`INNER JOIN ${GROUP_TABLE} g ON g.id = m."sys$usergroup_id" ` +
|
|
34
|
+
`WHERE m.user_id = ${id}`
|
|
35
|
+
);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/*
|
|
39
|
+
* The group names this user holds, or [].
|
|
40
|
+
*
|
|
41
|
+
* ⚠️ FAIL-CLOSED IN EVERY DIRECTION. An unreadable table, an odd row shape, a missing id — all yield
|
|
42
|
+
* NO groups. A rights check reading an empty list refuses, which is the safe outcome; a read that
|
|
43
|
+
* failed open would hand out the supervisor right on a database hiccup.
|
|
44
|
+
*/
|
|
45
|
+
export const resolveUserGroups = async ({ exec, dbIndex, userId }) => {
|
|
46
|
+
const sql = buildUserGroupsSql(userId);
|
|
47
|
+
if (!sql || typeof exec !== "function") return [];
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const rows = await exec(sql, dbIndex);
|
|
51
|
+
if (!Array.isArray(rows)) return [];
|
|
52
|
+
return rows
|
|
53
|
+
.map((row) => String(row?.group_name ?? row?.GROUP_NAME ?? "").trim())
|
|
54
|
+
.filter((name) => name.length > 0);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
logger.warn(
|
|
57
|
+
`[principal] could not read usergroups for user ${userId}: ${error?.message ?? error}. ` +
|
|
58
|
+
"Treating the caller as holding none, so any right checked against them is refused.",
|
|
59
|
+
);
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
};
|
package/package.json
CHANGED
package/scheduler/datalib.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
package/worker/cliWorkerPool.js
CHANGED
|
@@ -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
|
-
|
|
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") {
|