biz-a-cli 2.3.80-15271 → 2.3.80-15280
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/directHubEvent.js +34 -19
- package/bin/hub.js +40 -0
- package/bin/hubEvent.js +26 -0
- package/engine/ext/appStore.js +27 -0
- package/engine/ext/cdmClient.js +35 -0
- package/engine/ext/channel.js +75 -0
- package/engine/ext/cliInvoke.js +23 -0
- package/engine/ext/dispatch.js +91 -0
- package/engine/ext/esb.js +84 -0
- package/engine/ext/hubProxy.js +49 -0
- package/engine/ext/identityRepo.js +74 -0
- package/engine/ext/identityStore.js +25 -0
- package/engine/ext/intake.js +28 -0
- package/engine/ext/live.js +236 -0
- package/engine/ext/manifest.js +19 -0
- package/engine/ext/manifestStore.js +97 -0
- package/engine/ext/oidc.js +62 -0
- package/engine/ext/oidcHttp.js +78 -0
- package/engine/ext/projection.js +37 -0
- package/engine/ext/providers.js +51 -0
- package/engine/ext/rateLimit.js +28 -0
- package/engine/ext/responseFields.js +23 -0
- package/engine/ext/routes.js +232 -0
- package/engine/ext/slugResolver.js +46 -0
- package/engine/ext/socketListener.js +27 -0
- package/engine/ext/stateStore.js +30 -0
- package/engine/ext/token.js +168 -0
- package/engine/ext/webmanifest.js +36 -0
- package/message-broker/clientEmitter.js +22 -0
- package/message-broker/dispatcher.js +12 -0
- package/migrations/1787137466726__ext.sql +122 -0
- package/migrations/1787201361442__ext_cli.sql +13 -0
- package/package.json +2 -1
- package/worker/cliScriptWorker.js +18 -0
- package/worker/cliWorkerPool.js +10 -1
package/bin/directHubEvent.js
CHANGED
|
@@ -4,6 +4,8 @@ import { Tunnel as QuickTunnel } from "cloudflared";
|
|
|
4
4
|
import { Server as ioServer } from "socket.io";
|
|
5
5
|
import { CLIENT_ROOM } from "../message-broker/clientEmitter.js";
|
|
6
6
|
import { ClientEmitter } from "../message-broker/clientEmitter.js";
|
|
7
|
+
import { joinExtRooms } from "../engine/ext/esb.js";
|
|
8
|
+
import { extClientListener } from "../engine/ext/socketListener.js";
|
|
7
9
|
|
|
8
10
|
export async function localhostTunnel(port, notifier) {
|
|
9
11
|
let tunnelUrl = "";
|
|
@@ -67,27 +69,40 @@ export function directHubEvent(serverSocket, argv) {
|
|
|
67
69
|
|
|
68
70
|
if (clientSocket.handshake.query.isClient) {
|
|
69
71
|
setConnectListeners(clientSocket);
|
|
70
|
-
clientListener(clientSocket, argv);
|
|
71
|
-
clientSocket.join(CLIENT_ROOM);
|
|
72
72
|
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
73
|
+
const extToken = clientSocket.handshake.query.extToken;
|
|
74
|
+
const extSlug = clientSocket.handshake.query.extSlug;
|
|
75
|
+
if (extToken && extSlug) {
|
|
76
|
+
// EXTERNAL client: RESTRICTED listener (runExtCliScript only — no apiRequest /
|
|
77
|
+
// runcliscript / engine / emitter), and rooms derived from the VERIFIED token
|
|
78
|
+
// only (a forged userId/slug in the handshake is ignored). It does NOT join
|
|
79
|
+
// CLIENT_ROOM (internal broadcasts).
|
|
80
|
+
extClientListener(clientSocket, argv);
|
|
81
|
+
joinExtRooms(clientSocket, extToken, { slug: extSlug });
|
|
82
|
+
} else {
|
|
83
|
+
// INTERNAL client — full listener + existing rooms, unchanged.
|
|
84
|
+
clientListener(clientSocket, argv);
|
|
85
|
+
clientSocket.join(CLIENT_ROOM);
|
|
86
|
+
|
|
87
|
+
// isolated Client in a room using clientInstanceId
|
|
88
|
+
const clientInstanceId =
|
|
89
|
+
clientSocket.handshake.query.clientInstanceId;
|
|
90
|
+
if (clientInstanceId) {
|
|
91
|
+
clientSocket.join(clientInstanceId);
|
|
92
|
+
clientSocket.on("ESB_ACK", (msgId) => {
|
|
93
|
+
ClientEmitter.acknowledgeMessage(msgId, clientInstanceId);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
82
96
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
97
|
+
// isolated Client in a room using argv.subdomain and UserId
|
|
98
|
+
const userId = clientSocket.handshake.query.userId;
|
|
99
|
+
if (
|
|
100
|
+
userId !== undefined &&
|
|
101
|
+
userId !== null &&
|
|
102
|
+
String(userId) !== "-1"
|
|
103
|
+
) {
|
|
104
|
+
clientSocket.join(`${argv.subdomain}_USER_${userId}`);
|
|
105
|
+
}
|
|
91
106
|
}
|
|
92
107
|
} else if (clientSocket.handshake.query.isDeploy) {
|
|
93
108
|
setConnectListeners(clientSocket);
|
package/bin/hub.js
CHANGED
|
@@ -199,6 +199,8 @@ import http from "http";
|
|
|
199
199
|
import https from "https";
|
|
200
200
|
import path from "node:path";
|
|
201
201
|
import bpmRoutes from "../engine/bpm/routes.js";
|
|
202
|
+
import { createExtRouter } from "../engine/ext/routes.js";
|
|
203
|
+
import { createLiveExtDeps } from "../engine/ext/live.js";
|
|
202
204
|
|
|
203
205
|
app.use(compression());
|
|
204
206
|
app.use(cors());
|
|
@@ -218,6 +220,44 @@ app.use("/status", (req, res) => {
|
|
|
218
220
|
|
|
219
221
|
app.use("/bpm", bpmRoutes);
|
|
220
222
|
|
|
223
|
+
// External Access boundary (§ Biz-A External Access). Same process as the internal
|
|
224
|
+
// endpoints, but a narrow allowlist: anonymous entry + published-operation dispatch only.
|
|
225
|
+
// Single-tenant live wiring over this hub's own Firebird (argv.subdomain / argv.dbindex);
|
|
226
|
+
// requires the `ext.capability` secret + authored SYS$EXT_* / SYS$APPS rows to serve.
|
|
227
|
+
let extOidcConfig;
|
|
228
|
+
try {
|
|
229
|
+
extOidcConfig = JSON.parse(
|
|
230
|
+
fs.readFileSync(
|
|
231
|
+
path.join(process.cwd(), "config", "ext-oidc.json"),
|
|
232
|
+
"utf8",
|
|
233
|
+
),
|
|
234
|
+
);
|
|
235
|
+
} catch {
|
|
236
|
+
extOidcConfig = undefined; // no federated providers configured -> anonymous-only
|
|
237
|
+
}
|
|
238
|
+
const extDeps = createLiveExtDeps({
|
|
239
|
+
subdomain: argv.subdomain,
|
|
240
|
+
dbindex: argv.dbindex,
|
|
241
|
+
finaDbIndex: argv.finaDbindex,
|
|
242
|
+
apiUrl: `http://${argv.hostname}:${argv.port}`,
|
|
243
|
+
oidc: extOidcConfig,
|
|
244
|
+
});
|
|
245
|
+
app.use(
|
|
246
|
+
"/ext/:slug",
|
|
247
|
+
createExtRouter({
|
|
248
|
+
getManifest: extDeps.getManifest,
|
|
249
|
+
getAppBundle: extDeps.getAppBundle,
|
|
250
|
+
auditSink: extDeps.auditSink,
|
|
251
|
+
oidc: extDeps.oidc,
|
|
252
|
+
identityStore: extDeps.identityStore,
|
|
253
|
+
resolveTenant: extDeps.resolveTenant,
|
|
254
|
+
getChannelInfo: () => {
|
|
255
|
+
const a = argv.cliAddress ? argv.cliAddress() : {};
|
|
256
|
+
return { cliAddress: a.publicUrl, hubAddress: a.hubUrl };
|
|
257
|
+
},
|
|
258
|
+
}),
|
|
259
|
+
);
|
|
260
|
+
|
|
221
261
|
// create HTTP(s) Server
|
|
222
262
|
const keyFile = path.join(import.meta.dirname, "../cert/key.pem");
|
|
223
263
|
const certFile = path.join(import.meta.dirname, "../cert/cert.pem");
|
package/bin/hubEvent.js
CHANGED
|
@@ -11,6 +11,9 @@ const packageJson = require("../package.json");
|
|
|
11
11
|
import { deploymentListenerForHubServer } from "./deployEvent.js";
|
|
12
12
|
import { execCLIScriptWorker, workerStats } from "../worker/cliWorkerPool.js";
|
|
13
13
|
import { ClientEmitter } from "../message-broker/clientEmitter.js";
|
|
14
|
+
import { handleRunExtCliScript } from "../engine/ext/channel.js";
|
|
15
|
+
import { extDeriveRoomsHandler } from "../engine/ext/esb.js";
|
|
16
|
+
import { makeExtReqHandler } from "../engine/ext/hubProxy.js";
|
|
14
17
|
|
|
15
18
|
export const IDLE_SOCKET_TIMEOUT_MILLISECONDS = 1000 * 28;
|
|
16
19
|
export const RECONNECT_SOCKET_DELAY = 60 * 1000;
|
|
@@ -165,9 +168,19 @@ export const streamEvent = async (socket, argv) =>
|
|
|
165
168
|
}
|
|
166
169
|
};
|
|
167
170
|
|
|
171
|
+
// External Access: biz-a relays `/ext/:slug/*` here (routed by subdomain) so the CLI's ext
|
|
172
|
+
// boundary is reachable through biz-a without exposing the CLI to the browser. Unlike cli-req
|
|
173
|
+
// (which targets /cb), this forwards to the FULL path on the CLI's own express + preserves status.
|
|
174
|
+
const extReqCb = makeExtReqHandler({
|
|
175
|
+
request: (opts) => axios.request(opts),
|
|
176
|
+
baseUrl: () =>
|
|
177
|
+
`${process.env.HOST || "http://localhost"}:${argv.serverport}`,
|
|
178
|
+
});
|
|
179
|
+
|
|
168
180
|
socket.on("connect", connectCb);
|
|
169
181
|
socket.on("incomingClient", incomingHubCb);
|
|
170
182
|
socket.on("cli-req", cliReqCb);
|
|
183
|
+
socket.on("ext-req", extReqCb);
|
|
171
184
|
socket.on("cliCommand", (data, cb) => handleCliCommand(data, cb, argv));
|
|
172
185
|
|
|
173
186
|
socket.on("disconnect", (reason) => {
|
|
@@ -234,6 +247,12 @@ export const hubEvent = (socket, argv, notifier) => {
|
|
|
234
247
|
ClientEmitter.acknowledgeMessage(msgId, clientInstanceId);
|
|
235
248
|
});
|
|
236
249
|
|
|
250
|
+
// Hub-channel external real-time: the biz-a hub cannot verify a capability token, so it asks
|
|
251
|
+
// this CLI (which owns the ext secret) to derive an external client's rooms; we verify and ack
|
|
252
|
+
// the room list, and the hub joins the client to exactly those (forged/cross-slug token -> []).
|
|
253
|
+
socket.on("extDeriveRooms", extDeriveRoomsHandler);
|
|
254
|
+
// (Removed) extAuthCallback relay — sign-in is now client-orchestrated (see engine/ext/channel.js).
|
|
255
|
+
|
|
237
256
|
return socket;
|
|
238
257
|
};
|
|
239
258
|
|
|
@@ -337,6 +356,13 @@ const handleCliCommand = async (data, cb, argv) => {
|
|
|
337
356
|
),
|
|
338
357
|
);
|
|
339
358
|
break;
|
|
359
|
+
case "runextcliscript": {
|
|
360
|
+
// External-access op invoker — enforces the capability boundary (dispatch.js),
|
|
361
|
+
// unlike raw `runcliscript`. Safe to expose: no valid token -> UNAUTHENTICATED.
|
|
362
|
+
const r = await handleRunExtCliScript(data, argv);
|
|
363
|
+
cb(r.error || null, r.error ? null : r.data);
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
340
366
|
case "engine": {
|
|
341
367
|
// Ex: Extracts "bpm/instance" and "initiate" from "bpm/instance/initiate"
|
|
342
368
|
const targetParts = data.target.split("/");
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Delivers the tenant's external app bundle: the SYS$APPS rows (metadata + the packaged
|
|
2
|
+
// TEMPLATES blob) for exactly the apps the manifest references — the distinct APP_IDs on its
|
|
3
|
+
// published operations. No new "external app" flag is needed; the manifest already names the
|
|
4
|
+
// apps in play. The client caches + expands these into its slug-scoped IndexedDB, reusing
|
|
5
|
+
// the existing template pipeline.
|
|
6
|
+
//
|
|
7
|
+
// resolveTenant(slug) -> { dbindex, ... } | null (CDM slug resolver)
|
|
8
|
+
// loadManifest(slug) -> manifest | null
|
|
9
|
+
// readApps(tenant, appIds) -> [{ appName, metadata, templates, updatedAt }] (SYS$APPS read)
|
|
10
|
+
export function createAppBundleLoader({ resolveTenant, loadManifest, readApps }) {
|
|
11
|
+
async function getAppBundle(slug) {
|
|
12
|
+
const tenant = await resolveTenant(slug);
|
|
13
|
+
if (!tenant) return null; // unknown slug -> 404 (never reads apps)
|
|
14
|
+
|
|
15
|
+
const manifest = await loadManifest(slug);
|
|
16
|
+
const ops =
|
|
17
|
+
manifest && manifest.operations
|
|
18
|
+
? Object.values(manifest.operations)
|
|
19
|
+
: [];
|
|
20
|
+
const appIds = [...new Set(ops.map((o) => o.appId).filter(Boolean))];
|
|
21
|
+
if (!appIds.length) return { apps: [] };
|
|
22
|
+
|
|
23
|
+
const apps = await readApps(tenant, appIds);
|
|
24
|
+
return { apps: apps || [] };
|
|
25
|
+
}
|
|
26
|
+
return { getAppBundle };
|
|
27
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// CDM (Customer Database Manager) client for slug -> tenant resolution.
|
|
2
|
+
//
|
|
3
|
+
// CDM is the same license service that backs internal login (biz-a signInToCDM ->
|
|
4
|
+
// `${cdmAddr}/company/signIn`). For external access it exposes a PUBLIC, credential-less
|
|
5
|
+
// resolution of a slug (= companyID, public per D-5) to its tenant descriptor:
|
|
6
|
+
// GET `${cdmAddr}/company/resolve/{slug}` -> { success, data: { companyId, companyName, subdomain } }
|
|
7
|
+
// (added in cdm/controllers/company.js + routers/company.js).
|
|
8
|
+
//
|
|
9
|
+
// The cli hub is single-tenant — it already knows its own dbindex/subdomain from argv — so
|
|
10
|
+
// CDM only vouches for the slug and yields its subdomain; the hub supplies dbindex. This
|
|
11
|
+
// client merges the two into the tenant shape the slug resolver expects. Fail-closed:
|
|
12
|
+
// unknown slug (or subdomain mismatch, when the hub's subdomain is known) -> null.
|
|
13
|
+
import axios from "axios";
|
|
14
|
+
|
|
15
|
+
export function makeCdmClient({ cdmAddr, http = axios, dbindex, finaDbIndex, subdomain }) {
|
|
16
|
+
return {
|
|
17
|
+
async resolveTenant(slug) {
|
|
18
|
+
const res = await http.get(
|
|
19
|
+
`${cdmAddr}/company/resolve/${encodeURIComponent(slug)}`,
|
|
20
|
+
);
|
|
21
|
+
const d = res && res.data;
|
|
22
|
+
if (!d || d.success === false || !d.data) return null;
|
|
23
|
+
const t = d.data;
|
|
24
|
+
const resolvedSubdomain = t.subdomain || t.companyId || slug;
|
|
25
|
+
// This hub serves exactly one tenant; reject a slug that resolves elsewhere.
|
|
26
|
+
if (subdomain && resolvedSubdomain !== subdomain) return null;
|
|
27
|
+
return {
|
|
28
|
+
company: t.companyId || t.companyName || slug,
|
|
29
|
+
subdomain: resolvedSubdomain,
|
|
30
|
+
dbindex, // from the hub's own config (argv.dbindex)
|
|
31
|
+
finaDbIndex,
|
|
32
|
+
};
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// runExtCliScript — the CHANNEL-based external operation invoker (the socket equivalent of
|
|
2
|
+
// POST /ext/{slug}/op/{name}). The external client (or biz-a) reaches the cli over the existing
|
|
3
|
+
// socket channels via cliCommand, so external ops don't need a dedicated HTTP /ext proxy.
|
|
4
|
+
//
|
|
5
|
+
// CRUCIAL: this is NOT the plain `runcliscript` command, which runs any named script with the
|
|
6
|
+
// hub's own identity. It runs through the SHARED external dispatch core (dispatch.js), so the
|
|
7
|
+
// exact same enforcement as the HTTP boundary applies — capability verification, manifest lookup,
|
|
8
|
+
// tier gate, RESPONSE_FIELDS, audit. A caller with no valid capability token gets UNAUTHENTICATED.
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { makeExtDispatch } from "./dispatch.js";
|
|
12
|
+
|
|
13
|
+
let _deps = null;
|
|
14
|
+
let _dispatch = null;
|
|
15
|
+
|
|
16
|
+
// Build + cache the LIVE ext deps from the hub's argv (the same deps the HTTP boundary uses).
|
|
17
|
+
// live.js is imported LAZILY here so that merely importing this module (e.g. via hubEvent) does
|
|
18
|
+
// NOT pull live.js's db/db.js + worker-pool graph into unrelated test/module contexts — it
|
|
19
|
+
// loads only when an external channel request actually arrives.
|
|
20
|
+
async function getLiveDeps(argv) {
|
|
21
|
+
if (_deps) return _deps;
|
|
22
|
+
const { createLiveExtDeps } = await import("./live.js");
|
|
23
|
+
let oidcConfig;
|
|
24
|
+
try {
|
|
25
|
+
oidcConfig = JSON.parse(
|
|
26
|
+
fs.readFileSync(
|
|
27
|
+
path.join(process.cwd(), "config", "ext-oidc.json"),
|
|
28
|
+
"utf8",
|
|
29
|
+
),
|
|
30
|
+
);
|
|
31
|
+
} catch {
|
|
32
|
+
oidcConfig = undefined;
|
|
33
|
+
}
|
|
34
|
+
_deps = createLiveExtDeps({
|
|
35
|
+
subdomain: argv.subdomain,
|
|
36
|
+
dbindex: argv.dbindex,
|
|
37
|
+
finaDbIndex: argv.finaDbindex,
|
|
38
|
+
apiUrl: `http://${argv.hostname}:${argv.port}`,
|
|
39
|
+
oidc: oidcConfig,
|
|
40
|
+
});
|
|
41
|
+
return _deps;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Build + cache the dispatch core from the live deps.
|
|
45
|
+
async function getExtDispatch(argv) {
|
|
46
|
+
if (_dispatch) return _dispatch;
|
|
47
|
+
const deps = await getLiveDeps(argv);
|
|
48
|
+
_dispatch = makeExtDispatch({
|
|
49
|
+
getManifest: deps.getManifest,
|
|
50
|
+
auditSink: deps.auditSink,
|
|
51
|
+
});
|
|
52
|
+
return _dispatch;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// cliCommand data: { command:'runExtCliScript', slug, token, op, params }. Returns { data } or
|
|
56
|
+
// { error } (API-6). `dispatch` is injectable for testing.
|
|
57
|
+
export async function handleRunExtCliScript(data, argv, dispatch) {
|
|
58
|
+
const d = dispatch || (await getExtDispatch(argv));
|
|
59
|
+
return d.invokeOp({
|
|
60
|
+
slug: data.slug,
|
|
61
|
+
token: data.token,
|
|
62
|
+
name: data.op,
|
|
63
|
+
body: data.params,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// (Removed) OAuth callback OVER CHANNEL — sign-in is now CLIENT-orchestrated: the provider redirect
|
|
68
|
+
// lands on the app's own tenant-agnostic route, and the client completes the code exchange via the
|
|
69
|
+
// HTTP /ext/{slug}/auth/exchange endpoint. biz-a no longer relays the callback.
|
|
70
|
+
|
|
71
|
+
// test hook — drop the cached dispatch + live deps
|
|
72
|
+
export function _resetExtDispatch() {
|
|
73
|
+
_dispatch = null;
|
|
74
|
+
_deps = null;
|
|
75
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// M6 — CLI-backed operation execution.
|
|
2
|
+
//
|
|
3
|
+
// Runs ONLY the operation's OWN declared script (op.scriptRef — never a caller-named one),
|
|
4
|
+
// with a SERVER-DERIVED principal: an EXTERNAL actor carrying NO roles and no transition. The
|
|
5
|
+
// caller's op params are passed as inert DATA, so a body field like `userRoles` or
|
|
6
|
+
// `transition` can never become an authorization input. This is deliberately NOT the internal
|
|
7
|
+
// cliCommand/engine dispatch (hubEvent.js), which trusts caller-supplied identity/roles.
|
|
8
|
+
//
|
|
9
|
+
// `runScript(scriptRef, params, principal)` is injected — the live wiring runs the named
|
|
10
|
+
// script via the worker pool (execCLIScriptWorker), scoped to the tenant. RESPONSE_FIELDS is
|
|
11
|
+
// enforced by the router on whatever the script returns.
|
|
12
|
+
export function externalPrincipal(claims) {
|
|
13
|
+
return {
|
|
14
|
+
actorType: "EXTERNAL",
|
|
15
|
+
extUserId: claims?.uid ?? null,
|
|
16
|
+
roles: [], // never caller-supplied
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function executeCliOp(op, params, claims, runScript) {
|
|
21
|
+
if (!op || !op.scriptRef) throw new Error("NO_SCRIPT");
|
|
22
|
+
return runScript(op.scriptRef, params || {}, externalPrincipal(claims));
|
|
23
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Transport-agnostic external operation dispatch.
|
|
2
|
+
//
|
|
3
|
+
// The security enforcement of the External Access boundary — capability verification, manifest
|
|
4
|
+
// lookup, tier gate, RESPONSE_FIELDS, audit — lives HERE, in one place, so it is identical
|
|
5
|
+
// whether a request arrives over HTTP (routes.js /ext/op) or over a socket channel
|
|
6
|
+
// (runExtCliScript). invokeOp returns { data } on success or { error } from the stable API-6 set;
|
|
7
|
+
// the caller maps that to its transport (HTTP status code / socket callback).
|
|
8
|
+
import { verifyCapability, tierMeets } from "./token.js";
|
|
9
|
+
import { resolveOperation } from "./manifest.js";
|
|
10
|
+
import { applyResponseFields } from "./responseFields.js";
|
|
11
|
+
|
|
12
|
+
export const EXT_ERROR_STATUS = {
|
|
13
|
+
UNAUTHENTICATED: 401,
|
|
14
|
+
FORBIDDEN: 403,
|
|
15
|
+
NOT_FOUND: 404,
|
|
16
|
+
RATE_LIMITED: 429,
|
|
17
|
+
INVALID_PARAMS: 400,
|
|
18
|
+
PROVIDER_UNAVAILABLE: 400,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function makeExtDispatch({ getManifest, auditSink }) {
|
|
22
|
+
async function loadManifest(slug) {
|
|
23
|
+
try {
|
|
24
|
+
return await getManifest(slug);
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The caller has already established the token is present (surface classification is a
|
|
31
|
+
// transport concern); here we VERIFY it and run the operation.
|
|
32
|
+
async function invokeOp({ slug, token, name, body }) {
|
|
33
|
+
const claims = verifyCapability(token, { slug });
|
|
34
|
+
if (!claims) return { error: "UNAUTHENTICATED" };
|
|
35
|
+
|
|
36
|
+
const audit = (outcome, op) => {
|
|
37
|
+
if (!auditSink) return;
|
|
38
|
+
try {
|
|
39
|
+
auditSink({
|
|
40
|
+
slug,
|
|
41
|
+
opName: name,
|
|
42
|
+
actorTier: claims.tier,
|
|
43
|
+
actorExtUserId: claims.uid ?? null,
|
|
44
|
+
appId: op?.appId ?? null,
|
|
45
|
+
outcome,
|
|
46
|
+
});
|
|
47
|
+
} catch {
|
|
48
|
+
// audit must never break a request
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const manifest = await loadManifest(slug);
|
|
53
|
+
const op = manifest ? resolveOperation(manifest, name) : null;
|
|
54
|
+
if (!op) {
|
|
55
|
+
audit("NOT_FOUND");
|
|
56
|
+
return { error: "NOT_FOUND" };
|
|
57
|
+
}
|
|
58
|
+
if (!tierMeets(claims.tier, op.minTier)) {
|
|
59
|
+
audit("UNAUTHENTICATED", op);
|
|
60
|
+
return { error: "UNAUTHENTICATED" };
|
|
61
|
+
}
|
|
62
|
+
if (op.kind !== "DECLARATIVE" && op.kind !== "CLI") {
|
|
63
|
+
audit("INVALID_PARAMS", op);
|
|
64
|
+
return { error: "INVALID_PARAMS" };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let data;
|
|
68
|
+
try {
|
|
69
|
+
data =
|
|
70
|
+
typeof op.resolve === "function"
|
|
71
|
+
? await op.resolve(body || {}, claims)
|
|
72
|
+
: null;
|
|
73
|
+
} catch {
|
|
74
|
+
audit("INVALID_PARAMS", op);
|
|
75
|
+
return { error: "INVALID_PARAMS" };
|
|
76
|
+
}
|
|
77
|
+
audit("OK", op);
|
|
78
|
+
// A CLI op may return an EFFECTS envelope { effects:[...] } — the client's effect engine
|
|
79
|
+
// applies these verbatim (FORM_PATCH / NOTIF / NAVIGATE / …). The op's OWN server-authored
|
|
80
|
+
// script is the boundary for what those effects carry, so they are NOT run through the
|
|
81
|
+
// data-field RESPONSE_FIELDS allowlist (which governs client-param-driven read shapes).
|
|
82
|
+
// DECLARATIVE ops are always data-only and field-filtered — an `effects` key there is plain
|
|
83
|
+
// data, not an envelope.
|
|
84
|
+
if (op.kind === "CLI" && data && Array.isArray(data.effects)) {
|
|
85
|
+
return { data: { effects: data.effects } };
|
|
86
|
+
}
|
|
87
|
+
return { data: applyResponseFields(op.responseFields, data) };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { invokeOp, loadManifest };
|
|
91
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// M9 — authenticated ESB for external real-time.
|
|
2
|
+
//
|
|
3
|
+
// The internal ESB joins rooms from the socket handshake query (subdomain/userId/
|
|
4
|
+
// clientInstanceId), which an external caller could forge to receive another tenant's or
|
|
5
|
+
// user's events. For external clients the rooms are instead DERIVED from the VERIFIED
|
|
6
|
+
// capability token: a client only ever lands in its own tenant room (and, once signed in, its
|
|
7
|
+
// own user room). A forged/absent/cross-slug token joins nothing.
|
|
8
|
+
import crypto from "node:crypto";
|
|
9
|
+
import { verifyCapability } from "./token.js";
|
|
10
|
+
|
|
11
|
+
export function extRoom(slug) {
|
|
12
|
+
return `EXT_${slug}`;
|
|
13
|
+
}
|
|
14
|
+
export function extUserRoom(slug, extUserId) {
|
|
15
|
+
return `EXT_${slug}_USER_${extUserId}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Rooms an external client may join, from the verified token. Invalid/forged/cross-slug -> [].
|
|
19
|
+
export function deriveExtRooms(token, { slug } = {}) {
|
|
20
|
+
const claims = verifyCapability(token, { slug });
|
|
21
|
+
if (!claims) return [];
|
|
22
|
+
const rooms = [extRoom(claims.slug)];
|
|
23
|
+
if (claims.tier === "T1" && claims.uid != null) {
|
|
24
|
+
rooms.push(extUserRoom(claims.slug, claims.uid));
|
|
25
|
+
}
|
|
26
|
+
return rooms;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Join the derived rooms on a socket (ignoring anything the handshake asserts). Returns the
|
|
30
|
+
// rooms joined ([] if the token is invalid).
|
|
31
|
+
export function joinExtRooms(socket, token, opts = {}) {
|
|
32
|
+
const rooms = deriveExtRooms(token, opts);
|
|
33
|
+
for (const r of rooms) socket.join(r);
|
|
34
|
+
return rooms;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Emit helpers targeting the external rooms. Payload is wrapped like the internal ESB
|
|
38
|
+
// (msgId/timestamp/data) for a uniform client contract.
|
|
39
|
+
//
|
|
40
|
+
// `relaySocket` (the CLI's socket TO the biz-a hub) is optional: when supplied and connected, the
|
|
41
|
+
// SAME wrapped message is ALSO relayed to the hub as `extRelay {room, eventName, message}`, so
|
|
42
|
+
// external clients connected through the hub channel (not this CLI's own direct-hub server)
|
|
43
|
+
// receive it too. biz-a fans it out room-agnostically — the room name and wrap shape are owned
|
|
44
|
+
// here (single-sourced), so the hub never needs to know the EXT_* naming convention. This mirrors
|
|
45
|
+
// the internal broadcastToClient/emitToClient hub relay in clientEmitter.js.
|
|
46
|
+
export function makeExtEmitter(ioServerInstance, relaySocket) {
|
|
47
|
+
const wrap = (payload) => ({
|
|
48
|
+
msgId: crypto.randomUUID(),
|
|
49
|
+
timestamp: Date.now(),
|
|
50
|
+
data: payload,
|
|
51
|
+
});
|
|
52
|
+
const send = (room, eventName, payload) => {
|
|
53
|
+
const message = wrap(payload); // wrap ONCE — one msgId across both transports
|
|
54
|
+
if (ioServerInstance) {
|
|
55
|
+
ioServerInstance.to(room).emit(eventName, message);
|
|
56
|
+
}
|
|
57
|
+
if (relaySocket && relaySocket.connected) {
|
|
58
|
+
relaySocket.emit("extRelay", { room, eventName, message });
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
return {
|
|
62
|
+
broadcastExt(slug, eventName, payload) {
|
|
63
|
+
send(extRoom(slug), eventName, payload);
|
|
64
|
+
},
|
|
65
|
+
emitExtUser(slug, extUserId, eventName, payload) {
|
|
66
|
+
send(extUserRoom(slug, extUserId), eventName, payload);
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Registered by the CLI on its socket TO the biz-a hub. The hub cannot verify a capability token
|
|
72
|
+
// (it holds no ext secret), so when an external client connects through the hub channel the hub
|
|
73
|
+
// asks the tenant CLI — which owns the secret — to derive that client's rooms. The CLI verifies
|
|
74
|
+
// and acks the room list; the hub then joins the client to exactly those rooms. A forged/absent/
|
|
75
|
+
// cross-slug token yields [] (no rooms), so the hub channel is as forgery-proof as the direct one.
|
|
76
|
+
export function extDeriveRoomsHandler(data, ack) {
|
|
77
|
+
let rooms = [];
|
|
78
|
+
try {
|
|
79
|
+
rooms = deriveExtRooms(data?.token, { slug: data?.slug });
|
|
80
|
+
} catch {
|
|
81
|
+
rooms = [];
|
|
82
|
+
}
|
|
83
|
+
if (typeof ack === "function") ack(null, rooms);
|
|
84
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Slice 1 — CLI-side forwarder for external HTTP relayed from biz-a over the hub socket.
|
|
2
|
+
//
|
|
3
|
+
// In production the tenant CLI never faces the browser: an external request lands on biz-a
|
|
4
|
+
// (`/ext/:slug/*`), biz-a resolves slug -> subdomain and relays it to THIS CLI as an `ext-req`
|
|
5
|
+
// socket event, and this handler re-issues it against the CLI's OWN /ext express router. It mirrors
|
|
6
|
+
// the `/cb` `cli-req` forwarder (bin/hubEvent.js) but with two differences the external boundary
|
|
7
|
+
// requires: it preserves the FULL path (no `/cb` prefix) so `/ext/{slug}/…` hits the ext router, and
|
|
8
|
+
// it preserves the response STATUS (the boundary returns 401/404/429/400 as real answers, not throws).
|
|
9
|
+
//
|
|
10
|
+
// Only the headers the boundary needs are forwarded — the capability token, the content type, and the
|
|
11
|
+
// real client IP (for the per-IP rate limiter) — so internal headers (cookies, authorizedtoken) never
|
|
12
|
+
// leak across the boundary.
|
|
13
|
+
const EXT_HEADER_ALLOWLIST = ["x-ext-capability", "content-type", "x-forwarded-for"];
|
|
14
|
+
|
|
15
|
+
export function makeExtReqHandler({ request, baseUrl }) {
|
|
16
|
+
return async (data, callback) => {
|
|
17
|
+
const { path, method, query, body, headers } = data || {};
|
|
18
|
+
const src = headers || {};
|
|
19
|
+
const fwd = {};
|
|
20
|
+
for (const name of EXT_HEADER_ALLOWLIST) {
|
|
21
|
+
// header maps arrive lowercased from express; guard the odd upper-cased relay too.
|
|
22
|
+
const v = src[name] ?? src[name.toUpperCase()];
|
|
23
|
+
if (v != null && v !== "") fwd[name] = v;
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const res = await request({
|
|
27
|
+
method: method || "GET",
|
|
28
|
+
url: `${baseUrl()}${path || ""}`,
|
|
29
|
+
params: query || {},
|
|
30
|
+
data: body,
|
|
31
|
+
headers: fwd,
|
|
32
|
+
// 4xx from the boundary are real responses to relay, not exceptions to swallow.
|
|
33
|
+
validateStatus: () => true,
|
|
34
|
+
});
|
|
35
|
+
const rh = res.headers || {};
|
|
36
|
+
const contentType =
|
|
37
|
+
(typeof rh.get === "function" ? rh.get("content-type") : rh["content-type"]) ||
|
|
38
|
+
"application/json";
|
|
39
|
+
callback({ status: res.status, contentType, body: res.data });
|
|
40
|
+
} catch {
|
|
41
|
+
// CLI express unreachable — surface a stable 502 to biz-a (which relays it to the browser).
|
|
42
|
+
callback({
|
|
43
|
+
status: 502,
|
|
44
|
+
contentType: "application/json",
|
|
45
|
+
body: { error: "PROXY_UNAVAILABLE" },
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// M7 live — DB-backed identity repo behind createIdentityStore (identityStore.js). Implements
|
|
2
|
+
// the four repo methods over injected query/write/genId (wired to db/db.js + datalib genId in
|
|
3
|
+
// live.js), so it needs no database to test. SYS$EXT_USER ids are pre-generated with genId so
|
|
4
|
+
// the new id is known without relying on an insert to return it.
|
|
5
|
+
export function createLiveIdentityRepo({ query, write, genId }) {
|
|
6
|
+
// SQL string literal, single quotes doubled (the queryData filter interpolates value1).
|
|
7
|
+
const lit = (s) => `'${String(s).replace(/'/g, "''")}'`;
|
|
8
|
+
|
|
9
|
+
return {
|
|
10
|
+
async findIdentity(tenant, provider, subject) {
|
|
11
|
+
const rows = await query(tenant, {
|
|
12
|
+
length: 1,
|
|
13
|
+
filter: [
|
|
14
|
+
{
|
|
15
|
+
junction: "",
|
|
16
|
+
column: "SYS$EXT_USER_IDENTITY.PROVIDER",
|
|
17
|
+
operator: "=",
|
|
18
|
+
value1: lit(provider),
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
junction: "AND",
|
|
22
|
+
column: "SYS$EXT_USER_IDENTITY.PROVIDER_SUBJECT",
|
|
23
|
+
operator: "=",
|
|
24
|
+
value1: lit(subject),
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
columns: [
|
|
28
|
+
{
|
|
29
|
+
data: "SYS$EXT_USER_IDENTITY.SYS$EXT_USER_ID",
|
|
30
|
+
key: "ext_user_id",
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
});
|
|
34
|
+
return rows && rows[0] ? { ext_user_id: rows[0].ext_user_id } : null;
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
async createUser(tenant, { label }) {
|
|
38
|
+
const id = await genId(tenant, "SYS$EXT_USER_GEN");
|
|
39
|
+
await write(tenant, "SYS$EXT_USER", {
|
|
40
|
+
id,
|
|
41
|
+
label: label ?? null,
|
|
42
|
+
status: "ACTIVE",
|
|
43
|
+
});
|
|
44
|
+
return id;
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
async linkIdentity(tenant, { extUserId, provider, subject }) {
|
|
48
|
+
await write(tenant, "SYS$EXT_USER_IDENTITY", {
|
|
49
|
+
SYS$EXT_USER_ID: extUserId,
|
|
50
|
+
provider,
|
|
51
|
+
provider_subject: subject,
|
|
52
|
+
});
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
async getUser(tenant, extUserId) {
|
|
56
|
+
const rows = await query(tenant, {
|
|
57
|
+
length: 1,
|
|
58
|
+
filter: [
|
|
59
|
+
{
|
|
60
|
+
junction: "",
|
|
61
|
+
column: "SYS$EXT_USER.ID",
|
|
62
|
+
operator: "=",
|
|
63
|
+
value1: extUserId,
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
columns: [
|
|
67
|
+
{ data: "SYS$EXT_USER.ID", key: "id" },
|
|
68
|
+
{ data: "SYS$EXT_USER.LABEL", key: "label" },
|
|
69
|
+
],
|
|
70
|
+
});
|
|
71
|
+
return rows && rows[0] ? rows[0] : null;
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// External-user identity store (federated only — no stored credentials). Resolves a
|
|
2
|
+
// (provider, subject) pair to a SYS$EXT_USER, creating the user + SYS$EXT_USER_IDENTITY link
|
|
3
|
+
// on first sight (XI). The DB repo is injected so this is testable without a database; the
|
|
4
|
+
// live repo wraps db/ds scoped to the tenant's dbindex.
|
|
5
|
+
export function createIdentityStore({ repo }) {
|
|
6
|
+
return {
|
|
7
|
+
// Returns { extUserId, created }. `created` is true only on first sight of the subject.
|
|
8
|
+
async resolveOrCreateUser(tenant, { provider, subject, email }) {
|
|
9
|
+
const existing = await repo.findIdentity(tenant, provider, subject);
|
|
10
|
+
if (existing) {
|
|
11
|
+
return { extUserId: existing.ext_user_id, created: false };
|
|
12
|
+
}
|
|
13
|
+
const extUserId = await repo.createUser(tenant, {
|
|
14
|
+
label: email || null,
|
|
15
|
+
});
|
|
16
|
+
await repo.linkIdentity(tenant, { extUserId, provider, subject });
|
|
17
|
+
return { extUserId, created: true };
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
// The caller's own record (XI-28: no listing/search — scoped to one ext user id).
|
|
21
|
+
async getUser(tenant, extUserId) {
|
|
22
|
+
return repo.getUser(tenant, extUserId);
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|