biz-a-cli 2.3.80-15270 → 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.
@@ -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
- // isolated Client in a room using clientInstanceId
74
- const clientInstanceId =
75
- clientSocket.handshake.query.clientInstanceId;
76
- if (clientInstanceId) {
77
- clientSocket.join(clientInstanceId);
78
- clientSocket.on("ESB_ACK", (msgId) => {
79
- ClientEmitter.acknowledgeMessage(msgId, clientInstanceId);
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
- // isolated Client in a room using argv.subdomain and UserId
84
- const userId = clientSocket.handshake.query.userId;
85
- if (
86
- userId !== undefined &&
87
- userId !== null &&
88
- String(userId) !== "-1"
89
- ) {
90
- clientSocket.join(`${argv.subdomain}_USER_${userId}`);
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
+ }