biz-a-cli 2.3.80-15359 → 2.3.80-15367

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
@@ -53,6 +53,7 @@ import {
53
53
  } from "../engine/domain/publishedAdditions.js";
54
54
  import { validateTenancyDeclaration } from "../engine/orm/tenancyDeclaration.js";
55
55
  import { logger } from "../logger.js";
56
+ import { findRawColorsInSource } from "../engine/domain/styleColors.js";
56
57
 
57
58
  const getKeyFolderPath = () => {
58
59
  const scriptPath =
@@ -1093,6 +1094,36 @@ async function addApp({
1093
1094
  const scriptSource = bodyScripts.has(fileName)
1094
1095
  ? bodyScripts.get(fileName)
1095
1096
  : fs.readFileSync(sourceFilePath).toString();
1097
+ /*
1098
+ * Doc 6 §10 point 3 — raw colours in this library's own style declarations.
1099
+ *
1100
+ * ⚠️ SCANNED HERE BECAUSE NOTHING DOWNSTREAM CAN. `publishConfigs` reads the domain config
1101
+ * out of sys$config, and a library's styles never go there — they are emitted into
1102
+ * structures at publish. Measured on a real app, the config held 26 and the libraries 157,
1103
+ * and only the 26 were ever reported. This is the only point that has the source in hand.
1104
+ *
1105
+ * ⚠️ REPORT ONLY, like every other part of this check: a warning must never fail an upload.
1106
+ */
1107
+ try {
1108
+ const styleColors = findRawColorsInSource(scriptSource);
1109
+ if (styleColors.length > 0) {
1110
+ const total = styleColors.reduce((n, f) => n + f.colors.length, 0);
1111
+ logger.warn(
1112
+ `[Add] Doc 6 §10: ${total} raw colour(s) in ${fileName} style declarations ` +
1113
+ `(REPORT ONLY). A Style wins over the theme, so these will NOT follow a company's ` +
1114
+ `brand colour:`,
1115
+ );
1116
+ for (const f of styleColors.slice(0, 20)) {
1117
+ logger.warn(` ${fileName}:${f.line} ${f.colors.join(', ')}`);
1118
+ }
1119
+ if (styleColors.length > 20) {
1120
+ logger.warn(` ... and ${styleColors.length - 20} more line(s) in ${fileName}`);
1121
+ }
1122
+ }
1123
+ } catch (error) {
1124
+ logger.warn(`[Add] could not scan ${fileName} for style colours: ${error?.message ?? error}`);
1125
+ }
1126
+
1096
1127
  const preparedScript = await prepareScript(
1097
1128
  fileName,
1098
1129
  scriptSource,
package/bin/hub.js CHANGED
@@ -368,6 +368,11 @@ app.use(
368
368
  oidc: extDeps.oidc,
369
369
  identityStore: extDeps.identityStore,
370
370
  resolveTenant: extDeps.resolveTenant,
371
+ /* Doc 6 section 14 -- the anonymous theme + logo endpoints, and the PWA install card
372
+ they now also feed. Absent on a FINA tenant, where those routes simply 404. */
373
+ getPublicTheme: extDeps.getPublicTheme,
374
+ getPublicLogo: extDeps.getPublicLogo,
375
+ getBranding: extDeps.getBranding,
371
376
  /* Doc 3 section 11 -- without this the external surface has NO tenant context and the
372
377
  fail-closed default hides every multi-tenant row from it. */
373
378
  withTenantScope: extDeps.withTenantScope,
@@ -5,6 +5,10 @@ import { createHash } from "node:crypto";
5
5
  import * as firebirdDdl from "./firebird-ddl.js";
6
6
  import * as postgresDdl from "./postgres-ddl.js";
7
7
  import { ddlFor, isPostgresIndex, execPostgres } from "./dialect.js";
8
+ import {
9
+ findRawColorsInStyles,
10
+ describeRawColorFindings,
11
+ } from "./styleColors.js";
8
12
  import { json2List } from "../orm/index.js";
9
13
  import { toDbName } from "./naming.js";
10
14
  import { logger } from "../../logger.js";
@@ -3208,6 +3212,36 @@ export const publishConfigs = async (payload = {}, argv = {}) => {
3208
3212
 
3209
3213
  const domainConfigContent = String(domainRecord.data);
3210
3214
 
3215
+ /*
3216
+ * Doc 6 §10 point 3 — raw colours in a `Style` are reported here, with their locations.
3217
+ *
3218
+ * ⚠️ REPORT ONLY, exactly like the redundant-index report below: §10 point 3 says warning,
3219
+ * and it has to be — every existing application is full of raw colours today, so refusing
3220
+ * the publish would make the platform un-upgradable for the people who most need the fix.
3221
+ *
3222
+ * ⚠️ SCANNED HERE, BEFORE THE `tables.length === 0` EARLY RETURN. A domain that declares
3223
+ * only UI and no entities is exactly the kind that is all styles, and skipping it would
3224
+ * quietly exempt the configs with the most to report.
3225
+ *
3226
+ * ⚠️ Its own try/catch: a warning must never be the thing that fails a publish.
3227
+ */
3228
+ let rawStyleColors = [];
3229
+ try {
3230
+ rawStyleColors = findRawColorsInStyles(parseDomainConfig(domainConfigContent));
3231
+ if (rawStyleColors.length > 0) {
3232
+ logger.warn(
3233
+ `[AppConfig Publish] Doc 6 §10: ${rawStyleColors.length} raw colour(s) in Style ` +
3234
+ `declarations of ${domainName}@${domainVersion} (REPORT ONLY). A Style wins over the ` +
3235
+ `theme, so these will NOT follow a company's brand colour:`,
3236
+ );
3237
+ describeRawColorFindings(rawStyleColors).forEach((line) => logger.warn(line));
3238
+ }
3239
+ } catch (error) {
3240
+ logger.warn(
3241
+ `[AppConfig Publish] could not scan Style colours: ${error?.message ?? error}`,
3242
+ );
3243
+ }
3244
+
3211
3245
  /*
3212
3246
  * Doc 3 §11 — resolve the tenancy declaration across EVERY published domain, not just this one.
3213
3247
  *
@@ -3240,7 +3274,7 @@ export const publishConfigs = async (payload = {}, argv = {}) => {
3240
3274
 
3241
3275
  const report = [];
3242
3276
  if (preview.schema.tables.length === 0) {
3243
- return { success: true, domainName, domainVersion, tablesProcessed: 0, report };
3277
+ return { success: true, domainName, domainVersion, tablesProcessed: 0, report, rawStyleColors };
3244
3278
  }
3245
3279
 
3246
3280
  /* An explicitly declared index can duplicate a foreign key just as the auto-generated one
@@ -3322,6 +3356,7 @@ export const publishConfigs = async (payload = {}, argv = {}) => {
3322
3356
  tablesSkipped,
3323
3357
  redundantIndexes,
3324
3358
  orphanColumns,
3359
+ rawStyleColors,
3325
3360
  report,
3326
3361
  };
3327
3362
  } catch (error) {
@@ -135,19 +135,27 @@ export const nearestPassing = (foreground, background, { large = false, maxSteps
135
135
  * ⚠️ Checking every combination of twelve tokens would report failures for pairs nothing puts
136
136
  * together, and a check that cries wolf is one people learn to skip.
137
137
  *
138
- * ⚠️ `outlineColor` IS DELIBERATELY ABSENT, and the reason is a gap in Doc 6's vocabulary. WCAG
139
- * 1.4.11 wants 3:1 for a boundary that IDENTIFIES a control, but Material has two roles here —
140
- * `outline` (meaningful) and `outline-variant` (decorative divider) — with very different bars, and
141
- * §5 collapses both into one token. All three presets chose a pale value for it (~1.6:1 on white),
142
- * i.e. they mean the decorative role; enforcing 3:1 would refuse every shipped preset. The cost is
143
- * that a boundary which really does identify a control goes unchecked; the fix is a second
144
- * `outlineVariantColor` token, which is a Doc 6 change, not a code one.
138
+ * ⚠️⚠️ `outlineColor` IS CHECKED AT 3:1, NOT 4.5:1 — the WCAG 1.4.11 bar for a NON-TEXT boundary
139
+ * that identifies a control (the edge of a text input, the border of an outlined button). This is
140
+ * what the `true` in its row means.
141
+ *
142
+ * ⚠️ IT WAS SKIPPED ENTIRELY UNTIL 2026-09-23, and the reason is worth keeping. Material has TWO
143
+ * line roles with very different bars — `outline` (identifies a control) and `outline-variant` (a
144
+ * decorative divider) — and Doc 6 §5 originally collapsed them into one token. All three presets
145
+ * chose a pale value (~1.6:1 on white), i.e. they meant the decorative role, so enforcing 3:1 would
146
+ * have refused every shipped preset. §5 gained `outlineVariantColor`, the presets' pale values
147
+ * moved to it, and `outlineColor` took new values with real headroom (~3.3:1).
148
+ *
149
+ * ⚠️ `outlineVariantColor` IS STILL ABSENT, and must stay absent: WCAG sets no contrast requirement
150
+ * for a decorative line, and checking it would refuse every preset all over again.
145
151
  *
146
152
  * ⚠️ Kept identical to the client's `CHECKED_PAIRS` on purpose — one rule for Jalur A and Jalur B
147
153
  * (§7.1) means a theme accepted on save must not be refused at publish, or vice versa.
148
154
  */
149
155
  export const CHECKED_PAIRS = [
150
156
  ["onSurfaceColor", "surfaceColor", false],
157
+ /* ⚠️ `true` = the 3:1 boundary bar (WCAG 1.4.11), not the 4.5:1 text bar. */
158
+ ["outlineColor", "surfaceColor", true],
151
159
  ["onSurfaceColor", "surfaceContainerColor", false],
152
160
  ["onPrimaryColor", "primaryColor", false],
153
161
  ["primaryColor", "surfaceColor", false],
@@ -0,0 +1,235 @@
1
+ /*
2
+ * Doc 6 §10 point 3 — raw colours in a `Style` produce a WARNING at publish, with the list of
3
+ * locations to fix.
4
+ *
5
+ * ⚠️⚠️ WHY THIS MATTERS MORE THAN IT LOOKS. §10 point 4 makes `Style` WIN over the theme for its
6
+ * component. So when a company changes its brand colour, a screen carrying hard-coded hex values
7
+ * comes back half re-themed: the Material components follow, the styled ones do not. That reads as
8
+ * broken rather than merely unbranded — and nobody can tell which screens are at fault, because a
9
+ * raw colour is invisible until somebody changes the theme it was supposed to follow.
10
+ *
11
+ * ⚠️ A WARNING, NEVER A REFUSAL. Every existing application is full of raw colours today — the
12
+ * Tahap 0 scan counted 740 overrides across 36 projects. Refusing a publish would make the platform
13
+ * un-upgradable for the people who most need the fix. The LIST OF LOCATIONS is the deliverable: it
14
+ * turns "your styles are wrong somewhere" into work somebody can actually do.
15
+ *
16
+ * ⚠️ PLATFORM CODE, SO IT IS SHAPE-DRIVEN, NOT APP-DRIVEN. It knows only that a `style` object maps
17
+ * CSS properties to values; no application, screen or token name is written into it.
18
+ */
19
+
20
+ /*
21
+ * ⚠️ EXACTLY THE HEX SHAPES CSS ACCEPTS — 3, 4, 6 or 8 digits — longest first so `#aabbccdd` is
22
+ * not read as `#aabbcc` followed by junk. Matching a loose {3,8} would also accept `#12345`, which
23
+ * is not a colour at all, and a check that reports non-colours is a check people learn to ignore.
24
+ */
25
+ const HEX = /#(?:[0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{4}|[0-9a-f]{3})\b/gi;
26
+
27
+ /** `rgb(`, `rgba(`, `hsl(`, `hsla(` — the functional notations, wherever they appear in a value. */
28
+ const FUNCTIONAL = /\b(?:rgba?|hsla?)\s*\([^)]*\)/gi;
29
+
30
+ /*
31
+ * ⚠️ A `var()` FALLBACK IS NOT A RAW COLOUR. `var(--mat-sys-primary, #1B5FCB)` still follows the
32
+ * theme — the hex applies only if the token is missing, which is defensive spelling, not a
33
+ * hard-coded colour. Flagging it would punish the more careful author, so `var(...)` is removed
34
+ * before scanning.
35
+ *
36
+ * ⚠️ Non-greedy to the FIRST `)`, so it does not swallow the rest of a multi-value declaration:
37
+ * `0 0 0 1px var(--x), 0 2px 4px #00000022` must still report the hex. The cost is that a nested
38
+ * `var(--a, var(--b, #fff))` leaves a stray `#fff` visible — rare, and erring towards reporting.
39
+ */
40
+ const VAR_CALL = /\bvar\s*\([^()]*\)/gi;
41
+
42
+ /*
43
+ * ⚠️ NAMED COLOURS ARE DELIBERATELY NOT MATCHED. §10 point 3 says "hex/rgb", and the words that
44
+ * would have to be matched — `white`, `black`, `red` — also occur in class names, font families and
45
+ * content strings. A keyword list would report correct code, and a check with false positives gets
46
+ * switched off, taking the true positives with it.
47
+ */
48
+
49
+ /*
50
+ * ⚠️⚠️ A `template` IS A STYLESHEET TOO — the second blind spot this check had.
51
+ *
52
+ * Structures carry raw HTML strings with inline `style="..."` attributes, and 48 of them lived in
53
+ * ONE console structure while this reported none: the scan only understood a key literally named
54
+ * `style`. So it is the CONTENT that decides, not the field name — the same markup turns up under
55
+ * `template`, `label`, `html` and unnamed fields.
56
+ *
57
+ * ⚠️ ONLY THE ATTRIBUTE, NEVER THE WHOLE STRING. Markup is full of things shaped like colours that
58
+ * are not: `href="#abc123"`, an element id, the word "#ffffff" in visible prose. Grepping the
59
+ * document would report all of them, and a check with false positives gets switched off.
60
+ */
61
+ const STYLE_ATTR = /style\s*=\s*("([^"]*)"|'([^']*)')/gi;
62
+
63
+ const styleAttributeText = (markup) => {
64
+ const parts = [];
65
+ let m;
66
+ STYLE_ATTR.lastIndex = 0;
67
+ while ((m = STYLE_ATTR.exec(markup)) !== null) {
68
+ parts.push(m[2] !== undefined ? m[2] : m[3] || "");
69
+ }
70
+ return parts.join(" ; ");
71
+ };
72
+
73
+ const isPlainObject = (v) =>
74
+ typeof v === "object" && v !== null && !Array.isArray(v);
75
+
76
+ /*
77
+ * The key whose object is a CSS map. Everything else in a config is left alone.
78
+ *
79
+ * ⚠️ `style`, NOT `styles`. The real shape is `styles.<ref>.<variant>.style.<prop>` — `styles` is a
80
+ * namespace of style refs, and only the inner `style` object holds CSS. Treating the whole `styles`
81
+ * subtree as CSS flagged `className: 'card-#ffffff'` as a raw colour, which is exactly the kind of
82
+ * false positive that gets a check switched off.
83
+ */
84
+ const STYLE_KEYS = new Set(["style"]);
85
+
86
+ /**
87
+ * Raw colours in one CSS value, or `[]`.
88
+ *
89
+ * ⚠️ Order preserved and duplicates kept as written: the author is going to read this list against
90
+ * their own declaration, and reordering it makes that harder than it needs to be.
91
+ */
92
+ export const rawColorsIn = (value) => {
93
+ if (typeof value !== "string" || value === "") return [];
94
+ const scannable = value.replace(VAR_CALL, " ");
95
+ return [
96
+ ...(scannable.match(HEX) || []),
97
+ ...(scannable.match(FUNCTIONAL) || []),
98
+ ];
99
+ };
100
+
101
+ /**
102
+ * Every raw colour inside the config's `Style` declarations.
103
+ *
104
+ * @returns `[{ path, property, value, colors }]` — `path` is the dotted location in the config, so
105
+ * the author can go straight to it.
106
+ *
107
+ * ⚠️ NEVER THROWS. This runs inside publish, on data that may be any shape at all; a scan that
108
+ * failed would take down a publish for the sake of a warning.
109
+ */
110
+ export const findRawColorsInStyles = (config) => {
111
+ const findings = [];
112
+ if (!isPlainObject(config)) return findings;
113
+
114
+ const walk = (node, path, inStyle) => {
115
+ if (Array.isArray(node)) {
116
+ node.forEach((item, i) => walk(item, `${path}[${i}]`, inStyle));
117
+ return;
118
+ }
119
+ if (isPlainObject(node)) {
120
+ for (const [key, value] of Object.entries(node)) {
121
+ walk(
122
+ value,
123
+ path ? `${path}.${key}` : key,
124
+ /* Sticky: a CSS map is flat, but staying true costs nothing and covers a nested
125
+ value should one ever appear. */
126
+ inStyle || STYLE_KEYS.has(key),
127
+ );
128
+ }
129
+ return;
130
+ }
131
+ if (typeof node !== "string") return;
132
+ /* Inside a `style` map every value is CSS; anywhere else, only what a style ATTRIBUTE holds. */
133
+ const scannable = inStyle ? node : styleAttributeText(node);
134
+ const colors = rawColorsIn(scannable);
135
+ if (colors.length === 0) return;
136
+ const lastDot = path.lastIndexOf(".");
137
+ findings.push({
138
+ path,
139
+ property: lastDot >= 0 ? path.slice(lastDot + 1) : path,
140
+ value: String(node),
141
+ colors,
142
+ });
143
+ };
144
+
145
+ try {
146
+ walk(config, "", false);
147
+ } catch {
148
+ /* A cyclic or otherwise hostile config costs the warning, not the publish. */
149
+ return findings;
150
+ }
151
+ return findings;
152
+ };
153
+
154
+ /**
155
+ * The lines the publisher prints — §10 point 3's "daftar lokasi yang perlu diperbaiki".
156
+ *
157
+ * ⚠️ THE PATH IS THE DELIVERABLE. A count ("12 raw colours found") tells nobody what to change; the
158
+ * dotted path takes them to the exact declaration.
159
+ */
160
+ export const describeRawColorFindings = (findings) => {
161
+ const list = Array.isArray(findings) ? findings : [];
162
+ if (list.length === 0) return [];
163
+ return list.map(
164
+ (f) =>
165
+ ` ${f.path}: ${f.colors.join(", ")} (value: ${f.value})` +
166
+ ` - use a theme token, e.g. var(--mat-sys-primary)`,
167
+ );
168
+ };
169
+
170
+ /*
171
+ * ⚠️⚠️ THE THIRD POPULATION: styles written in APPLICATION SOURCE rather than in the domain config.
172
+ *
173
+ * Screens are also built by app libraries — JavaScript returning `{ style: {...} }` structures at
174
+ * publish time. Those colours never enter `sys$config`, so `findRawColorsInStyles` could not see
175
+ * them however well it scanned: there is nothing to walk. Measured on a real application, the
176
+ * config held 26 and the libraries held 157, and only the 26 were ever reported. A list that is six
177
+ * times short is worse than no list — it says the work is nearly done when it has barely started.
178
+ *
179
+ * ⚠️ TEXT, NOT A TREE, so this reports a LINE NUMBER. "Somewhere in a 4000-line library" is not a
180
+ * location anybody can act on.
181
+ */
182
+
183
+ /* Comments blanked but every newline kept, so the reported line stays true. Counting colours in
184
+ prose is what inflated an early estimate of this very population from 157 to 347. */
185
+ const NEWLINE = String.fromCharCode(10);
186
+ const blankComments = (text) =>
187
+ text
188
+ .replace(/\/\*[\s\S]*?\*\//g, (m) =>
189
+ m.split(NEWLINE).map((line) => " ".repeat(line.length)).join(NEWLINE),
190
+ )
191
+ .replace(/(^|[^:])\/\/.*/g, (m, p1) => p1 + " ".repeat(m.length - p1.length));
192
+
193
+ /** `style: { ... }` and `style: '...'` — the two shapes real configs and libraries both use. */
194
+ const SOURCE_STYLE = /style\s*:\s*(\{[^{}]*\}|'[^']*'|"[^"]*")/g;
195
+
196
+ /**
197
+ * Raw colours in a JavaScript/TypeScript source file's style declarations.
198
+ *
199
+ * @returns `[{ line, colors, snippet }]`
200
+ *
201
+ * ⚠️ NEVER THROWS, for the same reason as the object scan: this runs during a publish, and a
202
+ * warning must never be the thing that fails one.
203
+ */
204
+ export const findRawColorsInSource = (source) => {
205
+ const findings = [];
206
+ if (typeof source !== "string" || source === "") return findings;
207
+
208
+ try {
209
+ const text = blankComments(source);
210
+ const lineAt = (index) => text.slice(0, index).split(NEWLINE).length;
211
+
212
+ const record = (index, scannable, snippet) => {
213
+ const colors = rawColorsIn(scannable);
214
+ if (colors.length === 0) return;
215
+ findings.push({ line: lineAt(index), colors, snippet: snippet.slice(0, 120) });
216
+ };
217
+
218
+ SOURCE_STYLE.lastIndex = 0;
219
+ let m;
220
+ while ((m = SOURCE_STYLE.exec(text)) !== null) {
221
+ record(m.index, m[1], m[0]);
222
+ }
223
+
224
+ /* Markup the source emits — the same style-attribute rule as the object scan. */
225
+ STYLE_ATTR.lastIndex = 0;
226
+ while ((m = STYLE_ATTR.exec(text)) !== null) {
227
+ record(m.index, m[2] !== undefined ? m[2] : m[3] || "", m[0]);
228
+ }
229
+ } catch {
230
+ return findings;
231
+ }
232
+
233
+ /* One order, by position, so a reader can walk the file top to bottom. */
234
+ return findings.sort((a, b) => a.line - b.line);
235
+ };
@@ -36,6 +36,10 @@
36
36
  "dark": "#E4E8EF"
37
37
  },
38
38
  "outlineColor": {
39
+ "light": "#878C96",
40
+ "dark": "#636A76"
41
+ },
42
+ "outlineVariantColor": {
39
43
  "light": "#C5CBD6",
40
44
  "dark": "#454C59"
41
45
  },
@@ -98,6 +102,10 @@
98
102
  "dark": "#E3E6E0"
99
103
  },
100
104
  "outlineColor": {
105
+ "light": "#8B8B84",
106
+ "dark": "#666A66"
107
+ },
108
+ "outlineVariantColor": {
101
109
  "light": "#C9C7BD",
102
110
  "dark": "#474B47"
103
111
  },
@@ -160,6 +168,10 @@
160
168
  "dark": "#E8EAEE"
161
169
  },
162
170
  "outlineColor": {
171
+ "light": "#888C94",
172
+ "dark": "#62676F"
173
+ },
174
+ "outlineVariantColor": {
163
175
  "light": "#B9BEC7",
164
176
  "dark": "#3C424B"
165
177
  },
@@ -46,6 +46,7 @@ const TOKEN_KEYS = [
46
46
  "surfaceContainerColor",
47
47
  "onSurfaceColor",
48
48
  "outlineColor",
49
+ "outlineVariantColor",
49
50
  "errorColor",
50
51
  "warningColor",
51
52
  "successColor",
@@ -22,14 +22,39 @@ import { createOidcService } from "./oidc.js";
22
22
  import { OIDC_ENDPOINTS, makeOidcProvider } from "./providers.js";
23
23
  import { makeTokenExchange, makeIdTokenVerifier } from "./oidcHttp.js";
24
24
  import { mintT1 } from "./token.js";
25
+ import {
26
+ createPublicThemeReader,
27
+ createPublicLogoReader,
28
+ brandingFromPublicTheme,
29
+ } from "./publicTheme.js";
25
30
  import { createAllTenantScope } from "../orm/tenantScope.js";
26
31
  import { execPostgres, isPostgresIndex } from "../domain/dialect.js";
32
+ import { nextGeneratorValue } from "../orm/dialect.js";
27
33
 
28
34
  const DEFAULT_FINA_URL = "http://127.0.0.1:212";
29
35
 
30
36
  // Next value from a Firebird generator (for SYS$EXT_USER ids). Mirrors datalib.genId's
31
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.
32
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
+ }
33
58
  const url = `${cfg.url}/fina/rest/TOrmMethod/genId/${genName}/${cfg.dbindex}`;
34
59
  const res = await axios.get(url, {
35
60
  headers: { "content-type": "text/plain" },
@@ -280,10 +305,42 @@ export function createLiveExtDeps({
280
305
  ? createAllTenantScope({ exec: execPostgres, dbIndex: dbindex })
281
306
  : (fn) => fn();
282
307
 
308
+ /*
309
+ * Doc 6 §14 — the ANONYMOUS theme + logo endpoints, for a customer who has scanned a QR code
310
+ * and has no session yet. Both read through an allowlist of `sys$config` names and nothing else
311
+ * (see publicTheme.js); the router exposes them at GET /ext/{slug}/theme and /logo.
312
+ *
313
+ * ⚠️ POSTGRESQL ONLY, like every other part of the theming feature: `execPostgres` is the path
314
+ * these readers take, and on a FINA tenant the company theme is not stored here at all. On such
315
+ * a tenant the hooks are simply absent and the routes 404 — the same way the whole boundary
316
+ * 404s until a tenant is provisioned, rather than failing in some new way of its own.
317
+ */
318
+ const themeExecFor = (tenant) => (sql) => execPostgres(sql, tenant.dbindex);
319
+ const themingAvailable = isPostgresIndex(dbindex);
320
+
321
+ const getPublicTheme = themingAvailable
322
+ ? createPublicThemeReader({ resolveTenant, execFor: themeExecFor })
323
+ : undefined;
324
+ const getPublicLogo = themingAvailable
325
+ ? createPublicLogoReader({ resolveTenant, execFor: themeExecFor })
326
+ : undefined;
327
+
328
+ /*
329
+ * ⚠️ AND THIS FINALLY FEEDS `getBranding`, which has been an injected hook with no supplier
330
+ * since M10 — so every tenant's PWA install card has read "Biz-A · {slug}" in Material's
331
+ * default blue. Same payload, same read; see brandingFromPublicTheme.
332
+ */
333
+ const getBranding = getPublicTheme
334
+ ? async (slug) => brandingFromPublicTheme(await getPublicTheme(slug))
335
+ : undefined;
336
+
283
337
  return {
284
338
  resolveTenant,
285
339
  getManifest: manifestStore.loadManifest,
286
340
  getAppBundle: appBundle.getAppBundle,
341
+ getPublicTheme,
342
+ getPublicLogo,
343
+ getBranding,
287
344
  auditSink,
288
345
  identityStore,
289
346
  oidc,
@@ -0,0 +1,268 @@
1
+ /*
2
+ * Doc 6 §14 — the PUBLIC theme, for a customer who is not signed in.
3
+ *
4
+ * §14 describes a customer scanning a QR code at a table or opening a link to a merchant's menu.
5
+ * There is no session yet, so the normal theme path — which reads `sys$config` through an
6
+ * authenticated command — cannot run; §14 point 1 calls for "endpoint publik yang mengembalikan
7
+ * tema dan logo berdasarkan company/outlet di URL".
8
+ *
9
+ * ⚠️⚠️ §14 POINT 2 IS THE WHOLE SECURITY STORY. "Endpoint tersebut hanya boleh mengembalikan data
10
+ * tampilan (token, logo, nama). Tidak boleh membocorkan konfigurasi lain dari `sys$config`."
11
+ *
12
+ * That table is not a theme table. It holds APPLICATION_CONFIG, every domain's declarations, the
13
+ * per-entity DDL hashes and the publish status — the entire shape of the tenant's system. This
14
+ * endpoint is reachable by anyone who knows a slug, with no token and no login, so the reader must
15
+ * never ASK for anything outside an exact allowlist of names. A filter applied to a fetched row
16
+ * would be one refactor away from leaking; a query that was never issued cannot.
17
+ *
18
+ * ⚠️ IT RETURNS THE STORED LEVELS, NOT A RESOLVED PALETTE. The client already owns the resolver
19
+ * (`theme-resolver.ts`) and the seed→palette generator (`palette.ts`, which needs HCT and is not
20
+ * available here). Sending levels keeps ONE implementation of the merge — §8.1 point 3's reason for
21
+ * storing a diff rather than an expansion applies just as much on the wire.
22
+ */
23
+ import { APPLICATION_THEME_NAME } from "../domain/themeConfig.js";
24
+ import { resolveThemeTokens } from "../domain/themePresets.js";
25
+ import { COMPANY_THEME_NAME } from "../orm/companyTheme.js";
26
+ import {
27
+ COMPANY_LOGO_NAME,
28
+ readCompanyLogo,
29
+ parseLogoDataUrl,
30
+ } from "../orm/companyLogo.js";
31
+
32
+ /**
33
+ * ⚠️⚠️ THE BOUNDARY. Every `sys$config` name this endpoint may ever name, and it is exported so a
34
+ * test can assert that nothing else is asked for. Adding a name here is a security decision: the
35
+ * value becomes readable by the anonymous public.
36
+ */
37
+ export const PUBLIC_THEME_KEYS = [
38
+ APPLICATION_THEME_NAME,
39
+ COMPANY_THEME_NAME,
40
+ COMPANY_LOGO_NAME,
41
+ ];
42
+
43
+ /*
44
+ * ⚠️ §14 POINT 5 — WHAT `system` MEANS IN A CUSTOMER'S BROWSER.
45
+ *
46
+ * "Perlu diputuskan apakah `colorScheme: system` mengikuti preferensi device pelanggan atau
47
+ * dipaksa mengikuti pilihan merchant." The decision recorded there: the customer channel follows
48
+ * the MERCHANT. A customer whose phone happens to be in dark mode would otherwise see the
49
+ * merchant's menu in dark — the merchant's branding, decided by a stranger's OS setting.
50
+ *
51
+ * `system` is the merchant declining to choose, so there is nothing to follow; this falls back to
52
+ * the same LIGHT that §4.3 note 1 makes the level-1 fallback, rather than inventing a third rule.
53
+ */
54
+ const CUSTOMER_SCHEME_FOR_SYSTEM = "light";
55
+
56
+ const isPlainObject = (v) =>
57
+ typeof v === "object" && v !== null && !Array.isArray(v);
58
+
59
+ /*
60
+ * ⚠️ A STORED THEME IS DATA AND MAY BE MALFORMED (§8.1 point 1, AC 26) — hand-edited, or written by
61
+ * an older shape. Degrading to "no theme at this level" costs branding on one screen; throwing would
62
+ * take the endpoint down for every customer of every merchant on this hub.
63
+ */
64
+ const parseTheme = (raw) => {
65
+ if (raw == null || raw === "") return null;
66
+ if (isPlainObject(raw)) return raw;
67
+ try {
68
+ const parsed = JSON.parse(String(raw));
69
+ return isPlainObject(parsed) ? parsed : null;
70
+ } catch {
71
+ return null;
72
+ }
73
+ };
74
+
75
+ const forCustomer = (theme) => {
76
+ if (!theme) return null;
77
+ /* Applied at BOTH levels: the merge is per key, so an application-level `system` surfaces
78
+ whenever the company has not set one of its own. */
79
+ return theme.colorScheme === "system"
80
+ ? { ...theme, colorScheme: CUSTOMER_SCHEME_FOR_SYSTEM }
81
+ : theme;
82
+ };
83
+
84
+ /**
85
+ * Build the anonymous theme reader.
86
+ *
87
+ * @param resolveTenant slug → tenant, or null. ⚠️ THE SLUG IS NOT A CREDENTIAL, but it must still
88
+ * name a tenant this hub serves: an unknown slug gets `null`, never another
89
+ * merchant's branding.
90
+ * @param execFor tenant → an `exec(sql)` bound to that tenant's database, mirroring the
91
+ * `{exec}` shape `readCompanyTheme`/`readCompanyLogo` already take.
92
+ */
93
+ export const createPublicThemeReader = ({ resolveTenant, execFor }) => {
94
+ return async (slug) => {
95
+ const tenant = resolveTenant ? await resolveTenant(slug) : null;
96
+ if (resolveTenant && !tenant) return null;
97
+
98
+ const exec = execFor(tenant);
99
+
100
+ /*
101
+ * ⚠️ ONE QUERY PER ALLOWLISTED NAME, each spelling its name as a literal. There is no code
102
+ * path here that takes a name from the request — the customer cannot ask for a fourth row.
103
+ *
104
+ * ⚠️ ORDER BY ID for the same reason `readCompanyTheme` has it: SYS$CONFIG's only constraint
105
+ * is PRIMARY KEY (id), so nothing stops a duplicate, and picking the lowest id beats picking
106
+ * whichever row the planner returned.
107
+ */
108
+ const readOne = async (name, columns) => {
109
+ try {
110
+ const rows = await exec(
111
+ `SELECT ${columns} FROM SYS$CONFIG WHERE NAME = '${name}' ORDER BY ID`,
112
+ );
113
+ return Array.isArray(rows) ? rows[0] || null : null;
114
+ } catch {
115
+ /* ⚠️ A database that is down must not 500 the customer's first paint. They get the
116
+ level-1 fallback, which is what an unbranded merchant looks like anyway. */
117
+ return null;
118
+ }
119
+ };
120
+
121
+ const [applicationRow, companyRow, logoRow] = await Promise.all([
122
+ readOne(APPLICATION_THEME_NAME, "ID, DATA"),
123
+ readOne(COMPANY_THEME_NAME, "ID, DATA"),
124
+ /*
125
+ * ⚠️ ID ONLY — EXISTENCE, NOT THE IMAGE (§8.2 point 3, §14 point 3). A logo is up to 2 MB
126
+ * and base64 inflates it by a third; carrying it inside the theme payload would put a
127
+ * multi-megabyte download in front of a customer's first paint, on mobile data, every
128
+ * time the theme is read. The image has its own endpoint and its own cache lifetime.
129
+ */
130
+ readOne(COMPANY_LOGO_NAME, "ID"),
131
+ ]);
132
+
133
+ const application = forCustomer(
134
+ parseTheme(applicationRow?.DATA ?? applicationRow?.data ?? null),
135
+ );
136
+ const company = forCustomer(
137
+ parseTheme(companyRow?.DATA ?? companyRow?.data ?? null),
138
+ );
139
+
140
+ return {
141
+ application,
142
+ company,
143
+ /* The merchant's display name, company level first — §14 point 2 names it as one of the
144
+ three things a customer is allowed to see. */
145
+ name: company?.name ?? application?.name ?? null,
146
+ hasLogo: logoRow !== null,
147
+ };
148
+ };
149
+ };
150
+
151
+ /*
152
+ * ⚠️⚠️ THE STORED-SVG HAZARD, AND WHY THE LOGO COMES BACK AS JSON.
153
+ *
154
+ * §8.2 point 2 allows SVG, and it is the right format for a logo: one small file, sharp at every
155
+ * size. But an SVG is a DOCUMENT. Loaded through `<img src>` it is inert; served as `image/svg+xml`
156
+ * and opened as a TOP-LEVEL NAVIGATION it runs its own `<script>` in the origin that served it —
157
+ * and the file behind it was uploaded by a company admin, since `writeCompanyLogo` checks the media
158
+ * type and the size but never the markup inside.
159
+ *
160
+ * The first version of this endpoint returned the raw bytes under a `sandbox` CSP, which closes
161
+ * that. ⚠️ IT DOES NOT SURVIVE THE PRODUCTION TRANSPORT. In production the CLI never faces the
162
+ * browser: biz-a relays `/ext/{slug}/*` over a hub socket and returns `{status, contentType, body}`
163
+ * (`biz-a/src/hubController.js`) — EVERY OTHER RESPONSE HEADER IS DROPPED, the CSP among them. So
164
+ * the protection would have been present in every test and absent in production, on biz-a's own
165
+ * origin. (The relay would also have corrupted the bytes: `hubProxy.js` re-issues through axios as
166
+ * JSON, which decodes a PNG as UTF-8 text.)
167
+ *
168
+ * So the image travels as a base64 data URL inside an `application/json` body. A browser pointed at
169
+ * this URL gets JSON and renders text; the client puts the data URL in an `<img src>`, where SVG
170
+ * never executes script. The hazard is closed by the SHAPE of the response rather than by a header
171
+ * that a transport in the middle is free to discard.
172
+ *
173
+ * The headers below are still sent — they cost nothing and they do apply on the direct path, where
174
+ * the client reaches this router itself — but nothing depends on them any more.
175
+ */
176
+ export const LOGO_RESPONSE_HEADERS = {
177
+ "Content-Security-Policy":
178
+ "default-src 'none'; script-src 'none'; style-src 'unsafe-inline'; sandbox",
179
+ "X-Content-Type-Options": "nosniff",
180
+ /* ⚠️ §14 point 3 — the logo is the largest thing a customer downloads and it changes about once
181
+ a year. An hour is short enough that a rebrand is visible the same day. ⚠️ Also dropped by the
182
+ relay, so the CLIENT caches this per slug rather than relying on it. */
183
+ "Cache-Control": "public, max-age=3600",
184
+ };
185
+
186
+ /**
187
+ * Build the anonymous logo reader.
188
+ *
189
+ * ⚠️ RETURNS THE DATA URL, not bytes — see the note above on why the response is JSON.
190
+ *
191
+ * ⚠️ VALIDATION IS `readCompanyLogo`'s, unchanged: it re-checks the media type on the way out, so a
192
+ * row hand-edited in the database cannot turn this into a serve-anything endpoint. That check is now
193
+ * load-bearing rather than belt-and-braces, since the response carries no policy of its own.
194
+ */
195
+ export const createPublicLogoReader = ({ resolveTenant, execFor }) => {
196
+ return async (slug) => {
197
+ const tenant = resolveTenant ? await resolveTenant(slug) : null;
198
+ if (resolveTenant && !tenant) return null;
199
+
200
+ let dataUrl = null;
201
+ try {
202
+ dataUrl = await readCompanyLogo({ exec: execFor(tenant) });
203
+ } catch {
204
+ return null;
205
+ }
206
+
207
+ const parsed = dataUrl ? parseLogoDataUrl(dataUrl) : null;
208
+ if (!parsed) return null;
209
+
210
+ return { contentType: parsed.contentType, dataUrl: parsed.dataUrl };
211
+ };
212
+ };
213
+
214
+ /*
215
+ * The per-tenant PWA install card (webmanifest.js, PW-2) — `createExtRouter`'s `getBranding` hook.
216
+ *
217
+ * ⚠️ THAT HOOK HAS BEEN DEAD SINCE M10: it is an injected parameter that nothing ever supplied, so
218
+ * every tenant's install prompt has read "Biz-A · {slug}" in Material's default blue. The public
219
+ * theme is the missing source — §14's "nama, logo, tema" is precisely what an install card shows.
220
+ *
221
+ * ⚠️⚠️ THE COMPANY LEVEL IS USED WHOLE, NOT MERGED WITH THE APPLICATION'S. The real resolver has a
222
+ * rule this deliberately does NOT re-implement — §4.3's "a named preset REPLACES the palette, it
223
+ * does not blend with the level above" — and a second, subtly different merge living in the CLI is
224
+ * exactly the duplication this endpoint avoids by sending levels to the client. An install card
225
+ * needs a name and two colours; it does not need its own resolver.
226
+ */
227
+ export const brandingFromPublicTheme = (payload) => {
228
+ if (!isPlainObject(payload)) return {};
229
+
230
+ const level = payload.company ?? payload.application;
231
+ if (!isPlainObject(level)) {
232
+ return payload.name ? { name: payload.name } : {};
233
+ }
234
+
235
+ /* `system` is already resolved to a definite scheme by the reader (§14 point 5); this is the
236
+ same fallback for a level that never named one. */
237
+ const scheme = level.colorScheme === "dark" ? "dark" : "light";
238
+
239
+ /* ⚠️ A token is EITHER a `{light, dark}` pair or a single colour — an override may legitimately
240
+ supply one value for both. Assuming the pair is what an AOT build once caught at the same
241
+ spot in the client. */
242
+ const pick = (token) => {
243
+ if (typeof token === "string") return token;
244
+ return isPlainObject(token) ? token[scheme] || null : null;
245
+ };
246
+
247
+ const tokens = resolveThemeTokens(level) || {};
248
+
249
+ return {
250
+ ...(payload.name ? { name: payload.name } : {}),
251
+ ...(pick(tokens.primaryColor) ? { themeColor: pick(tokens.primaryColor) } : {}),
252
+ ...(pick(tokens.surfaceColor)
253
+ ? { backgroundColor: pick(tokens.surfaceColor) }
254
+ : {}),
255
+ /*
256
+ * ⚠️ NO ICON FROM THE LOGO, deliberately, even though `hasLogo` is right there. A PWA icon
257
+ * must declare its `sizes`, and declaring the wrong ones makes the app UNINSTALLABLE —
258
+ * worse than a default icon.
259
+ *
260
+ * ⚠️ AND THE SERVER CANNOT MEASURE IT. It stores a base64 data URL, not a decoded image;
261
+ * learning the dimensions here would mean an image library in `cli`, on every tenant's hub,
262
+ * to produce an install-card nicety. (§8.2 point 4's resize now caps the LONG edge at 512
263
+ * and preserves aspect ratio, so the result is usually not square either — which is what a
264
+ * maskable icon wants.) If this is ever worth doing, store the dimensions at upload time,
265
+ * where they are already known.
266
+ */
267
+ };
268
+ };
@@ -11,6 +11,7 @@ import { anonymousOperationNames } from "./manifest.js";
11
11
  import { makeExtDispatch, EXT_ERROR_STATUS } from "./dispatch.js";
12
12
  import { buildExtWebManifest } from "./webmanifest.js";
13
13
  import { createRateLimiter } from "./rateLimit.js";
14
+ import { LOGO_RESPONSE_HEADERS } from "./publicTheme.js";
14
15
 
15
16
  // Real source IP behind the hub/tunnel: first hop of X-Forwarded-For, else the socket peer.
16
17
  function clientIp(req) {
@@ -35,6 +36,8 @@ export function createExtRouter({
35
36
  resolveTenant,
36
37
  getChannelInfo,
37
38
  getBranding,
39
+ getPublicTheme,
40
+ getPublicLogo,
38
41
  rateLimit,
39
42
  withTenantScope,
40
43
  }) {
@@ -113,6 +116,58 @@ export function createExtRouter({
113
116
  .json(buildExtWebManifest(slug, branding));
114
117
  });
115
118
 
119
+ // Doc 6 §14 — GET /ext/:slug/theme. ANONYMOUS, like manifest.webmanifest: a customer who has
120
+ // scanned a QR code has no token yet, and the page must be branded before anything else
121
+ // happens (§14 point 4). It returns the stored theme LEVELS, which the client resolves with
122
+ // its own resolver + palette generator — one implementation of the merge, not two.
123
+ //
124
+ // ⚠️⚠️ AC 18: display data ONLY. The reader (publicTheme.js) is allowlist-driven and never asks
125
+ // sys$config for anything but the theme keys; this route adds NOTHING of its own — no tenant
126
+ // record, no manifest, no dbindex. Merging in "context" here is how an allowlisted reader
127
+ // quietly stops being enough.
128
+ router.get("/theme", async (req, res) => {
129
+ const { slug } = req.params;
130
+ if (!getPublicTheme) return fail(res, 404, "NOT_FOUND");
131
+ let theme = null;
132
+ try {
133
+ theme = await getPublicTheme(slug);
134
+ } catch {
135
+ // ⚠️ A reader that throws must not become a 500 on a customer's first paint — they get
136
+ // the level-1 fallback, which is what an unbranded merchant looks like anyway.
137
+ theme = null;
138
+ }
139
+ if (!theme) return fail(res, 404, "NOT_FOUND");
140
+ // Short: a company theme changes rarely but an admin editing it wants to see it, and this
141
+ // is small JSON. The LOGO is the one worth caching hard (LOGO_RESPONSE_HEADERS).
142
+ res.set("Cache-Control", "public, max-age=60");
143
+ return res.status(200).json(theme);
144
+ });
145
+
146
+ // Doc 6 §8.2 point 3 + §14 point 3 — GET /ext/:slug/logo, SEPARATE from the theme so a
147
+ // multi-megabyte image is not carried in front of the customer's first paint.
148
+ //
149
+ // ⚠️⚠️ JSON, NOT AN IMAGE RESPONSE, AND THAT IS THE SECURITY DECISION. The stored file may be an
150
+ // SVG (§8.2 point 2 allows it), and an SVG served as image/svg+xml runs its own <script> when a
151
+ // browser navigates straight to it. Serving it under a `sandbox` CSP would close that — except
152
+ // biz-a's relay forwards only {status, contentType, body} and drops every other header, so the
153
+ // CSP would exist in tests and be absent in production. A base64 data URL inside JSON cannot be
154
+ // navigated into an executing document at all. See LOGO_RESPONSE_HEADERS in publicTheme.js.
155
+ router.get("/logo", async (req, res) => {
156
+ const { slug } = req.params;
157
+ if (!getPublicLogo) return fail(res, 404, "NOT_FOUND");
158
+ let logo = null;
159
+ try {
160
+ logo = await getPublicLogo(slug);
161
+ } catch {
162
+ logo = null;
163
+ }
164
+ if (!logo) return fail(res, 404, "NOT_FOUND");
165
+ /* Defence in depth on the DIRECT path (local, or a client that reaches this router itself);
166
+ the relay drops them, which is why the JSON shape is what the safety rests on. */
167
+ res.set(LOGO_RESPONSE_HEADERS);
168
+ return res.status(200).json(logo);
169
+ });
170
+
116
171
  // GET /ext/:slug/app — the tenant's external app bundle (SYS$APPS metadata + packaged
117
172
  // TEMPLATES blob) for the apps the manifest references. T0+; the client caches + expands
118
173
  // it into its slug-scoped IndexedDB and renders operation forms from it.
@@ -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
 
@@ -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
@@ -210,6 +210,25 @@ const rewriteGenId = (statement) =>
210
210
  ),
211
211
  );
212
212
 
213
+ /*
214
+ * ASCII_CHAR(<n>) -> CHR(<n>). Firebird's spelling; PostgreSQL has only CHR.
215
+ *
216
+ * ⚠️⚠️ THIS IS HOW A NEWLINE REACHES EITHER ENGINE. A raw newline in the statement ends the command
217
+ * on the FINA/Firebird transport — the CLI Script screen failed live with "Unexpected end of
218
+ * command - line 1, column 59", column 59 being exactly where the script literal begins in
219
+ * `... SET SCRIPT = '`. The admin client therefore builds the break instead of typing it: one
220
+ * literal per line, joined by ASCII_CHAR(10). Collapsing the lines was not an option, because a CLI
221
+ * script is JavaScript and one `//` comment would comment out the rest of the file.
222
+ *
223
+ * ⚠️ OUTSIDE LITERALS ONLY, for the same reason as GEN_ID and more sharply: the value being written
224
+ * IS a script, and a script may legitimately contain the text "ASCII_CHAR". Rewriting it there
225
+ * would corrupt the very source being installed.
226
+ */
227
+ const rewriteAsciiChar = (statement) =>
228
+ outsideLiterals(statement, (segment) =>
229
+ segment.replace(/\bASCII_CHAR\s*\(\s*(\d+)\s*\)/gi, (_all, code) => `CHR(${code})`),
230
+ );
231
+
213
232
  /* DATEADD(<n> <unit> TO <expr>) -> (<expr> + INTERVAL '<n> <unit>').
214
233
 
215
234
  Firebird-only, and unlike GEN_ID it usually cannot be fixed app-side: most of the 23 call sites
@@ -459,7 +478,11 @@ const translateReadBlock = (sql) => {
459
478
  statement path cannot drift apart. */
460
479
  const translateStatement = (statement) =>
461
480
  lowerQuotedIdentifiers(
462
- rewriteFirstSkip(rewriteRdbDatabase(rewriteDateAdd(rewriteGenId(rewriteUpsert(statement))))),
481
+ rewriteFirstSkip(
482
+ rewriteRdbDatabase(
483
+ rewriteDateAdd(rewriteAsciiChar(rewriteGenId(rewriteUpsert(statement)))),
484
+ ),
485
+ ),
463
486
  );
464
487
 
465
488
  const WRAPPER = /^\s*EXECUTE\s+BLOCK\b[\s\S]*?\bAS\b\s*BEGIN\b/i;
@@ -4,6 +4,7 @@
4
4
  * Delphi TListRecord (QuerySQL / recordsTotalSQL).
5
5
  */
6
6
  import { buildPagingClause } from "./dialect.js";
7
+ import { isPostgresIndex } from "../domain/dialect.js";
7
8
  import { softDeleteFilter, tenantFilter, isBooleanColumn } from "./entityConfig.js";
8
9
  import { createJoinRegistry, splitKey } from "./joinChain.js";
9
10
 
@@ -105,6 +106,52 @@ const buildProjection = (columns, registry) => {
105
106
  * values mirror the JSON type "exactly as Delphi did"). An unknown table or column returns false from
106
107
  * `isBooleanColumn`, so the unsure case is always "leave it alone".
107
108
  */
109
+ /*
110
+ * ⚠️⚠️ FIREBIRD'S `CONTAINING`, WHICH POSTGRESQL DOES NOT HAVE.
111
+ *
112
+ * `buildCondition` renders `<column> <operator> <value>`, so whatever the client sends lands in the
113
+ * SQL as typed — and the client sends `containing` for every substring search: every lookup dropdown
114
+ * (`select-lookup.service.ts`) and every "contains" filter (`filter-list.service.ts`). On a
115
+ * PostgreSQL tenant that is a hard `syntax error at or near "containing"`, so no search box in the
116
+ * entire client worked.
117
+ *
118
+ * ⚠️ TRANSLATED HERE RATHER THAN IN THE CLIENT, for the reason the boolean coercion above already
119
+ * gives: the call sites sit inside generic components used everywhere, every miss stays silent until
120
+ * somebody types in a search box, and the next application would start the same hunt.
121
+ *
122
+ * ⚠️ `ILIKE`, NOT `LIKE`. Firebird's CONTAINING is case-INSENSITIVE; rendering `LIKE` would quietly
123
+ * narrow what every existing search matches, which is a wrong answer rather than an error.
124
+ */
125
+ const CONTAINING = "containing";
126
+ const NOT_CONTAINING = "not containing";
127
+
128
+ /*
129
+ * Wrap the value in wildcards, escaping the ones it already contains.
130
+ *
131
+ * ⚠️⚠️ THE ESCAPING IS THE POINT. `%` and `_` are wildcards to ILIKE but ORDINARY CHARACTERS to
132
+ * CONTAINING — so without this, searching for "50%" would match every row and "a_b" would match
133
+ * "axb". A wrong answer, silently, which is worse than the syntax error this replaces.
134
+ *
135
+ * ⚠️ Backslash FIRST, or the escapes added afterwards get escaped in turn.
136
+ */
137
+ const toContainingPattern = (value) => {
138
+ const text = value === null || value === undefined ? "" : String(value);
139
+ /* The client sends the value already quoted; take what is inside so the wildcards go within it. */
140
+ const quoted = /^'([\s\S]*)'$/.exec(text.trim());
141
+ const inner = quoted ? quoted[1] : text.trim();
142
+ /*
143
+ * ⚠️ Built with split/join and a char-code backslash rather than regex escapes — the escaping
144
+ * here is the whole point, and a backslash that does not survive editing produces a silently
145
+ * wrong pattern rather than a syntax error.
146
+ */
147
+ const BACKSLASH = String.fromCharCode(92);
148
+ const escaped = inner
149
+ .split(BACKSLASH).join(BACKSLASH + BACKSLASH)
150
+ .split('%').join(BACKSLASH + '%')
151
+ .split('_').join(BACKSLASH + '_');
152
+ return `'%${escaped}%'`;
153
+ };
154
+
108
155
  const BARE_BOOLEAN_LITERAL = /^[01]$/;
109
156
  const coerceBooleanFilterValue = (rawColumn, value, dbIndex) => {
110
157
  const text = value === null || value === undefined ? "" : String(value).trim();
@@ -138,6 +185,16 @@ const buildCondition = (filter, registry, dbIndex) => {
138
185
  if (VALUELESS_OPERATORS.has(operator.toLowerCase())) {
139
186
  return rendered;
140
187
  }
188
+ /*
189
+ * ⚠️ POSTGRESQL ONLY. On Firebird `CONTAINING` is the correct operator and must be left alone —
190
+ * this is a translation for one engine, not a change to the filter language.
191
+ */
192
+ const lowered = operator.toLowerCase().replace(/\s+/g, " ").trim();
193
+ if ((lowered === CONTAINING || lowered === NOT_CONTAINING) && isPostgresIndex(dbIndex)) {
194
+ const rendered2 = `${junction ? ` ${junction}` : ""} ${column} ${lowered === NOT_CONTAINING ? "NOT ILIKE" : "ILIKE"}`;
195
+ return `${rendered2} ${toContainingPattern(filter.value1)}`;
196
+ }
197
+
141
198
  if (operator.toLowerCase() === "between") {
142
199
  return `${rendered} ${filter.value1} AND ${filter.value2}`;
143
200
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "biz-a-cli",
3
- "version": "2.3.80-15359",
3
+ "version": "2.3.80-15367",
4
4
  "description": "",
5
5
  "main": "bin/index.js",
6
6
  "type": "module",