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 CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import "../envs/defaultEnv.js";
3
4
  import yargs from "yargs";
4
5
  import axios from "axios";
5
6
  import fs from "fs";
package/bin/deleteApp.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import "../envs/defaultEnv.js";
3
4
  import yargs from "yargs";
4
5
  import axios from "axios";
5
6
  import { logger } from "../logger.js";
package/bin/hub.js CHANGED
@@ -1,9 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- if (!process.env.NODE_ENV) {
4
- process.env.NODE_ENV = "production";
5
- }
6
-
3
+ import "../envs/defaultEnv.js";
7
4
  import yargs from "yargs";
8
5
  import { io as ioClient } from "socket.io-client";
9
6
  import {
package/bin/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import "../envs/defaultEnv.js";
3
4
  import yargs from "yargs";
4
5
  import axios from "axios";
5
6
  import { promises as fs } from "fs";
package/bin/proxy.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import "../envs/defaultEnv.js";
3
4
  import express from 'express';
4
5
  import cors from 'cors';
5
6
  const app = express();
package/bin/uploadApp.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import "../envs/defaultEnv.js";
3
4
  import yargs from "yargs";
4
5
  import axios from "axios";
5
6
  import { promises as fs } from "fs";
package/bin/watcher.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import "../envs/defaultEnv.js";
3
4
  import express from 'express';
4
5
  import cors from 'cors';
5
6
  const app = express();
@@ -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
- kept.push(group);
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
+ };
@@ -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" },
@@ -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
 
@@ -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
@@ -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
- /* `UPDATE t SET a = x, b = y [WHERE …]` — ASSIGNMENTS ONLY. The WHERE clause is a filter, handled
606
- where filters are built; rewriting it here too risks corrupting a comparison meant literally. */
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
 
@@ -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
- const { statements } = translateExecuteBlock(sqlString);
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.
@@ -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(`userId must be an integer — refused '${value}' (Doc 4 §6).`);
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 =
@@ -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
+ };
@@ -0,0 +1,3 @@
1
+ if (!process.env.NODE_ENV) {
2
+ process.env.NODE_ENV = "production";
3
+ }
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
- const isProduction = process.env.NODE_ENV === "production";
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "biz-a-cli",
3
- "version": "2.3.80-15365",
3
+ "version": "2.3.80-15372",
4
4
  "description": "",
5
5
  "main": "bin/index.js",
6
6
  "type": "module",
@@ -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