biz-a-cli 2.3.80-15407 → 2.3.80-15410
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/hubEvent.js +2 -1
- package/engine/cc/drift.js +18 -2
- package/engine/cc/mapStore.js +4 -3
- package/engine/orm/apiRoute.js +187 -61
- package/engine/orm/finaEndpoint.js +6 -0
- package/engine/orm/imgQuery.js +92 -0
- package/engine/orm/userApiDataset.js +153 -0
- package/package.json +1 -1
package/bin/hubEvent.js
CHANGED
|
@@ -1116,7 +1116,8 @@ export const clientListener = (socket, argv, via = "?") => {
|
|
|
1116
1116
|
status: routed.status,
|
|
1117
1117
|
statusText: routed.status === 200 ? "OK" : "Error",
|
|
1118
1118
|
headers: {
|
|
1119
|
-
|
|
1119
|
+
/* An image read answers bytes with its own type (apiRoute.js `img`). */
|
|
1120
|
+
"content-type": routed.contentType ?? "application/json",
|
|
1120
1121
|
...cliAddressHeaders(),
|
|
1121
1122
|
},
|
|
1122
1123
|
body: routed.body,
|
package/engine/cc/drift.js
CHANGED
|
@@ -202,10 +202,26 @@ export const unbuilt = (map, functionId, { linked, gapped }) =>
|
|
|
202
202
|
|
|
203
203
|
/* --- drift -------------------------------------------------------------------------------------------- */
|
|
204
204
|
|
|
205
|
+
/* { functionId: { domains } }: each function some link reaches, with the DOMAIN configs it was built into. */
|
|
206
|
+
const builtFunctions = (elements, links) => {
|
|
207
|
+
const out = {};
|
|
208
|
+
for (const link of links ?? []) {
|
|
209
|
+
const element = elements[link.mapElementId];
|
|
210
|
+
const fn = element ? (element.type === "FUNCTION" ? element.id : element.functionId) : null;
|
|
211
|
+
if (!fn) continue;
|
|
212
|
+
const domains = (out[fn] ??= { domains: [] }).domains;
|
|
213
|
+
if (link.configKind === "DOMAIN" && !domains.includes(link.configName)) domains.push(link.configName);
|
|
214
|
+
}
|
|
215
|
+
for (const f of Object.values(out)) f.domains.sort();
|
|
216
|
+
return out;
|
|
217
|
+
};
|
|
218
|
+
|
|
205
219
|
/*
|
|
206
220
|
* links: trace links with their baselines (elementSnapshot / targetSnapshot; null = made before baselines).
|
|
207
221
|
* configs: { "KIND:name": content | null } — the OPEN draft, else what is published.
|
|
208
|
-
* Returns the FLAGGED elements,
|
|
222
|
+
* Returns the FLAGGED elements, the links to baseline now (made before baselines, target present), and the
|
|
223
|
+
* functions that have config — with the domains their links point to (CC generates a function absent from it, and
|
|
224
|
+
* refines those domains for one in it).
|
|
209
225
|
*/
|
|
210
226
|
export const computeDrift = ({ map, links, gaps = [], configs = {} }) => {
|
|
211
227
|
const elements = map?.elements ?? {};
|
|
@@ -249,7 +265,7 @@ export const computeDrift = ({ map, links, gaps = [], configs = {} }) => {
|
|
|
249
265
|
for (const id of unbuilt(map, fn, { linked, gapped })) flag(id).pendingBuild ??= { why: "notBuilt", changes: [] };
|
|
250
266
|
}
|
|
251
267
|
|
|
252
|
-
return { elements: out, baseline };
|
|
268
|
+
return { elements: out, baseline, functions: builtFunctions(elements, links) };
|
|
253
269
|
};
|
|
254
270
|
|
|
255
271
|
/* Exported for the tests of what counts. */
|
package/engine/cc/mapStore.js
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
* links {configKind, configName} the map elements a config implements (the studios' back-links)
|
|
14
14
|
* unlink {linkId} a person removes one link
|
|
15
15
|
* drift {} Doc 1 §7.4 (4b): the elements whose config was changed by hand ("drifted")
|
|
16
|
-
* or that changed since built / are not built yet ("pending build")
|
|
16
|
+
* or that changed since built / are not built yet ("pending build"), and
|
|
17
|
+
* the functions that have config, with their domains
|
|
17
18
|
* markInSync {elementId} a person who fixed both sides re-baselines the element's links
|
|
18
19
|
* printData {version?} Doc 1 §4.8 (4c): a SAVED map version with every gap and link, to print
|
|
19
20
|
*
|
|
@@ -143,10 +144,10 @@ const actions = {
|
|
|
143
144
|
const key = `${link.configKind}:${link.configName}`;
|
|
144
145
|
if (!(key in configs)) configs[key] = (await readConfig(link.configKind, link.configName)).content ?? null;
|
|
145
146
|
}
|
|
146
|
-
const { elements, baseline } = computeDrift({ map: latest.map, links, gaps: await gapRepo.all(), configs });
|
|
147
|
+
const { elements, baseline, functions } = computeDrift({ map: latest.map, links, gaps: await gapRepo.all(), configs });
|
|
147
148
|
/* Links made before baselines existed take what is there now as in sync — once. */
|
|
148
149
|
if (baseline.length) await traceRepo.baseline(baseline);
|
|
149
|
-
return done({ version: latest.version, elements });
|
|
150
|
+
return done({ version: latest.version, elements, functions });
|
|
150
151
|
},
|
|
151
152
|
|
|
152
153
|
/* Ruling 2026-10-03: the implementor fixed both sides by hand — the element's links take both as they are now.
|
package/engine/orm/apiRoute.js
CHANGED
|
@@ -48,6 +48,16 @@ import { buildValidUserIdSql, verifyCredential, buildPasswordUpgradeSql, buildUs
|
|
|
48
48
|
import { resolveUserTenancy, capSessionExpiry } from "./tenantResolution.js";
|
|
49
49
|
import { describeTenants } from "./tenantApi.js";
|
|
50
50
|
import { translateExecuteBlock } from "./executeBlock.js";
|
|
51
|
+
import { isUserApiDataset, saveUserApiDataset } from "./userApiDataset.js";
|
|
52
|
+
import {
|
|
53
|
+
readUpdateImgRequest,
|
|
54
|
+
buildImgColumnTypeSql,
|
|
55
|
+
isTextColumnType,
|
|
56
|
+
buildPgUpdateImg,
|
|
57
|
+
parseImgGetPath,
|
|
58
|
+
buildPgGetImg,
|
|
59
|
+
getContentType,
|
|
60
|
+
} from "./imgQuery.js";
|
|
51
61
|
import zlib from "node:zlib";
|
|
52
62
|
|
|
53
63
|
/* /fina/rest/<Class>/%22<endpoint>%22 — the path the client builds. */
|
|
@@ -165,6 +175,36 @@ export const parseGenIdRequest = (path) => {
|
|
|
165
175
|
return { generator, dbIndex: Number.isFinite(dbIndex) ? dbIndex : null };
|
|
166
176
|
};
|
|
167
177
|
|
|
178
|
+
/*
|
|
179
|
+
* ⚠️⚠️ `img` — the BLOB write and read, which no other parser here can see.
|
|
180
|
+
*
|
|
181
|
+
* The client builds both paths UNQUOTED (`getUrlApi(config, 'img', false)`), so `DATASNAP_PATH`
|
|
182
|
+
* never matches them:
|
|
183
|
+
*
|
|
184
|
+
* write POST /fina/rest/TOrmMethod/img body = {dbIndex, tableName, imgCol, keyCol,
|
|
185
|
+
* keyValue, img: <base64>} — the bare
|
|
186
|
+
* object, no `_parameters`
|
|
187
|
+
* read GET /fina/rest/TOrmMethod/img/<db>/<table>/<keyCol>/<imgCol>/<'key'>[/<formatCol>]
|
|
188
|
+
*
|
|
189
|
+
* Unrecognised, both fell through to FINA. Returns { write: true, dbIndex, request } for a write,
|
|
190
|
+
* { write: false, dbIndex, ...read } for a read, or null.
|
|
191
|
+
*/
|
|
192
|
+
const IMG_WRITE_PATH = /\/fina\/rest\/\w+\/img\/?(?:[?#]|$)/i;
|
|
193
|
+
|
|
194
|
+
export const parseImgRequest = (path, body) => {
|
|
195
|
+
const read = parseImgGetPath(path);
|
|
196
|
+
if (read) return { write: false, ...read };
|
|
197
|
+
if (!IMG_WRITE_PATH.test(String(path ?? ""))) return null;
|
|
198
|
+
try {
|
|
199
|
+
const request = readEnvelope(body);
|
|
200
|
+
if (!request || typeof request !== "object") return null;
|
|
201
|
+
const dbIndex = Number(request.dbIndex);
|
|
202
|
+
return { write: true, dbIndex: Number.isFinite(dbIndex) ? dbIndex : null, request };
|
|
203
|
+
} catch {
|
|
204
|
+
return null; // a body we cannot read is not ours; it goes to FINA untouched
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
|
|
168
208
|
export const parseDataSnapRequest = (path, body) => {
|
|
169
209
|
const match = DATASNAP_PATH.exec(String(path ?? ""));
|
|
170
210
|
if (!match) return null;
|
|
@@ -532,6 +572,118 @@ const requestBaseTable = (parsed) => {
|
|
|
532
572
|
return String(Object.keys(param.object ?? {})[0] ?? "");
|
|
533
573
|
};
|
|
534
574
|
|
|
575
|
+
/* The write. Answered `{success: "OK"}` — FINA's own success shape, which data.service.ts
|
|
576
|
+
handleError accepts. */
|
|
577
|
+
export const IMG_ROW_NOT_FOUND_CODE = "NOT_FOUND";
|
|
578
|
+
const imgWriteResponse = async (request, dbIndex, exec) => {
|
|
579
|
+
/* Every name is checked BEFORE anything runs, the column-type probe included. */
|
|
580
|
+
const fields = readUpdateImgRequest(request);
|
|
581
|
+
const [column] = (await exec(buildImgColumnTypeSql(fields), dbIndex)) ?? [];
|
|
582
|
+
const updated =
|
|
583
|
+
(await exec(buildPgUpdateImg(fields, { textColumn: isTextColumnType(column?.data_type) }), dbIndex)) ?? [];
|
|
584
|
+
/* ⚠️ FINA answers OK here too. That silence is how an item image stayed NULL with nothing shown. */
|
|
585
|
+
if (updated.length === 0) {
|
|
586
|
+
return {
|
|
587
|
+
status: 404,
|
|
588
|
+
body: {
|
|
589
|
+
error: `img: no ${fields.tableName} row has ${fields.keyCol} = ${fields.key}, so the image was not stored`,
|
|
590
|
+
code: IMG_ROW_NOT_FOUND_CODE,
|
|
591
|
+
},
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
return { status: 200, body: { success: "OK" } };
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
/* The read. ⚠️ The body is a BUFFER, not JSON: both entrances check for one and send the bytes
|
|
598
|
+
with `contentType` (finaEndpoint.js, bin/hubEvent.js). FINA's answers are kept — `image/jpeg`
|
|
599
|
+
unless a format column names the type, and an EMPTY 200 for a missing row or a NULL blob. */
|
|
600
|
+
const imgReadResponse = async (read, dbIndex, exec) => {
|
|
601
|
+
const [row] = (await exec(buildPgGetImg(read), dbIndex)) ?? [];
|
|
602
|
+
/* Positional, as FINA reads Fields[0] / Fields[1]: the row's key casing is the driver's business. */
|
|
603
|
+
const [image, format] = Object.values(row ?? {});
|
|
604
|
+
const bytes =
|
|
605
|
+
image === null || image === undefined
|
|
606
|
+
? Buffer.alloc(0)
|
|
607
|
+
: Buffer.isBuffer(image)
|
|
608
|
+
? image
|
|
609
|
+
: Buffer.from(String(image), "utf8"); // a text column
|
|
610
|
+
return {
|
|
611
|
+
status: 200,
|
|
612
|
+
body: bytes,
|
|
613
|
+
contentType: read.formatCol && row ? getContentType(format) : "image/jpeg",
|
|
614
|
+
};
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
/*
|
|
618
|
+
* How a failure inside the router is answered. Shared by the ORM endpoints and `img`, so a refusal
|
|
619
|
+
* means the same thing whichever of them raised it.
|
|
620
|
+
*/
|
|
621
|
+
const failureResponse = (error, dbIndex) => {
|
|
622
|
+
/* Doc 3 §6.7 — a concurrency conflict is not a server fault, and must be distinguishable from
|
|
623
|
+
one WITHOUT parsing a message, so an application can offer reload/retry instead of showing a
|
|
624
|
+
generic failure. */
|
|
625
|
+
if (error?.code === IN_USE_CODE) {
|
|
626
|
+
return { status: 409, body: { error: error.message, code: IN_USE_CODE } };
|
|
627
|
+
}
|
|
628
|
+
if (error?.code === CONFLICT_CODE) {
|
|
629
|
+
return { status: 409, body: { error: error.message, code: CONFLICT_CODE } };
|
|
630
|
+
}
|
|
631
|
+
/* Doc 3 §6.3 — the caller asked the ORM to manage a collection its parent does not own. A
|
|
632
|
+
caller error, and distinguishable without parsing a message so an application can point at
|
|
633
|
+
the screen sending it. */
|
|
634
|
+
/* Doc 3 §11 write side. 403 vs 400 says WHICH kind of problem it is: naming somebody else's
|
|
635
|
+
tenant is an authorization failure, naming none is an under-specified request the caller can
|
|
636
|
+
fix by sending the column. */
|
|
637
|
+
if (error?.code === CROSS_TENANT_CODE) {
|
|
638
|
+
return { status: 403, body: { error: error.message, code: CROSS_TENANT_CODE } };
|
|
639
|
+
}
|
|
640
|
+
if (error?.code === AMBIGUOUS_TENANT_CODE) {
|
|
641
|
+
return { status: 400, body: { error: error.message, code: AMBIGUOUS_TENANT_CODE } };
|
|
642
|
+
}
|
|
643
|
+
/* Request input the ORM will not put into SQL text (sqlGuard.js) - a caller error, named. */
|
|
644
|
+
if (error?.code === GRAPH_READ_KEY_REQUIRED_CODE) {
|
|
645
|
+
return { status: 400, body: { error: error.message, code: GRAPH_READ_KEY_REQUIRED_CODE } };
|
|
646
|
+
}
|
|
647
|
+
if (error?.code === INCOMPLETE_COLLECTION_CODE) {
|
|
648
|
+
return { status: 400, body: { error: error.message, code: INCOMPLETE_COLLECTION_CODE } };
|
|
649
|
+
}
|
|
650
|
+
if (error?.code === UNSAFE_SQL_CODE) {
|
|
651
|
+
return { status: 400, body: { error: error.message, code: UNSAFE_SQL_CODE } };
|
|
652
|
+
}
|
|
653
|
+
if (error?.code === SHARED_RELATIONSHIP_CODE) {
|
|
654
|
+
return { status: 400, body: { error: error.message, code: SHARED_RELATIONSHIP_CODE } };
|
|
655
|
+
}
|
|
656
|
+
/* Doc 3 §11 layer 2 — an engine-level refusal from a raw-SQL path, translated into the same
|
|
657
|
+
vocabulary the ORM path uses. Without this it reaches the browser as a bare 500. */
|
|
658
|
+
const engineRefusal = describeRlsFailure(error);
|
|
659
|
+
if (engineRefusal) {
|
|
660
|
+
return {
|
|
661
|
+
status: engineRefusal.status,
|
|
662
|
+
body: { error: engineRefusal.message, code: engineRefusal.code },
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
/* Found live 2026-10-07: a refused save reached the user as SQL. A refusal the user can act on is named by the
|
|
666
|
+
field's label; anything else keeps its message, never the statement — that goes to the hub log only. */
|
|
667
|
+
const refusal = describeDatabaseRefusal(error, { labelOf: (table, column) => labelFor(table, column, dbIndex) });
|
|
668
|
+
if (refusal) {
|
|
669
|
+
logger.info(`[orm] ${refusal.code}: ${error?.message ?? error}`);
|
|
670
|
+
return { status: refusal.status, body: { error: refusal.message, code: refusal.code } };
|
|
671
|
+
}
|
|
672
|
+
logger.error(`[orm] ${error?.message ?? error}`);
|
|
673
|
+
return { status: 500, body: { error: withoutStatement(error?.message ?? String(error)) } };
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
/* Doc 3 §11 — a verified caller may only name its OWN PostgreSQL tenant. */
|
|
677
|
+
const tenantMismatch = (path, principal, requestedDbIndex) => {
|
|
678
|
+
logger.error(
|
|
679
|
+
`[apiRequest] refused ${path}: principal is for db ${principal.dbIndex} but the request asked for db ${requestedDbIndex}`,
|
|
680
|
+
);
|
|
681
|
+
return {
|
|
682
|
+
status: 403,
|
|
683
|
+
body: { error: "request names a different tenant than the principal", code: "TENANT_MISMATCH" },
|
|
684
|
+
};
|
|
685
|
+
};
|
|
686
|
+
|
|
535
687
|
export const routeApiRequest = async (
|
|
536
688
|
{ path, body },
|
|
537
689
|
/* `principal` is the VERIFIED caller (Doc 3 §11's precondition), or null while the boundary runs
|
|
@@ -759,6 +911,29 @@ export const routeApiRequest = async (
|
|
|
759
911
|
}
|
|
760
912
|
}
|
|
761
913
|
|
|
914
|
+
/*
|
|
915
|
+
* ⚠️⚠️ `img` — the BLOB write and read (parseImgRequest says why nothing else sees them).
|
|
916
|
+
*
|
|
917
|
+
* Forwarded, a PostgreSQL tenant's image went to a FINA holding a DIFFERENT database: the write
|
|
918
|
+
* answered OK and stored nothing, the read answered an empty image. A Firebird index really does
|
|
919
|
+
* keep its images in FINA, so it is still declined — forwarded byte for byte.
|
|
920
|
+
*/
|
|
921
|
+
const imgRequest = parseImgRequest(path, body);
|
|
922
|
+
if (imgRequest) {
|
|
923
|
+
const imgDbIndex = imgRequest.dbIndex;
|
|
924
|
+
if (imgDbIndex === null || !isPostgresIndex(imgDbIndex)) return null;
|
|
925
|
+
if (principal && imgDbIndex !== Number(principal.dbIndex)) {
|
|
926
|
+
return tenantMismatch(path, principal, imgDbIndex);
|
|
927
|
+
}
|
|
928
|
+
try {
|
|
929
|
+
return imgRequest.write
|
|
930
|
+
? await imgWriteResponse(imgRequest.request, imgDbIndex, exec)
|
|
931
|
+
: await imgReadResponse(imgRequest, imgDbIndex, exec);
|
|
932
|
+
} catch (error) {
|
|
933
|
+
return failureResponse(error, imgDbIndex);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
762
937
|
const parsed = parseDataSnapRequest(path, body);
|
|
763
938
|
if (!parsed) return null;
|
|
764
939
|
|
|
@@ -846,13 +1021,7 @@ export const routeApiRequest = async (
|
|
|
846
1021
|
*/
|
|
847
1022
|
if (principal && requestedDbIndex !== null && isPostgresIndex(requestedDbIndex)) {
|
|
848
1023
|
if (Number(requestedDbIndex) !== Number(principal.dbIndex)) {
|
|
849
|
-
|
|
850
|
-
`[apiRequest] refused ${path}: principal is for db ${principal.dbIndex} but the request asked for db ${requestedDbIndex}`,
|
|
851
|
-
);
|
|
852
|
-
return {
|
|
853
|
-
status: 403,
|
|
854
|
-
body: { error: "request names a different tenant than the principal", code: "TENANT_MISMATCH" },
|
|
855
|
-
};
|
|
1024
|
+
return tenantMismatch(path, principal, requestedDbIndex);
|
|
856
1025
|
}
|
|
857
1026
|
}
|
|
858
1027
|
|
|
@@ -934,61 +1103,18 @@ export const routeApiRequest = async (
|
|
|
934
1103
|
const rows = await execTransaction(statements, dbIndex);
|
|
935
1104
|
return { status: 200, body: { data: upperCaseKeys(rows), success: "OK" } };
|
|
936
1105
|
}
|
|
937
|
-
/*
|
|
938
|
-
|
|
1106
|
+
/* The admin Users screen saves through FINA's TUserApiDataset. On PostgreSQL the users are
|
|
1107
|
+
SYS$USERS, so it is answered here (userApiDataset.js); forwarded, it could only meet a FINA
|
|
1108
|
+
that is not running. Every OTHER json2fina module is a FINA accounting transaction with no
|
|
1109
|
+
PostgreSQL counterpart, so it still forwards. */
|
|
1110
|
+
if (parsed.endpoint === "json2fina") {
|
|
1111
|
+
if (!isUserApiDataset(parsed.param)) return null;
|
|
1112
|
+
return await saveUserApiDataset(parsed.param, { exec, dbIndex, principal });
|
|
1113
|
+
}
|
|
1114
|
+
/* Anything else — setApp, the other json2fina modules, TMfgMethod — is not part of the ORM
|
|
1115
|
+
surface. Returning null forwards it to FINA, which is honest: those still need it. */
|
|
939
1116
|
return null;
|
|
940
1117
|
} catch (error) {
|
|
941
|
-
|
|
942
|
-
one WITHOUT parsing a message, so an application can offer reload/retry instead of showing a
|
|
943
|
-
generic failure. */
|
|
944
|
-
if (error?.code === IN_USE_CODE) {
|
|
945
|
-
return { status: 409, body: { error: error.message, code: IN_USE_CODE } };
|
|
946
|
-
}
|
|
947
|
-
if (error?.code === CONFLICT_CODE) {
|
|
948
|
-
return { status: 409, body: { error: error.message, code: CONFLICT_CODE } };
|
|
949
|
-
}
|
|
950
|
-
/* Doc 3 §6.3 — the caller asked the ORM to manage a collection its parent does not own. A
|
|
951
|
-
caller error, and distinguishable without parsing a message so an application can point at
|
|
952
|
-
the screen sending it. */
|
|
953
|
-
/* Doc 3 §11 write side. 403 vs 400 says WHICH kind of problem it is: naming somebody else's
|
|
954
|
-
tenant is an authorization failure, naming none is an under-specified request the caller can
|
|
955
|
-
fix by sending the column. */
|
|
956
|
-
if (error?.code === CROSS_TENANT_CODE) {
|
|
957
|
-
return { status: 403, body: { error: error.message, code: CROSS_TENANT_CODE } };
|
|
958
|
-
}
|
|
959
|
-
if (error?.code === AMBIGUOUS_TENANT_CODE) {
|
|
960
|
-
return { status: 400, body: { error: error.message, code: AMBIGUOUS_TENANT_CODE } };
|
|
961
|
-
}
|
|
962
|
-
/* Request input the ORM will not put into SQL text (sqlGuard.js) - a caller error, named. */
|
|
963
|
-
if (error?.code === GRAPH_READ_KEY_REQUIRED_CODE) {
|
|
964
|
-
return { status: 400, body: { error: error.message, code: GRAPH_READ_KEY_REQUIRED_CODE } };
|
|
965
|
-
}
|
|
966
|
-
if (error?.code === INCOMPLETE_COLLECTION_CODE) {
|
|
967
|
-
return { status: 400, body: { error: error.message, code: INCOMPLETE_COLLECTION_CODE } };
|
|
968
|
-
}
|
|
969
|
-
if (error?.code === UNSAFE_SQL_CODE) {
|
|
970
|
-
return { status: 400, body: { error: error.message, code: UNSAFE_SQL_CODE } };
|
|
971
|
-
}
|
|
972
|
-
if (error?.code === SHARED_RELATIONSHIP_CODE) {
|
|
973
|
-
return { status: 400, body: { error: error.message, code: SHARED_RELATIONSHIP_CODE } };
|
|
974
|
-
}
|
|
975
|
-
/* Doc 3 §11 layer 2 — an engine-level refusal from a raw-SQL path, translated into the same
|
|
976
|
-
vocabulary the ORM path uses. Without this it reaches the browser as a bare 500. */
|
|
977
|
-
const engineRefusal = describeRlsFailure(error);
|
|
978
|
-
if (engineRefusal) {
|
|
979
|
-
return {
|
|
980
|
-
status: engineRefusal.status,
|
|
981
|
-
body: { error: engineRefusal.message, code: engineRefusal.code },
|
|
982
|
-
};
|
|
983
|
-
}
|
|
984
|
-
/* Found live 2026-10-07: a refused save reached the user as SQL. A refusal the user can act on is named by the
|
|
985
|
-
field's label; anything else keeps its message, never the statement — that goes to the hub log only. */
|
|
986
|
-
const refusal = describeDatabaseRefusal(error, { labelOf: (table, column) => labelFor(table, column, dbIndex) });
|
|
987
|
-
if (refusal) {
|
|
988
|
-
logger.info(`[orm] ${refusal.code}: ${error?.message ?? error}`);
|
|
989
|
-
return { status: refusal.status, body: { error: refusal.message, code: refusal.code } };
|
|
990
|
-
}
|
|
991
|
-
logger.error(`[orm] ${error?.message ?? error}`);
|
|
992
|
-
return { status: 500, body: { error: withoutStatement(error?.message ?? String(error)) } };
|
|
1118
|
+
return failureResponse(error, dbIndex);
|
|
993
1119
|
}
|
|
994
1120
|
};
|
|
@@ -232,6 +232,12 @@ export const createFinaRouter = (deps) => {
|
|
|
232
232
|
res.set("Access-Control-Expose-Headers", "biza-cli-address, biza-hub-address");
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
+
/* ⚠️ An image read answers BYTES (apiRoute.js `img`), and this is the route `<img src>` takes.
|
|
236
|
+
`res.json` would send `{"type":"Buffer","data":[…]}` — a 200 the browser cannot draw. */
|
|
237
|
+
if (Buffer.isBuffer(result.body)) {
|
|
238
|
+
res.status(result.status).type(result.contentType ?? "application/octet-stream").send(result.body);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
235
241
|
res.status(result.status).json(result.body);
|
|
236
242
|
});
|
|
237
243
|
return router;
|
package/engine/orm/imgQuery.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* together; the update binds `:logo` / `:format` / `:key` parameters, which the execution layer
|
|
6
6
|
* supplies. Values arrive pre-quoted from the caller, matching the rest of this ORM.
|
|
7
7
|
*/
|
|
8
|
+
import { assertIdentifier, assertQualifiedIdentifier, assertKeyLiteral } from "./sqlGuard.js";
|
|
8
9
|
|
|
9
10
|
const CONTENT_TYPES = {
|
|
10
11
|
jpg: "image/jpeg",
|
|
@@ -66,6 +67,97 @@ export const buildUpdateImg = (request = {}) => {
|
|
|
66
67
|
};
|
|
67
68
|
};
|
|
68
69
|
|
|
70
|
+
/*
|
|
71
|
+
* ⚠️⚠️ THE SAME ENDPOINT ON POSTGRESQL — and why the builders above cannot simply be run.
|
|
72
|
+
*
|
|
73
|
+
* `buildUpdateImg` emits Delphi's `:logo` / `:key` placeholders, which node-pg does not understand,
|
|
74
|
+
* and `buildGetImgSql` interpolates the key into the statement. Neither was ever wired to a route:
|
|
75
|
+
* the client builds both paths UNQUOTED (`getUrlApi(config, 'img', false)`), so apiRoute never
|
|
76
|
+
* recognised them and they fell through to FINA. On a PostgreSQL tenant that FINA holds a different
|
|
77
|
+
* database, and it answered `success: "OK"` for an UPDATE that matched nothing — Supertail's item
|
|
78
|
+
* image kept a NULL CONTENT and nobody was told (studioa, 2026-10-09).
|
|
79
|
+
*
|
|
80
|
+
* These build `{text, values}` for node-pg instead. Names are checked as plain identifiers; the key
|
|
81
|
+
* is BOUND, never interpolated, so a key value cannot become SQL. The FINA builders above stay as
|
|
82
|
+
* they are — tests/orm-parity.test.js pins them to orm.pas.
|
|
83
|
+
*/
|
|
84
|
+
const TEXT_COLUMN_TYPES = new Set(["text", "character varying", "character"]);
|
|
85
|
+
|
|
86
|
+
/* The write request, checked in orm.pas's order and with its messages, plus the identifier guard. */
|
|
87
|
+
export const readUpdateImgRequest = (request = {}) => {
|
|
88
|
+
const tableName = assertQualifiedIdentifier(requireField(request, "tableName"), "img table");
|
|
89
|
+
const imgCol = assertIdentifier(requireField(request, "imgCol"), "img column");
|
|
90
|
+
const keyCol = assertIdentifier(requireField(request, "keyCol"), "img key column");
|
|
91
|
+
const img = requireField(request, "img");
|
|
92
|
+
const key = requireField(request, "keyValue");
|
|
93
|
+
requireField(request, "dbIndex");
|
|
94
|
+
const useFormat = request.formatValue !== undefined && request.formatValue !== null;
|
|
95
|
+
return {
|
|
96
|
+
tableName,
|
|
97
|
+
imgCol,
|
|
98
|
+
keyCol,
|
|
99
|
+
img: String(img),
|
|
100
|
+
key: String(key),
|
|
101
|
+
formatCol: useFormat ? assertIdentifier(requireField(request, "formatCol"), "img format column") : "",
|
|
102
|
+
format: useFormat ? String(request.formatValue).toUpperCase() : "",
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/* Whether the target column is text rather than bytea. Firebird has one BLOB type for both, so the
|
|
107
|
+
request cannot say; binding a Buffer to a text column would store the literal `\x…` escape. */
|
|
108
|
+
export const buildImgColumnTypeSql = ({ tableName, imgCol }) => ({
|
|
109
|
+
text:
|
|
110
|
+
"SELECT data_type FROM information_schema.columns " +
|
|
111
|
+
"WHERE table_schema = current_schema() AND table_name = lower($1) AND column_name = lower($2)",
|
|
112
|
+
values: [String(tableName), String(imgCol)],
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
export const isTextColumnType = (dataType) => TEXT_COLUMN_TYPES.has(String(dataType ?? "").toLowerCase());
|
|
116
|
+
|
|
117
|
+
/* ⚠️ RETURNING the key: FINA cannot tell an UPDATE that hit nothing from one that worked, and that
|
|
118
|
+
blindness is the bug this exists to fix. The caller refuses an empty result. */
|
|
119
|
+
export const buildPgUpdateImg = ({ tableName, imgCol, keyCol, img, key, formatCol, format }, { textColumn = false } = {}) => {
|
|
120
|
+
const bytes = Buffer.from(img, "base64");
|
|
121
|
+
const values = [textColumn ? bytes.toString("utf8") : bytes, key];
|
|
122
|
+
let assignments = `${imgCol} = $1`;
|
|
123
|
+
if (formatCol) {
|
|
124
|
+
assignments += `, ${formatCol} = $3`;
|
|
125
|
+
values.push(format);
|
|
126
|
+
}
|
|
127
|
+
return { text: `UPDATE ${tableName} SET ${assignments} WHERE ${keyCol} = $2 RETURNING ${keyCol}`, values };
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/* GET /fina/rest/<Class>/img/<dbIndex>/<table>/<keyCol>/<imgCol>/<value>[/<formatCol>] — FINA's
|
|
131
|
+
`TOrmMethod.img(dbIndex, table, key, img, value, imgFormat)`, one segment per argument. */
|
|
132
|
+
const IMG_GET_PATH = /\/fina\/rest\/\w+\/img\/(\d+)\/([^/?#]+)\/([^/?#]+)\/([^/?#]+)\/([^/?#]+)(?:\/([^/?#]+))?\/?(?:[?#]|$)/i;
|
|
133
|
+
|
|
134
|
+
/* Never throws: a path it cannot read is not a read it serves. */
|
|
135
|
+
export const parseImgGetPath = (path) => {
|
|
136
|
+
const match = IMG_GET_PATH.exec(String(path ?? ""));
|
|
137
|
+
if (!match) return null;
|
|
138
|
+
try {
|
|
139
|
+
const [table, keyCol, imgCol, value, formatCol] = match.slice(2).map((part) =>
|
|
140
|
+
part === undefined ? "" : decodeURIComponent(part),
|
|
141
|
+
);
|
|
142
|
+
return { dbIndex: Number(match[1]), table, keyCol, imgCol, value, formatCol };
|
|
143
|
+
} catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/* The key arrives pre-quoted for FINA's interpolation (`'{GUID}'`, img-formly.component.ts). It must
|
|
149
|
+
be ONE literal; its quotes are then taken off and the value bound. */
|
|
150
|
+
export const buildPgGetImg = ({ table, keyCol, imgCol, formatCol = "", value }) => {
|
|
151
|
+
assertQualifiedIdentifier(table, "img table");
|
|
152
|
+
assertIdentifier(keyCol, "img key column");
|
|
153
|
+
assertIdentifier(imgCol, "img column");
|
|
154
|
+
if (formatCol) assertIdentifier(formatCol, "img format column");
|
|
155
|
+
const literal = String(assertKeyLiteral(value, "img key")).trim();
|
|
156
|
+
const key = literal.startsWith("'") ? literal.slice(1, -1).replace(/''/g, "'") : literal;
|
|
157
|
+
const columns = formatCol ? `${imgCol}, ${formatCol}` : imgCol;
|
|
158
|
+
return { text: `SELECT ${columns} FROM ${table} WHERE ${keyCol} = $1`, values: [key] };
|
|
159
|
+
};
|
|
160
|
+
|
|
69
161
|
/*
|
|
70
162
|
* Result post-processing: the driver returns rows keyed by bare column name, but callers address
|
|
71
163
|
* them by the FULL requested key (`tn.a`), which is what disambiguates two joined columns that
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* FINA's `TUserApiDataset`, served for a PostgreSQL index — the admin Users screen.
|
|
3
|
+
*
|
|
4
|
+
* User List → Add → Save (client/src/app/admin/userForm.component.ts modifyUser) and the list's
|
|
5
|
+
* Delete (usersList.component.ts) post TFinaMethod/"json2fina" with this data module. apiRoute did
|
|
6
|
+
* not route json2fina, so it went to FINA — which a PostgreSQL tenant does not run, and Add User
|
|
7
|
+
* failed as `502 ECONNREFUSED 127.0.0.1:212`. Users there live in SYS$USERS, beside the tenant data.
|
|
8
|
+
*
|
|
9
|
+
* The Delphi trail, so the behaviour is FINA's and not a guess:
|
|
10
|
+
* FINAMethod.pas:459 needAuthenticate — TUserApiDataset is the one module that demands `password`
|
|
11
|
+
* FINAMethod.pas:734 that password is the CALLER's (`userId`) own -> 'Incorrect password'
|
|
12
|
+
* FINAMethod.pas:499 isAppend — an `id` of '' or -1 inserts; `delete = 1` deletes; else edits
|
|
13
|
+
* FINAMethod.pas:538 AssignField — a master key with no matching column is ignored
|
|
14
|
+
* FINAMethod.pas:839 {"id": <key>} on success, {"error": <message>} on failure, HTTP 200 both
|
|
15
|
+
* UserApiDataset.pas USERNAME upper-cased; `plainpassword` hashed into USERPASSWORD
|
|
16
|
+
*
|
|
17
|
+
* ⚠️ ONE DELIBERATE DIFFERENCE: FINA skips the confirmation when `password` is '' (FINAMethod.pas:734
|
|
18
|
+
* tests `password <> Str_Empty`). Here an empty password is simply an incorrect one — the screen
|
|
19
|
+
* always asks for it, and a write to the user table must not be one empty field away from unguarded.
|
|
20
|
+
*
|
|
21
|
+
* Passwords are hashed with finaAuth.js's scrypt, the scheme the PostgreSQL login verifies, so a user
|
|
22
|
+
* created here can sign in straight away. The envelope carries plain-text passwords: nothing in this
|
|
23
|
+
* module logs or echoes it.
|
|
24
|
+
*/
|
|
25
|
+
import { hashPassword, verifyCredential } from "./finaAuth.js";
|
|
26
|
+
import { buildInsertUserSql } from "./userAdmin.js";
|
|
27
|
+
import { withoutStatement } from "./saveErrors.js";
|
|
28
|
+
import { logger } from "../../logger.js";
|
|
29
|
+
|
|
30
|
+
export const USER_API_DATASET = "TUserApiDataset";
|
|
31
|
+
|
|
32
|
+
export const isUserApiDataset = (param) => String(param?.dataModule ?? "") === USER_API_DATASET;
|
|
33
|
+
|
|
34
|
+
const quote = (value) => String(value ?? "").replace(/'/g, "''");
|
|
35
|
+
const upper = (value) => String(value ?? "").toUpperCase();
|
|
36
|
+
const has = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
|
|
37
|
+
const isBlank = (value) => String(value ?? "").trim() === "";
|
|
38
|
+
|
|
39
|
+
/* Delphi's FieldByName is case-insensitive; the client happens to send lower case. */
|
|
40
|
+
const lowerKeys = (object) =>
|
|
41
|
+
Object.fromEntries(Object.entries(object ?? {}).map(([key, value]) => [key.toLowerCase(), value]));
|
|
42
|
+
|
|
43
|
+
/* The columns verifyCredential reads — the same ones the login selects. */
|
|
44
|
+
export const buildFindUserByIdSql = (userId) =>
|
|
45
|
+
`SELECT USERID, USERNAME, USERPASSWORD, USERLEVEL FROM SYS$USERS WHERE USERID = ${Number(userId)}`;
|
|
46
|
+
|
|
47
|
+
/* Only the keys the master carries are written. ⚠️ USERPASSWORD only for a non-empty plainpassword:
|
|
48
|
+
the edit form omits it when the password is unchanged, and writing it anyway would hash ''. */
|
|
49
|
+
export const buildUpdateUserSql = (userId, master = {}) => {
|
|
50
|
+
const fields = lowerKeys(master);
|
|
51
|
+
const sets = [];
|
|
52
|
+
if (has(fields, "username")) sets.push(`USERNAME = '${quote(upper(fields.username))}'`);
|
|
53
|
+
if (has(fields, "fullname")) {
|
|
54
|
+
sets.push(`FULLNAME = ${fields.fullname === null ? "NULL" : `'${quote(fields.fullname)}'`}`);
|
|
55
|
+
}
|
|
56
|
+
if (has(fields, "userlevel") && !isBlank(fields.userlevel) && Number.isInteger(Number(fields.userlevel))) {
|
|
57
|
+
sets.push(`USERLEVEL = ${Number(fields.userlevel)}`);
|
|
58
|
+
}
|
|
59
|
+
if (!isBlank(fields.plainpassword)) {
|
|
60
|
+
sets.push(`USERPASSWORD = '${quote(hashPassword(fields.plainpassword))}'`);
|
|
61
|
+
}
|
|
62
|
+
if (sets.length === 0) return null;
|
|
63
|
+
return `UPDATE SYS$USERS SET ${sets.join(", ")} WHERE USERID = ${Number(userId)} RETURNING USERID`;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/* SYS$SESSION, SYS$USER_TENANT and SYS$USER_PREFERENCES cascade from SYS$USERS. */
|
|
67
|
+
export const buildDeleteUserSql = (userId) =>
|
|
68
|
+
`DELETE FROM SYS$USERS WHERE USERID = ${Number(userId)} RETURNING USERID`;
|
|
69
|
+
|
|
70
|
+
const answer = (entry) => ({ status: 200, body: { result: [entry] } });
|
|
71
|
+
const refuse = (error) => answer({ error });
|
|
72
|
+
const keyOf = (rows) => rows?.[0]?.userid ?? rows?.[0]?.USERID;
|
|
73
|
+
|
|
74
|
+
/* FINAMethod.pas isAppend: '' or -1. A JSON null reads as '' in Delphi, so it appends too. */
|
|
75
|
+
const isAppend = (id) => id === undefined || id === null || String(id).trim() === "" || Number(id) === -1;
|
|
76
|
+
|
|
77
|
+
/* dialect.js decorate keeps the driver error on `cause` and copies `code` — read both. */
|
|
78
|
+
const describeFailure = (error, master) => {
|
|
79
|
+
const code = String(error?.code ?? error?.cause?.code ?? "");
|
|
80
|
+
if (code === "23505") return `User name '${upper(master.username)}' is already used.`;
|
|
81
|
+
return withoutStatement(error?.message ?? String(error));
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/*
|
|
85
|
+
* The caller whose password confirms the change.
|
|
86
|
+
*
|
|
87
|
+
* ⚠️ A verified principal WINS over the body's `userId`, and a body naming someone else is refused
|
|
88
|
+
* rather than quietly overridden: it is either a stale tab or a forged request, and neither should
|
|
89
|
+
* reach the user table. With no principal (observe mode) the body is all there is — FINA's own rule.
|
|
90
|
+
*/
|
|
91
|
+
const confirmingUserOf = (param, principal) => {
|
|
92
|
+
const claimed = param.userId ?? param.userid;
|
|
93
|
+
if (principal?.userId !== undefined && principal?.userId !== null) {
|
|
94
|
+
if (claimed !== undefined && claimed !== null && Number(claimed) !== Number(principal.userId)) {
|
|
95
|
+
return { error: "The request names a different user than the one signed in." };
|
|
96
|
+
}
|
|
97
|
+
return { userId: Number(principal.userId) };
|
|
98
|
+
}
|
|
99
|
+
return { userId: Number(claimed) };
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/* Returns a DataSnap response — always, and never throws: the caller is a socket handler. */
|
|
103
|
+
export const saveUserApiDataset = async (param, { exec, dbIndex, principal = null }) => {
|
|
104
|
+
const master = lowerKeys(param?.data?.master);
|
|
105
|
+
const isDelete = String(param?.delete ?? "") === "1";
|
|
106
|
+
const inserting = !isDelete && isAppend(param?.id);
|
|
107
|
+
const id = Number(param?.id);
|
|
108
|
+
if (!inserting && !Number.isInteger(id)) return refuse(`Invalid user id: ${param?.id}`);
|
|
109
|
+
|
|
110
|
+
const confirming = confirmingUserOf(param ?? {}, principal);
|
|
111
|
+
if (confirming.error) return refuse(confirming.error);
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const password = param?.password;
|
|
115
|
+
if (!Number.isInteger(confirming.userId) || typeof password !== "string" || password === "") {
|
|
116
|
+
return refuse("Incorrect password");
|
|
117
|
+
}
|
|
118
|
+
const confirmRows = (await exec(buildFindUserByIdSql(confirming.userId), dbIndex)) ?? [];
|
|
119
|
+
const confirmName = confirmRows[0]?.username ?? confirmRows[0]?.USERNAME;
|
|
120
|
+
if (verifyCredential(confirmRows, confirmName, password).userId !== confirming.userId) {
|
|
121
|
+
return refuse("Incorrect password");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (isDelete) {
|
|
125
|
+
const rows = await exec(buildDeleteUserSql(id), dbIndex);
|
|
126
|
+
return keyOf(rows) === undefined ? refuse(`User ${id} not found`) : answer({ id: String(keyOf(rows)) });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (inserting) {
|
|
130
|
+
if (isBlank(master.username)) return refuse("A new user needs a user name.");
|
|
131
|
+
if (isBlank(master.plainpassword)) return refuse("A new user needs a password.");
|
|
132
|
+
const rows = await exec(
|
|
133
|
+
buildInsertUserSql({
|
|
134
|
+
username: master.username,
|
|
135
|
+
password: master.plainpassword,
|
|
136
|
+
fullname: master.fullname,
|
|
137
|
+
userlevel: master.userlevel,
|
|
138
|
+
}),
|
|
139
|
+
dbIndex,
|
|
140
|
+
);
|
|
141
|
+
return answer({ id: String(keyOf(rows) ?? "") });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const update = buildUpdateUserSql(id, master);
|
|
145
|
+
/* Nothing to change still has to name a user that exists, as FINA's DoEdit would. */
|
|
146
|
+
const rows = await exec(update ?? buildFindUserByIdSql(id), dbIndex);
|
|
147
|
+
return keyOf(rows) === undefined ? refuse(`User ${id} not found`) : answer({ id: String(keyOf(rows)) });
|
|
148
|
+
} catch (error) {
|
|
149
|
+
const message = describeFailure(error, master);
|
|
150
|
+
logger.error(`[json2fina] ${USER_API_DATASET}: ${message}`);
|
|
151
|
+
return refuse(message);
|
|
152
|
+
}
|
|
153
|
+
};
|