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.
- package/bin/hub.js +13 -2
- package/bin/hubEvent.js +70 -17
- package/bin/watcher.js +108 -51
- package/engine/bpm/bpmContext.js +23 -0
- package/engine/bpm/bpmRoles.js +55 -0
- package/engine/bpm/routes.js +21 -14
- package/engine/bpm/stateMirror.js +26 -0
- package/engine/bpm/workflow-runtime.js +44 -16
- package/engine/bpm/workflowOnCreate.js +13 -0
- package/engine/cc/aiTurns.js +15 -2
- package/engine/cc/attributeNames.js +50 -0
- package/engine/cc/configTools.js +18 -4
- package/engine/cc/configValidate.js +688 -85
- package/engine/cc/derive.js +2 -2
- package/engine/cc/guides/V2-SCREEN-AUTHORING.md +512 -0
- package/engine/cc/layer3Behavior.js +54 -54
- package/engine/cc/layer3Schema.d.ts +1 -0
- package/engine/cc/layer3Schema.js +185 -153
- package/engine/cc/livePipelines.js +3 -1
- package/engine/cc/mapPrint.js +566 -519
- package/engine/cc/mapTools.js +81 -5
- package/engine/cc/pgRepo.js +11 -0
- package/engine/cc/phaseA.js +153 -9
- package/engine/cc/phaseB.js +2 -1
- package/engine/cc/processMap.d.ts +21 -1
- package/engine/cc/processMap.js +781 -470
- package/engine/cc/prompts/phaseA.js +88 -41
- package/engine/cc/prompts/phaseB.js +77 -30
- package/engine/cc/publish.js +51 -2
- package/engine/cc/turnLoop.js +48 -5
- package/engine/cc/turnRepo.js +13 -0
- package/engine/domain/appConfig.js +3643 -3412
- package/engine/domain/firebird-ddl.js +1173 -1163
- package/engine/domain/numberFormat.js +136 -0
- package/engine/domain/postgres-ddl.js +50 -3
- package/engine/domain/serverValues.js +202 -0
- package/engine/orm/apiRoute.js +994 -944
- package/engine/orm/entityConfig.js +114 -1
- package/engine/orm/finaEndpoint.js +3 -0
- package/engine/orm/listQuery.js +319 -285
- package/engine/orm/registryClient.js +26 -2
- package/engine/orm/saveErrors.js +58 -0
- package/migrations/postgres/1790400000000__bpm_role_group.sql +16 -0
- package/migrations/postgres/1790500000000__numbering.sql +35 -0
- package/package.json +1 -1
- package/scheduler/configController.js +114 -76
- package/scheduler/datalib.js +16 -5
- 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)}
|
|
359
|
+
`${new Date()}: ${getIdText(id)}connection error to BizA Server at ${argv["server"]} using sub domain "${argv["subdomain"]}". Error : ${msg}`,
|
|
324
360
|
);
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
+
};
|
package/engine/bpm/routes.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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",
|
|
41
|
+
router.post("/workflowRT/initiate", runtimeWrapper(workflowRT.initiate));
|
|
35
42
|
router.post(
|
|
36
43
|
"/workflowRT/availableTransitions",
|
|
37
|
-
|
|
44
|
+
runtimeWrapper(workflowRT.availableTransitions),
|
|
38
45
|
);
|
|
39
46
|
router.post(
|
|
40
47
|
"/workflowRT/executeTransition",
|
|
41
|
-
|
|
48
|
+
runtimeWrapper(workflowRT.executeTransition),
|
|
42
49
|
);
|
|
43
|
-
router.post("/workflowRT/terminate",
|
|
44
|
-
router.post("/workflowRT/remove",
|
|
45
|
-
router.post("/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
|
-
|
|
55
|
+
runtimeWrapper(workflowRT.instanceState),
|
|
49
56
|
);
|
|
50
57
|
router.post(
|
|
51
58
|
"/workflowRT/instanceLogs",
|
|
52
|
-
|
|
59
|
+
runtimeWrapper(workflowRT.instanceLogs),
|
|
53
60
|
);
|
|
54
61
|
router.post(
|
|
55
62
|
"/workflowRT/instanceTasks",
|
|
56
|
-
|
|
63
|
+
runtimeWrapper(workflowRT.instanceTasks),
|
|
57
64
|
);
|
|
58
65
|
router.post(
|
|
59
66
|
"/workflowRT/instanceTimers",
|
|
60
|
-
|
|
67
|
+
runtimeWrapper(workflowRT.instanceTimers),
|
|
61
68
|
);
|
|
62
69
|
router.post(
|
|
63
70
|
"/workflowRT/instanceEffectLogs",
|
|
64
|
-
|
|
71
|
+
runtimeWrapper(workflowRT.instanceEffectLogs),
|
|
65
72
|
);
|
|
66
|
-
router.post("/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
|
+
};
|