biz-a-cli 2.3.80-15381 → 2.3.80-15390
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 +30 -42
- package/bin/directHubEvent.js +6 -2
- package/bin/hub.js +14 -0
- package/bin/hubEvent.js +123 -65
- package/bin/tunnelTarget.js +60 -7
- package/db/db.js +2 -2
- package/db/ds.js +21 -2
- package/engine/bpm/domainRules.js +71 -0
- package/engine/bpm/routes.js +2 -0
- package/engine/bpm/rules.d.ts +121 -0
- package/engine/bpm/rules.js +241 -0
- package/engine/bpm/validateReport.js +60 -0
- package/engine/bpm/validateWorkflow.d.ts +37 -0
- package/engine/bpm/validateWorkflow.js +400 -0
- package/engine/bpm/vmGuard.js +13 -0
- package/engine/bpm/workflow-runtime.js +193 -246
- package/engine/bpm/workflow.js +81 -54
- package/engine/cc/aiTurns.js +167 -0
- package/engine/cc/appMenus.d.ts +25 -0
- package/engine/cc/appMenus.js +154 -0
- package/engine/cc/draftStore.js +443 -0
- package/engine/cc/jsonDiff.js +49 -0
- package/engine/cc/jsonPatch.js +161 -0
- package/engine/cc/livePipelines.js +65 -0
- package/engine/cc/ownershipGuard.js +45 -0
- package/engine/cc/pgRepo.js +185 -0
- package/engine/cc/publish.js +264 -0
- package/engine/cc/published.js +51 -0
- package/engine/cc/turnRepo.js +109 -0
- package/engine/domain/appConfig.js +22 -26
- package/engine/domain/applicationConfig.js +3 -0
- package/engine/domain/entityConfigLoader.js +2 -1
- package/engine/domain/finaBlocks.js +121 -0
- package/engine/orm/apiRoute.js +38 -1
- package/engine/orm/companyLogo.js +53 -1
- package/engine/orm/companyTheme.js +12 -8
- package/engine/orm/finaEndpoint.js +7 -2
- package/engine/orm/principalGuards.js +12 -6
- package/engine/orm/registryClient.js +18 -6
- package/engine/orm/requestScope.js +6 -1
- package/engine/orm/sqlDialect.js +32 -0
- package/engine/orm/tenantSql.js +55 -0
- package/engine/orm/userPreferences.js +16 -11
- package/envs/env.dev.js +4 -5
- package/envs/env.js +9 -5
- package/envs/hosts.js +4 -0
- package/migrations/postgres/1789700000000__theme_audit.sql +2 -1
- package/migrations/postgres/1789800000000__cc_draft.sql +64 -0
- package/migrations/postgres/1789900000000__cc_draft_content_json.sql +10 -0
- package/migrations/postgres/1790000000000__cc_doc1.sql +139 -0
- package/migrations/postgres/1790100000000__cc_doc1_revoke.sql +16 -0
- package/migrations/postgres/1790200000000__theme_audit_revoke.sql +9 -0
- package/package.json +2 -3
- package/readme.md +17 -1
- package/scheduler/configController.js +57 -98
- package/scheduler/timer.js +4 -3
- package/security/aiGatewayClient.js +131 -0
- package/security/whoami.js +25 -0
- package/config/databases.json +0 -20
- package/config/ext-oidc.json +0 -7
- package/config/secrets.json +0 -21
package/bin/app.js
CHANGED
|
@@ -33,7 +33,6 @@ import {
|
|
|
33
33
|
buildPostgresSetAppSql,
|
|
34
34
|
extractConfigVersion,
|
|
35
35
|
getChunkedUpsertBlocks,
|
|
36
|
-
getUpsertBlock,
|
|
37
36
|
isSemanticVersion,
|
|
38
37
|
parseConfigForVersion,
|
|
39
38
|
readConfigRows,
|
|
@@ -52,6 +51,7 @@ import {
|
|
|
52
51
|
renderApplicationConfig,
|
|
53
52
|
uploadSourceConfigName,
|
|
54
53
|
} from "../engine/domain/publishedAdditions.js";
|
|
54
|
+
import { guardCcOwnedDomains } from "../engine/cc/ownershipGuard.js";
|
|
55
55
|
import { validateTenancyDeclaration } from "../engine/orm/tenancyDeclaration.js";
|
|
56
56
|
import { logger } from "../logger.js";
|
|
57
57
|
import { findRawColorsInSource } from "../engine/domain/styleColors.js";
|
|
@@ -155,6 +155,13 @@ const addCommandOptions = {
|
|
|
155
155
|
demandOption: false,
|
|
156
156
|
default: false,
|
|
157
157
|
},
|
|
158
|
+
releaseCc: {
|
|
159
|
+
describe:
|
|
160
|
+
"Config mode: upload a domain that Control Center owns, handing it back to SVN. Without it such a domain is refused, so CC edits are never silently overwritten",
|
|
161
|
+
type: "boolean",
|
|
162
|
+
demandOption: false,
|
|
163
|
+
default: false,
|
|
164
|
+
},
|
|
158
165
|
};
|
|
159
166
|
|
|
160
167
|
const removeCommandOptions = {
|
|
@@ -377,9 +384,6 @@ const APPLICATION_CONFIG_FILE = "application.js";
|
|
|
377
384
|
/* Doc 6 §8.1 — the application theme source. JSON, because it is DATA that reaches the browser
|
|
378
385
|
via sys$config and must never be evaluated (AC 26). */
|
|
379
386
|
const THEME_CONFIG_FILE = "theme.json";
|
|
380
|
-
/* Parity with the admin editor (appConfig.component.ts executeBlockBodyLimit): the DataSnap
|
|
381
|
-
`block` call body must stay under 64KB, so an oversized config is upserted in chunks. */
|
|
382
|
-
const EXECUTE_BLOCK_BODY_LIMIT = 2 ** 16;
|
|
383
387
|
|
|
384
388
|
const findApplicationConfigFile = (fileNames = []) =>
|
|
385
389
|
fileNames.find(
|
|
@@ -405,7 +409,7 @@ const isConfigModeDir = (workingDir, files = null) => {
|
|
|
405
409
|
flattenSql(), whose `--` comment stripping mangles a config upsert — the whole config is embedded
|
|
406
410
|
as a single-line quoted literal, so the first `--` ANYWHERE in the config text (a JS comment, a
|
|
407
411
|
CSS custom property, an HTML template) silently truncates the rest of the statement and Firebird
|
|
408
|
-
reports "Unexpected end of command".
|
|
412
|
+
reports "Unexpected end of command". getChunkedUpsertBlocks already emits single-line SQL, so there is
|
|
409
413
|
nothing to flatten. This mirrors what the admin editor sends (dataService.executeBlock). */
|
|
410
414
|
const executeConfigBlock = async (blockSql, { server, apiPort, dbIndex }) => {
|
|
411
415
|
const url = `${server}:${apiPort}/fina/rest/TOrmMethod/%22block%22`;
|
|
@@ -425,43 +429,9 @@ const executeConfigBlock = async (blockSql, { server, apiPort, dbIndex }) => {
|
|
|
425
429
|
return parsed;
|
|
426
430
|
};
|
|
427
431
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
JSON.stringify({ dbIndex, method: "block", object: blockSql }),
|
|
432
|
-
],
|
|
433
|
-
}).length;
|
|
434
|
-
|
|
435
|
-
/* One upsert block when it fits, else halve the chunk size until every block fits. Mirrors
|
|
436
|
-
appConfig.component.ts resolveUpsertBlocksByBodyLimit. */
|
|
437
|
-
const resolveUpsertBlocks = (name, version, content, dbIndex) => {
|
|
438
|
-
const singleBlock = getUpsertBlock(name, version, content);
|
|
439
|
-
if (
|
|
440
|
-
estimateExecuteBlockBodyLength(singleBlock, dbIndex) <=
|
|
441
|
-
EXECUTE_BLOCK_BODY_LIMIT
|
|
442
|
-
) {
|
|
443
|
-
return [singleBlock];
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
let chunkSize = EXECUTE_BLOCK_BODY_LIMIT;
|
|
447
|
-
while (chunkSize > 0) {
|
|
448
|
-
const blocks = getChunkedUpsertBlocks(name, version, content, chunkSize);
|
|
449
|
-
if (
|
|
450
|
-
blocks.every(
|
|
451
|
-
(block) =>
|
|
452
|
-
estimateExecuteBlockBodyLength(block, dbIndex) <=
|
|
453
|
-
EXECUTE_BLOCK_BODY_LIMIT,
|
|
454
|
-
)
|
|
455
|
-
) {
|
|
456
|
-
return blocks;
|
|
457
|
-
}
|
|
458
|
-
chunkSize = Math.floor(chunkSize / 2);
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
throw new Error(
|
|
462
|
-
`Failed to split "${name}" into executeBlock-sized chunks.`,
|
|
463
|
-
);
|
|
464
|
-
};
|
|
432
|
+
/* The upsert blocks for one config. getChunkedUpsertBlocks sizes them by the finished SQL in bytes,
|
|
433
|
+
under Firebird's 65,535-byte statement limit (engine/domain/finaBlocks.js) — one block when it fits. */
|
|
434
|
+
const resolveUpsertBlocks = (name, version, content) => getChunkedUpsertBlocks(name, version, content);
|
|
465
435
|
|
|
466
436
|
/* PURE-ish (reads files, no network): resolve every config in the directory and validate the
|
|
467
437
|
WHOLE set before anything is uploaded, so a stray lib or a typo'd metadata can never leave a
|
|
@@ -728,6 +698,7 @@ async function uploadConfigs({
|
|
|
728
698
|
sub,
|
|
729
699
|
publish = false,
|
|
730
700
|
forceFull = false,
|
|
701
|
+
releaseCc = false,
|
|
731
702
|
serverMode = "prod",
|
|
732
703
|
}) {
|
|
733
704
|
/* Phase B2: ask biz-a for this tenant's database coordinates before anything reads the
|
|
@@ -750,6 +721,19 @@ async function uploadConfigs({
|
|
|
750
721
|
timeout: 60000,
|
|
751
722
|
};
|
|
752
723
|
|
|
724
|
+
/*
|
|
725
|
+
* Doc 1 step 0a.2 — ⚠️ one owner at a time. A domain Control Center owns is refused BEFORE anything
|
|
726
|
+
* is saved (the application config included), so a refused upload writes nothing at all.
|
|
727
|
+
*/
|
|
728
|
+
if (isPostgresIndex(dbIndex)) {
|
|
729
|
+
const { released } = await guardCcOwnedDomains({
|
|
730
|
+
exec: (sql) => execPostgres(sql, dbIndex),
|
|
731
|
+
domainNames: domains.map(({ name }) => name),
|
|
732
|
+
releaseCc,
|
|
733
|
+
});
|
|
734
|
+
if (released.length > 0) logger.info(`handed back to SVN (no longer owned by Control Center): ${released.join(", ")}`);
|
|
735
|
+
}
|
|
736
|
+
|
|
753
737
|
const saveOne = async ({ name, version, content }) => {
|
|
754
738
|
/* A PostgreSQL index writes straight to its pool. No chunking: the block splitting below
|
|
755
739
|
exists only because the FINA HTTP body has a size limit, which a direct connection does
|
|
@@ -1039,6 +1023,7 @@ async function addApp({
|
|
|
1039
1023
|
body = null,
|
|
1040
1024
|
publish = false,
|
|
1041
1025
|
forceFull = false,
|
|
1026
|
+
releaseCc = false,
|
|
1042
1027
|
serverMode = "prod",
|
|
1043
1028
|
/* Extra directories whose DOMAIN configs upload alongside this one's (see --include). */
|
|
1044
1029
|
include = [],
|
|
@@ -1095,6 +1080,7 @@ async function addApp({
|
|
|
1095
1080
|
sub,
|
|
1096
1081
|
publish,
|
|
1097
1082
|
forceFull,
|
|
1083
|
+
releaseCc,
|
|
1098
1084
|
serverMode,
|
|
1099
1085
|
});
|
|
1100
1086
|
}
|
|
@@ -1303,6 +1289,7 @@ async function runUnitTests(options) {
|
|
|
1303
1289
|
files: options.files,
|
|
1304
1290
|
publish: options.publish,
|
|
1305
1291
|
forceFull: options.forceFull,
|
|
1292
|
+
releaseCc: options.releaseCc,
|
|
1306
1293
|
serverMode: options.serverMode,
|
|
1307
1294
|
});
|
|
1308
1295
|
} else {
|
|
@@ -1336,6 +1323,7 @@ const buildCli = () =>
|
|
|
1336
1323
|
files: commandOptions.files,
|
|
1337
1324
|
publish: commandOptions.publish,
|
|
1338
1325
|
forceFull: commandOptions.forceFull,
|
|
1326
|
+
releaseCc: commandOptions.releaseCc,
|
|
1339
1327
|
serverMode: commandOptions.serverMode,
|
|
1340
1328
|
include: commandOptions.include,
|
|
1341
1329
|
});
|
package/bin/directHubEvent.js
CHANGED
|
@@ -9,7 +9,7 @@ import { extClientListener } from "../engine/ext/socketListener.js";
|
|
|
9
9
|
import { io as ioClient } from "socket.io-client";
|
|
10
10
|
|
|
11
11
|
import { prefixedLogger } from "../logger.js";
|
|
12
|
-
import { APP_URL, TEST_URL, ADMIN_URL, TEST_ADMIN_URL } from "../envs/hosts.js";
|
|
12
|
+
import { APP_URL, TEST_URL, ADMIN_URL, TEST_ADMIN_URL, CC_URL } from "../envs/hosts.js";
|
|
13
13
|
|
|
14
14
|
const logger = prefixedLogger("[DirectHub]");
|
|
15
15
|
|
|
@@ -203,10 +203,12 @@ export function createSocketServer(httpServer, cliIpAddress = "127.0.0.1") {
|
|
|
203
203
|
TEST_URL,
|
|
204
204
|
ADMIN_URL,
|
|
205
205
|
TEST_ADMIN_URL,
|
|
206
|
+
CC_URL,
|
|
206
207
|
// biz-a.id is still served alongside biz-a.app, so its browser origins stay allowed.
|
|
207
208
|
"https://biz-a.id",
|
|
208
209
|
"https://test.biz-a.id",
|
|
209
210
|
"https://admin.biz-a.id",
|
|
211
|
+
"https://cc.biz-a.id",
|
|
210
212
|
// NOT the wildcard (see above):
|
|
211
213
|
// /\.biz-a\.id$/,
|
|
212
214
|
"vscode-file://vscode-app",
|
|
@@ -217,8 +219,10 @@ export function createSocketServer(httpServer, cliIpAddress = "127.0.0.1") {
|
|
|
217
219
|
`http://${cliIpAddress}:4200`,
|
|
218
220
|
"http://localhost:4200",
|
|
219
221
|
"http://localhost",
|
|
222
|
+
// Control Center (bcc) dev server, beside the client on 4200.
|
|
223
|
+
"http://localhost:4300",
|
|
220
224
|
]
|
|
221
|
-
: [`http://${cliIpAddress}:4200`, "http://localhost:4200"],
|
|
225
|
+
: [`http://${cliIpAddress}:4200`, "http://localhost:4200", "http://localhost:4300"],
|
|
222
226
|
),
|
|
223
227
|
},
|
|
224
228
|
maxHttpBufferSize: 1e8, // 100 MB
|
package/bin/hub.js
CHANGED
|
@@ -121,6 +121,20 @@ const argv = yargs(process.argv.slice(2))
|
|
|
121
121
|
rebuilt to send a principal. A tenant opts in explicitly. */
|
|
122
122
|
default: "observe",
|
|
123
123
|
})
|
|
124
|
+
.options("ruleMode", {
|
|
125
|
+
describe:
|
|
126
|
+
"Doc 1 0b.2: DOMAIN_RULE guards run their rule script. 'observe' logs what a rule would block and lets it pass; 'enforce' blocks.",
|
|
127
|
+
type: "string",
|
|
128
|
+
choices: ["observe", "enforce"],
|
|
129
|
+
/* ⚠️ The DEFAULT STAYS `observe`, as for principalMode: a tenant switches to enforce once its logs
|
|
130
|
+
show its rules behave. */
|
|
131
|
+
default: "observe",
|
|
132
|
+
})
|
|
133
|
+
.options("aiGateway", {
|
|
134
|
+
describe:
|
|
135
|
+
"Doc 1 0b.6b: base URL of the central AI gateway (biz-a-server /ai) that ccai calls. Defaults to this hub's server (serverMode).",
|
|
136
|
+
type: "string",
|
|
137
|
+
})
|
|
124
138
|
.options("services", {
|
|
125
139
|
describe:
|
|
126
140
|
"Comma-separated list of engine jobs this agent handles (e.g., 'BPM,EMAIL')",
|
package/bin/hubEvent.js
CHANGED
|
@@ -16,7 +16,8 @@ import { extDeriveRoomsHandler } from "../engine/ext/esb.js";
|
|
|
16
16
|
import { makeExtReqHandler } from "../engine/ext/hubProxy.js";
|
|
17
17
|
|
|
18
18
|
import { logger } from "../logger.js";
|
|
19
|
-
import { routeApiRequest } from "../engine/orm/apiRoute.js";
|
|
19
|
+
import { routeApiRequest, parseAuthRequest, withFinaLoginPrincipal } from "../engine/orm/apiRoute.js";
|
|
20
|
+
import { tenantSqlFor } from "../engine/orm/tenantSql.js";
|
|
20
21
|
import { ensureEntityConfig } from "../engine/domain/entityConfigPrimer.js";
|
|
21
22
|
import { runAsPrincipal, runTrusted, ACTIVE_TENANT_NOT_HELD } from "../engine/orm/tenantContext.js";
|
|
22
23
|
import { resolveRequestScope, SESSION_NOT_USABLE } from "../engine/orm/requestScope.js";
|
|
@@ -24,6 +25,12 @@ import { createSessionStore } from "../engine/orm/sessionStore.js";
|
|
|
24
25
|
import { resolveUserTenancy } from "../engine/orm/tenantResolution.js";
|
|
25
26
|
import { buildUserTenantSql } from "../engine/orm/finaAuth.js";
|
|
26
27
|
import { authorizeRequest, principalForCliScript } from "../security/internalPrincipal.js";
|
|
28
|
+
import { whoami } from "../security/whoami.js";
|
|
29
|
+
import { runDraftAction } from "../engine/cc/draftStore.js";
|
|
30
|
+
import { createPgDraftRepo } from "../engine/cc/pgRepo.js";
|
|
31
|
+
import { runAiAction } from "../engine/cc/aiTurns.js";
|
|
32
|
+
import { createTurnRepo } from "../engine/cc/turnRepo.js";
|
|
33
|
+
import { createAiGatewayClient } from "../security/aiGatewayClient.js";
|
|
27
34
|
import {
|
|
28
35
|
readUserPreferences,
|
|
29
36
|
writeUserPreferences,
|
|
@@ -43,7 +50,7 @@ import {
|
|
|
43
50
|
execPostgresTransaction,
|
|
44
51
|
isPostgresIndex,
|
|
45
52
|
} from "../engine/domain/dialect.js";
|
|
46
|
-
import {
|
|
53
|
+
import { pipeTunnel } from "./tunnelTarget.js";
|
|
47
54
|
/*
|
|
48
55
|
* Doc 4 §8 — ⚠️ `runAsPrincipal` VALIDATES SYNCHRONOUSLY, so a browser-supplied active tenant outside
|
|
49
56
|
* the signed claim throws out of the CALL, not out of the promise it would have returned. On this
|
|
@@ -228,59 +235,34 @@ export const streamEvent = async (socket, argv) =>
|
|
|
228
235
|
},
|
|
229
236
|
});
|
|
230
237
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
socket.once(clientId, () => {
|
|
260
|
-
// hub server shall notify us to end pipeline as soon as possible
|
|
261
|
-
client.end();
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
ss(socket).emit(clientId, s);
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
client.setTimeout(IDLE_SOCKET_TIMEOUT_MILLISECONDS);
|
|
268
|
-
client.on("timeout", () => {
|
|
269
|
-
// in case client not notify to end pipeline, then inactivity timeout will end it
|
|
270
|
-
client.end();
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
client.on("error", (err) => {
|
|
274
|
-
// handle connection refusal (create a stream and immediately close it)
|
|
275
|
-
logger.error(
|
|
276
|
-
`API Error : tunnel could not reach ${target.local ? "this CLI's own endpoint" : "the API"} at ` +
|
|
277
|
-
`${target.host}:${target.port} — `,
|
|
278
|
-
err,
|
|
279
|
-
);
|
|
280
|
-
let s = ss.createStream();
|
|
281
|
-
ss(socket).emit(clientId, s);
|
|
282
|
-
s.end();
|
|
238
|
+
/*
|
|
239
|
+
* Where this pipe ends is decided by the request's FIRST BYTES (bin/tunnelTarget.js): a tenant
|
|
240
|
+
* this CLI serves on PostgreSQL, and — since 2026-10-01 — a Firebird tenant's LOGIN, are looped
|
|
241
|
+
* back to the hub's own FINA-compatible endpoint; every other request goes to FINA unchanged.
|
|
242
|
+
* Without the loop-back a Firebird login never passed through the CLI, so it never received
|
|
243
|
+
* the signed principal (Doc 6 on Firebird).
|
|
244
|
+
*/
|
|
245
|
+
const s = ss.createStream();
|
|
246
|
+
pipeTunnel({
|
|
247
|
+
stream: s,
|
|
248
|
+
argv,
|
|
249
|
+
isPostgresIndex,
|
|
250
|
+
connect: (target) =>
|
|
251
|
+
target.secure
|
|
252
|
+
? tls.connect({
|
|
253
|
+
port: target.port,
|
|
254
|
+
host: target.host,
|
|
255
|
+
// Looping back to ourselves, the certificate is our own.
|
|
256
|
+
rejectUnauthorized: !target.local,
|
|
257
|
+
})
|
|
258
|
+
: net.connect(target.port, target.host),
|
|
259
|
+
transform: () => addCLIAddressAsResponseHeader,
|
|
260
|
+
idleMs: IDLE_SOCKET_TIMEOUT_MILLISECONDS,
|
|
261
|
+
// hub server shall notify us to end pipeline as soon as possible
|
|
262
|
+
onClient: (client) => socket.once(clientId, () => client.end()),
|
|
263
|
+
logger,
|
|
283
264
|
});
|
|
265
|
+
ss(socket).emit(clientId, s);
|
|
284
266
|
};
|
|
285
267
|
|
|
286
268
|
// const cliReqCb = async (data, callback) => {
|
|
@@ -483,6 +465,14 @@ const handleCliCommand = async (data, cb, argv) => {
|
|
|
483
465
|
case "status":
|
|
484
466
|
cb(null, status(argv));
|
|
485
467
|
break;
|
|
468
|
+
/*
|
|
469
|
+
* Doc 1 step 0a.1 — who THIS CLI verified is asking. Control Center checks it before
|
|
470
|
+
* offering any editing: a successful login at biz-a does not prove the principal
|
|
471
|
+
* verifies here. Headers only; see security/whoami.js.
|
|
472
|
+
*/
|
|
473
|
+
case "whoami":
|
|
474
|
+
cb(null, whoami(data.headers, { mode: argv["principalMode"] || "observe" }));
|
|
475
|
+
break;
|
|
486
476
|
case "runcliscript":
|
|
487
477
|
const liveTriggerPayload = {
|
|
488
478
|
arguments: argv,
|
|
@@ -542,16 +532,19 @@ const handleCliCommand = async (data, cb, argv) => {
|
|
|
542
532
|
const prefPrincipal = principalForCliScript(data.headers, {
|
|
543
533
|
mode: argv["principalMode"] || "observe",
|
|
544
534
|
});
|
|
545
|
-
|
|
535
|
+
/* Doc 6 on Firebird (2026-10-01) — the tenant seam runs this on either engine. */
|
|
536
|
+
const prefDb = tenantSqlFor(argv["dbindex"]);
|
|
546
537
|
const prefs =
|
|
547
538
|
data.action === "write"
|
|
548
539
|
? await writeUserPreferences({
|
|
549
|
-
exec:
|
|
540
|
+
exec: prefDb.exec,
|
|
541
|
+
execStatements: prefDb.atomic,
|
|
542
|
+
sql: prefDb,
|
|
550
543
|
principal: prefPrincipal,
|
|
551
544
|
patch: data.patch,
|
|
552
545
|
})
|
|
553
546
|
: await readUserPreferences({
|
|
554
|
-
exec:
|
|
547
|
+
exec: prefDb.exec,
|
|
555
548
|
principal: prefPrincipal,
|
|
556
549
|
});
|
|
557
550
|
cb(null, { success: true, data: prefs });
|
|
@@ -573,12 +566,15 @@ const handleCliCommand = async (data, cb, argv) => {
|
|
|
573
566
|
const themePrincipal = principalForCliScript(data.headers, {
|
|
574
567
|
mode: argv["principalMode"] || "observe",
|
|
575
568
|
});
|
|
576
|
-
|
|
569
|
+
/* Doc 6 on Firebird (2026-10-01) — one transaction on either engine: a PostgreSQL
|
|
570
|
+
transaction, or ONE Firebird EXECUTE BLOCK (engine/orm/tenantSql.js). */
|
|
571
|
+
const themeDb = tenantSqlFor(argv["dbindex"]);
|
|
572
|
+
const themeExec = themeDb.exec;
|
|
577
573
|
if (data.action === "write") {
|
|
578
574
|
const saved = await writeCompanyTheme({
|
|
579
575
|
exec: themeExec,
|
|
580
|
-
execStatements:
|
|
581
|
-
|
|
576
|
+
execStatements: themeDb.atomic,
|
|
577
|
+
sql: themeDb,
|
|
582
578
|
principal: themePrincipal,
|
|
583
579
|
theme: data.theme,
|
|
584
580
|
});
|
|
@@ -612,14 +608,17 @@ const handleCliCommand = async (data, cb, argv) => {
|
|
|
612
608
|
const logoPrincipal = principalForCliScript(data.headers, {
|
|
613
609
|
mode: argv["principalMode"] || "observe",
|
|
614
610
|
});
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
611
|
+
/* Doc 6 on Firebird (2026-10-01) — the tenant seam; on Firebird a logo is staged in
|
|
612
|
+
blocks (runBlocks) and swapped in atomically (companyLogo.js). */
|
|
613
|
+
const logoDb = tenantSqlFor(argv["dbindex"]);
|
|
614
|
+
const logoExec = logoDb.exec;
|
|
618
615
|
|
|
619
616
|
if (data.action === "write") {
|
|
620
617
|
const saved = await writeCompanyLogo({
|
|
621
618
|
exec: logoExec,
|
|
622
|
-
execStatements:
|
|
619
|
+
execStatements: logoDb.atomic,
|
|
620
|
+
sql: logoDb,
|
|
621
|
+
runBlocks: logoDb.runBlocks,
|
|
623
622
|
principal: logoPrincipal,
|
|
624
623
|
dataUrl: data.dataUrl,
|
|
625
624
|
});
|
|
@@ -633,7 +632,8 @@ const handleCliCommand = async (data, cb, argv) => {
|
|
|
633
632
|
} else if (data.action === "clear") {
|
|
634
633
|
const cleared = await clearCompanyLogo({
|
|
635
634
|
exec: logoExec,
|
|
636
|
-
execStatements:
|
|
635
|
+
execStatements: logoDb.atomic,
|
|
636
|
+
sql: logoDb,
|
|
637
637
|
principal: logoPrincipal,
|
|
638
638
|
});
|
|
639
639
|
cb(null, { success: cleared.ok, problems: cleared.problems, data: null });
|
|
@@ -645,6 +645,59 @@ const handleCliCommand = async (data, cb, argv) => {
|
|
|
645
645
|
}
|
|
646
646
|
break;
|
|
647
647
|
}
|
|
648
|
+
/*
|
|
649
|
+
* Doc 1 step 0a.2 — Control Center drafts (engine/cc/draftStore.js). Administrators only,
|
|
650
|
+
* from the principal in the HEADERS; a draft never reaches SYS$CONFIG or SYS$BPM_* (only
|
|
651
|
+
* publish may). A refusal the user can act on is returned as `problems`, as companytheme
|
|
652
|
+
* does; `code` carries CC_DRAFT_STALE for a save made from an old revision.
|
|
653
|
+
*/
|
|
654
|
+
/*
|
|
655
|
+
* Doc 1 0b.6b — an AI turn from Control Center, through the central gateway
|
|
656
|
+
* (engine/cc/aiTurns.js). `send` answers at once; the turn finishes in the background, is
|
|
657
|
+
* recorded in SYS$CC_TURN with its cost, and the VERIFIED user is told {turnId, outcome}.
|
|
658
|
+
* The CLI holds no AI key: the gateway client signs with this company's biza.ai key.
|
|
659
|
+
*/
|
|
660
|
+
case "ccai": {
|
|
661
|
+
const aiResult = await runAiAction({
|
|
662
|
+
repo: createTurnRepo({ exec: (sql) => execPostgres(sql, argv["dbindex"]) }),
|
|
663
|
+
gateway: createAiGatewayClient({ baseUrl: argv["aiGateway"] || argv["server"], subdomain: argv["subdomain"] }),
|
|
664
|
+
principal: principalForCliScript(data.headers, { mode: argv["principalMode"] || "observe" }),
|
|
665
|
+
isPostgres: isPostgresIndex(argv["dbindex"]),
|
|
666
|
+
action: data.action,
|
|
667
|
+
args: data.args,
|
|
668
|
+
notify: (userId, eventName, payload) => ClientEmitter.emitUsers([userId], eventName, payload),
|
|
669
|
+
logger,
|
|
670
|
+
});
|
|
671
|
+
cb(null, {
|
|
672
|
+
success: aiResult.ok,
|
|
673
|
+
...(aiResult.code ? { code: aiResult.code } : {}),
|
|
674
|
+
problems: aiResult.problems ?? [],
|
|
675
|
+
data: aiResult.data ?? null,
|
|
676
|
+
});
|
|
677
|
+
break;
|
|
678
|
+
}
|
|
679
|
+
case "ccdraft": {
|
|
680
|
+
const ccResult = await runDraftAction({
|
|
681
|
+
repo: createPgDraftRepo({
|
|
682
|
+
exec: (sql) => execPostgres(sql, argv["dbindex"]),
|
|
683
|
+
execStatements: (statements) => execPostgresStatements(statements, argv["dbindex"]),
|
|
684
|
+
}),
|
|
685
|
+
principal: principalForCliScript(data.headers, { mode: argv["principalMode"] || "observe" }),
|
|
686
|
+
isPostgres: isPostgresIndex(argv["dbindex"]),
|
|
687
|
+
action: data.action,
|
|
688
|
+
args: data.args,
|
|
689
|
+
/* 0b.3: publish writes SYS$CONFIG, SYS$APPS and SYS$BPM_* through the existing pipelines —
|
|
690
|
+
loaded only for a publish (appConfig.js imports this file, and must not pull the BPM engine in). */
|
|
691
|
+
pipelines: data.action === "publish" ? ((m) => (userId) => m.createLivePipelines({ argv, userId }))(await import("../engine/cc/livePipelines.js")) : undefined,
|
|
692
|
+
});
|
|
693
|
+
cb(null, {
|
|
694
|
+
success: ccResult.ok,
|
|
695
|
+
...(ccResult.code ? { code: ccResult.code } : {}),
|
|
696
|
+
problems: ccResult.problems ?? [],
|
|
697
|
+
data: ccResult.data ?? null,
|
|
698
|
+
});
|
|
699
|
+
break;
|
|
700
|
+
}
|
|
648
701
|
case "runextcliscript": {
|
|
649
702
|
// External-access op invoker — enforces the capability boundary (dispatch.js),
|
|
650
703
|
// unlike raw `runcliscript`. Safe to expose: no valid token -> UNAUTHENTICATED.
|
|
@@ -836,6 +889,11 @@ export const clientListener = (socket, argv, via = "?") => {
|
|
|
836
889
|
maxContentLength: Infinity,
|
|
837
890
|
})
|
|
838
891
|
.then((response) => {
|
|
892
|
+
/* Doc 6 on Firebird — a login FINA answered gets its principal, on this
|
|
893
|
+
transport as on the HTTP one (finaEndpoint.js); see apiRoute.js. */
|
|
894
|
+
if (parseAuthRequest(reqData.path, reqBody)) {
|
|
895
|
+
response.data = withFinaLoginPrincipal(response.data, Number(argv["dbindex"]));
|
|
896
|
+
}
|
|
839
897
|
resCB(null, socketResponse(response));
|
|
840
898
|
})
|
|
841
899
|
.catch((error) => {
|
package/bin/tunnelTarget.js
CHANGED
|
@@ -11,8 +11,35 @@
|
|
|
11
11
|
* database index, as it is everywhere else in this port.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
/*
|
|
15
|
+
* ⚠️ Doc 6 on Firebird (found live 2026-10-01): the LOGIN of a Firebird tenant went straight to FINA
|
|
16
|
+
* through this tunnel, so the CLI never saw FINA's answer and never added the signed principal — and
|
|
17
|
+
* every principal-guarded command (company theme, logo, user preferences) refused on Firebird. The login
|
|
18
|
+
* is now looped back to the CLI's own endpoint too (finaEndpoint.js forwards it to FINA and appends the
|
|
19
|
+
* principal); every other Firebird request still goes straight to FINA, byte for byte.
|
|
20
|
+
*
|
|
21
|
+
* Recognised by its REQUEST LINE only (method + path), never by anything in a body.
|
|
22
|
+
*/
|
|
23
|
+
const LOGIN_PATH = /%22get_valid_user_id%22|"get_valid_user_id"/i;
|
|
24
|
+
|
|
25
|
+
export const isLoginRequest = (firstChunk) => {
|
|
26
|
+
if (!firstChunk || firstChunk.length === 0) return false;
|
|
27
|
+
const head = Buffer.from(firstChunk).subarray(0, 2048).toString("latin1");
|
|
28
|
+
const lineEnd = head.indexOf("\r\n");
|
|
29
|
+
const requestLine = lineEnd === -1 ? head : head.slice(0, lineEnd);
|
|
30
|
+
const [method, path] = requestLine.split(" ");
|
|
31
|
+
return method === "POST" && typeof path === "string" && LOGIN_PATH.test(path);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const ownEndpoint = (argv) => ({
|
|
35
|
+
host: "127.0.0.1", // ourselves — never the public interface
|
|
36
|
+
port: argv.serverport,
|
|
37
|
+
secure: argv.isHttps === true, // how WE listen; argv.secure describes FINA
|
|
38
|
+
local: true,
|
|
39
|
+
});
|
|
40
|
+
|
|
14
41
|
/* Kept separate from hubEvent.js so it can be exercised without importing the socket machinery. */
|
|
15
|
-
export const resolveTunnelTarget = (argv, isPostgresIndex) => {
|
|
42
|
+
export const resolveTunnelTarget = (argv, isPostgresIndex, firstChunk) => {
|
|
16
43
|
const fina = {
|
|
17
44
|
host: argv.hostname,
|
|
18
45
|
port: argv.port,
|
|
@@ -20,6 +47,7 @@ export const resolveTunnelTarget = (argv, isPostgresIndex) => {
|
|
|
20
47
|
local: false,
|
|
21
48
|
};
|
|
22
49
|
|
|
50
|
+
if (isLoginRequest(firstChunk)) return ownEndpoint(argv);
|
|
23
51
|
if (!argv.dbindex) return fina;
|
|
24
52
|
|
|
25
53
|
let servedHere = false;
|
|
@@ -32,10 +60,35 @@ export const resolveTunnelTarget = (argv, isPostgresIndex) => {
|
|
|
32
60
|
}
|
|
33
61
|
if (!servedHere) return fina;
|
|
34
62
|
|
|
35
|
-
return
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
63
|
+
return ownEndpoint(argv);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/*
|
|
67
|
+
* One tunnelled request: biz-a's bytes arrive on `stream`; the connection is opened only once the first
|
|
68
|
+
* bytes say where it must go (resolveTunnelTarget), that first chunk is written first so nothing is
|
|
69
|
+
* lost, and the rest is piped. The answer flows back through `transform()` (the response-header
|
|
70
|
+
* injection). A connection that fails ends the stream, so biz-a is not left waiting.
|
|
71
|
+
*/
|
|
72
|
+
export const pipeTunnel = ({ stream, argv, isPostgresIndex, connect, transform, idleMs, onClient, logger = console }) => {
|
|
73
|
+
stream.once("data", (first) => {
|
|
74
|
+
stream.pause();
|
|
75
|
+
const target = resolveTunnelTarget(argv, isPostgresIndex, first);
|
|
76
|
+
const client = connect(target);
|
|
77
|
+
onClient?.(client);
|
|
78
|
+
if (idleMs) client.setTimeout(idleMs);
|
|
79
|
+
client.on("timeout", () => client.end());
|
|
80
|
+
client.on("connect", () => {
|
|
81
|
+
client.write(first);
|
|
82
|
+
stream.pipe(client).pipe(transform()).pipe(stream);
|
|
83
|
+
stream.resume();
|
|
84
|
+
});
|
|
85
|
+
client.on("error", (err) => {
|
|
86
|
+
logger.error?.(
|
|
87
|
+
`API Error : tunnel could not reach ${target.local ? "this CLI's own endpoint" : "the API"} at ${target.host}:${target.port} — `,
|
|
88
|
+
err,
|
|
89
|
+
);
|
|
90
|
+
stream.end();
|
|
91
|
+
});
|
|
92
|
+
stream.on("end", () => client.destroy());
|
|
93
|
+
});
|
|
41
94
|
};
|
package/db/db.js
CHANGED
|
@@ -115,11 +115,11 @@ export const list = async (payload, config = {}) => {
|
|
|
115
115
|
};
|
|
116
116
|
|
|
117
117
|
/* Refused on PostgreSQL — the bodies are Firebird PSQL. See executor.js for the reasoning. */
|
|
118
|
-
export const executeBlock = async (sqlString, config = {}) => {
|
|
118
|
+
export const executeBlock = async (sqlString, config = {}, options = {}) => {
|
|
119
119
|
if (onPostgres(config)) {
|
|
120
120
|
return await executor.executeBlock(sqlString, resolveDbIndex(config));
|
|
121
121
|
}
|
|
122
|
-
return await adapter.executeBlock(sqlString, config);
|
|
122
|
+
return await adapter.executeBlock(sqlString, config, options);
|
|
123
123
|
};
|
|
124
124
|
|
|
125
125
|
/* An optimistic claim: flip one row's status if it is still in an expected state, and report
|
package/db/ds.js
CHANGED
|
@@ -261,15 +261,34 @@ function flattenSql(sqlString) {
|
|
|
261
261
|
.join(" "); // 5. Join safely with exactly one space
|
|
262
262
|
}
|
|
263
263
|
|
|
264
|
-
|
|
264
|
+
/* `flatten: false` sends the SQL as written — for a block that is single-line already and carries a
|
|
265
|
+
text VALUE, where flattenSql's `--` stripping would cut the value (engine/domain/finaBlocks.js). */
|
|
266
|
+
export const executeBlock = async (sqlString, config = {}, { flatten = true } = {}) => {
|
|
265
267
|
const cfg = resolveConfig(config);
|
|
266
268
|
const url = buildDataSnapUrl(cfg, "block");
|
|
267
|
-
const flatSql = flattenSql(sqlString);
|
|
269
|
+
const flatSql = flatten ? flattenSql(sqlString) : sqlString;
|
|
268
270
|
// const data = buildDataSnapPayload(cfg, "block", sqlString);
|
|
269
271
|
const data = buildDataSnapPayload(cfg, "block", flatSql);
|
|
270
272
|
return await dsReq(url, data, cfg.timeout);
|
|
271
273
|
};
|
|
272
274
|
|
|
275
|
+
/* FINA's ExecuteSQL: one raw statement, positional parameters `[sql, dbIndex]` (not the ORM envelope),
|
|
276
|
+
rows as a JSON string inside `result[0]` (an empty string when there are none). Keys are lower-cased,
|
|
277
|
+
as execPostgres returns them, so a caller reads one shape on either engine (engine/orm/tenantSql.js).
|
|
278
|
+
The SQL must be single-line: a raw newline ends the command on this transport. */
|
|
279
|
+
export const executeSql = async (sql, config = {}) => {
|
|
280
|
+
const cfg = resolveConfig({ ...config, methodClass: "TFinaMethod" });
|
|
281
|
+
const url = buildDataSnapUrl(cfg, "ExecuteSQL");
|
|
282
|
+
const res = await dsReq(url, JSON.stringify({ _parameters: [sql, String(cfg.dbindex)] }), cfg.timeout);
|
|
283
|
+
const first = res?.result?.[0];
|
|
284
|
+
if (first && typeof first === "object" && first.error) throw new Error(first.error);
|
|
285
|
+
if (typeof first !== "string" || first === "") return [];
|
|
286
|
+
const rows = JSON.parse(first)?.data;
|
|
287
|
+
return (Array.isArray(rows) ? rows : []).map((row) =>
|
|
288
|
+
Object.fromEntries(Object.entries(row).map(([key, value]) => [key.toLowerCase(), value])),
|
|
289
|
+
);
|
|
290
|
+
};
|
|
291
|
+
|
|
273
292
|
export const ormGet = async (payload, config = {}) => {
|
|
274
293
|
const cfg = resolveConfig(config);
|
|
275
294
|
const url = buildDataSnapUrl(cfg, "orm");
|