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 +31 -0
- package/bin/hub.js +5 -0
- package/engine/domain/appConfig.js +36 -1
- package/engine/domain/contrast.js +15 -7
- package/engine/domain/styleColors.js +235 -0
- package/engine/domain/theme-presets.json +12 -0
- package/engine/domain/themeConfig.js +1 -0
- package/engine/ext/live.js +57 -0
- package/engine/ext/publicTheme.js +268 -0
- package/engine/ext/routes.js +55 -0
- package/engine/orm/apiRoute.js +69 -0
- package/engine/orm/dialect.js +65 -0
- package/engine/orm/executeBlock.js +24 -1
- package/engine/orm/listQuery.js +57 -0
- package/package.json +1 -1
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
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
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
|
},
|
package/engine/ext/live.js
CHANGED
|
@@ -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
|
+
};
|
package/engine/ext/routes.js
CHANGED
|
@@ -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.
|
package/engine/orm/apiRoute.js
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* failure -> {"error":"..."} , which db/ds.js's dsReq already looks for.
|
|
21
21
|
*/
|
|
22
22
|
import { json2List, addTableNameToArray, lookupToSql, lookupTotalToSql, resolveDbIndex, buildWriteSql } from "./index.js";
|
|
23
|
+
import { nextGeneratorValue } from "./dialect.js";
|
|
23
24
|
import { createReaders } from "./changeInference.js";
|
|
24
25
|
import { CONFLICT_CODE, ROW_VERSION_KEY, refreshRowVersion } from "./concurrency.js";
|
|
25
26
|
import {
|
|
@@ -135,6 +136,27 @@ export const parseExecuteSqlRequest = (path, body) => {
|
|
|
135
136
|
}
|
|
136
137
|
};
|
|
137
138
|
|
|
139
|
+
/*
|
|
140
|
+
* `genId` carries EVERYTHING IN THE PATH and posts a null body:
|
|
141
|
+
* `/fina/rest/TOrmMethod/%22genId%22/SYS$USERGROUP_GEN/3` (data.service.ts genId).
|
|
142
|
+
*
|
|
143
|
+
* ⚠️ Which is why `parseDataSnapRequest` never recognised it — that one needs `_parameters[0]` and
|
|
144
|
+
* quietly declines a body it cannot read. The call fell through to FINA, and on a tenant with no
|
|
145
|
+
* FINA the admin screen answered `502 ECONNREFUSED 127.0.0.1:212`.
|
|
146
|
+
*
|
|
147
|
+
* Returns { generator, dbIndex } or null.
|
|
148
|
+
*/
|
|
149
|
+
const GENID_PATH = /\/fina\/rest\/\w+\/(?:%22|")genId(?:%22|")\/([^/?#]+)\/(\d+)/i;
|
|
150
|
+
|
|
151
|
+
export const parseGenIdRequest = (path) => {
|
|
152
|
+
const match = GENID_PATH.exec(String(path ?? ""));
|
|
153
|
+
if (!match) return null;
|
|
154
|
+
const generator = decodeURIComponent(match[1]).trim();
|
|
155
|
+
if (!generator) return null;
|
|
156
|
+
const dbIndex = Number(match[2]);
|
|
157
|
+
return { generator, dbIndex: Number.isFinite(dbIndex) ? dbIndex : null };
|
|
158
|
+
};
|
|
159
|
+
|
|
138
160
|
export const parseDataSnapRequest = (path, body) => {
|
|
139
161
|
const match = DATASNAP_PATH.exec(String(path ?? ""));
|
|
140
162
|
if (!match) return null;
|
|
@@ -580,6 +602,53 @@ export const routeApiRequest = async (
|
|
|
580
602
|
}
|
|
581
603
|
}
|
|
582
604
|
|
|
605
|
+
/*
|
|
606
|
+
* ⚠️⚠️ `genId` — a Firebird GENERATOR, answered from PostgreSQL's own counter.
|
|
607
|
+
*
|
|
608
|
+
* This used to fall through to FINA with the rest of the non-ORM surface, which was honest while
|
|
609
|
+
* the generator was merely SOMEWHERE ELSE. It is not: the PostgreSQL migrations translate
|
|
610
|
+
* `<TABLE>_GEN` + BI trigger into `GENERATED BY DEFAULT AS IDENTITY`, so on a PostgreSQL tenant
|
|
611
|
+
* the generator does not exist at all and the forward could only ever reach a Firebird counter
|
|
612
|
+
* for a different database — or, on a tenant with no FINA, `ECONNREFUSED`.
|
|
613
|
+
*
|
|
614
|
+
* ⚠️ Answering it from FINA was the WORSE outcome of the two. `buildUpsert` honours a supplied
|
|
615
|
+
* key, so a value drawn from one database and inserted into another collides with the identity
|
|
616
|
+
* column's own sequence as soon as the two counters cross. Resolving to the table's OWN sequence
|
|
617
|
+
* is what makes the pre-allocated key and the column default one counter again.
|
|
618
|
+
*
|
|
619
|
+
* ⚠️ A Firebird index still has a real generator, so it still forwards — the mixed tenant
|
|
620
|
+
* `finaEndpoint.js` protects keeps working untouched.
|
|
621
|
+
*/
|
|
622
|
+
const genIdRequest = parseGenIdRequest(path);
|
|
623
|
+
if (genIdRequest) {
|
|
624
|
+
if (!isPostgresIndex(genIdRequest.dbIndex)) return null;
|
|
625
|
+
try {
|
|
626
|
+
const { sequence, value } = await nextGeneratorValue({
|
|
627
|
+
exec,
|
|
628
|
+
dbIndex: genIdRequest.dbIndex,
|
|
629
|
+
generator: genIdRequest.generator,
|
|
630
|
+
});
|
|
631
|
+
/* ⚠️ A caller can act on "no sequence backs SYS$NOPE_GEN". It cannot act on a connection
|
|
632
|
+
error, and telling the two apart is most of what made this bug hard to place. */
|
|
633
|
+
if (sequence === null) {
|
|
634
|
+
return {
|
|
635
|
+
status: 400,
|
|
636
|
+
body: {
|
|
637
|
+
error:
|
|
638
|
+
`no sequence backs generator ${genIdRequest.generator} on database ` +
|
|
639
|
+
`${genIdRequest.dbIndex} — no sequence of that name, and no identity column on ` +
|
|
640
|
+
`${genIdRequest.generator.replace(/_GEN$/i, "")}`,
|
|
641
|
+
},
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
/* ⚠️ A BARE NUMBER, captured from the live FINA rather than guessed: it answers `2`, not
|
|
645
|
+
`{data:…}` and not a string. data.service.ts hands the body straight to the caller. */
|
|
646
|
+
return { status: 200, body: value };
|
|
647
|
+
} catch (error) {
|
|
648
|
+
return { status: 500, body: { error: error?.message ?? String(error) } };
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
583
652
|
const parsed = parseDataSnapRequest(path, body);
|
|
584
653
|
if (!parsed) return null;
|
|
585
654
|
|
package/engine/orm/dialect.js
CHANGED
|
@@ -25,6 +25,71 @@ export const buildPagingClause = (start, length) => {
|
|
|
25
25
|
export const buildSequenceNextValue = (sequenceName) =>
|
|
26
26
|
`SELECT nextval('${String(sequenceName).replace(/'/g, "''")}')`;
|
|
27
27
|
|
|
28
|
+
/*
|
|
29
|
+
* Doc 3 §5 item 11 — which PostgreSQL sequence stands in for a Firebird GENERATOR.
|
|
30
|
+
*
|
|
31
|
+
* ⚠️⚠️ THE SECOND BRANCH IS THE WHOLE POINT. On Firebird a table's key comes from `<TABLE>_GEN`
|
|
32
|
+
* plus a BEFORE INSERT trigger; the PostgreSQL migrations translate that pair into
|
|
33
|
+
* `GENERATED BY DEFAULT AS IDENTITY`, so the generator does not exist here and cannot. Measured on
|
|
34
|
+
* both live PostgreSQL databases: not one `%_GEN` sequence.
|
|
35
|
+
*
|
|
36
|
+
* So `SYS$USERGROUP_GEN` resolves to the sequence `SYS$USERGROUP`'s identity column ALREADY OWNS.
|
|
37
|
+
* That is not a convenience — it is the correctness condition. A caller that pre-allocates a key
|
|
38
|
+
* and then inserts it explicitly (`admin/userGroup.component.ts`, because SYS$UGPRIV is written as
|
|
39
|
+
* an independent row carrying `usergroup_id`) must draw from the SAME counter the column's DEFAULT
|
|
40
|
+
* would use, or the two drift apart and collide on the primary key. That is exactly what was
|
|
41
|
+
* happening while `genId` was forwarded to a FINA whose own generator was a different number in a
|
|
42
|
+
* different database.
|
|
43
|
+
*
|
|
44
|
+
* ⚠️ The exact-name branch comes FIRST so an application that genuinely created `FOO_GEN` keeps
|
|
45
|
+
* working, and so a Firebird-shaped schema restored onto PostgreSQL is still answered correctly.
|
|
46
|
+
*
|
|
47
|
+
* ⚠️ The column is not assumed to be `id`: the lookup asks which of the table's columns owns a
|
|
48
|
+
* sequence. A table whose key is named otherwise resolves just the same.
|
|
49
|
+
*
|
|
50
|
+
* ⚠️ `pg_table_is_visible` keeps both branches inside the caller's search path — without it a
|
|
51
|
+
* same-named sequence in another schema could answer for a table this connection cannot even see.
|
|
52
|
+
*
|
|
53
|
+
* ⚠️ Both names are rendered as LITERALS, compared with `lower(...)`, never as identifiers. Nothing
|
|
54
|
+
* from the request reaches an identifier position: the `nextval` that follows uses the name the
|
|
55
|
+
* DATABASE returned, not the one the caller sent.
|
|
56
|
+
*/
|
|
57
|
+
export const buildGeneratorSequenceSql = (generator) => {
|
|
58
|
+
const name = String(generator ?? "");
|
|
59
|
+
const table = name.replace(/_GEN$/i, "");
|
|
60
|
+
return (
|
|
61
|
+
"SELECT COALESCE(" +
|
|
62
|
+
"(SELECT c.oid::regclass::text FROM pg_class c " +
|
|
63
|
+
`WHERE c.relkind = 'S' AND pg_table_is_visible(c.oid) AND lower(c.relname) = lower(${quoteLiteral(name)}) ` +
|
|
64
|
+
"LIMIT 1), " +
|
|
65
|
+
"(SELECT pg_get_serial_sequence(t.oid::regclass::text, a.attname) FROM pg_class t " +
|
|
66
|
+
"JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum > 0 AND NOT a.attisdropped " +
|
|
67
|
+
`WHERE t.relkind = 'r' AND pg_table_is_visible(t.oid) AND lower(t.relname) = lower(${quoteLiteral(table)}) ` +
|
|
68
|
+
"AND pg_get_serial_sequence(t.oid::regclass::text, a.attname) IS NOT NULL " +
|
|
69
|
+
"LIMIT 1)" +
|
|
70
|
+
") AS sequence_name"
|
|
71
|
+
);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/*
|
|
75
|
+
* The next value of a Firebird-named generator, from PostgreSQL.
|
|
76
|
+
*
|
|
77
|
+
* ⚠️ ONE COPY, because there are two callers and they must not drift: `apiRoute.js` answers the
|
|
78
|
+
* admin client's `genId`, and `engine/ext/live.js` pre-generates SYS$EXT_USER ids. The second was
|
|
79
|
+
* a hand-rolled axios call to FINA and carried the identical latent failure — a PostgreSQL tenant
|
|
80
|
+
* has no FINA to ask and no generator to ask for.
|
|
81
|
+
*
|
|
82
|
+
* Resolves to `{ sequence, value }`, or `{ sequence: null, value: null }` when nothing backs the
|
|
83
|
+
* name. The caller decides what an unresolvable generator means; here it is simply a fact.
|
|
84
|
+
*/
|
|
85
|
+
export const nextGeneratorValue = async ({ exec, dbIndex, generator }) => {
|
|
86
|
+
const resolved = (await exec(buildGeneratorSequenceSql(generator), dbIndex)) ?? [];
|
|
87
|
+
const sequence = resolved[0]?.sequence_name ?? resolved[0]?.SEQUENCE_NAME ?? null;
|
|
88
|
+
if (!sequence) return { sequence: null, value: null };
|
|
89
|
+
const next = (await exec(buildSequenceNextValue(sequence), dbIndex)) ?? [];
|
|
90
|
+
return { sequence, value: Number(next[0]?.nextval ?? next[0]?.NEXTVAL) };
|
|
91
|
+
};
|
|
92
|
+
|
|
28
93
|
export const quoteLiteral = (value) => `'${String(value).replace(/'/g, "''")}'`;
|
|
29
94
|
|
|
30
95
|
/* A JS Date as a SQL literal. `String(date)` gives
|
|
@@ -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(
|
|
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;
|
package/engine/orm/listQuery.js
CHANGED
|
@@ -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
|
}
|