biz-a-cli 2.3.80-15375 → 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 (51) hide show
  1. package/bin/app.js +72 -55
  2. package/bin/directHubEvent.js +87 -5
  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/domainNavigation.js +17 -0
  31. package/engine/domain/entityConfigLoader.js +2 -1
  32. package/engine/domain/finaBlocks.js +121 -0
  33. package/engine/orm/apiRoute.js +38 -1
  34. package/engine/orm/companyLogo.js +53 -1
  35. package/engine/orm/companyTheme.js +12 -8
  36. package/engine/orm/finaEndpoint.js +7 -2
  37. package/engine/orm/principalGuards.js +12 -6
  38. package/engine/orm/requestScope.js +6 -1
  39. package/engine/orm/sqlDialect.js +32 -0
  40. package/engine/orm/tenantSql.js +55 -0
  41. package/engine/orm/userPreferences.js +16 -11
  42. package/envs/hosts.js +4 -0
  43. package/migrations/postgres/1789700000000__theme_audit.sql +2 -1
  44. package/migrations/postgres/1789800000000__cc_draft.sql +64 -0
  45. package/migrations/postgres/1789900000000__cc_draft_content_json.sql +10 -0
  46. package/migrations/postgres/1790000000000__cc_doc1.sql +139 -0
  47. package/migrations/postgres/1790100000000__cc_doc1_revoke.sql +16 -0
  48. package/migrations/postgres/1790200000000__theme_audit_revoke.sql +9 -0
  49. package/package.json +2 -2
  50. package/security/aiGatewayClient.js +123 -0
  51. 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,9 +51,11 @@ 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";
58
+ import { resolveMenuLayout } from "../engine/domain/domainNavigation.js";
58
59
 
59
60
  const getKeyFolderPath = () => {
60
61
  const scriptPath =
@@ -154,6 +155,13 @@ const addCommandOptions = {
154
155
  demandOption: false,
155
156
  default: false,
156
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
+ },
157
165
  };
158
166
 
159
167
  const removeCommandOptions = {
@@ -376,9 +384,6 @@ const APPLICATION_CONFIG_FILE = "application.js";
376
384
  /* Doc 6 §8.1 — the application theme source. JSON, because it is DATA that reaches the browser
377
385
  via sys$config and must never be evaluated (AC 26). */
378
386
  const THEME_CONFIG_FILE = "theme.json";
379
- /* Parity with the admin editor (appConfig.component.ts executeBlockBodyLimit): the DataSnap
380
- `block` call body must stay under 64KB, so an oversized config is upserted in chunks. */
381
- const EXECUTE_BLOCK_BODY_LIMIT = 2 ** 16;
382
387
 
383
388
  const findApplicationConfigFile = (fileNames = []) =>
384
389
  fileNames.find(
@@ -404,7 +409,7 @@ const isConfigModeDir = (workingDir, files = null) => {
404
409
  flattenSql(), whose `--` comment stripping mangles a config upsert — the whole config is embedded
405
410
  as a single-line quoted literal, so the first `--` ANYWHERE in the config text (a JS comment, a
406
411
  CSS custom property, an HTML template) silently truncates the rest of the statement and Firebird
407
- 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
408
413
  nothing to flatten. This mirrors what the admin editor sends (dataService.executeBlock). */
409
414
  const executeConfigBlock = async (blockSql, { server, apiPort, dbIndex }) => {
410
415
  const url = `${server}:${apiPort}/fina/rest/TOrmMethod/%22block%22`;
@@ -424,43 +429,9 @@ const executeConfigBlock = async (blockSql, { server, apiPort, dbIndex }) => {
424
429
  return parsed;
425
430
  };
426
431
 
427
- const estimateExecuteBlockBodyLength = (blockSql, dbIndex) =>
428
- JSON.stringify({
429
- _parameters: [
430
- JSON.stringify({ dbIndex, method: "block", object: blockSql }),
431
- ],
432
- }).length;
433
-
434
- /* One upsert block when it fits, else halve the chunk size until every block fits. Mirrors
435
- appConfig.component.ts resolveUpsertBlocksByBodyLimit. */
436
- const resolveUpsertBlocks = (name, version, content, dbIndex) => {
437
- const singleBlock = getUpsertBlock(name, version, content);
438
- if (
439
- estimateExecuteBlockBodyLength(singleBlock, dbIndex) <=
440
- EXECUTE_BLOCK_BODY_LIMIT
441
- ) {
442
- return [singleBlock];
443
- }
444
-
445
- let chunkSize = EXECUTE_BLOCK_BODY_LIMIT;
446
- while (chunkSize > 0) {
447
- const blocks = getChunkedUpsertBlocks(name, version, content, chunkSize);
448
- if (
449
- blocks.every(
450
- (block) =>
451
- estimateExecuteBlockBodyLength(block, dbIndex) <=
452
- EXECUTE_BLOCK_BODY_LIMIT,
453
- )
454
- ) {
455
- return blocks;
456
- }
457
- chunkSize = Math.floor(chunkSize / 2);
458
- }
459
-
460
- throw new Error(
461
- `Failed to split "${name}" into executeBlock-sized chunks.`,
462
- );
463
- };
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);
464
435
 
465
436
  /* PURE-ish (reads files, no network): resolve every config in the directory and validate the
466
437
  WHOLE set before anything is uploaded, so a stray lib or a typo'd metadata can never leave a
@@ -727,6 +698,7 @@ async function uploadConfigs({
727
698
  sub,
728
699
  publish = false,
729
700
  forceFull = false,
701
+ releaseCc = false,
730
702
  serverMode = "prod",
731
703
  }) {
732
704
  /* Phase B2: ask biz-a for this tenant's database coordinates before anything reads the
@@ -749,6 +721,19 @@ async function uploadConfigs({
749
721
  timeout: 60000,
750
722
  };
751
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
+
752
737
  const saveOne = async ({ name, version, content }) => {
753
738
  /* A PostgreSQL index writes straight to its pool. No chunking: the block splitting below
754
739
  exists only because the FINA HTTP body has a size limit, which a direct connection does
@@ -922,13 +907,39 @@ const parseApplicationConfigData = (rawConfig) => {
922
907
  }
923
908
  };
924
909
 
925
- const resolveAppDisplayNameFromApplicationConfig = async ({
910
+ /*
911
+ * What the stored APPLICATION_CONFIG contributes to the SYS$APPS metadata: the display name (which
912
+ * also keys the row) and the menu layout the client shell draws.
913
+ */
914
+ const resolveAppMetadataFromApplicationConfig = async ({
926
915
  server,
927
916
  apiPort,
928
917
  dbIndex,
929
918
  sub,
930
919
  }) => {
931
- /* A PostgreSQL tenant reads SYS$CONFIG directly. This must not silently return "": the
920
+ const applicationConfig = await readStoredApplicationConfig({
921
+ server,
922
+ apiPort,
923
+ dbIndex,
924
+ sub,
925
+ });
926
+ const displayName = applicationConfig?.metadata?.name;
927
+ const declaredLayout = applicationConfig?.navigation?.layout;
928
+ const menuLayout = resolveMenuLayout(applicationConfig);
929
+ if (declaredLayout != null && menuLayout == null) {
930
+ logger.warn(
931
+ `[Add] navigation.layout ${JSON.stringify(declaredLayout)} is not one of ` +
932
+ `"horizontal" or "vertical"; the client keeps its default menu.`,
933
+ );
934
+ }
935
+ return {
936
+ displayName: typeof displayName === "string" ? displayName.trim() : "",
937
+ menuLayout,
938
+ };
939
+ };
940
+
941
+ const readStoredApplicationConfig = async ({ server, apiPort, dbIndex, sub }) => {
942
+ /* A PostgreSQL tenant reads SYS$CONFIG directly. This must not silently come back empty: the
932
943
  display name keys the SYS$APPS row, so falling back to the random bundle name would insert
933
944
  a fresh app row on every publish instead of updating the existing one. */
934
945
  if (isPostgresIndex(dbIndex)) {
@@ -937,15 +948,14 @@ const resolveAppDisplayNameFromApplicationConfig = async ({
937
948
  buildPostgresApplicationConfigLookupSql(),
938
949
  dbIndex,
939
950
  );
940
- const displayName = parseApplicationConfigData(rows?.[0]?.data)?.metadata?.name;
941
- return typeof displayName === "string" ? displayName.trim() : "";
951
+ return parseApplicationConfigData(rows?.[0]?.data);
942
952
  } catch {
943
- return "";
953
+ return null;
944
954
  }
945
955
  }
946
956
 
947
957
  if (typeof axios.request !== "function") {
948
- return "";
958
+ return null;
949
959
  }
950
960
 
951
961
  const baseUrl = "fina/rest/TOrmMethod/%22list%22";
@@ -989,18 +999,16 @@ const resolveAppDisplayNameFromApplicationConfig = async ({
989
999
  return name === "APPLICATION_CONFIG";
990
1000
  });
991
1001
  if (!applicationConfigRow) {
992
- return "";
1002
+ return null;
993
1003
  }
994
1004
 
995
- const applicationConfig = parseApplicationConfigData(
1005
+ return parseApplicationConfigData(
996
1006
  applicationConfigRow?.["SYS$CONFIG.DATA"] ??
997
1007
  applicationConfigRow?.DATA ??
998
1008
  applicationConfigRow?.data,
999
1009
  );
1000
- const displayName = applicationConfig?.metadata?.name;
1001
- return typeof displayName === "string" ? displayName.trim() : "";
1002
1010
  } catch {
1003
- return "";
1011
+ return null;
1004
1012
  }
1005
1013
  };
1006
1014
 
@@ -1015,6 +1023,7 @@ async function addApp({
1015
1023
  body = null,
1016
1024
  publish = false,
1017
1025
  forceFull = false,
1026
+ releaseCc = false,
1018
1027
  serverMode = "prod",
1019
1028
  /* Extra directories whose DOMAIN configs upload alongside this one's (see --include). */
1020
1029
  include = [],
@@ -1071,6 +1080,7 @@ async function addApp({
1071
1080
  sub,
1072
1081
  publish,
1073
1082
  forceFull,
1083
+ releaseCc,
1074
1084
  serverMode,
1075
1085
  });
1076
1086
  }
@@ -1161,8 +1171,8 @@ async function addApp({
1161
1171
  : `${server}:${apiPort}/${baseUrl}`;
1162
1172
 
1163
1173
  const headers = { "Content-Type": "text/plain" };
1164
- const appDisplayName =
1165
- await resolveAppDisplayNameFromApplicationConfig({
1174
+ const { displayName: appDisplayName, menuLayout } =
1175
+ await resolveAppMetadataFromApplicationConfig({
1166
1176
  server,
1167
1177
  apiPort,
1168
1178
  dbIndex,
@@ -1172,6 +1182,11 @@ async function addApp({
1172
1182
  if (useApplicationConfigName && appDisplayName.length > 0) {
1173
1183
  keys.metadata["name"] = appDisplayName;
1174
1184
  }
1185
+ /* navigation.layout -> how the client shell draws this app's menu. Only a value it
1186
+ understands is copied; without one the metadata stays exactly as before. */
1187
+ if (useApplicationConfigName && menuLayout) {
1188
+ keys.metadata["menuLayout"] = menuLayout;
1189
+ }
1175
1190
  const uploadAppName = normalizeAppName(
1176
1191
  (useApplicationConfigName ? appDisplayName : "") || bundleName,
1177
1192
  );
@@ -1274,6 +1289,7 @@ async function runUnitTests(options) {
1274
1289
  files: options.files,
1275
1290
  publish: options.publish,
1276
1291
  forceFull: options.forceFull,
1292
+ releaseCc: options.releaseCc,
1277
1293
  serverMode: options.serverMode,
1278
1294
  });
1279
1295
  } else {
@@ -1307,6 +1323,7 @@ const buildCli = () =>
1307
1323
  files: commandOptions.files,
1308
1324
  publish: commandOptions.publish,
1309
1325
  forceFull: commandOptions.forceFull,
1326
+ releaseCc: commandOptions.releaseCc,
1310
1327
  serverMode: commandOptions.serverMode,
1311
1328
  include: commandOptions.include,
1312
1329
  });
@@ -6,36 +6,109 @@ import { CLIENT_ROOM } from "../message-broker/clientEmitter.js";
6
6
  import { ClientEmitter } from "../message-broker/clientEmitter.js";
7
7
  import { joinExtRooms } from "../engine/ext/esb.js";
8
8
  import { extClientListener } from "../engine/ext/socketListener.js";
9
+ import { io as ioClient } from "socket.io-client";
10
+
9
11
  import { prefixedLogger } from "../logger.js";
10
- 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";
11
13
 
12
14
  const logger = prefixedLogger("[DirectHub]");
13
15
 
14
16
  export async function localhostTunnel(port, notifier) {
15
17
  let tunnelUrl = "";
16
18
  let qt = QuickTunnel.quick("127.0.0.1:" + port);
19
+ let isReconnecting = false;
20
+ let loopbackSocket = null;
21
+
22
+ function destroyLoopback() {
23
+ if (loopbackSocket) {
24
+ loopbackSocket.disconnect();
25
+ loopbackSocket.close();
26
+ loopbackSocket = null;
27
+ }
28
+ }
29
+
30
+ const gracefulProcessShutdown = () => {
31
+ destroyLoopback();
32
+ if (qt && typeof qt.stop === "function") {
33
+ try {
34
+ qt.stop();
35
+ } catch (e) {}
36
+ }
37
+ };
38
+ process.once("SIGINT", gracefulProcessShutdown);
39
+ process.once("SIGTERM", gracefulProcessShutdown);
40
+
17
41
  function reconnectTunnel() {
42
+ process.off("SIGINT", gracefulProcessShutdown);
43
+ process.off("SIGTERM", gracefulProcessShutdown);
44
+ destroyLoopback();
45
+
46
+ if (isReconnecting) return;
47
+ isReconnecting = true;
48
+
49
+ if (qt && typeof qt.stop === "function") {
50
+ try {
51
+ qt.stop();
52
+ } catch (e) {}
53
+ }
54
+
18
55
  setTimeout(() => {
19
56
  qt = localhostTunnel(port, notifier);
20
57
  }, RECONNECT_SOCKET_DELAY);
21
58
  }
59
+
60
+ function startDirectHubHeartbeat() {
61
+ if (loopbackSocket && loopbackSocket.connected) return;
62
+
63
+ if (!tunnelUrl || !tunnelUrl.includes("trycloudflare.com")) {
64
+ logger.warn(`Invalid remote URL detected. Heartbeat aborted.`);
65
+ return;
66
+ }
67
+
68
+ destroyLoopback();
69
+
70
+ loopbackSocket = ioClient(tunnelUrl, {
71
+ query: { isLoopback: true },
72
+ transports: ["websocket"],
73
+ reconnectionDelay: 5000,
74
+ reconnectionAttempts: 3,
75
+ });
76
+
77
+ loopbackSocket.on("connect", () => {
78
+ logger.debug(
79
+ `Heartbeat established via public internet. Cloudflare idle-drop prevented.`,
80
+ );
81
+ });
82
+
83
+ loopbackSocket.io.on("reconnect_failed", () => {
84
+ logger.warn(
85
+ `Heartbeat failed to reach Edge. Ghost tunnel confirmed. Terminating...`,
86
+ );
87
+ notifier("");
88
+ destroyLoopback();
89
+ if (qt && typeof qt.stop === "function")
90
+ try {
91
+ qt.stop();
92
+ } catch (e) {}
93
+ });
94
+ }
95
+
22
96
  qt.on("url", (qtUrl) => {
23
97
  tunnelUrl = qtUrl;
24
98
  })
25
99
  .on("connected", (conn) => {
26
100
  notifier(tunnelUrl);
27
101
  logger.info(`Connected to Direct Hub at public URL ${tunnelUrl}`);
102
+ startDirectHubHeartbeat();
28
103
  })
29
104
  .on("disconnected", (conn) => {
30
105
  notifier("");
31
- qt.stop();
32
106
  logger.info(
33
107
  `Disconnected from edge node. Awaiting native reconnect...`,
34
108
  );
35
109
  })
36
110
  .on("error", (err) => {
37
111
  notifier("");
38
- qt.stop();
39
112
  logger.error(
40
113
  `Tunnel error: ${err.message || err}. Awaiting native reconnect...`,
41
114
  );
@@ -91,7 +164,10 @@ export function directHubEvent(serverSocket, argv) {
91
164
  if (clientInstanceId) {
92
165
  clientSocket.join(clientInstanceId);
93
166
  clientSocket.on("ESB_ACK", (msgId) => {
94
- ClientEmitter.acknowledgeMessage(msgId, clientInstanceId);
167
+ ClientEmitter.acknowledgeMessage(
168
+ msgId,
169
+ clientInstanceId,
170
+ );
95
171
  });
96
172
  }
97
173
 
@@ -108,6 +184,8 @@ export function directHubEvent(serverSocket, argv) {
108
184
  } else if (clientSocket.handshake.query.isDeploy) {
109
185
  setConnectListeners(clientSocket);
110
186
  deploymentListenerForVSCode(clientSocket, argv);
187
+ } else if (clientSocket.handshake.query.isLoopback) {
188
+ setConnectListeners(clientSocket);
111
189
  } else {
112
190
  clientSocket.disconnect();
113
191
  }
@@ -125,10 +203,12 @@ export function createSocketServer(httpServer, cliIpAddress = "127.0.0.1") {
125
203
  TEST_URL,
126
204
  ADMIN_URL,
127
205
  TEST_ADMIN_URL,
206
+ CC_URL,
128
207
  // biz-a.id is still served alongside biz-a.app, so its browser origins stay allowed.
129
208
  "https://biz-a.id",
130
209
  "https://test.biz-a.id",
131
210
  "https://admin.biz-a.id",
211
+ "https://cc.biz-a.id",
132
212
  // NOT the wildcard (see above):
133
213
  // /\.biz-a\.id$/,
134
214
  "vscode-file://vscode-app",
@@ -139,8 +219,10 @@ export function createSocketServer(httpServer, cliIpAddress = "127.0.0.1") {
139
219
  `http://${cliIpAddress}:4200`,
140
220
  "http://localhost:4200",
141
221
  "http://localhost",
222
+ // Control Center (bcc) dev server, beside the client on 4200.
223
+ "http://localhost:4300",
142
224
  ]
143
- : [`http://${cliIpAddress}:4200`, "http://localhost:4200"],
225
+ : [`http://${cliIpAddress}:4200`, "http://localhost:4200", "http://localhost:4300"],
144
226
  ),
145
227
  },
146
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