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.
Files changed (61) hide show
  1. package/bin/app.js +30 -42
  2. package/bin/directHubEvent.js +6 -2
  3. package/bin/hub.js +14 -0
  4. package/bin/hubEvent.js +123 -65
  5. package/bin/tunnelTarget.js +60 -7
  6. package/db/db.js +2 -2
  7. package/db/ds.js +21 -2
  8. package/engine/bpm/domainRules.js +71 -0
  9. package/engine/bpm/routes.js +2 -0
  10. package/engine/bpm/rules.d.ts +121 -0
  11. package/engine/bpm/rules.js +241 -0
  12. package/engine/bpm/validateReport.js +60 -0
  13. package/engine/bpm/validateWorkflow.d.ts +37 -0
  14. package/engine/bpm/validateWorkflow.js +400 -0
  15. package/engine/bpm/vmGuard.js +13 -0
  16. package/engine/bpm/workflow-runtime.js +193 -246
  17. package/engine/bpm/workflow.js +81 -54
  18. package/engine/cc/aiTurns.js +167 -0
  19. package/engine/cc/appMenus.d.ts +25 -0
  20. package/engine/cc/appMenus.js +154 -0
  21. package/engine/cc/draftStore.js +443 -0
  22. package/engine/cc/jsonDiff.js +49 -0
  23. package/engine/cc/jsonPatch.js +161 -0
  24. package/engine/cc/livePipelines.js +65 -0
  25. package/engine/cc/ownershipGuard.js +45 -0
  26. package/engine/cc/pgRepo.js +185 -0
  27. package/engine/cc/publish.js +264 -0
  28. package/engine/cc/published.js +51 -0
  29. package/engine/cc/turnRepo.js +109 -0
  30. package/engine/domain/appConfig.js +22 -26
  31. package/engine/domain/applicationConfig.js +3 -0
  32. package/engine/domain/entityConfigLoader.js +2 -1
  33. package/engine/domain/finaBlocks.js +121 -0
  34. package/engine/orm/apiRoute.js +38 -1
  35. package/engine/orm/companyLogo.js +53 -1
  36. package/engine/orm/companyTheme.js +12 -8
  37. package/engine/orm/finaEndpoint.js +7 -2
  38. package/engine/orm/principalGuards.js +12 -6
  39. package/engine/orm/registryClient.js +18 -6
  40. package/engine/orm/requestScope.js +6 -1
  41. package/engine/orm/sqlDialect.js +32 -0
  42. package/engine/orm/tenantSql.js +55 -0
  43. package/engine/orm/userPreferences.js +16 -11
  44. package/envs/env.dev.js +4 -5
  45. package/envs/env.js +9 -5
  46. package/envs/hosts.js +4 -0
  47. package/migrations/postgres/1789700000000__theme_audit.sql +2 -1
  48. package/migrations/postgres/1789800000000__cc_draft.sql +64 -0
  49. package/migrations/postgres/1789900000000__cc_draft_content_json.sql +10 -0
  50. package/migrations/postgres/1790000000000__cc_doc1.sql +139 -0
  51. package/migrations/postgres/1790100000000__cc_doc1_revoke.sql +16 -0
  52. package/migrations/postgres/1790200000000__theme_audit_revoke.sql +9 -0
  53. package/package.json +2 -3
  54. package/readme.md +17 -1
  55. package/scheduler/configController.js +57 -98
  56. package/scheduler/timer.js +4 -3
  57. package/security/aiGatewayClient.js +131 -0
  58. package/security/whoami.js +25 -0
  59. package/config/databases.json +0 -20
  60. package/config/ext-oidc.json +0 -7
  61. 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". getUpsertBlock already emits single-line SQL, so there is
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
- const estimateExecuteBlockBodyLength = (blockSql, dbIndex) =>
429
- JSON.stringify({
430
- _parameters: [
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
  });
@@ -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 { resolveTunnelTarget } from "./tunnelTarget.js";
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
- // let client = net.connect(argv['port'], argv['hostname']);
232
- /* Where this pipe ends. A tenant this CLI serves on PostgreSQL is looped back to the
233
- hub's own FINA-compatible endpoint; every other tenant goes to FINA unchanged.
234
- Without this, biz-a's login — which only ever travels this tunnel — could not be
235
- answered at all on a tenant with no FINA running. */
236
- const target = resolveTunnelTarget(argv, isPostgresIndex);
237
- let client = target.secure
238
- ? tls.connect({
239
- port: target.port,
240
- host: target.host,
241
- // Looping back to ourselves, the certificate is our own.
242
- rejectUnauthorized: !target.local,
243
- })
244
- : net.connect(target.port, target.host);
245
- client.on("connect", () => {
246
- // logger.info(`client connected to ${argv['hostname']}:${argv['port']}`)
247
- let s = ss.createStream();
248
-
249
- // s.pipe(client).pipe(s);
250
- s.pipe(client).pipe(addCLIAddressAsResponseHeader).pipe(s);
251
-
252
- // use pipeline for better performance, back pressure, memory management, error handling and clean up
253
- // pipeline(s, client, addCLIAddressAsResponseHeader, s, (err)=>{if (err) {logger.error(err)}}) // Not work well with heroku
254
-
255
- s.on("end", () => {
256
- client.destroy();
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
- const prefExec = (sql) => execPostgres(sql, argv["dbindex"]);
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: prefExec,
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: prefExec,
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
- const themeExec = (sql) => execPostgres(sql, argv["dbindex"]);
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: (statements) =>
581
- execPostgresStatements(statements, argv["dbindex"]),
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
- const logoExec = (sql) => execPostgres(sql, argv["dbindex"]);
616
- const logoStatements = (statements) =>
617
- execPostgresStatements(statements, argv["dbindex"]);
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: logoStatements,
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: logoStatements,
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) => {
@@ -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
- host: "127.0.0.1", // ourselves — never the public interface
37
- port: argv.serverport,
38
- secure: argv.isHttps === true, // how WE listen; argv.secure describes FINA
39
- local: true,
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
- export const executeBlock = async (sqlString, config = {}) => {
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");