biz-a-cli 2.3.80-15365 → 2.3.80-15372
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 +1 -0
- package/bin/deleteApp.js +1 -0
- package/bin/hub.js +1 -4
- package/bin/index.js +1 -0
- package/bin/proxy.js +1 -0
- package/bin/uploadApp.js +1 -0
- package/bin/watcher.js +1 -0
- package/engine/domain/appConfig.js +48 -0
- package/engine/domain/domainNavigation.js +19 -1
- package/engine/domain/numericScale.js +113 -0
- package/engine/ext/live.js +20 -0
- package/engine/orm/apiRoute.js +69 -0
- package/engine/orm/connection.js +51 -0
- package/engine/orm/dialect.js +65 -0
- package/engine/orm/executeBlock.js +96 -3
- package/engine/orm/executor.js +6 -1
- package/engine/orm/tenantApi.js +20 -2
- package/engine/orm/userGroups.js +39 -0
- package/envs/defaultEnv.js +3 -0
- package/logger.js +13 -2
- package/package.json +1 -1
- package/scheduler/datalib.js +9 -0
- package/worker/cliScriptWorker.js +9 -1
package/bin/app.js
CHANGED
package/bin/deleteApp.js
CHANGED
package/bin/hub.js
CHANGED
package/bin/index.js
CHANGED
package/bin/proxy.js
CHANGED
package/bin/uploadApp.js
CHANGED
package/bin/watcher.js
CHANGED
|
@@ -9,6 +9,11 @@ import {
|
|
|
9
9
|
findRawColorsInStyles,
|
|
10
10
|
describeRawColorFindings,
|
|
11
11
|
} from "./styleColors.js";
|
|
12
|
+
import {
|
|
13
|
+
findImplicitNumericScale,
|
|
14
|
+
describeImplicitScaleFindings,
|
|
15
|
+
selectScaleRisks,
|
|
16
|
+
} from "./numericScale.js";
|
|
12
17
|
import { json2List } from "../orm/index.js";
|
|
13
18
|
import { toDbName } from "./naming.js";
|
|
14
19
|
import { logger } from "../../logger.js";
|
|
@@ -3242,6 +3247,49 @@ export const publishConfigs = async (payload = {}, argv = {}) => {
|
|
|
3242
3247
|
);
|
|
3243
3248
|
}
|
|
3244
3249
|
|
|
3250
|
+
/*
|
|
3251
|
+
* ⚠️⚠️ WHICH `number` COLUMNS LET A DISPLAY HINT CHOOSE THEIR STORAGE PRECISION.
|
|
3252
|
+
*
|
|
3253
|
+
* `resolveColumnType` takes a column's scale from `uiIntent` when none is declared: 'currency'
|
|
3254
|
+
* gives 2, anything else gives 0. In Supertail that made `st_order`'s header BIGINT while the
|
|
3255
|
+
* order LINES it sums stayed NUMERIC(18,2), and made `tax_rate` unable to hold 11.5%.
|
|
3256
|
+
*
|
|
3257
|
+
* ⚠️ REPORT-ONLY, and scanned here beside the Style colours for the same reason: 204 `number`
|
|
3258
|
+
* attributes across biz-a-template declare no scale at all, so refusing a publish would make
|
|
3259
|
+
* the platform un-upgradable for every application that has the problem. The countable list is
|
|
3260
|
+
* the deliverable.
|
|
3261
|
+
*
|
|
3262
|
+
* ⚠️ Its own try/catch: a warning must never be the thing that fails a publish.
|
|
3263
|
+
*/
|
|
3264
|
+
try {
|
|
3265
|
+
const implicitScale = findImplicitNumericScale(parseDomainConfig(domainConfigContent));
|
|
3266
|
+
const scaleRisks = selectScaleRisks(implicitScale);
|
|
3267
|
+
/*
|
|
3268
|
+
* ⚠️ ONLY THE RISKS ARE LISTED. The raw scan finds 158 columns in one real config and most
|
|
3269
|
+
* are legitimately whole (sequence_no, timeout_minutes, foreign keys) — printing them all
|
|
3270
|
+
* is how a check gets switched off. The rest is a count, so the number is never lost.
|
|
3271
|
+
*/
|
|
3272
|
+
if (scaleRisks.length > 0) {
|
|
3273
|
+
logger.warn(
|
|
3274
|
+
`[AppConfig Publish] ${scaleRisks.length} money/rate column(s) in ` +
|
|
3275
|
+
`${domainName}@${domainVersion} resolve to a WHOLE-NUMBER type because no \`scale\` ` +
|
|
3276
|
+
"is declared and uiIntent decides it (REPORT ONLY). These cannot hold cents or a " +
|
|
3277
|
+
"fractional rate:",
|
|
3278
|
+
);
|
|
3279
|
+
describeImplicitScaleFindings(scaleRisks).forEach((line) => logger.warn(line));
|
|
3280
|
+
}
|
|
3281
|
+
if (implicitScale.length > 0) {
|
|
3282
|
+
logger.warn(
|
|
3283
|
+
`[AppConfig Publish] (${implicitScale.length} numeric column(s) in total take their ` +
|
|
3284
|
+
"scale from uiIntent rather than declaring it.)",
|
|
3285
|
+
);
|
|
3286
|
+
}
|
|
3287
|
+
} catch (error) {
|
|
3288
|
+
logger.warn(
|
|
3289
|
+
`[AppConfig Publish] could not scan numeric scales: ${error?.message ?? error}`,
|
|
3290
|
+
);
|
|
3291
|
+
}
|
|
3292
|
+
|
|
3245
3293
|
/*
|
|
3246
3294
|
* Doc 3 §11 — resolve the tenancy declaration across EVERY published domain, not just this one.
|
|
3247
3295
|
*
|
|
@@ -71,7 +71,25 @@ export const mergeDomainNavigation = ({ applicationConfig, domainKey, menus } =
|
|
|
71
71
|
let group = kept.find((g) => String(g?.caption ?? "") === groupCaption);
|
|
72
72
|
if (!group) {
|
|
73
73
|
group = { caption: groupCaption, link: [], subMenu: [] };
|
|
74
|
-
|
|
74
|
+
/*
|
|
75
|
+
* ⚠️ WHERE a group the host does not have is placed. Appending is fine for one stray leaf
|
|
76
|
+
* and wrong for a domain that owns a whole group: "HR, beside Setup" appended after every
|
|
77
|
+
* group the host emits is a group at the BOTTOM of the menu, which is not what was asked
|
|
78
|
+
* for and not where anyone looks.
|
|
79
|
+
*
|
|
80
|
+
* ⚠️ ON CREATION ONLY. Once the group exists, `kept.find` above keeps its position, and so
|
|
81
|
+
* does the host's own injector, which concats into a group it finds rather than
|
|
82
|
+
* re-appending. Every entry of a multi-screen domain therefore carries the same hint and
|
|
83
|
+
* only the first one acts on it.
|
|
84
|
+
*
|
|
85
|
+
* ⚠️ AN ANCHOR THE HOST DOES NOT HAVE APPENDS. Declining to place the group would hide
|
|
86
|
+
* every screen the domain owns — far worse than placing it last, and a domain cannot know
|
|
87
|
+
* what groups its host happens to emit.
|
|
88
|
+
*/
|
|
89
|
+
const anchor = String(entry?.groupAfter ?? "").trim();
|
|
90
|
+
const at = anchor ? kept.findIndex((g) => String(g?.caption ?? "") === anchor) : -1;
|
|
91
|
+
if (at >= 0) kept.splice(at + 1, 0, group);
|
|
92
|
+
else kept.push(group);
|
|
75
93
|
}
|
|
76
94
|
if (!Array.isArray(group.subMenu)) group.subMenu = [];
|
|
77
95
|
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ⚠️⚠️ WHICH `number` COLUMNS LET A PRESENTATION HINT CHOOSE THEIR STORAGE.
|
|
3
|
+
*
|
|
4
|
+
* `appConfig.js resolveColumnType` infers a column's scale from `uiIntent`: 'currency' gives 2, ANY
|
|
5
|
+
* other value gives 0 — so a money column labelled for DISPLAY silently becomes an integer, and the
|
|
6
|
+
* rounding is invisible until something produces a fraction.
|
|
7
|
+
*
|
|
8
|
+
* Found in Supertail, 2026-09-24: `st_order`'s header (`subtotal_amount`, `tax_amount`,
|
|
9
|
+
* `service_charge_amount`, `final_amount`) carried `uiIntent: 'label'` and became BIGINT, while the
|
|
10
|
+
* order LINES they sum kept NUMERIC(18,2) — one order, money at two precisions. And
|
|
11
|
+
* `st_finalisasi_profile.tax_rate` was BIGINT: 11% expressible, 11.5% silently truncated.
|
|
12
|
+
*
|
|
13
|
+
* ⚠️ WHY A REPORT AND NOT A RULE CHANGE. Measured across `biz-a-template`: 204 `type: 'number'`
|
|
14
|
+
* attributes, and NOT ONE declares an explicit `scale`. Flipping the inference today would drop the
|
|
15
|
+
* decimals on the 66 that currently rely on `uiIntent: 'currency'`. So the gap is made countable
|
|
16
|
+
* first, the configs state their storage over time, and only then can the default move. The same
|
|
17
|
+
* staging the raw-colour warning used (§10 point 3).
|
|
18
|
+
*
|
|
19
|
+
* ⚠️ REPORT-ONLY, ALWAYS. Refusing a publish would make the platform un-upgradable for exactly the
|
|
20
|
+
* applications that most need the fix — the argument styleColors.js already makes, and this file
|
|
21
|
+
* deliberately mirrors its shape so the two read as one idea.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/* Everything here fails SOFT and returns a list: a warning must never fail a publish. */
|
|
25
|
+
const entitiesOf = (config) => {
|
|
26
|
+
const entities = config?.model?.entities;
|
|
27
|
+
if (!entities || typeof entities !== "object" || Array.isArray(entities)) return [];
|
|
28
|
+
return Object.entries(entities);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Every `type: 'number'` attribute whose scale is INFERRED rather than declared.
|
|
33
|
+
*
|
|
34
|
+
* ⚠️ `uiIntent: 'currency'` is included too, deliberately. Those columns get the right storage for
|
|
35
|
+
* the wrong reason — a display hint — which is the coupling being retired. `resolved` says what each
|
|
36
|
+
* one becomes, so the two groups stay countable apart.
|
|
37
|
+
*
|
|
38
|
+
* Returns `[{ table, column, uiIntent, resolved }]`, or `[]` for anything it cannot read.
|
|
39
|
+
*/
|
|
40
|
+
export const findImplicitNumericScale = (config) => {
|
|
41
|
+
const findings = [];
|
|
42
|
+
for (const [entityName, entity] of entitiesOf(config)) {
|
|
43
|
+
const attributes = entity?.attributes;
|
|
44
|
+
if (!attributes || typeof attributes !== "object" || Array.isArray(attributes)) continue;
|
|
45
|
+
/* An entity may name no table; the entity key is what a reader would search for. */
|
|
46
|
+
const table = String(entity?.table ?? entityName);
|
|
47
|
+
|
|
48
|
+
for (const [column, attribute] of Object.entries(attributes)) {
|
|
49
|
+
if (!attribute || typeof attribute !== "object") continue;
|
|
50
|
+
if (String(attribute.type ?? "").toLowerCase() !== "number") continue;
|
|
51
|
+
/* Declared scale is the answer this check exists to encourage — including scale 0, which
|
|
52
|
+
is a perfectly good statement that a column holds whole units. */
|
|
53
|
+
if (typeof attribute.scale === "number" && Number.isFinite(attribute.scale)) continue;
|
|
54
|
+
|
|
55
|
+
const uiIntent = String(attribute.uiIntent ?? "");
|
|
56
|
+
findings.push({
|
|
57
|
+
table,
|
|
58
|
+
column,
|
|
59
|
+
uiIntent: uiIntent || "(none)",
|
|
60
|
+
resolved:
|
|
61
|
+
uiIntent.toLowerCase() === "currency"
|
|
62
|
+
? `NUMERIC(${attribute.precision ?? attribute.size ?? 18},2)`
|
|
63
|
+
: "INTEGER/BIGINT",
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return findings;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/*
|
|
71
|
+
* ⚠️⚠️ WHAT IS WORTH PRINTING AT EVERY PUBLISH — a much smaller set than what the scan finds.
|
|
72
|
+
*
|
|
73
|
+
* The raw scan reports 158 columns in Supertail alone, and most are legitimately whole: `sequence_no`,
|
|
74
|
+
* `timeout_minutes`, foreign-key `_id`s, `user_id`. A report that long is noise, and styleColors.js
|
|
75
|
+
* already records where noise leads: "false positives will make people switch the check off, along
|
|
76
|
+
* with the findings that were real". Named colours are excluded there for exactly this reason.
|
|
77
|
+
*
|
|
78
|
+
* So the publish prints the defect SHAPE that was actually found: a column whose NAME says money or
|
|
79
|
+
* rate, which nonetheless resolved to an integer. Everything else stays available from the scan and
|
|
80
|
+
* is summarised as a count.
|
|
81
|
+
*
|
|
82
|
+
* ⚠️ A column ending `_id` is a key however it is named — `payment_amount_id` is not money.
|
|
83
|
+
*/
|
|
84
|
+
const MONEY_OR_RATE = /(amount|price|total|subtotal|cost|fee|discount|paid|change|balance|tax|charge|rate|percent|pct)/i;
|
|
85
|
+
const LOOKS_LIKE_KEY = /(^id$|_id$)/i;
|
|
86
|
+
|
|
87
|
+
export const selectScaleRisks = (findings) =>
|
|
88
|
+
(Array.isArray(findings) ? findings : []).filter(
|
|
89
|
+
(f) =>
|
|
90
|
+
f?.resolved === "INTEGER/BIGINT" &&
|
|
91
|
+
MONEY_OR_RATE.test(String(f.column)) &&
|
|
92
|
+
!LOOKS_LIKE_KEY.test(String(f.column)),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
/* ⚠️ CAPPED. A report that dumps 204 lines is one nobody reads, and the point is that somebody
|
|
96
|
+
reads it. The tail says how many were hidden so the number is never lost. */
|
|
97
|
+
const MAX_LINES = 20;
|
|
98
|
+
|
|
99
|
+
export const describeImplicitScaleFindings = (findings) => {
|
|
100
|
+
const list = Array.isArray(findings) ? findings : [];
|
|
101
|
+
if (list.length === 0) return [];
|
|
102
|
+
|
|
103
|
+
const lines = list
|
|
104
|
+
.slice(0, MAX_LINES)
|
|
105
|
+
.map(
|
|
106
|
+
(f) =>
|
|
107
|
+
` ${f.table}.${f.column} uiIntent=${f.uiIntent} -> ${f.resolved}`,
|
|
108
|
+
);
|
|
109
|
+
if (list.length > MAX_LINES) {
|
|
110
|
+
lines.push(` ... and ${list.length - MAX_LINES} more`);
|
|
111
|
+
}
|
|
112
|
+
return lines;
|
|
113
|
+
};
|
package/engine/ext/live.js
CHANGED
|
@@ -29,12 +29,32 @@ import {
|
|
|
29
29
|
} from "./publicTheme.js";
|
|
30
30
|
import { createAllTenantScope } from "../orm/tenantScope.js";
|
|
31
31
|
import { execPostgres, isPostgresIndex } from "../domain/dialect.js";
|
|
32
|
+
import { nextGeneratorValue } from "../orm/dialect.js";
|
|
32
33
|
|
|
33
34
|
const DEFAULT_FINA_URL = "http://127.0.0.1:212";
|
|
34
35
|
|
|
35
36
|
// Next value from a Firebird generator (for SYS$EXT_USER ids). Mirrors datalib.genId's
|
|
36
37
|
// endpoint without importing datalib (which pulls the worker pool + watcher).
|
|
38
|
+
//
|
|
39
|
+
// ⚠️ ON POSTGRESQL THERE IS NO GENERATOR AND NO FINA TO ASK. The migrations translate
|
|
40
|
+
// `<TABLE>_GEN` + BI trigger into an IDENTITY column, so this call could only ever fail — the same
|
|
41
|
+
// defect the admin client's `genId` hit live ("add user group" -> ECONNREFUSED 127.0.0.1:212), in a
|
|
42
|
+
// second hand-rolled copy. Shared with apiRoute.js through `nextGeneratorValue` so the two cannot
|
|
43
|
+
// drift; the Firebird branch below is untouched.
|
|
37
44
|
async function genId(cfg, genName) {
|
|
45
|
+
if (isPostgresIndex(cfg.dbindex)) {
|
|
46
|
+
const { sequence, value } = await nextGeneratorValue({
|
|
47
|
+
exec: execPostgres,
|
|
48
|
+
dbIndex: cfg.dbindex,
|
|
49
|
+
generator: genName,
|
|
50
|
+
});
|
|
51
|
+
if (sequence === null) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`no sequence backs generator ${genName} on database ${cfg.dbindex}`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
38
58
|
const url = `${cfg.url}/fina/rest/TOrmMethod/genId/${genName}/${cfg.dbindex}`;
|
|
39
59
|
const res = await axios.get(url, {
|
|
40
60
|
headers: { "content-type": "text/plain" },
|
package/engine/orm/apiRoute.js
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* failure -> {"error":"..."} , which db/ds.js's dsReq already looks for.
|
|
21
21
|
*/
|
|
22
22
|
import { json2List, addTableNameToArray, lookupToSql, lookupTotalToSql, resolveDbIndex, buildWriteSql } from "./index.js";
|
|
23
|
+
import { nextGeneratorValue } from "./dialect.js";
|
|
23
24
|
import { createReaders } from "./changeInference.js";
|
|
24
25
|
import { CONFLICT_CODE, ROW_VERSION_KEY, refreshRowVersion } from "./concurrency.js";
|
|
25
26
|
import {
|
|
@@ -135,6 +136,27 @@ export const parseExecuteSqlRequest = (path, body) => {
|
|
|
135
136
|
}
|
|
136
137
|
};
|
|
137
138
|
|
|
139
|
+
/*
|
|
140
|
+
* `genId` carries EVERYTHING IN THE PATH and posts a null body:
|
|
141
|
+
* `/fina/rest/TOrmMethod/%22genId%22/SYS$USERGROUP_GEN/3` (data.service.ts genId).
|
|
142
|
+
*
|
|
143
|
+
* ⚠️ Which is why `parseDataSnapRequest` never recognised it — that one needs `_parameters[0]` and
|
|
144
|
+
* quietly declines a body it cannot read. The call fell through to FINA, and on a tenant with no
|
|
145
|
+
* FINA the admin screen answered `502 ECONNREFUSED 127.0.0.1:212`.
|
|
146
|
+
*
|
|
147
|
+
* Returns { generator, dbIndex } or null.
|
|
148
|
+
*/
|
|
149
|
+
const GENID_PATH = /\/fina\/rest\/\w+\/(?:%22|")genId(?:%22|")\/([^/?#]+)\/(\d+)/i;
|
|
150
|
+
|
|
151
|
+
export const parseGenIdRequest = (path) => {
|
|
152
|
+
const match = GENID_PATH.exec(String(path ?? ""));
|
|
153
|
+
if (!match) return null;
|
|
154
|
+
const generator = decodeURIComponent(match[1]).trim();
|
|
155
|
+
if (!generator) return null;
|
|
156
|
+
const dbIndex = Number(match[2]);
|
|
157
|
+
return { generator, dbIndex: Number.isFinite(dbIndex) ? dbIndex : null };
|
|
158
|
+
};
|
|
159
|
+
|
|
138
160
|
export const parseDataSnapRequest = (path, body) => {
|
|
139
161
|
const match = DATASNAP_PATH.exec(String(path ?? ""));
|
|
140
162
|
if (!match) return null;
|
|
@@ -580,6 +602,53 @@ export const routeApiRequest = async (
|
|
|
580
602
|
}
|
|
581
603
|
}
|
|
582
604
|
|
|
605
|
+
/*
|
|
606
|
+
* ⚠️⚠️ `genId` — a Firebird GENERATOR, answered from PostgreSQL's own counter.
|
|
607
|
+
*
|
|
608
|
+
* This used to fall through to FINA with the rest of the non-ORM surface, which was honest while
|
|
609
|
+
* the generator was merely SOMEWHERE ELSE. It is not: the PostgreSQL migrations translate
|
|
610
|
+
* `<TABLE>_GEN` + BI trigger into `GENERATED BY DEFAULT AS IDENTITY`, so on a PostgreSQL tenant
|
|
611
|
+
* the generator does not exist at all and the forward could only ever reach a Firebird counter
|
|
612
|
+
* for a different database — or, on a tenant with no FINA, `ECONNREFUSED`.
|
|
613
|
+
*
|
|
614
|
+
* ⚠️ Answering it from FINA was the WORSE outcome of the two. `buildUpsert` honours a supplied
|
|
615
|
+
* key, so a value drawn from one database and inserted into another collides with the identity
|
|
616
|
+
* column's own sequence as soon as the two counters cross. Resolving to the table's OWN sequence
|
|
617
|
+
* is what makes the pre-allocated key and the column default one counter again.
|
|
618
|
+
*
|
|
619
|
+
* ⚠️ A Firebird index still has a real generator, so it still forwards — the mixed tenant
|
|
620
|
+
* `finaEndpoint.js` protects keeps working untouched.
|
|
621
|
+
*/
|
|
622
|
+
const genIdRequest = parseGenIdRequest(path);
|
|
623
|
+
if (genIdRequest) {
|
|
624
|
+
if (!isPostgresIndex(genIdRequest.dbIndex)) return null;
|
|
625
|
+
try {
|
|
626
|
+
const { sequence, value } = await nextGeneratorValue({
|
|
627
|
+
exec,
|
|
628
|
+
dbIndex: genIdRequest.dbIndex,
|
|
629
|
+
generator: genIdRequest.generator,
|
|
630
|
+
});
|
|
631
|
+
/* ⚠️ A caller can act on "no sequence backs SYS$NOPE_GEN". It cannot act on a connection
|
|
632
|
+
error, and telling the two apart is most of what made this bug hard to place. */
|
|
633
|
+
if (sequence === null) {
|
|
634
|
+
return {
|
|
635
|
+
status: 400,
|
|
636
|
+
body: {
|
|
637
|
+
error:
|
|
638
|
+
`no sequence backs generator ${genIdRequest.generator} on database ` +
|
|
639
|
+
`${genIdRequest.dbIndex} — no sequence of that name, and no identity column on ` +
|
|
640
|
+
`${genIdRequest.generator.replace(/_GEN$/i, "")}`,
|
|
641
|
+
},
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
/* ⚠️ A BARE NUMBER, captured from the live FINA rather than guessed: it answers `2`, not
|
|
645
|
+
`{data:…}` and not a string. data.service.ts hands the body straight to the caller. */
|
|
646
|
+
return { status: 200, body: value };
|
|
647
|
+
} catch (error) {
|
|
648
|
+
return { status: 500, body: { error: error?.message ?? String(error) } };
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
583
652
|
const parsed = parseDataSnapRequest(path, body);
|
|
584
653
|
if (!parsed) return null;
|
|
585
654
|
|
package/engine/orm/connection.js
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
import fs from "node:fs";
|
|
22
22
|
import path from "node:path";
|
|
23
23
|
import { fileURLToPath } from "node:url";
|
|
24
|
+
import { logger } from "../../logger.js";
|
|
24
25
|
|
|
25
26
|
/* ABSOLUTE, resolved from this module — NOT process.cwd(). The hub is routinely launched from
|
|
26
27
|
another directory (a global `hub` binary, a service wrapper, `node cli/bin/hub.js` from the repo
|
|
@@ -257,11 +258,61 @@ export const PG_DATE_OID = 1082;
|
|
|
257
258
|
*/
|
|
258
259
|
export const PG_BOOL_OID = 16;
|
|
259
260
|
|
|
261
|
+
/*
|
|
262
|
+
* ⚠️⚠️ NUMERIC AND BIGINT MUST ARRIVE AS NUMBERS — the same argument as DATE and BOOL above, and the
|
|
263
|
+
* one that cost a real mis-priced screen.
|
|
264
|
+
*
|
|
265
|
+
* node-postgres hands arbitrary-precision types back as STRINGS so precision is never silently lost
|
|
266
|
+
* to IEEE-754. That is the right default for a general driver and the wrong one for an application
|
|
267
|
+
* written against what FINA sent, which was numbers. Left as strings, `a + b` CONCATENATES and
|
|
268
|
+
* `row.qty > 5` compares text.
|
|
269
|
+
*
|
|
270
|
+
* ⚠️ Live, 2026-09-23 (company bkd): `NUMERIC(18,2)` arrived as `"18000.00"` and the client read it
|
|
271
|
+
* as LOCALE text — Indonesian groups with `.` — so an 18,000 item rendered as Rp 1.800.000 on the
|
|
272
|
+
* screen used to cancel, void and refund. The display edge was fixed then; this fixes the shape.
|
|
273
|
+
*/
|
|
274
|
+
export const PG_NUMERIC_OID = 1700;
|
|
275
|
+
export const PG_INT8_OID = 20;
|
|
276
|
+
|
|
277
|
+
/*
|
|
278
|
+
* ⚠️⚠️ THE PRECISION RISK IS REAL, SO IT IS MADE LOUD RATHER THAN ASSUMED AWAY.
|
|
279
|
+
*
|
|
280
|
+
* A double represents every integer exactly only up to 2^53-1; `NUMERIC(18,2)` can hold more, which
|
|
281
|
+
* is exactly why node-pg returns strings. Measured on db 3 the largest real amount is 900,000 —
|
|
282
|
+
* roughly ten million times of headroom — but "today's data fits" is not a guarantee, and a value
|
|
283
|
+
* that does not fit must NOT be rounded in silence. That would be the same class of defect this
|
|
284
|
+
* change exists to remove, one layer down.
|
|
285
|
+
*
|
|
286
|
+
* ⚠️ IT WARNS, IT DOES NOT THROW. A type parser runs inside every row of every read; throwing would
|
|
287
|
+
* turn one oversized value into a failed query with no obvious cause.
|
|
288
|
+
*
|
|
289
|
+
* ⚠️ AND AN UNPARSEABLE VALUE IS HANDED BACK UNTOUCHED, never NaN. PostgreSQL's `numeric` accepts
|
|
290
|
+
* 'NaN' and the infinities; coercing those would spread a JS NaN through every sum that touched them
|
|
291
|
+
* with nothing to trace it to.
|
|
292
|
+
*/
|
|
293
|
+
const parseNumeric = (value) => {
|
|
294
|
+
if (value === null || value === undefined) return value;
|
|
295
|
+
const text = String(value);
|
|
296
|
+
const parsed = Number(text);
|
|
297
|
+
if (!Number.isFinite(parsed)) return value;
|
|
298
|
+
|
|
299
|
+
if (Math.abs(parsed) > Number.MAX_SAFE_INTEGER) {
|
|
300
|
+
logger.warn(
|
|
301
|
+
`[pg] ${text} exceeds the range a JavaScript number represents exactly ` +
|
|
302
|
+
`(±${Number.MAX_SAFE_INTEGER}); it has been read as ${parsed} and is no longer exact. ` +
|
|
303
|
+
"Handle this column as a string at the call site.",
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
return parsed;
|
|
307
|
+
};
|
|
308
|
+
|
|
260
309
|
export const registerPgTypeParsers = (pg) => {
|
|
261
310
|
pg.types.setTypeParser(PG_DATE_OID, (value) => value);
|
|
262
311
|
pg.types.setTypeParser(PG_BOOL_OID, (value) =>
|
|
263
312
|
value === null || value === undefined ? value : value === "t" ? 1 : 0,
|
|
264
313
|
);
|
|
314
|
+
pg.types.setTypeParser(PG_NUMERIC_OID, parseNumeric);
|
|
315
|
+
pg.types.setTypeParser(PG_INT8_OID, parseNumeric);
|
|
265
316
|
};
|
|
266
317
|
|
|
267
318
|
/* Registered once per process, at the same lazy point `pg` itself is first loaded — the parser is
|
package/engine/orm/dialect.js
CHANGED
|
@@ -25,6 +25,71 @@ export const buildPagingClause = (start, length) => {
|
|
|
25
25
|
export const buildSequenceNextValue = (sequenceName) =>
|
|
26
26
|
`SELECT nextval('${String(sequenceName).replace(/'/g, "''")}')`;
|
|
27
27
|
|
|
28
|
+
/*
|
|
29
|
+
* Doc 3 §5 item 11 — which PostgreSQL sequence stands in for a Firebird GENERATOR.
|
|
30
|
+
*
|
|
31
|
+
* ⚠️⚠️ THE SECOND BRANCH IS THE WHOLE POINT. On Firebird a table's key comes from `<TABLE>_GEN`
|
|
32
|
+
* plus a BEFORE INSERT trigger; the PostgreSQL migrations translate that pair into
|
|
33
|
+
* `GENERATED BY DEFAULT AS IDENTITY`, so the generator does not exist here and cannot. Measured on
|
|
34
|
+
* both live PostgreSQL databases: not one `%_GEN` sequence.
|
|
35
|
+
*
|
|
36
|
+
* So `SYS$USERGROUP_GEN` resolves to the sequence `SYS$USERGROUP`'s identity column ALREADY OWNS.
|
|
37
|
+
* That is not a convenience — it is the correctness condition. A caller that pre-allocates a key
|
|
38
|
+
* and then inserts it explicitly (`admin/userGroup.component.ts`, because SYS$UGPRIV is written as
|
|
39
|
+
* an independent row carrying `usergroup_id`) must draw from the SAME counter the column's DEFAULT
|
|
40
|
+
* would use, or the two drift apart and collide on the primary key. That is exactly what was
|
|
41
|
+
* happening while `genId` was forwarded to a FINA whose own generator was a different number in a
|
|
42
|
+
* different database.
|
|
43
|
+
*
|
|
44
|
+
* ⚠️ The exact-name branch comes FIRST so an application that genuinely created `FOO_GEN` keeps
|
|
45
|
+
* working, and so a Firebird-shaped schema restored onto PostgreSQL is still answered correctly.
|
|
46
|
+
*
|
|
47
|
+
* ⚠️ The column is not assumed to be `id`: the lookup asks which of the table's columns owns a
|
|
48
|
+
* sequence. A table whose key is named otherwise resolves just the same.
|
|
49
|
+
*
|
|
50
|
+
* ⚠️ `pg_table_is_visible` keeps both branches inside the caller's search path — without it a
|
|
51
|
+
* same-named sequence in another schema could answer for a table this connection cannot even see.
|
|
52
|
+
*
|
|
53
|
+
* ⚠️ Both names are rendered as LITERALS, compared with `lower(...)`, never as identifiers. Nothing
|
|
54
|
+
* from the request reaches an identifier position: the `nextval` that follows uses the name the
|
|
55
|
+
* DATABASE returned, not the one the caller sent.
|
|
56
|
+
*/
|
|
57
|
+
export const buildGeneratorSequenceSql = (generator) => {
|
|
58
|
+
const name = String(generator ?? "");
|
|
59
|
+
const table = name.replace(/_GEN$/i, "");
|
|
60
|
+
return (
|
|
61
|
+
"SELECT COALESCE(" +
|
|
62
|
+
"(SELECT c.oid::regclass::text FROM pg_class c " +
|
|
63
|
+
`WHERE c.relkind = 'S' AND pg_table_is_visible(c.oid) AND lower(c.relname) = lower(${quoteLiteral(name)}) ` +
|
|
64
|
+
"LIMIT 1), " +
|
|
65
|
+
"(SELECT pg_get_serial_sequence(t.oid::regclass::text, a.attname) FROM pg_class t " +
|
|
66
|
+
"JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum > 0 AND NOT a.attisdropped " +
|
|
67
|
+
`WHERE t.relkind = 'r' AND pg_table_is_visible(t.oid) AND lower(t.relname) = lower(${quoteLiteral(table)}) ` +
|
|
68
|
+
"AND pg_get_serial_sequence(t.oid::regclass::text, a.attname) IS NOT NULL " +
|
|
69
|
+
"LIMIT 1)" +
|
|
70
|
+
") AS sequence_name"
|
|
71
|
+
);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/*
|
|
75
|
+
* The next value of a Firebird-named generator, from PostgreSQL.
|
|
76
|
+
*
|
|
77
|
+
* ⚠️ ONE COPY, because there are two callers and they must not drift: `apiRoute.js` answers the
|
|
78
|
+
* admin client's `genId`, and `engine/ext/live.js` pre-generates SYS$EXT_USER ids. The second was
|
|
79
|
+
* a hand-rolled axios call to FINA and carried the identical latent failure — a PostgreSQL tenant
|
|
80
|
+
* has no FINA to ask and no generator to ask for.
|
|
81
|
+
*
|
|
82
|
+
* Resolves to `{ sequence, value }`, or `{ sequence: null, value: null }` when nothing backs the
|
|
83
|
+
* name. The caller decides what an unresolvable generator means; here it is simply a fact.
|
|
84
|
+
*/
|
|
85
|
+
export const nextGeneratorValue = async ({ exec, dbIndex, generator }) => {
|
|
86
|
+
const resolved = (await exec(buildGeneratorSequenceSql(generator), dbIndex)) ?? [];
|
|
87
|
+
const sequence = resolved[0]?.sequence_name ?? resolved[0]?.SEQUENCE_NAME ?? null;
|
|
88
|
+
if (!sequence) return { sequence: null, value: null };
|
|
89
|
+
const next = (await exec(buildSequenceNextValue(sequence), dbIndex)) ?? [];
|
|
90
|
+
return { sequence, value: Number(next[0]?.nextval ?? next[0]?.NEXTVAL) };
|
|
91
|
+
};
|
|
92
|
+
|
|
28
93
|
export const quoteLiteral = (value) => `'${String(value).replace(/'/g, "''")}'`;
|
|
29
94
|
|
|
30
95
|
/* A JS Date as a SQL literal. `String(date)` gives
|
|
@@ -602,8 +602,91 @@ const coerceInsertSelect = (statement, dbIndex) => {
|
|
|
602
602
|
);
|
|
603
603
|
};
|
|
604
604
|
|
|
605
|
-
/*
|
|
606
|
-
|
|
605
|
+
/*
|
|
606
|
+
* ⚠️⚠️ THE FILTER HALF — `WHERE ACTIVE = 1` AGAINST A NATIVE BOOLEAN.
|
|
607
|
+
*
|
|
608
|
+
* This used to read "ASSIGNMENTS ONLY. The WHERE clause is a filter, handled where filters are
|
|
609
|
+
* built", and that premise is true of the ORM and FALSE of this module. A filter reaching
|
|
610
|
+
* listQuery.js was BUILT by the ORM from a declared column and a value, so the type is known there.
|
|
611
|
+
* A WHERE inside a raw EXECUTE BLOCK is a string the application concatenated — no filter builder
|
|
612
|
+
* ever sees it. This module is the only place that knows both the table and the declaration.
|
|
613
|
+
*
|
|
614
|
+
* The cost of believing otherwise, reported by QA 2026-09-25 from Manage Order > Batalkan Paksa:
|
|
615
|
+
*
|
|
616
|
+
* UPDATE ST_ORDER_RESOURCE SET ACTIVE='0', RELEASED_AT=CURRENT_TIMESTAMP
|
|
617
|
+
* WHERE ST_ORDER_ID='{…}' AND ACTIVE=1
|
|
618
|
+
* -> operator does not exist: boolean = integer
|
|
619
|
+
*
|
|
620
|
+
* ⚠️ Half-rewritten: the platform coerced its own SET and handed PostgreSQL the raw WHERE. A rule
|
|
621
|
+
* that fires on one side of a statement and not the other is worse than one that never fires, because
|
|
622
|
+
* the statement in the error message no longer looks like the statement in the source.
|
|
623
|
+
*
|
|
624
|
+
* ⚠️⚠️ A SUBQUERY IS THE LINE THIS MUST NOT CROSS. Inside `IN (SELECT … FROM other …)` the column
|
|
625
|
+
* names belong to ANOTHER table, whose declarations this function does not have — coercing there
|
|
626
|
+
* would quote a value by the wrong table's type, silently, inside a transactional statement. So a
|
|
627
|
+
* WHERE containing a SELECT is passed through whole. Same trade as `coerceInsertSelect`: a statement
|
|
628
|
+
* left alone may fail loudly; a statement MISALIGNED corrupts a row quietly.
|
|
629
|
+
*/
|
|
630
|
+
|
|
631
|
+
/* Split into alternating unquoted / quoted runs so a rewrite never reaches inside a string literal:
|
|
632
|
+
`WHERE NOTE = 'ACTIVE = 1'` is prose, not a comparison. */
|
|
633
|
+
const splitQuotedRuns = (text) => {
|
|
634
|
+
const runs = [];
|
|
635
|
+
let current = "";
|
|
636
|
+
let quoted = false;
|
|
637
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
638
|
+
const ch = text[i];
|
|
639
|
+
if (quoted) {
|
|
640
|
+
current += ch;
|
|
641
|
+
if (ch === "'") {
|
|
642
|
+
if (text[i + 1] === "'") { current += text[++i]; continue; }
|
|
643
|
+
runs.push({ quoted: true, text: current });
|
|
644
|
+
current = "";
|
|
645
|
+
quoted = false;
|
|
646
|
+
}
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
if (ch === "'") {
|
|
650
|
+
runs.push({ quoted: false, text: current });
|
|
651
|
+
current = "'";
|
|
652
|
+
quoted = true;
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
current += ch;
|
|
656
|
+
}
|
|
657
|
+
runs.push({ quoted, text: current });
|
|
658
|
+
return runs;
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
/*
|
|
662
|
+
* `<column> = 0` / `<> 0` / `!= 0`, bare on both sides.
|
|
663
|
+
*
|
|
664
|
+
* ⚠️ `[^\w.]` before the name rejects a QUALIFIED column (`x.active`): the qualifier may be an alias
|
|
665
|
+
* for a different table, and this function only holds one table's declarations. `(?![\w.])` after the
|
|
666
|
+
* digit rejects `10` and `1.5`, which were never booleans. `>=` and `<=` do not match — the operator
|
|
667
|
+
* alternation has no bare `<` or `>` — and they are nonsense on a boolean anyway.
|
|
668
|
+
*/
|
|
669
|
+
const BOOLEAN_COMPARISON = /(^|[^\w.])([A-Za-z_]\w*)(\s*(?:<>|!=|=)\s*)([01])(?![\w.])/g;
|
|
670
|
+
|
|
671
|
+
const coerceBooleanFilter = (table, whereText, dbIndex) => {
|
|
672
|
+
if (dbIndex === null || dbIndex === undefined) return whereText;
|
|
673
|
+
/* ⚠️ Any depth, not just the top level — the subquery that matters is inside parentheses. */
|
|
674
|
+
if (/(^|[^A-Za-z_])SELECT([^A-Za-z_]|$)/i.test(stripLiterals(whereText))) return whereText;
|
|
675
|
+
|
|
676
|
+
return splitQuotedRuns(whereText)
|
|
677
|
+
.map((run) =>
|
|
678
|
+
run.quoted
|
|
679
|
+
? run.text
|
|
680
|
+
: run.text.replace(BOOLEAN_COMPARISON, (whole, before, column, operator, digit) =>
|
|
681
|
+
isBooleanColumn(table, column.replace(/^"|"$/g, ""), dbIndex)
|
|
682
|
+
? before + column + operator + "'" + digit + "'"
|
|
683
|
+
: whole,
|
|
684
|
+
),
|
|
685
|
+
)
|
|
686
|
+
.join("");
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
/* `UPDATE t SET a = x, b = y [WHERE …]` — assignments, then the filter. */
|
|
607
690
|
const UPDATE_SET = /^(UPDATE\s+([\w$."]+)\s+SET\s+)([\s\S]*?)(\s+WHERE[\s\S]*)?$/i;
|
|
608
691
|
const coerceUpdate = (statement, dbIndex) => {
|
|
609
692
|
const match = UPDATE_SET.exec(statement.trim());
|
|
@@ -618,7 +701,16 @@ const coerceUpdate = (statement, dbIndex) => {
|
|
|
618
701
|
const column = pair.slice(0, eq).trim().replace(/^"|"$/g, "");
|
|
619
702
|
return pair.slice(0, eq) + "=" + coerceOne(table, column, pair.slice(eq + 1), dbIndex);
|
|
620
703
|
});
|
|
621
|
-
return head + assignments.join(",") + whereText;
|
|
704
|
+
return head + assignments.join(",") + coerceBooleanFilter(table, whereText, dbIndex);
|
|
705
|
+
};
|
|
706
|
+
|
|
707
|
+
/* `DELETE FROM t WHERE …` — the same defect with a different verb. There is no SET to align, so the
|
|
708
|
+
filter is the whole of the work. */
|
|
709
|
+
const DELETE_FROM = /^(DELETE\s+FROM\s+([\w$."]+))(\s+WHERE[\s\S]*)?$/i;
|
|
710
|
+
const coerceDelete = (statement, dbIndex) => {
|
|
711
|
+
const match = DELETE_FROM.exec(statement.trim());
|
|
712
|
+
if (!match) return statement;
|
|
713
|
+
return match[1] + coerceBooleanFilter(match[2], match[3] ?? "", dbIndex);
|
|
622
714
|
};
|
|
623
715
|
|
|
624
716
|
/*
|
|
@@ -763,6 +855,7 @@ const coerceBooleanLiterals = (statement, dbIndex) => {
|
|
|
763
855
|
);
|
|
764
856
|
}
|
|
765
857
|
if (/^\s*UPDATE\s/i.test(statement)) return coerceUpdate(statement, dbIndex);
|
|
858
|
+
if (/^\s*DELETE\s/i.test(statement)) return coerceDelete(statement, dbIndex);
|
|
766
859
|
return statement;
|
|
767
860
|
};
|
|
768
861
|
|
package/engine/orm/executor.js
CHANGED
|
@@ -251,7 +251,12 @@ export const createExecutor = ({ exec, execStatements, execTransaction }) => ({
|
|
|
251
251
|
still refuses the procedural nine by name. The whole body runs as one transaction, because
|
|
252
252
|
EXECUTE BLOCK is one statement in Firebird. */
|
|
253
253
|
async executeBlock(sqlString, dbIndex) {
|
|
254
|
-
|
|
254
|
+
/* ⚠️ dbIndex IS NOT OPTIONAL HERE. executeBlock.js can only coerce a boolean literal or stamp
|
|
255
|
+
authorship if it knows which database DECLARED the column; without it every rewrite is
|
|
256
|
+
silently skipped. It was omitted until 2026-09-25, so the same raw block behaved one way from
|
|
257
|
+
a browser (apiRoute.js passed it) and another from a CLI script. Fenced in
|
|
258
|
+
tests/block-executor-dbindex.test.js. */
|
|
259
|
+
const { statements } = translateExecuteBlock(sqlString, dbIndex);
|
|
255
260
|
const rows = await execTransaction(statements, dbIndex);
|
|
256
261
|
// Firebird's shape: db/db.js claimRow reads res?.data || res, and the admin client reads
|
|
257
262
|
// data[0].RESULT — upper-cased, as unquoted Firebird identifiers come back.
|
package/engine/orm/tenantApi.js
CHANGED
|
@@ -32,10 +32,10 @@ const literal = (value) => `'${String(value ?? "").replace(/'/g, "''")}'`;
|
|
|
32
32
|
|
|
33
33
|
/* ⚠️ `Number()`, never `parseInt`: "7; DROP TABLE x" must become NaN and be refused, not have its
|
|
34
34
|
leading digits salvaged into a valid-looking id. */
|
|
35
|
-
const assertUserId = (value) => {
|
|
35
|
+
const assertUserId = (value, what = "userId") => {
|
|
36
36
|
const id = Number(value);
|
|
37
37
|
if (!Number.isInteger(id)) {
|
|
38
|
-
throw new Error(
|
|
38
|
+
throw new Error(`${what} must be an integer — refused '${value}' (Doc 4 §6).`);
|
|
39
39
|
}
|
|
40
40
|
return id;
|
|
41
41
|
};
|
|
@@ -246,6 +246,18 @@ export const createTenantApi = ({
|
|
|
246
246
|
sourceRef = null,
|
|
247
247
|
includeDescendants = false,
|
|
248
248
|
grace = null,
|
|
249
|
+
/*
|
|
250
|
+
* §6 declares `created_by integer` and the migration creates the column, but nothing ever
|
|
251
|
+
* wrote it — so the table could not answer "who granted this", which is precisely what §10
|
|
252
|
+
* exists to keep answerable.
|
|
253
|
+
*
|
|
254
|
+
* ⚠️ It only starts mattering once a HUMAN can grant from a screen. A roster-driven row is
|
|
255
|
+
* explained by its `source_ref`; a hand-made one is explained by nobody.
|
|
256
|
+
*
|
|
257
|
+
* ⚠️ The CALLER must take this from a VERIFIED principal, never from its own request body —
|
|
258
|
+
* a field naming the granter is worth nothing if the granter can choose it.
|
|
259
|
+
*/
|
|
260
|
+
createdBy = null,
|
|
249
261
|
}) => {
|
|
250
262
|
const user = assertUserId(userId);
|
|
251
263
|
const tenant = assertText(tenantId, "tenantId");
|
|
@@ -270,6 +282,12 @@ export const createTenantApi = ({
|
|
|
270
282
|
["SOURCE_REF", sourceRef === null ? "NULL" : `${literal(assertUuid(sourceRef, "sourceRef"))}::uuid`],
|
|
271
283
|
["GRACE_BEFORE", `INTERVAL ${literal(before)}`],
|
|
272
284
|
["GRACE_AFTER", `INTERVAL ${literal(after)}`],
|
|
285
|
+
/* ⚠️ Through `assertUserId`, the same guard the subject of the assignment gets:
|
|
286
|
+
"3; DROP TABLE …" must become NaN and be refused, not have its leading digits
|
|
287
|
+
salvaged into a valid-looking id. */
|
|
288
|
+
["CREATED_BY", createdBy === null || createdBy === undefined
|
|
289
|
+
? "NULL"
|
|
290
|
+
: String(assertUserId(createdBy, "createdBy"))],
|
|
273
291
|
];
|
|
274
292
|
|
|
275
293
|
const rows =
|
package/engine/orm/userGroups.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* id would let anyone enumerate somebody else's rights.
|
|
12
12
|
*/
|
|
13
13
|
import { logger } from "../../logger.js";
|
|
14
|
+
import { buildFindAdminForUserSql } from "./userAdmin.js";
|
|
14
15
|
|
|
15
16
|
const MEMBERSHIP_TABLE = "sys$ugmember";
|
|
16
17
|
const GROUP_TABLE = "sys$usergroup";
|
|
@@ -60,3 +61,41 @@ export const resolveUserGroups = async ({ exec, dbIndex, userId }) => {
|
|
|
60
61
|
return [];
|
|
61
62
|
}
|
|
62
63
|
};
|
|
64
|
+
|
|
65
|
+
/*
|
|
66
|
+
* Is this VERIFIED caller a company administrator?
|
|
67
|
+
*
|
|
68
|
+
* ⚠️ PLATFORM CODE FOR THE SAME REASON `resolveUserGroups` IS. SYS$ADMINISTRATOR is a `sys$` table
|
|
69
|
+
* and Dok. 5 §1–2 forbids a Layer 3 domain from touching one, so the domain states the right it
|
|
70
|
+
* needs and the platform answers. HR's Akses Outlet gates its writes on this: granting a person
|
|
71
|
+
* access to an outlet is an administrative act, and hiding a menu entry stops nobody — anyone can
|
|
72
|
+
* open a socket and send `runCLIScript`.
|
|
73
|
+
*
|
|
74
|
+
* ⚠️ SYS$ADMINISTRATOR is the same table the client already reads to decide whether to show the
|
|
75
|
+
* admin menu at all (`data.service.ts validateAdminTable`), and the same one the platform's own
|
|
76
|
+
* branding guard uses (`principalGuards.requireAdministrator`). Reusing it rather than inventing a
|
|
77
|
+
* second notion of "administrator" is the point — two would drift, and the forgotten one is the hole.
|
|
78
|
+
*
|
|
79
|
+
* ⚠️ IT TAKES NO USER ID FROM A CALLER. The id comes from inside a signature; handing this a
|
|
80
|
+
* caller-supplied id would let anyone claim somebody else's rights.
|
|
81
|
+
*
|
|
82
|
+
* ⚠️ FAIL-CLOSED: an unreadable table, a bad id, no exec — all `false`. A gate that failed open on a
|
|
83
|
+
* database hiccup would hand out the administrator right.
|
|
84
|
+
*/
|
|
85
|
+
export const resolveIsAdministrator = async ({ exec, dbIndex, userId }) => {
|
|
86
|
+
if (userId === null || userId === undefined || userId === "") return false;
|
|
87
|
+
if (typeof userId === "object" || typeof userId === "boolean") return false;
|
|
88
|
+
const id = Number(userId);
|
|
89
|
+
if (!Number.isInteger(id) || typeof exec !== "function") return false;
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const rows = await exec(buildFindAdminForUserSql(id), dbIndex);
|
|
93
|
+
return Array.isArray(rows) && rows.length > 0;
|
|
94
|
+
} catch (error) {
|
|
95
|
+
logger.warn(
|
|
96
|
+
`[principal] could not read SYS$ADMINISTRATOR for user ${id}: ${error?.message ?? error}. ` +
|
|
97
|
+
"Treating the caller as NOT an administrator, so any administrative action is refused.",
|
|
98
|
+
);
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
};
|
package/logger.js
CHANGED
|
@@ -30,7 +30,18 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
30
30
|
same trap cliWorkerPool.js documents for the secrets store. */
|
|
31
31
|
export const LOG_DIR = path.join(__dirname, "log");
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
let isProductionCache = null;
|
|
34
|
+
|
|
35
|
+
export const _resetLoggerCache = () => {
|
|
36
|
+
isProductionCache = null;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const isProduction = () => {
|
|
40
|
+
if (isProductionCache === null) {
|
|
41
|
+
isProductionCache = process.env.NODE_ENV === "production";
|
|
42
|
+
}
|
|
43
|
+
return isProductionCache;
|
|
44
|
+
};
|
|
34
45
|
|
|
35
46
|
/* Under test, log to the console only. A winston File transport opens a write stream on first
|
|
36
47
|
use and holds it, which keeps the event loop alive and makes jest hang after a suite finishes —
|
|
@@ -60,7 +71,7 @@ const base = createLogger({
|
|
|
60
71
|
|
|
61
72
|
/* debug is the only level that goes quiet in production — error and info still print, exactly as
|
|
62
73
|
the console.* calls they replaced always did. */
|
|
63
|
-
const isEnabled = (level) => !(isProduction && level === "debug");
|
|
74
|
+
const isEnabled = (level) => !(isProduction() && level === "debug");
|
|
64
75
|
|
|
65
76
|
const emit = (level, args, prefix = null) => {
|
|
66
77
|
if (!isEnabled(level)) return;
|
package/package.json
CHANGED
package/scheduler/datalib.js
CHANGED
|
@@ -190,6 +190,15 @@ const normalisePrincipal = (principal) => {
|
|
|
190
190
|
may not read sys$ tables). Frozen for the same reason: a script that could push onto it
|
|
191
191
|
would grant itself the very right it is about to check. */
|
|
192
192
|
groups: Object.freeze(Array.isArray(principal.groups) ? [...principal.groups] : []),
|
|
193
|
+
/*
|
|
194
|
+
* Doc 6 §8.3 — whether the platform found this caller in SYS$ADMINISTRATOR.
|
|
195
|
+
*
|
|
196
|
+
* ⚠️ `=== true`, NOT a coercion. A script gates a write on this, so "the platform did not say"
|
|
197
|
+
* and "not an administrator" must be the same value. A truthy string or a stray 1 arriving
|
|
198
|
+
* here would open the gate the field exists to shut — and the object is frozen, so a script
|
|
199
|
+
* cannot set it either.
|
|
200
|
+
*/
|
|
201
|
+
isAdministrator: principal.isAdministrator === true,
|
|
193
202
|
});
|
|
194
203
|
};
|
|
195
204
|
|
|
@@ -2,7 +2,7 @@ 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";
|
|
5
|
+
import { resolveUserGroups, resolveIsAdministrator } from "../engine/orm/userGroups.js";
|
|
6
6
|
import { execPostgres } from "../engine/domain/dialect.js";
|
|
7
7
|
import { logger } from "../logger.js";
|
|
8
8
|
|
|
@@ -224,6 +224,14 @@ export const run = async (apiConfig, scriptIdentifier, scriptData, principal = n
|
|
|
224
224
|
dbIndex: db.resolveDbIndex(apiConfig),
|
|
225
225
|
userId: principal.userId,
|
|
226
226
|
}),
|
|
227
|
+
/* ⚠️ Whether this caller is a company administrator — SYS$ADMINISTRATOR is a `sys$`
|
|
228
|
+
table, so the domain may not ask it directly (Dok. 5 §1–2). Same seam, same
|
|
229
|
+
fail-closed shape: unreadable means NOT an administrator. */
|
|
230
|
+
isAdministrator: await resolveIsAdministrator({
|
|
231
|
+
exec: execPostgres,
|
|
232
|
+
dbIndex: db.resolveDbIndex(apiConfig),
|
|
233
|
+
userId: principal.userId,
|
|
234
|
+
}),
|
|
227
235
|
}
|
|
228
236
|
: null;
|
|
229
237
|
|