biz-a-cli 2.3.80-15381 → 2.3.80-15385

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 (50) hide show
  1. package/bin/app.js +30 -42
  2. package/bin/directHubEvent.js +6 -2
  3. package/bin/hub.js +9 -0
  4. package/bin/hubEvent.js +67 -12
  5. package/config/secrets.json +25 -19
  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/appMenus.d.ts +25 -0
  19. package/engine/cc/appMenus.js +154 -0
  20. package/engine/cc/draftStore.js +443 -0
  21. package/engine/cc/jsonDiff.js +49 -0
  22. package/engine/cc/jsonPatch.js +161 -0
  23. package/engine/cc/livePipelines.js +65 -0
  24. package/engine/cc/ownershipGuard.js +45 -0
  25. package/engine/cc/pgRepo.js +185 -0
  26. package/engine/cc/publish.js +264 -0
  27. package/engine/cc/published.js +51 -0
  28. package/engine/domain/appConfig.js +22 -26
  29. package/engine/domain/applicationConfig.js +3 -0
  30. package/engine/domain/entityConfigLoader.js +2 -1
  31. package/engine/domain/finaBlocks.js +121 -0
  32. package/engine/orm/apiRoute.js +38 -1
  33. package/engine/orm/companyLogo.js +53 -1
  34. package/engine/orm/companyTheme.js +12 -8
  35. package/engine/orm/finaEndpoint.js +7 -2
  36. package/engine/orm/principalGuards.js +12 -6
  37. package/engine/orm/requestScope.js +6 -1
  38. package/engine/orm/sqlDialect.js +32 -0
  39. package/engine/orm/tenantSql.js +55 -0
  40. package/engine/orm/userPreferences.js +16 -11
  41. package/envs/hosts.js +4 -0
  42. package/migrations/postgres/1789700000000__theme_audit.sql +2 -1
  43. package/migrations/postgres/1789800000000__cc_draft.sql +64 -0
  44. package/migrations/postgres/1789900000000__cc_draft_content_json.sql +10 -0
  45. package/migrations/postgres/1790000000000__cc_doc1.sql +139 -0
  46. package/migrations/postgres/1790100000000__cc_doc1_revoke.sql +16 -0
  47. package/migrations/postgres/1790200000000__theme_audit_revoke.sql +9 -0
  48. package/package.json +1 -1
  49. package/security/aiGatewayClient.js +123 -0
  50. package/security/whoami.js +25 -0
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,15 @@ 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
+ })
124
133
  .options("services", {
125
134
  describe:
126
135
  "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,9 @@ 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";
27
31
  import {
28
32
  readUserPreferences,
29
33
  writeUserPreferences,
@@ -483,6 +487,14 @@ const handleCliCommand = async (data, cb, argv) => {
483
487
  case "status":
484
488
  cb(null, status(argv));
485
489
  break;
490
+ /*
491
+ * Doc 1 step 0a.1 — who THIS CLI verified is asking. Control Center checks it before
492
+ * offering any editing: a successful login at biz-a does not prove the principal
493
+ * verifies here. Headers only; see security/whoami.js.
494
+ */
495
+ case "whoami":
496
+ cb(null, whoami(data.headers, { mode: argv["principalMode"] || "observe" }));
497
+ break;
486
498
  case "runcliscript":
487
499
  const liveTriggerPayload = {
488
500
  arguments: argv,
@@ -542,16 +554,19 @@ const handleCliCommand = async (data, cb, argv) => {
542
554
  const prefPrincipal = principalForCliScript(data.headers, {
543
555
  mode: argv["principalMode"] || "observe",
544
556
  });
545
- const prefExec = (sql) => execPostgres(sql, argv["dbindex"]);
557
+ /* Doc 6 on Firebird (2026-10-01) — the tenant seam runs this on either engine. */
558
+ const prefDb = tenantSqlFor(argv["dbindex"]);
546
559
  const prefs =
547
560
  data.action === "write"
548
561
  ? await writeUserPreferences({
549
- exec: prefExec,
562
+ exec: prefDb.exec,
563
+ execStatements: prefDb.atomic,
564
+ sql: prefDb,
550
565
  principal: prefPrincipal,
551
566
  patch: data.patch,
552
567
  })
553
568
  : await readUserPreferences({
554
- exec: prefExec,
569
+ exec: prefDb.exec,
555
570
  principal: prefPrincipal,
556
571
  });
557
572
  cb(null, { success: true, data: prefs });
@@ -573,12 +588,15 @@ const handleCliCommand = async (data, cb, argv) => {
573
588
  const themePrincipal = principalForCliScript(data.headers, {
574
589
  mode: argv["principalMode"] || "observe",
575
590
  });
576
- const themeExec = (sql) => execPostgres(sql, argv["dbindex"]);
591
+ /* Doc 6 on Firebird (2026-10-01) — one transaction on either engine: a PostgreSQL
592
+ transaction, or ONE Firebird EXECUTE BLOCK (engine/orm/tenantSql.js). */
593
+ const themeDb = tenantSqlFor(argv["dbindex"]);
594
+ const themeExec = themeDb.exec;
577
595
  if (data.action === "write") {
578
596
  const saved = await writeCompanyTheme({
579
597
  exec: themeExec,
580
- execStatements: (statements) =>
581
- execPostgresStatements(statements, argv["dbindex"]),
598
+ execStatements: themeDb.atomic,
599
+ sql: themeDb,
582
600
  principal: themePrincipal,
583
601
  theme: data.theme,
584
602
  });
@@ -612,14 +630,17 @@ const handleCliCommand = async (data, cb, argv) => {
612
630
  const logoPrincipal = principalForCliScript(data.headers, {
613
631
  mode: argv["principalMode"] || "observe",
614
632
  });
615
- const logoExec = (sql) => execPostgres(sql, argv["dbindex"]);
616
- const logoStatements = (statements) =>
617
- execPostgresStatements(statements, argv["dbindex"]);
633
+ /* Doc 6 on Firebird (2026-10-01) — the tenant seam; on Firebird a logo is staged in
634
+ blocks (runBlocks) and swapped in atomically (companyLogo.js). */
635
+ const logoDb = tenantSqlFor(argv["dbindex"]);
636
+ const logoExec = logoDb.exec;
618
637
 
619
638
  if (data.action === "write") {
620
639
  const saved = await writeCompanyLogo({
621
640
  exec: logoExec,
622
- execStatements: logoStatements,
641
+ execStatements: logoDb.atomic,
642
+ sql: logoDb,
643
+ runBlocks: logoDb.runBlocks,
623
644
  principal: logoPrincipal,
624
645
  dataUrl: data.dataUrl,
625
646
  });
@@ -633,7 +654,8 @@ const handleCliCommand = async (data, cb, argv) => {
633
654
  } else if (data.action === "clear") {
634
655
  const cleared = await clearCompanyLogo({
635
656
  exec: logoExec,
636
- execStatements: logoStatements,
657
+ execStatements: logoDb.atomic,
658
+ sql: logoDb,
637
659
  principal: logoPrincipal,
638
660
  });
639
661
  cb(null, { success: cleared.ok, problems: cleared.problems, data: null });
@@ -645,6 +667,34 @@ const handleCliCommand = async (data, cb, argv) => {
645
667
  }
646
668
  break;
647
669
  }
670
+ /*
671
+ * Doc 1 step 0a.2 — Control Center drafts (engine/cc/draftStore.js). Administrators only,
672
+ * from the principal in the HEADERS; a draft never reaches SYS$CONFIG or SYS$BPM_* (only
673
+ * publish may). A refusal the user can act on is returned as `problems`, as companytheme
674
+ * does; `code` carries CC_DRAFT_STALE for a save made from an old revision.
675
+ */
676
+ case "ccdraft": {
677
+ const ccResult = await runDraftAction({
678
+ repo: createPgDraftRepo({
679
+ exec: (sql) => execPostgres(sql, argv["dbindex"]),
680
+ execStatements: (statements) => execPostgresStatements(statements, argv["dbindex"]),
681
+ }),
682
+ principal: principalForCliScript(data.headers, { mode: argv["principalMode"] || "observe" }),
683
+ isPostgres: isPostgresIndex(argv["dbindex"]),
684
+ action: data.action,
685
+ args: data.args,
686
+ /* 0b.3: publish writes SYS$CONFIG, SYS$APPS and SYS$BPM_* through the existing pipelines —
687
+ loaded only for a publish (appConfig.js imports this file, and must not pull the BPM engine in). */
688
+ pipelines: data.action === "publish" ? ((m) => (userId) => m.createLivePipelines({ argv, userId }))(await import("../engine/cc/livePipelines.js")) : undefined,
689
+ });
690
+ cb(null, {
691
+ success: ccResult.ok,
692
+ ...(ccResult.code ? { code: ccResult.code } : {}),
693
+ problems: ccResult.problems ?? [],
694
+ data: ccResult.data ?? null,
695
+ });
696
+ break;
697
+ }
648
698
  case "runextcliscript": {
649
699
  // External-access op invoker — enforces the capability boundary (dispatch.js),
650
700
  // unlike raw `runcliscript`. Safe to expose: no valid token -> UNAUTHENTICATED.
@@ -836,6 +886,11 @@ export const clientListener = (socket, argv, via = "?") => {
836
886
  maxContentLength: Infinity,
837
887
  })
838
888
  .then((response) => {
889
+ /* Doc 6 on Firebird — a login FINA answered gets its principal, on this
890
+ transport as on the HTTP one (finaEndpoint.js); see apiRoute.js. */
891
+ if (parseAuthRequest(reqData.path, reqBody)) {
892
+ response.data = withFinaLoginPrincipal(response.data, Number(argv["dbindex"]));
893
+ }
839
894
  resCB(null, socketResponse(response));
840
895
  })
841
896
  .catch((error) => {
@@ -1,21 +1,27 @@
1
1
  {
2
- "supertail.ledger": {
3
- "currentKeyId": "st_ledger_v1",
4
- "keys": {
5
- "st_ledger_v1": "CdgBjUShgx7t9Wo/dfSOOJAHjHOD5KwAlRi6xwDMEfs="
6
- }
7
- },
8
- "supertail.session": "3jepiFkSV6HxlbCSWiGqYWpmfapYxLHeHY/YgxyFQ8Q=",
9
- "ext.capability": {
10
- "currentKeyId": "ext_v1",
11
- "keys": {
12
- "ext_v1": "f4PLkG2QLn6OFyv0IlqWhq3tlSGs2VAyaYDM4kDKLZ8="
13
- }
14
- },
15
- "internal.principal": {
16
- "currentKeyId": "ip_v1",
17
- "keys": {
18
- "ip_v1": "GsoRIpPG0D7c0q+AtmMNrtKpZ104Uv4/tfmLHtAtbIM="
19
- }
20
- }
2
+ "supertail.ledger": {
3
+ "currentKeyId": "st_ledger_v1",
4
+ "keys": {
5
+ "st_ledger_v1": "CdgBjUShgx7t9Wo/dfSOOJAHjHOD5KwAlRi6xwDMEfs="
6
+ }
7
+ },
8
+ "supertail.session": "3jepiFkSV6HxlbCSWiGqYWpmfapYxLHeHY/YgxyFQ8Q=",
9
+ "ext.capability": {
10
+ "currentKeyId": "ext_v1",
11
+ "keys": {
12
+ "ext_v1": "f4PLkG2QLn6OFyv0IlqWhq3tlSGs2VAyaYDM4kDKLZ8="
13
+ }
14
+ },
15
+ "internal.principal": {
16
+ "currentKeyId": "ip_v1",
17
+ "keys": {
18
+ "ip_v1": "GsoRIpPG0D7c0q+AtmMNrtKpZ104Uv4/tfmLHtAtbIM="
19
+ }
20
+ },
21
+ "biza.ai.studiob": {
22
+ "currentKeyId": "ai_v1",
23
+ "keys": {
24
+ "ai_v1": "QxE6YiZdcjVRK1UbWGRMLuEgBKM3zFUte9euFldduZc="
25
+ }
26
+ }
21
27
  }
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");
@@ -0,0 +1,71 @@
1
+ /*
2
+ * Doc 1 step 0b.2 — DOMAIN_RULE guards (design CC-DRAFTS-AND-ENGINE-GAPS §5B).
3
+ *
4
+ * A rule key names a REGISTERED CLI script (SYS$CLI_SCRIPT.SCRIPT_NAME = the key). The engine runs it with
5
+ * { entityType, entityId, entityData, transitionCode, principal } and reads back { pass, hint? }:
6
+ *
7
+ * - FAIL-CLOSED: only `pass === true` passes. A missing script (the worker answers with a string, it does
8
+ * not throw), an error, or no answer within the limit all block, with a hint naming the rule.
9
+ * - Rolled out like --principalMode: `observe` evaluates and logs every would-block but lets the
10
+ * transition pass (today's behaviour, with evidence); `enforce` blocks. Absent = observe; an
11
+ * unrecognised value = enforce.
12
+ *
13
+ * The principal is the caller's CLAIM (userId, userRoles) marked `verified: false` — no verified principal
14
+ * reaches the BPM runtime yet (ruling 2026-09-29).
15
+ *
16
+ * Main-thread only (it runs scripts through the worker pool); rules.js stays pure and takes the evaluator
17
+ * it is given.
18
+ */
19
+ export const RULE_TIMEOUT_MS = 2000;
20
+
21
+ export const ruleModeOf = (config) => {
22
+ const mode = config?.ruleMode;
23
+ if (!mode || mode === "observe") return "observe";
24
+ return "enforce";
25
+ };
26
+
27
+ const seconds = (ms) => `${ms / 1000} s`;
28
+
29
+ /* The verdict of one rule script: { pass, hint }. Never throws. */
30
+ export async function judgeRule(key, ctx, { runScript, timeoutMs = RULE_TIMEOUT_MS }) {
31
+ let timer;
32
+ try {
33
+ const running = runScript(key, ctx);
34
+ /* workerpool can also stop the worker itself once the task leaves the queue. */
35
+ if (running && typeof running.timeout === "function") running.timeout(timeoutMs);
36
+ const expired = new Promise((_, reject) => {
37
+ timer = setTimeout(() => reject(Object.assign(new Error("timeout"), { ruleTimeout: true })), timeoutMs);
38
+ });
39
+ const result = await Promise.race([running, expired]);
40
+
41
+ if (typeof result === "string" && /not found/i.test(result)) {
42
+ return { pass: false, hint: `Rule ${key} is not registered: there is no CLI script of that name.` };
43
+ }
44
+ if (result && typeof result === "object" && result.pass === true) return { pass: true, hint: "" };
45
+ const hint = result && typeof result === "object" && typeof result.hint === "string" && result.hint ? result.hint : `Rule ${key} did not pass.`;
46
+ return { pass: false, hint };
47
+ } catch (error) {
48
+ if (error?.ruleTimeout || error?.name === "TimeoutError") return { pass: false, hint: `Rule ${key} did not answer within ${seconds(timeoutMs)}.` };
49
+ return { pass: false, hint: `Rule ${key} failed: ${error?.message ?? error}` };
50
+ } finally {
51
+ clearTimeout(timer);
52
+ }
53
+ }
54
+
55
+ /* evaluateDomain(key, ctx) → { pass, hint }, with the rollout mode applied. */
56
+ export function createDomainEvaluator({ mode, runScript, timeoutMs = RULE_TIMEOUT_MS, logger }) {
57
+ return async function evaluateDomain(key, ctx) {
58
+ const verdict = await judgeRule(key, ctx, { runScript, timeoutMs });
59
+ const where = `${ctx.transitionCode} on ${ctx.entityType} ${ctx.entityId}: ${key}`;
60
+ if (verdict.pass) {
61
+ logger?.debug?.(`[rules] passed ${where} (mode=${mode})`);
62
+ return verdict;
63
+ }
64
+ if (mode === "observe") {
65
+ logger?.warn?.(`[rules] would block ${where} — ${verdict.hint} (mode=observe)`);
66
+ return { pass: true, hint: "" };
67
+ }
68
+ logger?.info?.(`[rules] blocked ${where} — ${verdict.hint} (mode=${mode})`);
69
+ return verdict;
70
+ };
71
+ }
@@ -24,6 +24,8 @@ const expressWrapper = (engineFunction) => async (req, res) => {
24
24
  // ==========================================
25
25
  router.post("/workflow/save", expressWrapper(workflow.save));
26
26
  router.post("/workflow/publish", expressWrapper(workflow.publish));
27
+ /* Doc 1 0b.1: what publish would refuse, without publishing (read-only). */
28
+ router.post("/workflow/validate", expressWrapper(workflow.validate));
27
29
  router.post("/workflow/remove", expressWrapper(workflow.remove));
28
30
 
29
31
  // ==========================================
@@ -0,0 +1,121 @@
1
+ /* Types of rules.js for Control Center (bcc imports the engine's rules directly — Doc 1 0a.6). */
2
+
3
+ /* Doc 1 0b.2: a DOMAIN_RULE verdict (the engine injects the rule-script evaluator). */
4
+ export type DomainEvaluate = (key: string, transitionCode?: string) => Promise<{ pass: boolean; hint: string }>;
5
+
6
+ export type GuardEvaluate = (expr: string, m: unknown) => unknown | Promise<unknown>;
7
+
8
+ export interface CompiledRole {
9
+ role_code: string;
10
+ }
11
+
12
+ export interface CompiledEffect {
13
+ effect_type: string;
14
+ event_name: string;
15
+ payload_template: string | null;
16
+ sort_no: number;
17
+ }
18
+
19
+ export interface CompiledState {
20
+ state_code: string;
21
+ label: string;
22
+ task_enabled: 0 | 1;
23
+ task_name: string | null;
24
+ assignment_type: string | null;
25
+ assignment_value: string | null;
26
+ sla_duration_minutes: number | null;
27
+ timeout_action_type: string | null;
28
+ timeout_event_name: string | null;
29
+ sort_no: number;
30
+ }
31
+
32
+ export interface CompiledTransition {
33
+ transition_code: string;
34
+ from_state_code: string;
35
+ to_state_code: string;
36
+ trigger_type: string;
37
+ label: string | undefined;
38
+ guard_type: string | null;
39
+ guard_rule_key: string | null;
40
+ sort_no: number;
41
+ roles: CompiledRole[];
42
+ effects: CompiledEffect[];
43
+ }
44
+
45
+ export interface CompiledDefinition {
46
+ workflow: { workflow_key: string; name: string; entity_type: string; description: string; initial_state_code: string };
47
+ states: CompiledState[];
48
+ transitions: CompiledTransition[];
49
+ }
50
+
51
+ export interface GuardResult {
52
+ isPassed: boolean;
53
+ errorMsg: string;
54
+ }
55
+
56
+ export type WithGuard<T> = T & { isGuardPassed: boolean; guardHint: string };
57
+
58
+ export interface EntryTimer {
59
+ id: null;
60
+ sys$bpm_wf_instance_id: number | null;
61
+ sys$bpm_task_id?: null;
62
+ state_code: string;
63
+ timer_type: 'SLA_TIMEOUT';
64
+ sla_minutes: number;
65
+ status: 'OPEN';
66
+ }
67
+
68
+ export interface EntryTask {
69
+ id: null;
70
+ state_code: string;
71
+ name: string | null;
72
+ status: 'OPEN';
73
+ assignment_type: string | null;
74
+ assignment_value: string | null;
75
+ assigned_user_id: number | null;
76
+ sla_minutes: number | null;
77
+ SYS$BPM_TIMER_JOB?: EntryTimer[];
78
+ }
79
+
80
+ export type TimerOutcome<T> =
81
+ | { kind: 'EMIT'; eventName: string }
82
+ | { kind: 'AUTO'; transition: T }
83
+ | { kind: 'ERROR'; message: string }
84
+ | { kind: 'NONE' };
85
+
86
+ export const DEFAULT_TRIGGER: 'USER_EVENT';
87
+ export const SYSTEM_ROLES: string[];
88
+
89
+ export function compileDefinition(json: Record<string, any>): CompiledDefinition;
90
+ export function permitsRoles(roleCodes: string[] | null | undefined, userRoles: string[]): boolean;
91
+ export function guardVerdict(
92
+ guardType: string | null,
93
+ guardRuleKey: string | null,
94
+ entityData: unknown,
95
+ evaluate: GuardEvaluate,
96
+ evaluateDomain?: (key: string) => Promise<{ pass: boolean; hint: string }>,
97
+ ): Promise<GuardResult>;
98
+ export function withGuardVerdicts<T extends { guard_type: string | null; guard_rule_key: string | null }>(
99
+ transitions: T[],
100
+ entityData: unknown,
101
+ evaluate: GuardEvaluate,
102
+ evaluateDomain?: DomainEvaluate,
103
+ ): Promise<WithGuard<T>[]>;
104
+ export function selectTransitions(
105
+ transitions: CompiledTransition[],
106
+ options: { fromState: string; triggerType?: string; userRoles?: string[]; m?: unknown; evaluate: GuardEvaluate; evaluateDomain?: DomainEvaluate },
107
+ ): Promise<WithGuard<CompiledTransition>[]>;
108
+ export function isTerminal(transitions: CompiledTransition[], stateCode: string): boolean;
109
+ export function stateEntry(
110
+ targetStateDef: CompiledState | null | undefined,
111
+ terminal: boolean,
112
+ instanceId: number | string | null,
113
+ ): { tasks: EntryTask[]; standaloneTimers: EntryTimer[] };
114
+ export function timerOutcome<T extends { guard_type: string | null; guard_rule_key: string | null }>(
115
+ stateRow: CompiledState | null | undefined,
116
+ stateCode: string,
117
+ loadTimerTransitions: () => Promise<T[]>,
118
+ evaluate: GuardEvaluate,
119
+ evaluateDomain?: DomainEvaluate,
120
+ ): Promise<TimerOutcome<T>>;
121
+ export function effectPayload(payloadTemplate: string | null | undefined, effectData: Record<string, unknown> | null | undefined): string | null;