biz-a-cli 2.3.80-15397 → 2.3.80-15407

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 (48) hide show
  1. package/bin/hub.js +13 -2
  2. package/bin/hubEvent.js +70 -17
  3. package/bin/watcher.js +108 -51
  4. package/engine/bpm/bpmContext.js +23 -0
  5. package/engine/bpm/bpmRoles.js +55 -0
  6. package/engine/bpm/routes.js +21 -14
  7. package/engine/bpm/stateMirror.js +26 -0
  8. package/engine/bpm/workflow-runtime.js +44 -16
  9. package/engine/bpm/workflowOnCreate.js +13 -0
  10. package/engine/cc/aiTurns.js +15 -2
  11. package/engine/cc/attributeNames.js +50 -0
  12. package/engine/cc/configTools.js +18 -4
  13. package/engine/cc/configValidate.js +688 -85
  14. package/engine/cc/derive.js +2 -2
  15. package/engine/cc/guides/V2-SCREEN-AUTHORING.md +512 -0
  16. package/engine/cc/layer3Behavior.js +54 -54
  17. package/engine/cc/layer3Schema.d.ts +1 -0
  18. package/engine/cc/layer3Schema.js +185 -153
  19. package/engine/cc/livePipelines.js +3 -1
  20. package/engine/cc/mapPrint.js +566 -519
  21. package/engine/cc/mapTools.js +81 -5
  22. package/engine/cc/pgRepo.js +11 -0
  23. package/engine/cc/phaseA.js +153 -9
  24. package/engine/cc/phaseB.js +2 -1
  25. package/engine/cc/processMap.d.ts +21 -1
  26. package/engine/cc/processMap.js +781 -470
  27. package/engine/cc/prompts/phaseA.js +88 -41
  28. package/engine/cc/prompts/phaseB.js +77 -30
  29. package/engine/cc/publish.js +51 -2
  30. package/engine/cc/turnLoop.js +48 -5
  31. package/engine/cc/turnRepo.js +13 -0
  32. package/engine/domain/appConfig.js +3643 -3412
  33. package/engine/domain/firebird-ddl.js +1173 -1163
  34. package/engine/domain/numberFormat.js +136 -0
  35. package/engine/domain/postgres-ddl.js +50 -3
  36. package/engine/domain/serverValues.js +202 -0
  37. package/engine/orm/apiRoute.js +994 -944
  38. package/engine/orm/entityConfig.js +114 -1
  39. package/engine/orm/finaEndpoint.js +3 -0
  40. package/engine/orm/listQuery.js +319 -285
  41. package/engine/orm/registryClient.js +26 -2
  42. package/engine/orm/saveErrors.js +58 -0
  43. package/migrations/postgres/1790400000000__bpm_role_group.sql +16 -0
  44. package/migrations/postgres/1790500000000__numbering.sql +35 -0
  45. package/package.json +1 -1
  46. package/scheduler/configController.js +114 -76
  47. package/scheduler/datalib.js +16 -5
  48. package/scheduler/timer.js +98 -89
package/bin/hub.js CHANGED
@@ -26,6 +26,7 @@ import { CLI_Agent } from "../message-broker/agent.js";
26
26
  import { initBpmAgent } from "../engine/bpm/bpm-agent.js";
27
27
  import { bootMicroservices } from "../message-broker/orchestrator.js";
28
28
  import { ClientEmitter } from "../message-broker/clientEmitter.js";
29
+ import crypto from "node:crypto";
29
30
 
30
31
  process.on("uncaughtException", (err) => {
31
32
  logger.error("Unhandled Exception:", err);
@@ -197,6 +198,7 @@ import http from "http";
197
198
  import https from "https";
198
199
  import path from "node:path";
199
200
  import bpmRoutes from "../engine/bpm/routes.js";
201
+ import { startWorkflowOnCreate } from "../engine/bpm/workflowOnCreate.js";
200
202
  import { createExtRouter } from "../engine/ext/routes.js";
201
203
  import { createLiveExtDeps } from "../engine/ext/live.js";
202
204
  import axios from "axios";
@@ -235,6 +237,11 @@ app.use("/status", (req, res) => {
235
237
  res.status(200).json(status(argv));
236
238
  });
237
239
 
240
+ /* The engine mirrors a workflow's state onto a declaring entity's record (bpm/stateMirror.js) — keep the
241
+ declarations current on this route too. `.finally(next)`: never refuse a request over the freshness check. */
242
+ app.use("/bpm", (_req, _res, next) => {
243
+ ensureEntityConfig(Number(argv.dbindex)).finally(() => next());
244
+ });
238
245
  app.use("/bpm", bpmRoutes);
239
246
 
240
247
  // FINA-compatible endpoint. Requests reach this CLI two ways: the client's data calls over the
@@ -322,6 +329,9 @@ app.use(
322
329
  buildBindingSql: buildUserTenantSql,
323
330
  })
324
331
  : null,
332
+ /* A v2 save that creates a record of an entity declaring its workflow starts it — the same
333
+ starter the socket entrance (bin/hubEvent.js) hands the router. */
334
+ startWorkflow: startWorkflowOnCreate,
325
335
  /* So a locally-served response still tells the client where to open its sockets — the
326
336
  tunnel's byte-level injection only fires for real FINA responses. */
327
337
  cliAddress: () => (argv.cliAddress ? argv.cliAddress() : {}),
@@ -504,9 +514,10 @@ if (argv.mode.trim().toLowerCase() === "agent") {
504
514
  const genericAgent = new CLI_Agent(argv.dispatcherUrl, argv.services);
505
515
  initBpmAgent(genericAgent, argv.services);
506
516
  } else {
517
+ const cliInstanceId = crypto.randomUUID();
507
518
  // as socket client to BizA SERVER
508
519
  await streamEvent(
509
- ioClient(argv["server"], { query: { isSockStream: true } }),
520
+ ioClient(argv["server"], { query: { isSockStream: true, CLIInstanceId: cliInstanceId } }),
510
521
  argv,
511
522
  );
512
523
 
@@ -516,7 +527,7 @@ if (argv.mode.trim().toLowerCase() === "agent") {
516
527
  ioClient(argv["hubServer"], {
517
528
  reconnectionDelay: RECONNECT_SOCKET_DELAY,
518
529
  reconnectionDelayMax: RECONNECT_SOCKET_DELAY,
519
- query: { isCLI: true, room: argv["subdomain"] },
530
+ query: { isCLI: true, room: argv["subdomain"], CLIInstanceId: cliInstanceId },
520
531
  }),
521
532
  argv,
522
533
  (url) => {
package/bin/hubEvent.js CHANGED
@@ -19,6 +19,8 @@ import { logger } from "../logger.js";
19
19
  import { routeApiRequest, parseAuthRequest, withFinaLoginPrincipal } from "../engine/orm/apiRoute.js";
20
20
  import { tenantSqlFor } from "../engine/orm/tenantSql.js";
21
21
  import { ensureEntityConfig } from "../engine/domain/entityConfigPrimer.js";
22
+ import { startWorkflowOnCreate } from "../engine/bpm/workflowOnCreate.js";
23
+ import { bpmContextFromHeaders } from "../engine/bpm/bpmContext.js";
22
24
  import { runAsPrincipal, runTrusted, ACTIVE_TENANT_NOT_HELD } from "../engine/orm/tenantContext.js";
23
25
  import { resolveRequestScope, SESSION_NOT_USABLE } from "../engine/orm/requestScope.js";
24
26
  import { createSessionStore } from "../engine/orm/sessionStore.js";
@@ -107,6 +109,29 @@ export const IDLE_SOCKET_TIMEOUT_MILLISECONDS = 1000 * 28;
107
109
  export const RECONNECT_SOCKET_DELAY = 60 * 1000;
108
110
  const DISCONNECT_REASON_BY_SOCKET_SERVER = "io server disconnect";
109
111
 
112
+ export const JITTER_DELAY_MIN_MS = 500;
113
+ export const JITTER_DELAY_MAX_MS = 2500;
114
+
115
+ export const attachResilientDisconnect = (socket, getPrefix, serverName, notifier = null) => {
116
+ socket.on("disconnect", (reason) => {
117
+ if (notifier) notifier("");
118
+ logger.info(
119
+ `${new Date()}: ${getPrefix()}disconnected from ${serverName}. Reason: ${reason}`,
120
+ );
121
+ if (reason.toLowerCase() === DISCONNECT_REASON_BY_SOCKET_SERVER) {
122
+ socket.connect();
123
+ } else if (reason === "transport close" || reason === "ping timeout" || reason === "transport error") {
124
+ const jitterMs = Math.floor(Math.random() * (JITTER_DELAY_MAX_MS - JITTER_DELAY_MIN_MS)) + JITTER_DELAY_MIN_MS;
125
+ setTimeout(() => {
126
+ if (socket.disconnected) {
127
+ logger.info(`${new Date()}: ${getPrefix()}injecting jittered reconnect to ${serverName} (${jitterMs}ms)`);
128
+ socket.connect();
129
+ }
130
+ }, jitterMs);
131
+ }
132
+ });
133
+ };
134
+
110
135
  /* biz-a's answer when the subdomain is still registered to a socket it has not yet reaped
111
136
  (hubController.js createSockStream2CLI). It clears itself once that socket times out. */
112
137
  const isSubdomainClaimConflict = (error) =>
@@ -318,14 +343,27 @@ export const streamEvent = async (socket, argv) =>
318
343
  socket.on("ext-req", extReqCb);
319
344
  socket.on("cliCommand", (data, cb) => handleCliCommand(data, cb, argv));
320
345
 
321
- socket.on("disconnect", (reason) => {
346
+ // socket.on("disconnect", (reason) => {
347
+ // logger.info(
348
+ // `${new Date()}: ${getIdText(id)}disconnected from BizA Server. Reason: ${reason}`,
349
+ // );
350
+ // id = undefined;
351
+ // if (reason.toLowerCase() === DISCONNECT_REASON_BY_SOCKET_SERVER) {
352
+ // socket.connect();
353
+ // }
354
+ // });
355
+
356
+ attachResilientDisconnect(socket, () => getIdText(id), "BizA Server");
357
+ const logError = (msg) => {
322
358
  logger.info(
323
- `${new Date()}: ${getIdText(id)}disconnected from BizA Server. Reason: ${reason}`,
359
+ `${new Date()}: ${getIdText(id)}connection error to BizA Server at ${argv["server"]} using sub domain "${argv["subdomain"]}". Error : ${msg}`,
324
360
  );
325
- id = undefined;
326
- if (reason.toLowerCase() === DISCONNECT_REASON_BY_SOCKET_SERVER) {
327
- socket.connect();
328
- }
361
+ };
362
+ socket.on("connect_error", (error) => {
363
+ logError(error.message);
364
+ });
365
+ socket.on("error", (error) => {
366
+ logError(error);
329
367
  });
330
368
 
331
369
  socket.io.on("reconnect", () => {
@@ -345,16 +383,17 @@ export const hubEvent = (socket, argv, notifier) => {
345
383
  `${new Date()}: ${getIdText(id)}connected to BizA Hub at ${argv["hubServer"]} using sub domain "${argv["subdomain"]}"`,
346
384
  );
347
385
  });
348
- socket.on("disconnect", (reason) => {
349
- notifier("");
350
- logger.info(
351
- `${new Date()}: ${getIdText(id)}disconnected from BizA Hub. Reason: ${reason}`,
352
- );
353
- id = undefined;
354
- if (reason.toLowerCase() === DISCONNECT_REASON_BY_SOCKET_SERVER) {
355
- socket.connect();
356
- }
357
- });
386
+ // socket.on("disconnect", (reason) => {
387
+ // notifier("");
388
+ // logger.info(
389
+ // `${new Date()}: ${getIdText(id)}disconnected from BizA Hub. Reason: ${reason}`,
390
+ // );
391
+ // id = undefined;
392
+ // if (reason.toLowerCase() === DISCONNECT_REASON_BY_SOCKET_SERVER) {
393
+ // socket.connect();
394
+ // }
395
+ // });
396
+ attachResilientDisconnect(socket, () => getIdText(id), "BizA Hub", notifier);
358
397
  const logError = (msg) => {
359
398
  logger.info(
360
399
  `${new Date()}: ${getIdText(id)}connection error to BizA Hub at ${argv["hubServer"]} using sub domain "${argv["subdomain"]}". Error : ${msg}`,
@@ -781,6 +820,11 @@ const handleCliCommand = async (data, cb, argv) => {
781
820
 
782
821
  const engineModule = await EngineRegistry[modulePath]();
783
822
 
823
+ /* The BPM engine mirrors a workflow's state onto the record of an entity that declares it
824
+ (bpm/stateMirror.js reads the entity declarations) — keep them current here as the data
825
+ entrance below does. Never fatal: the declarations already held still answer. */
826
+ await ensureEntityConfig(Number(argv["dbindex"])).catch(() => {});
827
+
784
828
  if (typeof engineModule[methodName] !== "function") {
785
829
  throw new Error(
786
830
  `Method '${methodName}' does not exist on target '${modulePath}'.`,
@@ -802,8 +846,16 @@ const handleCliCommand = async (data, cb, argv) => {
802
846
  * These are engine operations invoked by an administrator, not tenant data reads, so
803
847
  * they are trusted for the same reason everything behind db/db.js is.
804
848
  */
849
+ /* The BPM runtime alone gets who is asking — the signed principal in `data.headers`, never the
850
+ caller-shaped payload — for transitions that ask for verified roles (bpm/bpmContext.js). */
851
+ const engineContext =
852
+ modulePath === "bpm/workflowRT"
853
+ ? bpmContextFromHeaders(data.headers, { mode: argv["principalMode"] || "observe" })
854
+ : undefined;
805
855
  const result = await runTrusted(() =>
806
- engineModule[methodName](data.payload, argv),
856
+ engineContext
857
+ ? engineModule[methodName](data.payload, argv, engineContext)
858
+ : engineModule[methodName](data.payload, argv),
807
859
  );
808
860
 
809
861
  cb(null, { success: true, data: result });
@@ -1044,6 +1096,7 @@ export const clientListener = (socket, argv, via = "?") => {
1044
1096
  openSession: openSessionFor(Number(argv["dbindex"])),
1045
1097
  chooseActiveTenant: (sid, tenantId) =>
1046
1098
  sessionStoreFor(Number(argv["dbindex"]))?.chooseActiveTenant(sid, tenantId),
1099
+ startWorkflow: startWorkflowOnCreate,
1047
1100
  },
1048
1101
  ),
1049
1102
  ),
package/bin/watcher.js CHANGED
@@ -1,51 +1,108 @@
1
- #!/usr/bin/env node
2
-
3
- import "../envs/defaultEnv.js";
4
- import express from 'express';
5
- import cors from 'cors';
6
- const app = express();
7
- import yargs from 'yargs';
8
- import * as timer from '../scheduler/timer.js'
9
- import { logger } from "../logger.js";
10
-
11
- process.on('uncaughtException', (err) => {
12
- logger.error('Unhandled Exception:', err);
13
- });
14
-
15
- process.on('unhandledRejection', (err) => {
16
- logger.error('Unhandled Rejection:', err);
17
- });
18
-
19
- const port = 3001;
20
-
21
- const options = yargs(process.argv.slice(2))
22
- .option('c', {
23
- alias: 'company',
24
- describe: 'Company',
25
- type: 'string',
26
- demandOption: true
27
- })
28
- .option('p', {
29
- alias: 'port',
30
- default: port,
31
- describe: 'Port to use',
32
- type: 'string',
33
- demandOption: false
34
- })
35
- // .option('m', {
36
- // alias: 'mode',
37
- // describe: 'Fill with "prod" / "dev"',
38
- // type: 'string',
39
- // demandOption: false
40
- // })
41
- .argv;
42
-
43
-
44
- app.use(cors());
45
- timer.runScheduler(options.company);
46
-
47
- app.listen((options.port || port), () => {
48
- logger.info(`Biz-A CLI is listening at ${process.env.HOST || 'http://localhost'}:${options.port || port} `);
49
- });
50
-
51
- // export const MODE = (options.mode == 'prod') ? 'production' : 'development';
1
+ #!/usr/bin/env node
2
+
3
+ import "../envs/defaultEnv.js";
4
+ import express from 'express';
5
+ import cors from 'cors';
6
+ const app = express();
7
+ import yargs from 'yargs';
8
+ import * as timer from '../scheduler/timer.js'
9
+ import { logger } from "../logger.js";
10
+ import { SERVER_MODES, resolveServerLink } from "../envs/serverEndpoints.js";
11
+
12
+ process.on('uncaughtException', (err) => {
13
+ logger.error('Unhandled Exception:', err);
14
+ });
15
+
16
+ process.on('unhandledRejection', (err) => {
17
+ logger.error('Unhandled Rejection:', err);
18
+ });
19
+
20
+ const port = 3001;
21
+
22
+ const options = yargs(process.argv.slice(2))
23
+ .option('c', {
24
+ alias: 'company',
25
+ describe: 'Company',
26
+ type: 'string',
27
+ demandOption: true
28
+ })
29
+ .option('p', {
30
+ alias: 'port',
31
+ default: port,
32
+ describe: 'Port to use',
33
+ type: 'string',
34
+ demandOption: false
35
+ })
36
+ /* Which Biz-A backend to ask for this company's configuration. Without it the scheduler can only
37
+ ever reach PRODUCTION, because nothing on the config path reads NODE_ENV, so envs/env.dev.js is
38
+ never consulted — and production currently serves a build with no registry endpoint at all. */
39
+ .options('serverMode', {
40
+ alias: 'sMode',
41
+ describe: `Backend server mode: ${SERVER_MODES.join(', ')}`,
42
+ type: 'string',
43
+ choices: SERVER_MODES,
44
+ default: 'prod'
45
+ })
46
+ /*
47
+ * ⚠️ REFUSE UNKNOWN FLAGS, because the fallback when one is ignored is PRODUCTION. yargs aliases
48
+ * are case-sensitive, so `--sMod dev` is not `--sMode dev`: without this the unknown key is
49
+ * silently dropped, serverMode keeps its `prod` default, and the watcher quietly asks a server that
50
+ * cannot answer — surfacing as the confusing `Cannot POST /config/resolveBySubdomain` 404 this flag
51
+ * exists to avoid. bin/app.js and bin/hub.js both do this already.
52
+ */
53
+ .strict()
54
+ .argv;
55
+
56
+ const serverLink = resolveServerLink(options.serverMode);
57
+
58
+ app.use(cors());
59
+
60
+ /* Say what is being asked, and of whom, before asking it. This names the CONFIG server only; which API
61
+ the timers then call is decided by that company's API_URL and is logged from the resolved config
62
+ below — the two are different addresses and conflating them cost a long debugging detour. */
63
+ logger.info(`Biz-A watcher: company "${options.company}" via ${options.serverMode} -> ${serverLink}`);
64
+
65
+ /* CATCH THE STARTUP FAILURE. This used to be fire-and-forget, so a legitimate "no such company"
66
+ surfaced through the unhandledRejection handler as a bare `Unhandled Rejection: { code: 'ENOENT' }`
67
+ — and app.listen() ran regardless, leaving a process that reported itself listening while no
68
+ scheduler existed.
69
+ *
70
+ * ⚠️ Listen from `onReady`, NOT from .then(): runScheduler's interval loop never resolves, so a
71
+ * `.then()` here would never fire and the HTTP server would never start. */
72
+ let started = false;
73
+
74
+ timer
75
+ .runScheduler(options.company, {
76
+ serverLink,
77
+ onReady: (config) => {
78
+ started = true;
79
+ /* THE ADDRESS EVERY TIMER AND CLI SCRIPT WILL CALL, taken from this company's API_URL in
80
+ the configuration document. It is the thing to change when the scheduler is talking to
81
+ the wrong place, and printing it once is the difference between a bare "502" and
82
+ "502 from the host I can now go and check". */
83
+ logger.info(
84
+ `Resolved config for subdomain "${config?.subdomain}": api=${config?.url} dbindex=${config?.dbindex}`,
85
+ );
86
+ app.listen((options.port || port), () => {
87
+ logger.info(`Biz-A CLI is listening at ${process.env.HOST || 'http://localhost'}:${options.port || port} `);
88
+ });
89
+ },
90
+ })
91
+ .catch((err) => {
92
+ /* STARTUP AND RUNTIME FAILURES ARE DIFFERENT THINGS, and `started` is the only way to tell
93
+ them apart — runScheduler rejects for either. Reporting a timer that died hours in as
94
+ "cannot start" sends whoever reads the log looking at the wrong thing. */
95
+ if (!started) {
96
+ /* ENOENT is an answer, not a crash: the server has no such company. Its message already
97
+ names the company and the server, so print it plainly. Nothing is listening yet, so the
98
+ process exits on its own — non-zero, so a supervisor sees it. */
99
+ logger.error(
100
+ err?.code === 'ENOENT'
101
+ ? `Cannot start the watcher: ${err.message}`
102
+ : `Cannot start the watcher (${options.serverMode} -> ${serverLink}): ${err?.message || err}`,
103
+ );
104
+ } else {
105
+ logger.error(`Scheduler stopped after startup: ${err?.message || err}`);
106
+ }
107
+ process.exitCode = 1;
108
+ });
@@ -0,0 +1,23 @@
1
+ /*
2
+ * The verified caller of a BPM engine command — ruling 2026-10-06 (Agung): workflow roles come from user groups.
3
+ *
4
+ * Both entrances build it the same way: the socket `engine` command (bin/hubEvent.js, `data.headers`) and the HTTP
5
+ * `/bpm` routes (engine/bpm/routes.js, `req.headers`). The runtime (workflow-runtime.js callerOf) uses it only for a
6
+ * call that asks for verified roles (`verifiedRoles: true`, what v2 screens send).
7
+ *
8
+ * ⚠️ THE HEADERS, NEVER THE PAYLOAD: the payload is caller-shaped. Everything here comes from inside the signature
9
+ * (security/internalPrincipal.js principalForCliScript); a missing or forged token is nobody (`principal: null`).
10
+ * ⚠️ Roles are read TRUSTED (no tenant scope) — SYS$ tables, server-side, for the id the signature names.
11
+ */
12
+ import { principalForCliScript } from "../../security/internalPrincipal.js";
13
+ import { execPostgres } from "../domain/dialect.js";
14
+ import { runTrusted } from "../orm/tenantContext.js";
15
+ import { resolveBpmRoles } from "./bpmRoles.js";
16
+
17
+ export const bpmContextFromHeaders = (headers, { mode = "observe", exec = execPostgres } = {}) => {
18
+ const verified = principalForCliScript(headers ?? {}, { mode });
19
+ return {
20
+ principal: verified && verified.userId !== null && verified.userId !== undefined ? { userId: verified.userId, dbIndex: verified.dbIndex } : null,
21
+ resolveRoles: ({ userId, dbIndex }) => resolveBpmRoles({ exec: (sql, db) => runTrusted(() => exec(sql, db)), dbIndex, userId }),
22
+ };
23
+ };
@@ -0,0 +1,55 @@
1
+ /*
2
+ * Which workflow roles a VERIFIED user holds — ruling 2026-10-06 (Agung): map roles to user groups.
3
+ *
4
+ * A transition names role codes (SYS$BPM_TRANS_ROLE, e.g. MARKETING); SYS$BPM_ROLE_GROUP maps a role code to the user
5
+ * groups whose members hold it (edited on the admin User Group form). A company administrator (SYS$ADMINISTRATOR)
6
+ * holds every role any workflow names, so a company can work before it has mapped anything.
7
+ *
8
+ * ⚠️ THE USER ID COMES FROM INSIDE A SIGNATURE (security/internalPrincipal.js), never from the request: the roles a
9
+ * caller CLAIMED are exactly what this replaces. Same guard as orm/userGroups.js — numeric or nothing.
10
+ * ⚠️ FAIL-CLOSED: a missing table (a Firebird tenant has none), a bad id, an unreadable row — no role. A role check
11
+ * that failed open would hand out every approval on a database hiccup.
12
+ */
13
+ import { logger } from "../../logger.js";
14
+ import { resolveIsAdministrator } from "../orm/userGroups.js";
15
+
16
+ const numericId = (userId) => {
17
+ if (userId === null || userId === undefined || userId === "") return null;
18
+ if (typeof userId === "object" || typeof userId === "boolean") return null;
19
+ const id = Number(userId);
20
+ return Number.isInteger(id) && String(userId).trim() === String(id) ? id : null;
21
+ };
22
+
23
+ export const buildBpmRolesSql = (userId) => {
24
+ const id = numericId(userId);
25
+ if (id === null) return null;
26
+ return (
27
+ `SELECT DISTINCT r.role_code FROM sys$bpm_role_group r ` +
28
+ `INNER JOIN sys$ugmember m ON m."sys$usergroup_id" = r."sys$usergroup_id" ` +
29
+ `WHERE m.user_id = ${id}`
30
+ );
31
+ };
32
+ const ALL_ROLE_CODES_SQL = "SELECT DISTINCT role_code FROM sys$bpm_trans_role";
33
+
34
+ const codesOf = (rows) =>
35
+ (Array.isArray(rows) ? rows : [])
36
+ .map((row) => String(row?.role_code ?? row?.ROLE_CODE ?? "").trim().toUpperCase())
37
+ .filter((code) => code.length > 0);
38
+
39
+ const read = async (exec, sql, dbIndex, what, userId) => {
40
+ try {
41
+ return codesOf(await exec(sql, dbIndex));
42
+ } catch (error) {
43
+ logger.warn(`[bpm] could not read ${what} for user ${userId}: ${error?.message ?? error}. Treating it as none.`);
44
+ return [];
45
+ }
46
+ };
47
+
48
+ export const resolveBpmRoles = async ({ exec, dbIndex, userId }) => {
49
+ const sql = buildBpmRolesSql(userId);
50
+ if (!sql || typeof exec !== "function") return [];
51
+ const mapped = await read(exec, sql, dbIndex, "workflow roles", userId);
52
+ const admin = await resolveIsAdministrator({ exec, dbIndex, userId: numericId(userId) });
53
+ const all = admin ? await read(exec, ALL_ROLE_CODES_SQL, dbIndex, "the workflow role codes", userId) : [];
54
+ return [...new Set([...mapped, ...all])];
55
+ };
@@ -2,13 +2,18 @@ import express from "express";
2
2
  import { logger } from "../../logger.js";
3
3
  import * as workflow from "./workflow.js";
4
4
  import * as workflowRT from "./workflow-runtime.js";
5
+ import { bpmContextFromHeaders } from "./bpmContext.js";
5
6
 
6
7
  const router = express.Router();
7
8
 
8
9
  // The Wrapper: Translates HTTP requests into pure Engine payloads
9
- const expressWrapper = (engineFunction) => async (req, res) => {
10
+ /* `withCaller` (the runtime routes): a third argument, who is asking, from the signed header — what the socket
11
+ entrance hands the runtime too (bpm/bpmContext.js); used only by a transition that asks for verified roles. */
12
+ const expressWrapper = (engineFunction, withCaller = false) => async (req, res) => {
10
13
  try {
11
- const result = await engineFunction(req.body);
14
+ const result = withCaller
15
+ ? await engineFunction(req.body, undefined, bpmContextFromHeaders(req.headers))
16
+ : await engineFunction(req.body);
12
17
  res.json({ success: true, data: result });
13
18
  } catch (error) {
14
19
  /* The message goes to the browser, where it is shown as a toast and then lost. An engine
@@ -19,6 +24,8 @@ const expressWrapper = (engineFunction) => async (req, res) => {
19
24
  }
20
25
  };
21
26
 
27
+ const runtimeWrapper = (engineFunction) => expressWrapper(engineFunction, true);
28
+
22
29
  // ==========================================
23
30
  // DESIGN TIME: /workflow/ Prefix
24
31
  // ==========================================
@@ -31,38 +38,38 @@ router.post("/workflow/remove", expressWrapper(workflow.remove));
31
38
  // ==========================================
32
39
  // RUNTIME EXECUTION: /workflowRT/ Prefix
33
40
  // ==========================================
34
- router.post("/workflowRT/initiate", expressWrapper(workflowRT.initiate));
41
+ router.post("/workflowRT/initiate", runtimeWrapper(workflowRT.initiate));
35
42
  router.post(
36
43
  "/workflowRT/availableTransitions",
37
- expressWrapper(workflowRT.availableTransitions),
44
+ runtimeWrapper(workflowRT.availableTransitions),
38
45
  );
39
46
  router.post(
40
47
  "/workflowRT/executeTransition",
41
- expressWrapper(workflowRT.executeTransition),
48
+ runtimeWrapper(workflowRT.executeTransition),
42
49
  );
43
- router.post("/workflowRT/terminate", expressWrapper(workflowRT.terminate));
44
- router.post("/workflowRT/remove", expressWrapper(workflowRT.remove));
45
- router.post("/workflowRT/myTasks", expressWrapper(workflowRT.myTasks));
50
+ router.post("/workflowRT/terminate", runtimeWrapper(workflowRT.terminate));
51
+ router.post("/workflowRT/remove", runtimeWrapper(workflowRT.remove));
52
+ router.post("/workflowRT/myTasks", runtimeWrapper(workflowRT.myTasks));
46
53
  router.post(
47
54
  "/workflowRT/instanceState",
48
- expressWrapper(workflowRT.instanceState),
55
+ runtimeWrapper(workflowRT.instanceState),
49
56
  );
50
57
  router.post(
51
58
  "/workflowRT/instanceLogs",
52
- expressWrapper(workflowRT.instanceLogs),
59
+ runtimeWrapper(workflowRT.instanceLogs),
53
60
  );
54
61
  router.post(
55
62
  "/workflowRT/instanceTasks",
56
- expressWrapper(workflowRT.instanceTasks),
63
+ runtimeWrapper(workflowRT.instanceTasks),
57
64
  );
58
65
  router.post(
59
66
  "/workflowRT/instanceTimers",
60
- expressWrapper(workflowRT.instanceTimers),
67
+ runtimeWrapper(workflowRT.instanceTimers),
61
68
  );
62
69
  router.post(
63
70
  "/workflowRT/instanceEffectLogs",
64
- expressWrapper(workflowRT.instanceEffectLogs),
71
+ runtimeWrapper(workflowRT.instanceEffectLogs),
65
72
  );
66
- router.post("/workflowRT/retryEffect", expressWrapper(workflowRT.retryEffect));
73
+ router.post("/workflowRT/retryEffect", runtimeWrapper(workflowRT.retryEffect));
67
74
 
68
75
  export default router;
@@ -0,0 +1,26 @@
1
+ /*
2
+ * The workflow state, mirrored onto the record of an entity that DECLARES it (entityConfig.buildWorkflowMap).
3
+ *
4
+ * Found live 2026-10-06 (studiob "marketing"): a v2 record's state lived only in SYS$BPM_WF_INSTANCE, so its form
5
+ * and its list could not show it. stateMirror(entityType, entityId, stateCode, dbIndex) is the extra top-level table
6
+ * the engine adds to the SAME save that moves the instance — { <table>: { id, <stateColumn>: stateCode } } — so the
7
+ * record and the instance can never disagree. The v1 keyed upsert updates only the columns it is given
8
+ * (orm/masterDetail.js upsertByKeyCtes). Null when the entity declares nothing: every other workflow is unchanged.
9
+ *
10
+ * ⚠️ Keyed by `id` only — the ORM's key convention (masterDetail isPrimaryKey). An entity keyed otherwise would be
11
+ * INSERTED, not updated, so it is refused with a warning instead.
12
+ */
13
+ import { workflowOfEntityType } from "../orm/entityConfig.js";
14
+ import { logger } from "../../logger.js";
15
+
16
+ export const stateMirror = (entityType, entityId, stateCode, dbIndex) => {
17
+ const workflow = workflowOfEntityType(entityType, dbIndex);
18
+ if (!workflow || entityId === null || entityId === undefined || entityId === "" || !stateCode) return null;
19
+ if (workflow.primaryKey !== "id") {
20
+ logger.warn(`[workflow] ${entityType} is keyed by ${workflow.primaryKey}, not id; its state is not mirrored.`);
21
+ return null;
22
+ }
23
+ const numeric = Number(entityId);
24
+ const id = Number.isFinite(numeric) && String(numeric) === String(entityId).trim() ? numeric : entityId;
25
+ return { [workflow.table]: { id, [workflow.stateColumn]: stateCode } };
26
+ };