biz-a-cli 2.3.80-15419 → 2.3.80
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 +20 -255
- package/bin/hub.js +0 -16
- package/bin/hubEvent.js +5 -98
- package/bin/tunnelTarget.js +0 -4
- package/engine/bpm/workflow.js +6 -7
- package/engine/cc/configValidate.js +0 -53
- package/engine/cc/guides/V2-SCREEN-AUTHORING.md +512 -531
- package/engine/cc/layer3Behavior.js +1 -2
- package/engine/cc/layer3Schema.js +1 -3
- package/engine/cc/livePipelines.js +3 -9
- package/engine/cc/publish.js +105 -86
- package/engine/domain/appConfig.js +18 -108
- package/engine/domain/applicationConfig.js +12 -87
- package/engine/domain/dialect.js +0 -138
- package/engine/domain/domainNavigation.js +50 -86
- package/engine/domain/entityConfigLoader.js +4 -16
- package/engine/domain/numericScale.js +0 -2
- package/engine/domain/system.js +0 -2
- package/engine/ext/publicTheme.js +1 -8
- package/engine/orm/apiRoute.js +61 -187
- package/engine/orm/entityConfig.js +2 -32
- package/engine/orm/finaAuth.js +0 -0
- package/engine/orm/finaEndpoint.js +0 -19
- package/engine/orm/imgQuery.js +0 -92
- package/engine/orm/listQuery.js +1 -6
- package/package.json +1 -1
- package/worker/cliScriptWorker.js +0 -5
- package/engine/attachments/store.js +0 -195
- package/engine/domain/domainRegistration.js +0 -65
- package/engine/fina/approval.js +0 -141
- package/engine/fina/approvalService.js +0 -59
- package/engine/fina/attachments.js +0 -165
- package/engine/fina/client.js +0 -84
- package/engine/fina/config.js +0 -26
- package/engine/fina/context.js +0 -58
- package/engine/fina/document.js +0 -67
- package/engine/fina/documentWrite.js +0 -173
- package/engine/fina/documents/index.js +0 -28
- package/engine/fina/documents/jv.js +0 -105
- package/engine/fina/documents/jvWrite.js +0 -260
- package/engine/fina/documents/si.js +0 -40
- package/engine/fina/errors.js +0 -17
- package/engine/fina/features.js +0 -86
- package/engine/fina/guard.js +0 -102
- package/engine/fina/identity.js +0 -87
- package/engine/fina/live.js +0 -19
- package/engine/fina/lock.js +0 -35
- package/engine/fina/master.js +0 -331
- package/engine/fina/masters/coa.js +0 -104
- package/engine/fina/masters/currency.js +0 -39
- package/engine/fina/masters/department.js +0 -32
- package/engine/fina/masters/index.js +0 -21
- package/engine/fina/masters/project.js +0 -53
- package/engine/fina/period.js +0 -56
- package/engine/fina/print.js +0 -136
- package/engine/fina/readPolicy.js +0 -67
- package/engine/fina/recurring.js +0 -200
- package/engine/fina/route.js +0 -61
- package/engine/fina/routes/attachment.js +0 -41
- package/engine/fina/routes/document.js +0 -66
- package/engine/fina/routes/master.js +0 -26
- package/engine/fina/routes/period.js +0 -24
- package/engine/fina/routes/print.js +0 -19
- package/engine/fina/routes/recurring.js +0 -27
- package/engine/fina/routes/session.js +0 -23
- package/engine/fina/routes/setup.js +0 -58
- package/engine/fina/routes/submission.js +0 -46
- package/engine/fina/routes/template.js +0 -31
- package/engine/fina/settings.js +0 -159
- package/engine/fina/sql.js +0 -34
- package/engine/fina/submission.js +0 -290
- package/engine/fina/submissionCompare.js +0 -171
- package/engine/fina/submissionRepo.js +0 -114
- package/engine/fina/template.js +0 -118
- package/engine/fina/userMap.js +0 -79
- package/engine/orm/companyLocale.js +0 -74
- package/engine/orm/userApiDataset.js +0 -153
- package/migrations/postgres/1790600000000__fina.sql +0 -33
- package/migrations/postgres/1790700000000__attachment.sql +0 -29
- package/migrations/postgres/1790800000000__fina_jv_submission.sql +0 -41
package/engine/orm/apiRoute.js
CHANGED
|
@@ -48,16 +48,6 @@ 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";
|
|
61
51
|
import zlib from "node:zlib";
|
|
62
52
|
|
|
63
53
|
/* /fina/rest/<Class>/%22<endpoint>%22 — the path the client builds. */
|
|
@@ -175,36 +165,6 @@ export const parseGenIdRequest = (path) => {
|
|
|
175
165
|
return { generator, dbIndex: Number.isFinite(dbIndex) ? dbIndex : null };
|
|
176
166
|
};
|
|
177
167
|
|
|
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
|
-
|
|
208
168
|
export const parseDataSnapRequest = (path, body) => {
|
|
209
169
|
const match = DATASNAP_PATH.exec(String(path ?? ""));
|
|
210
170
|
if (!match) return null;
|
|
@@ -572,118 +532,6 @@ const requestBaseTable = (parsed) => {
|
|
|
572
532
|
return String(Object.keys(param.object ?? {})[0] ?? "");
|
|
573
533
|
};
|
|
574
534
|
|
|
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
|
-
|
|
687
535
|
export const routeApiRequest = async (
|
|
688
536
|
{ path, body },
|
|
689
537
|
/* `principal` is the VERIFIED caller (Doc 3 §11's precondition), or null while the boundary runs
|
|
@@ -911,29 +759,6 @@ export const routeApiRequest = async (
|
|
|
911
759
|
}
|
|
912
760
|
}
|
|
913
761
|
|
|
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
|
-
|
|
937
762
|
const parsed = parseDataSnapRequest(path, body);
|
|
938
763
|
if (!parsed) return null;
|
|
939
764
|
|
|
@@ -1021,7 +846,13 @@ export const routeApiRequest = async (
|
|
|
1021
846
|
*/
|
|
1022
847
|
if (principal && requestedDbIndex !== null && isPostgresIndex(requestedDbIndex)) {
|
|
1023
848
|
if (Number(requestedDbIndex) !== Number(principal.dbIndex)) {
|
|
1024
|
-
|
|
849
|
+
logger.error(
|
|
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
|
+
};
|
|
1025
856
|
}
|
|
1026
857
|
}
|
|
1027
858
|
|
|
@@ -1103,18 +934,61 @@ export const routeApiRequest = async (
|
|
|
1103
934
|
const rows = await execTransaction(statements, dbIndex);
|
|
1104
935
|
return { status: 200, body: { data: upperCaseKeys(rows), success: "OK" } };
|
|
1105
936
|
}
|
|
1106
|
-
/*
|
|
1107
|
-
|
|
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. */
|
|
937
|
+
/* Anything else — genId, setApp, ExecuteSQL, TMfgMethod — is not part of the ORM surface.
|
|
938
|
+
Returning null forwards it to FINA, which is honest: those still need it. */
|
|
1116
939
|
return null;
|
|
1117
940
|
} catch (error) {
|
|
1118
|
-
|
|
941
|
+
/* Doc 3 §6.7 — a concurrency conflict is not a server fault, and must be distinguishable from
|
|
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)) } };
|
|
1119
993
|
}
|
|
1120
994
|
};
|
|
@@ -340,39 +340,9 @@ export const buildTenancyMap = (parsedDomainConfigs = []) => {
|
|
|
340
340
|
* the answer — and is also what creates the `deleted_at` column, so a declaration and its column
|
|
341
341
|
* arrive together.
|
|
342
342
|
*/
|
|
343
|
-
/*
|
|
344
|
-
* Doc 8 M3 — an EXTERNAL entity is another system's table (FINA's GLACCNT, in FINA's own database), declared for the
|
|
345
|
-
* screens only. Every map below is keyed by TABLE NAME for the tenant database, so its declarations — a soft delete, a
|
|
346
|
-
* boolean column, a relationship — would apply to any tenant table that happened to share the name. They are dropped
|
|
347
|
-
* before any map is built, with every relationship that names one of them.
|
|
348
|
-
*/
|
|
349
|
-
const isExternal = (entity) => entity?.external === true || String(entity?.external ?? "").trim().toLowerCase() === "true";
|
|
350
|
-
const withoutExternalEntities = (configs) =>
|
|
351
|
-
(Array.isArray(configs) ? configs : []).map((config) => {
|
|
352
|
-
const entities = config?.model?.entities;
|
|
353
|
-
if (!entities || typeof entities !== "object") return config;
|
|
354
|
-
const external = new Set();
|
|
355
|
-
const kept = {};
|
|
356
|
-
for (const [name, entity] of Object.entries(entities)) {
|
|
357
|
-
if (!isExternal(entity)) {
|
|
358
|
-
kept[name] = entity;
|
|
359
|
-
continue;
|
|
360
|
-
}
|
|
361
|
-
external.add(String(name).toLowerCase());
|
|
362
|
-
if (entity?.table) external.add(String(entity.table).toLowerCase());
|
|
363
|
-
}
|
|
364
|
-
if (external.size === 0) return config;
|
|
365
|
-
const relationships = Object.fromEntries(
|
|
366
|
-
Object.entries(config.model.relationships ?? {}).filter(
|
|
367
|
-
([, r]) => !external.has(String(r?.from ?? "").toLowerCase()) && !external.has(String(r?.to ?? "").toLowerCase()),
|
|
368
|
-
),
|
|
369
|
-
);
|
|
370
|
-
return { ...config, model: { ...config.model, entities: kept, relationships } };
|
|
371
|
-
});
|
|
372
|
-
|
|
373
343
|
export const primeEntityConfig = async ({ dbIndex, loadDomainConfigs }) => {
|
|
374
344
|
try {
|
|
375
|
-
const configs =
|
|
345
|
+
const configs = await loadDomainConfigs();
|
|
376
346
|
const map = buildEntityMap(configs);
|
|
377
347
|
const relationships = buildRelationshipMap(configs);
|
|
378
348
|
const foreignKeys = buildForeignKeyMap(configs);
|
|
@@ -683,7 +653,7 @@ export const tenantColumnFor = (table, dbIndex) => {
|
|
|
683
653
|
table's — see stampTenant. */
|
|
684
654
|
const isTenantTable = (table, dbIndex) =>
|
|
685
655
|
String(table ?? "").trim().toLowerCase() ===
|
|
686
|
-
(byDbIndex.get(Number(dbIndex))?.tenancy?.tenantTable ?? "
|
|
656
|
+
(byDbIndex.get(Number(dbIndex))?.tenancy?.tenantTable ?? "");
|
|
687
657
|
|
|
688
658
|
/* The entity whose rows ARE tenants, for the RLS policy emission in a later slice. */
|
|
689
659
|
export const tenantTableFor = (dbIndex) => byDbIndex.get(Number(dbIndex))?.tenancy?.tenantTable ?? null;
|
package/engine/orm/finaAuth.js
CHANGED
|
Binary file
|
|
@@ -24,7 +24,6 @@ import { runAsPrincipal, ACTIVE_TENANT_NOT_HELD } from "./tenantContext.js";
|
|
|
24
24
|
import { resolveRequestScope, SESSION_NOT_USABLE } from "./requestScope.js";
|
|
25
25
|
import { authorizeRequest } from "../../security/internalPrincipal.js";
|
|
26
26
|
import { describePrincipalObservation, isPreAuthRequest } from "./dataSnapShape.js";
|
|
27
|
-
import { refuseFinaCloudWrite } from "../fina/guard.js";
|
|
28
27
|
import { logger } from "../../logger.js";
|
|
29
28
|
|
|
30
29
|
const DATASNAP_PATH = /\/fina\/rest\/(\w+)\/(?:%22|")(\w+)(?:%22|")/i;
|
|
@@ -53,10 +52,6 @@ export const handleFinaRequest = async (
|
|
|
53
52
|
chooseActiveTenant = null,
|
|
54
53
|
/* Starts the workflow an entity declares when a v2 save creates one of its records (apiRoute). */
|
|
55
54
|
startWorkflow = null,
|
|
56
|
-
/* Doc 8 — the FINA Cloud database (hub --finaCloudDbindex); absent = FINA Cloud off. */
|
|
57
|
-
finaCloudDbIndex = null,
|
|
58
|
-
/* Doc 8 M8 — the FINA read policy (engine/fina/readPolicy.js): reading FINA takes a mapped FINA user. */
|
|
59
|
-
finaReadPolicy = null,
|
|
60
55
|
},
|
|
61
56
|
) => {
|
|
62
57
|
/*
|
|
@@ -162,14 +157,6 @@ export const handleFinaRequest = async (
|
|
|
162
157
|
};
|
|
163
158
|
}
|
|
164
159
|
|
|
165
|
-
/* Doc 8 D2 — a browser may not write to the FINA Cloud database directly; engine/fina does it, checking the
|
|
166
|
-
user's FINA rights and the date lock. Same guard as the socket entrance (bin/hubEvent.js). */
|
|
167
|
-
const refused = refuseFinaCloudWrite({ path, body, finaCloudDbIndex }) ?? (finaReadPolicy ? await finaReadPolicy({ path, body, headers }) : null);
|
|
168
|
-
if (refused) {
|
|
169
|
-
logger.warn(`[apiRequest] refused ${path}: ${refused.body.error}`);
|
|
170
|
-
return refused;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
160
|
try {
|
|
174
161
|
const forwarded = await forwardToApi({ path, body });
|
|
175
162
|
/* Doc 6 on Firebird — a login FINA answered gets its principal here (apiRoute.js). */
|
|
@@ -245,12 +232,6 @@ export const createFinaRouter = (deps) => {
|
|
|
245
232
|
res.set("Access-Control-Expose-Headers", "biza-cli-address, biza-hub-address");
|
|
246
233
|
}
|
|
247
234
|
|
|
248
|
-
/* ⚠️ An image read answers BYTES (apiRoute.js `img`), and this is the route `<img src>` takes.
|
|
249
|
-
`res.json` would send `{"type":"Buffer","data":[…]}` — a 200 the browser cannot draw. */
|
|
250
|
-
if (Buffer.isBuffer(result.body)) {
|
|
251
|
-
res.status(result.status).type(result.contentType ?? "application/octet-stream").send(result.body);
|
|
252
|
-
return;
|
|
253
|
-
}
|
|
254
235
|
res.status(result.status).json(result.body);
|
|
255
236
|
});
|
|
256
237
|
return router;
|
package/engine/orm/imgQuery.js
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
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";
|
|
9
8
|
|
|
10
9
|
const CONTENT_TYPES = {
|
|
11
10
|
jpg: "image/jpeg",
|
|
@@ -67,97 +66,6 @@ export const buildUpdateImg = (request = {}) => {
|
|
|
67
66
|
};
|
|
68
67
|
};
|
|
69
68
|
|
|
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
|
-
|
|
161
69
|
/*
|
|
162
70
|
* Result post-processing: the driver returns rows keyed by bare column name, but callers address
|
|
163
71
|
* them by the FULL requested key (`tn.a`), which is what disambiguates two joined columns that
|
package/engine/orm/listQuery.js
CHANGED
|
@@ -192,12 +192,7 @@ const buildCondition = (filter, registry, dbIndex) => {
|
|
|
192
192
|
*/
|
|
193
193
|
const lowered = operator.toLowerCase().replace(/\s+/g, " ").trim();
|
|
194
194
|
if ((lowered === CONTAINING || lowered === NOT_CONTAINING) && isPostgresIndex(dbIndex)) {
|
|
195
|
-
|
|
196
|
-
* ⚠️ Read as text: Firebird's CONTAINING converts a number or a date, PostgreSQL's ILIKE takes text
|
|
197
|
-
* only — `bigint ILIKE` is "operator does not exist", and one number column in a search made the
|
|
198
|
-
* whole search an error. A no-op for a text column.
|
|
199
|
-
*/
|
|
200
|
-
const rendered2 = `${junction ? ` ${junction}` : ""} CAST(${column} AS text) ${lowered === NOT_CONTAINING ? "NOT ILIKE" : "ILIKE"}`;
|
|
195
|
+
const rendered2 = `${junction ? ` ${junction}` : ""} ${column} ${lowered === NOT_CONTAINING ? "NOT ILIKE" : "ILIKE"}`;
|
|
201
196
|
return `${rendered2} ${toContainingPattern(assertSearchLiteral(filter.value1, "containing value"))}`;
|
|
202
197
|
}
|
|
203
198
|
|
package/package.json
CHANGED
|
@@ -4,7 +4,6 @@ import * as db from "../db/db.js";
|
|
|
4
4
|
import { ensureEntityConfig } from "../engine/domain/entityConfigPrimer.js";
|
|
5
5
|
import { resolveUserGroups, resolveIsAdministrator } from "../engine/orm/userGroups.js";
|
|
6
6
|
import { execPostgres } from "../engine/domain/dialect.js";
|
|
7
|
-
import { finaScriptLib } from "../engine/fina/approvalService.js";
|
|
8
7
|
import { logger } from "../logger.js";
|
|
9
8
|
|
|
10
9
|
// export const run = async (apiConfig, scriptIdentifier, scriptData) => {
|
|
@@ -181,10 +180,6 @@ export const run = async (apiConfig, scriptIdentifier, scriptData, principal = n
|
|
|
181
180
|
/* Doc 4 §9.4 — terminate sessions; see db.js session(). */
|
|
182
181
|
session: (customCfg) => db.session(mergeConfig(customCfg)),
|
|
183
182
|
};
|
|
184
|
-
|
|
185
|
-
/* Doc 8 M7 — FINA Cloud's JV approval effects (engine/fina/approvalService.js). The instance a script names decides
|
|
186
|
-
what happens and the JV is posted as its submitter, never as anyone the script says. */
|
|
187
|
-
lib.fina = finaScriptLib(baseConfig, { emitUsers: lib.emitUsers });
|
|
188
183
|
};
|
|
189
184
|
|
|
190
185
|
try {
|