@objectstack/runtime 15.0.0 → 15.1.0
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/dist/index.cjs +330 -68
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +215 -21
- package/dist/index.d.ts +215 -21
- package/dist/index.js +339 -68
- package/dist/index.js.map +1 -1
- package/package.json +21 -20
package/dist/index.cjs
CHANGED
|
@@ -913,11 +913,12 @@ function collectBundleFunctions(bundle) {
|
|
|
913
913
|
merge(bundle?.manifest?.functions);
|
|
914
914
|
return out;
|
|
915
915
|
}
|
|
916
|
-
var import_core2, import_types, AppPlugin;
|
|
916
|
+
var import_core2, import_metadata_core, import_types, AppPlugin;
|
|
917
917
|
var init_app_plugin = __esm({
|
|
918
918
|
"src/app-plugin.ts"() {
|
|
919
919
|
"use strict";
|
|
920
920
|
import_core2 = require("@objectstack/core");
|
|
921
|
+
import_metadata_core = require("@objectstack/metadata-core");
|
|
921
922
|
import_types = require("@objectstack/types");
|
|
922
923
|
init_seed_loader();
|
|
923
924
|
init_package_state_store();
|
|
@@ -940,6 +941,7 @@ var init_app_plugin = __esm({
|
|
|
940
941
|
}
|
|
941
942
|
const sys = this.bundle.manifest || this.bundle;
|
|
942
943
|
const appId = sys.id || sys.name;
|
|
944
|
+
(0, import_metadata_core.assertProtocolCompat)(sys, void 0, (m) => ctx.logger.warn(`[AppPlugin] ${m}`));
|
|
943
945
|
ctx.logger.info("Registering App Service", {
|
|
944
946
|
appId,
|
|
945
947
|
pluginName: this.name,
|
|
@@ -1387,6 +1389,18 @@ var init_app_plugin = __esm({
|
|
|
1387
1389
|
const budget = new Promise((resolve) => {
|
|
1388
1390
|
timer = setTimeout(() => resolve("budget"), seedBudgetMs);
|
|
1389
1391
|
});
|
|
1392
|
+
const emitSeedSettled = (overBudget) => {
|
|
1393
|
+
const trigger = ctx.trigger;
|
|
1394
|
+
if (typeof trigger !== "function") return;
|
|
1395
|
+
try {
|
|
1396
|
+
const p = trigger.call(ctx, "app:seeded", { appId, overBudget });
|
|
1397
|
+
if (p && typeof p.catch === "function") {
|
|
1398
|
+
p.catch((err) => ctx.logger.debug("[Seeder] app:seeded trigger failed", { appId, error: err?.message ?? String(err) }));
|
|
1399
|
+
}
|
|
1400
|
+
} catch (err) {
|
|
1401
|
+
ctx.logger.debug("[Seeder] app:seeded trigger failed", { appId, error: err?.message ?? String(err) });
|
|
1402
|
+
}
|
|
1403
|
+
};
|
|
1390
1404
|
const winner = await Promise.race([seedPromise.then(() => "done"), budget]);
|
|
1391
1405
|
if (timer) clearTimeout(timer);
|
|
1392
1406
|
if (winner === "budget") {
|
|
@@ -1395,7 +1409,9 @@ var init_app_plugin = __esm({
|
|
|
1395
1409
|
);
|
|
1396
1410
|
seedPromise.catch((err) => {
|
|
1397
1411
|
ctx.logger.warn("[Seeder] Background seed failed after budget", { appId, error: err?.message ?? String(err) });
|
|
1398
|
-
});
|
|
1412
|
+
}).then(() => emitSeedSettled(true));
|
|
1413
|
+
} else {
|
|
1414
|
+
emitSeedSettled(false);
|
|
1399
1415
|
}
|
|
1400
1416
|
}
|
|
1401
1417
|
}
|
|
@@ -2165,6 +2181,7 @@ function safeGet(ctx, name) {
|
|
|
2165
2181
|
var import_core5 = require("@objectstack/core");
|
|
2166
2182
|
var import_types3 = require("@objectstack/types");
|
|
2167
2183
|
var import_system = require("@objectstack/spec/system");
|
|
2184
|
+
var import_api = require("@objectstack/spec/api");
|
|
2168
2185
|
var import_ai2 = require("@objectstack/spec/ai");
|
|
2169
2186
|
var import_shared2 = require("@objectstack/spec/shared");
|
|
2170
2187
|
init_package_state_store();
|
|
@@ -2177,6 +2194,9 @@ var ACTION_TO_API_METHOD = {
|
|
|
2177
2194
|
delete: "delete",
|
|
2178
2195
|
query: "list",
|
|
2179
2196
|
find: "list",
|
|
2197
|
+
// Aggregation is a list-class read: an object whose whitelist excludes
|
|
2198
|
+
// `list` must not leak row statistics through GROUP BY either.
|
|
2199
|
+
aggregate: "list",
|
|
2180
2200
|
batch: "bulk"
|
|
2181
2201
|
};
|
|
2182
2202
|
function checkApiExposure(def, action) {
|
|
@@ -2278,6 +2298,7 @@ async function resolveExecutionContext(opts) {
|
|
|
2278
2298
|
if (authz.email) ctx.email = authz.email;
|
|
2279
2299
|
if (authz.accessToken) ctx.accessToken = authz.accessToken;
|
|
2280
2300
|
if (authz.tabPermissions) ctx.tabPermissions = authz.tabPermissions;
|
|
2301
|
+
if (authz.posture) ctx.posture = authz.posture;
|
|
2281
2302
|
ctx.org_user_ids = authz.org_user_ids;
|
|
2282
2303
|
if (oauthPrincipal && ctx.userId === oauthPrincipal.userId) {
|
|
2283
2304
|
ctx.oauthScopes = oauthPrincipal.scopes;
|
|
@@ -2517,6 +2538,26 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
2517
2538
|
}
|
|
2518
2539
|
throw { statusCode: 503, message: "Data service not available" };
|
|
2519
2540
|
}
|
|
2541
|
+
if (action === "aggregate") {
|
|
2542
|
+
if (!Array.isArray(params.aggregations) || params.aggregations.length === 0) {
|
|
2543
|
+
throw { statusCode: 400, message: "aggregate requires at least one aggregation" };
|
|
2544
|
+
}
|
|
2545
|
+
const engine = await this.getObjectQLService(scopeId) ?? await this.resolveService("objectql", scopeId).catch(() => null);
|
|
2546
|
+
if (engine && typeof engine.aggregate === "function") {
|
|
2547
|
+
const rows = await engine.aggregate(
|
|
2548
|
+
params.object,
|
|
2549
|
+
{
|
|
2550
|
+
...params.where ? { where: params.where } : {},
|
|
2551
|
+
...params.groupBy ? { groupBy: params.groupBy } : {},
|
|
2552
|
+
...params.aggregations ? { aggregations: params.aggregations } : {},
|
|
2553
|
+
...params.timezone ? { timezone: params.timezone } : {},
|
|
2554
|
+
...executionContext ? { context: executionContext } : {}
|
|
2555
|
+
}
|
|
2556
|
+
);
|
|
2557
|
+
return { object: params.object, rows: rows ?? [] };
|
|
2558
|
+
}
|
|
2559
|
+
throw { statusCode: 503, message: "Data service not available" };
|
|
2560
|
+
}
|
|
2520
2561
|
if (action === "batch") {
|
|
2521
2562
|
return { object: params.object, results: [] };
|
|
2522
2563
|
}
|
|
@@ -2800,31 +2841,45 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
2800
2841
|
const res = await callData("get", { object, id }, driver, envId, ec);
|
|
2801
2842
|
return res?.record ?? res ?? null;
|
|
2802
2843
|
},
|
|
2844
|
+
aggregate: async (object, o) => {
|
|
2845
|
+
const res = await callData(
|
|
2846
|
+
"aggregate",
|
|
2847
|
+
{
|
|
2848
|
+
object,
|
|
2849
|
+
where: o?.where,
|
|
2850
|
+
groupBy: o?.groupBy,
|
|
2851
|
+
aggregations: o?.aggregations,
|
|
2852
|
+
timezone: o?.timezone
|
|
2853
|
+
},
|
|
2854
|
+
void 0,
|
|
2855
|
+
envId,
|
|
2856
|
+
ec
|
|
2857
|
+
);
|
|
2858
|
+
return res?.rows ?? [];
|
|
2859
|
+
},
|
|
2803
2860
|
create: async (object, data) => await callData("create", { object, data }, driver, envId, ec),
|
|
2804
2861
|
update: async (object, id, data) => await callData("update", { object, id, data }, driver, envId, ec),
|
|
2805
2862
|
remove: async (object, id) => await callData("delete", { object, id }, driver, envId, ec),
|
|
2806
2863
|
// ── Business-action surface (McpActionBridge) ──────────────
|
|
2807
2864
|
// Resolution + dispatch flow through the framework's own action
|
|
2808
|
-
// mechanism (engine.executeAction / automation flow runner)
|
|
2809
|
-
//
|
|
2810
|
-
// the
|
|
2865
|
+
// mechanism (engine.executeAction / automation flow runner). All
|
|
2866
|
+
// gating is at INVOKE time — `ai.exposed` (author opt-in, #2849) +
|
|
2867
|
+
// the ADR-0066 D4 capability gate + record load under the caller's
|
|
2868
|
+
// RLS. Script/body handlers then run TRUSTED (see
|
|
2869
|
+
// buildActionEngineFacade); flows honour `runAs` with the caller's
|
|
2870
|
+
// identity forwarded. No `@objectstack/service-ai`.
|
|
2811
2871
|
listActions: async () => {
|
|
2812
2872
|
const meta = await getMeta();
|
|
2813
|
-
const objs = await meta?.listObjects?.() ?? [];
|
|
2814
2873
|
const hasAutomation = Boolean(
|
|
2815
2874
|
await this.resolveService("automation", envId).catch(() => null)
|
|
2816
2875
|
);
|
|
2817
2876
|
const out = [];
|
|
2818
|
-
for (const obj of
|
|
2819
|
-
const objectName = obj?.name;
|
|
2877
|
+
for (const { action, objectName, obj } of await this.collectActionDeclarations(meta)) {
|
|
2820
2878
|
if (!objectName || isSystemObjectName(objectName)) continue;
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
if (this.actionPermissionError(action, ec)) continue;
|
|
2826
|
-
out.push(this.summarizeAction(action, obj, objectName));
|
|
2827
|
-
}
|
|
2879
|
+
if (!this.isHeadlessInvokableAction(action, hasAutomation)) continue;
|
|
2880
|
+
if (this.actionAiExposureError(action)) continue;
|
|
2881
|
+
if (this.actionPermissionError(action, ec)) continue;
|
|
2882
|
+
out.push(this.summarizeAction(action, obj, objectName));
|
|
2828
2883
|
}
|
|
2829
2884
|
return out;
|
|
2830
2885
|
},
|
|
@@ -2850,6 +2905,23 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
2850
2905
|
const on = objectName ? ` on '${objectName}'` : "";
|
|
2851
2906
|
return `Action '${actionDef?.name ?? "unknown"}'${on} requires capability [${required.join(", ")}] \u2014 caller is missing [${missing.join(", ")}]`;
|
|
2852
2907
|
}
|
|
2908
|
+
/**
|
|
2909
|
+
* [#2849 / ADR-0011] AI-exposure gate for the MCP action surface. Returns a
|
|
2910
|
+
* human-readable error string unless the action's author explicitly opted it
|
|
2911
|
+
* into the AI surface with `ai.exposed: true`, or `null` when exposed.
|
|
2912
|
+
*
|
|
2913
|
+
* This gate is the REAL agent-facing boundary for actions: script/body
|
|
2914
|
+
* handlers execute as TRUSTED application code (the engine facade carries no
|
|
2915
|
+
* ExecutionContext — see {@link buildActionEngineFacade}), so once invoked, a
|
|
2916
|
+
* body's reads/writes are NOT bounded by the caller's RLS/FLS or an agent's
|
|
2917
|
+
* data ceiling (ADR-0090 D10). The author's explicit opt-in — not a data-layer
|
|
2918
|
+
* backstop — therefore decides what AI may trigger. Fail-closed by default.
|
|
2919
|
+
*/
|
|
2920
|
+
actionAiExposureError(actionDef, objectName) {
|
|
2921
|
+
if (actionDef?.ai?.exposed === true) return null;
|
|
2922
|
+
const on = objectName ? ` on '${objectName}'` : "";
|
|
2923
|
+
return `Action '${actionDef?.name ?? "unknown"}'${on} is not exposed to AI \u2014 the app author must opt it in with \`ai: { exposed: true, description: \u2026 }\``;
|
|
2924
|
+
}
|
|
2853
2925
|
/**
|
|
2854
2926
|
* Whether an action has a headless invocation path (so MCP can run it).
|
|
2855
2927
|
* Mirrors the supported-type set of the (now cloud-side) action-tools
|
|
@@ -2932,7 +3004,18 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
2932
3004
|
}
|
|
2933
3005
|
return out;
|
|
2934
3006
|
}
|
|
2935
|
-
/**
|
|
3007
|
+
/**
|
|
3008
|
+
* Slim engine facade matching the ActionContext.engine shape handlers expect.
|
|
3009
|
+
*
|
|
3010
|
+
* ⚠️ TRUSTED (SECURITY-DEFINER-like) BY DESIGN (#2849): these calls carry NO
|
|
3011
|
+
* ExecutionContext, so the data engine's security middleware skips RLS / FLS /
|
|
3012
|
+
* CRUD / tenant scoping entirely. Action bodies are the app author's own code
|
|
3013
|
+
* and legitimately perform cross-object writes the invoking user could not
|
|
3014
|
+
* (convert-lead, cascade-close). The boundary is therefore enforced at INVOKE
|
|
3015
|
+
* time (`ai.exposed` + ADR-0066 D4 capability gate), and every dispatch is
|
|
3016
|
+
* audit-logged. Longer-term direction: an action-level `runAs: 'user'|'system'`
|
|
3017
|
+
* mirroring flows (ADR-0049) — tracked in #2849.
|
|
3018
|
+
*/
|
|
2936
3019
|
buildActionEngineFacade(ql) {
|
|
2937
3020
|
return {
|
|
2938
3021
|
async insert(object, data) {
|
|
@@ -2960,11 +3043,19 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
2960
3043
|
}
|
|
2961
3044
|
/**
|
|
2962
3045
|
* Resolve + invoke a business action by its declarative name for the MCP
|
|
2963
|
-
* `run_action` tool. Enforces the
|
|
2964
|
-
*
|
|
2965
|
-
* dispatches through the framework's
|
|
2966
|
-
* automation flow runner (flow).
|
|
2967
|
-
* failure so the tool surfaces a
|
|
3046
|
+
* `run_action` tool. Enforces the AI-exposure gate (`ai.exposed`, #2849), the
|
|
3047
|
+
* ADR-0066 D4 capability gate, loads the subject record under the caller's
|
|
3048
|
+
* RLS for row-context actions, and dispatches through the framework's
|
|
3049
|
+
* `engine.executeAction` (script/body) or automation flow runner (flow).
|
|
3050
|
+
* Throws on denial / not-found / handler failure so the tool surfaces a
|
|
3051
|
+
* clean tool-error. No service-ai dependency.
|
|
3052
|
+
*
|
|
3053
|
+
* SECURITY MODEL (#2849): all gating happens at INVOKE time. A script/body
|
|
3054
|
+
* handler then runs as trusted code — its engine facade performs
|
|
3055
|
+
* context-less reads/writes that bypass RLS/FLS (SECURITY-DEFINER-like), so
|
|
3056
|
+
* the caller's permissions and an agent's ADR-0090 D10 data ceiling do NOT
|
|
3057
|
+
* bound what the body does internally. Flow actions differ: the flow engine
|
|
3058
|
+
* receives the caller's identity below and honours `runAs` (ADR-0049).
|
|
2968
3059
|
*/
|
|
2969
3060
|
async invokeBusinessAction(name, input, wiring) {
|
|
2970
3061
|
const { driver, envId, ec, getMeta, callData } = wiring;
|
|
@@ -2987,6 +3078,8 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
2987
3078
|
`Action '${name}' (type='${action?.type ?? "script"}') cannot be invoked via MCP`
|
|
2988
3079
|
);
|
|
2989
3080
|
}
|
|
3081
|
+
const exposureError = this.actionAiExposureError(action, objectName);
|
|
3082
|
+
if (exposureError) throw new Error(exposureError);
|
|
2990
3083
|
const gateError = this.actionPermissionError(action, ec, objectName);
|
|
2991
3084
|
if (gateError) throw new Error(gateError);
|
|
2992
3085
|
let record = {};
|
|
@@ -3005,7 +3098,15 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
3005
3098
|
throw new Error(`Action '${name}' is a flow but no automation service is available`);
|
|
3006
3099
|
}
|
|
3007
3100
|
const result = await automation.execute(action.target, {
|
|
3008
|
-
|
|
3101
|
+
record,
|
|
3102
|
+
...objectName !== "global" ? { object: objectName } : {},
|
|
3103
|
+
userId: ec?.userId,
|
|
3104
|
+
...Array.isArray(ec?.positions) && ec.positions.length ? { positions: ec.positions } : {},
|
|
3105
|
+
...Array.isArray(ec?.permissions) && ec.permissions.length ? { permissions: ec.permissions } : {},
|
|
3106
|
+
...ec?.tenantId ? { tenantId: ec.tenantId } : {},
|
|
3107
|
+
// Record fields seed flows' named `isInput` variables (like the
|
|
3108
|
+
// record-change trigger); explicit action params win on clash.
|
|
3109
|
+
params: { ...record, ...params }
|
|
3009
3110
|
});
|
|
3010
3111
|
if (result && typeof result === "object" && "success" in result && result.success === false) {
|
|
3011
3112
|
throw new Error(`Flow '${action.target}' failed: ${result.error ?? "unknown error"}`);
|
|
@@ -3016,6 +3117,9 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
3016
3117
|
if (!ql || typeof ql.executeAction !== "function") {
|
|
3017
3118
|
throw new Error("Data engine not available for action dispatch");
|
|
3018
3119
|
}
|
|
3120
|
+
console.info(
|
|
3121
|
+
`[action-audit] MCP run_action '${action.name}' on '${objectName}' \u2014 body executes TRUSTED (context-less engine, RLS/FLS-bypassing) for user '${ec?.userId ?? "anonymous"}'` + (ec?.principalKind === "agent" ? ` (AGENT on behalf of '${ec?.onBehalfOf?.userId ?? "unknown"}')` : "")
|
|
3122
|
+
);
|
|
3019
3123
|
const actionContext = {
|
|
3020
3124
|
record,
|
|
3021
3125
|
user,
|
|
@@ -3046,24 +3150,82 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
3046
3150
|
* and no `objectName` was supplied (so `run_action` can ask for one).
|
|
3047
3151
|
*/
|
|
3048
3152
|
async resolveActionByName(meta, name, objectName) {
|
|
3153
|
+
const decls = await this.collectActionDeclarations(meta);
|
|
3049
3154
|
if (objectName) {
|
|
3050
|
-
const
|
|
3051
|
-
|
|
3052
|
-
return action ? { action, objectName } : null;
|
|
3053
|
-
}
|
|
3054
|
-
const objs = await meta?.listObjects?.() ?? [];
|
|
3055
|
-
const matches = [];
|
|
3056
|
-
for (const obj of objs) {
|
|
3057
|
-
if (!obj?.name) continue;
|
|
3058
|
-
const action = Array.isArray(obj?.actions) ? obj.actions.find((a) => a?.name === name) : void 0;
|
|
3059
|
-
if (action) matches.push({ action, objectName: obj.name });
|
|
3155
|
+
const hit = decls.find((d) => d.objectName === objectName && d.action?.name === name);
|
|
3156
|
+
return hit ? { action: hit.action, objectName } : null;
|
|
3060
3157
|
}
|
|
3158
|
+
const matches = decls.filter((d) => d.action?.name === name);
|
|
3061
3159
|
if (matches.length === 0) return null;
|
|
3062
3160
|
if (matches.length > 1) {
|
|
3063
3161
|
const where = matches.map((m) => m.objectName).join(", ");
|
|
3064
3162
|
throw new Error(`Action '${name}' exists on multiple objects (${where}); pass objectName to disambiguate`);
|
|
3065
3163
|
}
|
|
3066
|
-
return matches[0];
|
|
3164
|
+
return { action: matches[0].action, objectName: matches[0].objectName };
|
|
3165
|
+
}
|
|
3166
|
+
/**
|
|
3167
|
+
* The MCP surface's single declaration source: every action declaration the
|
|
3168
|
+
* bridge may list or invoke, as `{ action, objectName, obj }` rows.
|
|
3169
|
+
*
|
|
3170
|
+
* Two shapes feed it (#3010):
|
|
3171
|
+
* 1. `object.actions` — bundle/artifact objects and authored object rows.
|
|
3172
|
+
* 2. Standalone `action` metadata items — Studio-authored rows that the
|
|
3173
|
+
* engine executes since #2608 (`resyncAuthoredActions`) but that never
|
|
3174
|
+
* appear inside any object definition. Their owning object follows the
|
|
3175
|
+
* same convention as the engine registration key (`objectName` field,
|
|
3176
|
+
* legacy `object` field, else the `'global'` wildcard).
|
|
3177
|
+
*
|
|
3178
|
+
* On a key clash (`objectName:name`) the object-embedded declaration wins,
|
|
3179
|
+
* mirroring the execution layer's artifact-wins rule — `resyncAuthoredActions`
|
|
3180
|
+
* refuses to clobber an artifact-registered handler, so the embedded
|
|
3181
|
+
* declaration is the one that matches what actually runs. All MCP gating
|
|
3182
|
+
* (`ai.exposed`, ADR-0066 D4, headless-invokability) applies downstream of
|
|
3183
|
+
* this collection, unchanged.
|
|
3184
|
+
*/
|
|
3185
|
+
async collectActionDeclarations(meta) {
|
|
3186
|
+
const objs = await meta?.listObjects?.() ?? [];
|
|
3187
|
+
const objByName = /* @__PURE__ */ new Map();
|
|
3188
|
+
for (const obj of objs) {
|
|
3189
|
+
if (typeof obj?.name === "string") objByName.set(obj.name, obj);
|
|
3190
|
+
}
|
|
3191
|
+
const out = [];
|
|
3192
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3193
|
+
for (const obj of objs) {
|
|
3194
|
+
const objectName = obj?.name;
|
|
3195
|
+
if (!objectName) continue;
|
|
3196
|
+
for (const action of Array.isArray(obj?.actions) ? obj.actions : []) {
|
|
3197
|
+
if (!action || typeof action.name !== "string") continue;
|
|
3198
|
+
seen.add(`${objectName}:${action.name}`);
|
|
3199
|
+
out.push({ action, objectName, obj });
|
|
3200
|
+
}
|
|
3201
|
+
}
|
|
3202
|
+
let standalone = [];
|
|
3203
|
+
try {
|
|
3204
|
+
standalone = await meta?.loadMany?.("action") ?? [];
|
|
3205
|
+
} catch {
|
|
3206
|
+
standalone = [];
|
|
3207
|
+
}
|
|
3208
|
+
for (const action of standalone) {
|
|
3209
|
+
if (!action || typeof action.name !== "string") continue;
|
|
3210
|
+
const objectName = this.standaloneActionObjectName(action);
|
|
3211
|
+
const key = `${objectName}:${action.name}`;
|
|
3212
|
+
if (seen.has(key)) continue;
|
|
3213
|
+
seen.add(key);
|
|
3214
|
+
out.push({ action, objectName, obj: objByName.get(objectName) });
|
|
3215
|
+
}
|
|
3216
|
+
return out;
|
|
3217
|
+
}
|
|
3218
|
+
/**
|
|
3219
|
+
* Owning object of a standalone `action` item — must stay in lockstep with
|
|
3220
|
+
* the ObjectQL plugin's `actionObjectKey` (the engine registration key), so
|
|
3221
|
+
* the declaration the MCP surface resolves is the one whose handler
|
|
3222
|
+
* `executeAction` will find: spec `objectName`, bundle-collector `object`,
|
|
3223
|
+
* else the `'global'` wildcard.
|
|
3224
|
+
*/
|
|
3225
|
+
standaloneActionObjectName(action) {
|
|
3226
|
+
if (typeof action?.objectName === "string" && action.objectName.length > 0) return action.objectName;
|
|
3227
|
+
if (typeof action?.object === "string" && action.object.length > 0) return action.object;
|
|
3228
|
+
return "global";
|
|
3067
3229
|
}
|
|
3068
3230
|
/**
|
|
3069
3231
|
* Generate a `sys_api_key` and return the raw secret EXACTLY ONCE
|
|
@@ -3332,7 +3494,6 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
3332
3494
|
const hasAuth = !!authSvc;
|
|
3333
3495
|
const hasGraphQL = !!(graphqlSvc || this.kernel.graphql);
|
|
3334
3496
|
const hasSearch = !!searchSvc;
|
|
3335
|
-
const hasWebSockets = !!realtimeSvc;
|
|
3336
3497
|
const hasFiles = !!filesSvc;
|
|
3337
3498
|
const hasAnalytics = !!analyticsSvc;
|
|
3338
3499
|
const hasWorkflow = !!workflowSvc;
|
|
@@ -3355,7 +3516,12 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
3355
3516
|
analytics: hasAnalytics ? `${prefix}/analytics` : void 0,
|
|
3356
3517
|
automation: hasAutomation ? `${prefix}/automation` : void 0,
|
|
3357
3518
|
workflow: hasWorkflow ? `${prefix}/workflow` : void 0,
|
|
3358
|
-
|
|
3519
|
+
// Never advertised (ADR-0076 D12, #2462): service-realtime is an
|
|
3520
|
+
// in-process pub/sub bus — the dispatcher has no /realtime branch
|
|
3521
|
+
// and no plugin mounts one, so an advertised route would 404.
|
|
3522
|
+
// Re-add only when a real HTTP/WS surface exists (and then it must
|
|
3523
|
+
// pass through the shouldDenyAnonymous gate, #2567).
|
|
3524
|
+
realtime: void 0,
|
|
3359
3525
|
notifications: hasNotification ? `${prefix}/notifications` : void 0,
|
|
3360
3526
|
ai: hasAi ? `${prefix}/ai` : void 0,
|
|
3361
3527
|
i18n: hasI18n ? `${prefix}/i18n` : void 0,
|
|
@@ -3364,19 +3530,27 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
3364
3530
|
// out. The objectui Integrations page reads this.
|
|
3365
3531
|
mcp: _HttpDispatcher.isMcpEnabled() ? `${prefix}/mcp` : void 0
|
|
3366
3532
|
};
|
|
3367
|
-
const svcAvailable = (route, provider) =>
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3533
|
+
const svcAvailable = (route, provider, svc) => {
|
|
3534
|
+
const self = svc ? (0, import_api.readServiceSelfInfo)(svc) : void 0;
|
|
3535
|
+
if (self) {
|
|
3536
|
+
return {
|
|
3537
|
+
enabled: true,
|
|
3538
|
+
status: self.status,
|
|
3539
|
+
handlerReady: self.handlerReady ?? false,
|
|
3540
|
+
route,
|
|
3541
|
+
provider,
|
|
3542
|
+
message: self.message
|
|
3543
|
+
};
|
|
3544
|
+
}
|
|
3545
|
+
return { enabled: true, status: "available", handlerReady: true, route, provider };
|
|
3546
|
+
};
|
|
3374
3547
|
const svcUnavailable = (name) => ({
|
|
3375
3548
|
enabled: false,
|
|
3376
3549
|
status: "unavailable",
|
|
3377
3550
|
handlerReady: false,
|
|
3378
3551
|
message: `Install a ${name} plugin to enable`
|
|
3379
3552
|
});
|
|
3553
|
+
const realtimeSelf = realtimeSvc ? (0, import_api.readServiceSelfInfo)(realtimeSvc) : void 0;
|
|
3380
3554
|
let locale = { default: "en", supported: ["en"], timezone: "UTC" };
|
|
3381
3555
|
if (hasI18n && i18nSvc) {
|
|
3382
3556
|
const defaultLocale = typeof i18nSvc.getDefaultLocale === "function" ? i18nSvc.getDefaultLocale() : "en";
|
|
@@ -3397,7 +3571,10 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
3397
3571
|
features: {
|
|
3398
3572
|
graphql: hasGraphQL,
|
|
3399
3573
|
search: hasSearch,
|
|
3400
|
-
|
|
3574
|
+
// No WS/HTTP realtime surface is mounted anywhere — a mere
|
|
3575
|
+
// in-process realtime service must not advertise websockets
|
|
3576
|
+
// (ADR-0076 D12, #2462).
|
|
3577
|
+
websockets: false,
|
|
3401
3578
|
files: hasFiles,
|
|
3402
3579
|
analytics: hasAnalytics,
|
|
3403
3580
|
ai: hasAi,
|
|
@@ -3410,21 +3587,31 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
3410
3587
|
metadata: { enabled: true, status: "degraded", handlerReady: true, route: routes.metadata, provider: "kernel", message: "In-memory registry; DB persistence pending" },
|
|
3411
3588
|
data: svcAvailable(routes.data, "kernel"),
|
|
3412
3589
|
// Plugin-provided — only available when a plugin registers the service
|
|
3413
|
-
auth: hasAuth ? svcAvailable(routes.auth) : svcUnavailable("auth"),
|
|
3414
|
-
automation: hasAutomation ? svcAvailable(routes.automation) : svcUnavailable("automation"),
|
|
3415
|
-
analytics: hasAnalytics ? svcAvailable(routes.analytics) : svcUnavailable("analytics"),
|
|
3416
|
-
cache: hasCache ? svcAvailable() : svcUnavailable("cache"),
|
|
3417
|
-
queue: hasQueue ? svcAvailable() : svcUnavailable("queue"),
|
|
3418
|
-
job: hasJob ? svcAvailable() : svcUnavailable("job"),
|
|
3419
|
-
ui: hasUi ? svcAvailable(routes.ui) : svcUnavailable("ui"),
|
|
3420
|
-
workflow: hasWorkflow ? svcAvailable(routes.workflow) : svcUnavailable("workflow"),
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3590
|
+
auth: hasAuth ? svcAvailable(routes.auth, void 0, authSvc) : svcUnavailable("auth"),
|
|
3591
|
+
automation: hasAutomation ? svcAvailable(routes.automation, void 0, automationSvc) : svcUnavailable("automation"),
|
|
3592
|
+
analytics: hasAnalytics ? svcAvailable(routes.analytics, void 0, analyticsSvc) : svcUnavailable("analytics"),
|
|
3593
|
+
cache: hasCache ? svcAvailable(void 0, void 0, cacheSvc) : svcUnavailable("cache"),
|
|
3594
|
+
queue: hasQueue ? svcAvailable(void 0, void 0, queueSvc) : svcUnavailable("queue"),
|
|
3595
|
+
job: hasJob ? svcAvailable(void 0, void 0, jobSvc) : svcUnavailable("job"),
|
|
3596
|
+
ui: hasUi ? svcAvailable(routes.ui, void 0, uiSvc) : svcUnavailable("ui"),
|
|
3597
|
+
workflow: hasWorkflow ? svcAvailable(routes.workflow, void 0, workflowSvc) : svcUnavailable("workflow"),
|
|
3598
|
+
// Honest entry (ADR-0076 D12, #2462): the registered realtime
|
|
3599
|
+
// service is an in-process event bus with NO mounted HTTP/WS
|
|
3600
|
+
// surface — report it degraded with handlerReady:false (or as
|
|
3601
|
+
// the stub it declares itself to be), never as an available
|
|
3602
|
+
// HTTP capability with a route that would 404.
|
|
3603
|
+
realtime: realtimeSvc ? {
|
|
3604
|
+
enabled: true,
|
|
3605
|
+
status: realtimeSelf?.status ?? "degraded",
|
|
3606
|
+
handlerReady: false,
|
|
3607
|
+
message: realtimeSelf?.message ?? "In-process event bus only \u2014 no HTTP/WS realtime surface is mounted"
|
|
3608
|
+
} : svcUnavailable("realtime"),
|
|
3609
|
+
notification: hasNotification ? svcAvailable(routes.notifications, void 0, notificationSvc) : svcUnavailable("notification"),
|
|
3610
|
+
ai: hasAi ? svcAvailable(routes.ai, void 0, aiSvc) : svcUnavailable("ai"),
|
|
3611
|
+
i18n: hasI18n ? svcAvailable(routes.i18n, void 0, i18nSvc) : svcUnavailable("i18n"),
|
|
3612
|
+
graphql: hasGraphQL ? svcAvailable(routes.graphql, void 0, graphqlSvc) : svcUnavailable("graphql"),
|
|
3613
|
+
"file-storage": hasFiles ? svcAvailable(routes.storage, void 0, filesSvc) : svcUnavailable("file-storage"),
|
|
3614
|
+
search: hasSearch ? svcAvailable(void 0, void 0, searchSvc) : svcUnavailable("search")
|
|
3428
3615
|
},
|
|
3429
3616
|
locale
|
|
3430
3617
|
};
|
|
@@ -3436,13 +3623,58 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
3436
3623
|
if (!body || !body.query) {
|
|
3437
3624
|
throw { statusCode: 400, message: "Missing query in request body" };
|
|
3438
3625
|
}
|
|
3626
|
+
let ec = context.executionContext;
|
|
3627
|
+
if (!ec) {
|
|
3628
|
+
ec = await this.resolveRequestExecutionContext(context);
|
|
3629
|
+
if (ec) context.executionContext = ec;
|
|
3630
|
+
}
|
|
3631
|
+
if ((0, import_core5.shouldDenyAnonymous)({ requireAuth: this.requireAuth, userId: ec?.userId, isSystem: ec?.isSystem })) {
|
|
3632
|
+
throw {
|
|
3633
|
+
statusCode: import_core5.ANONYMOUS_DENY_STATUS,
|
|
3634
|
+
message: import_core5.ANONYMOUS_DENY_MESSAGE,
|
|
3635
|
+
code: import_core5.ANONYMOUS_DENY_CODE
|
|
3636
|
+
};
|
|
3637
|
+
}
|
|
3439
3638
|
if (typeof this.kernel.graphql !== "function") {
|
|
3440
3639
|
throw { statusCode: 501, message: "GraphQL service not available" };
|
|
3441
3640
|
}
|
|
3442
3641
|
return this.kernel.graphql(body.query, body.variables, {
|
|
3443
|
-
request: context.request
|
|
3642
|
+
request: context.request,
|
|
3643
|
+
context: ec
|
|
3444
3644
|
});
|
|
3445
3645
|
}
|
|
3646
|
+
/**
|
|
3647
|
+
* Resolve the RBAC/RLS execution context for a request that did NOT flow
|
|
3648
|
+
* through {@link dispatch} (which resolves and caches it on
|
|
3649
|
+
* `context.executionContext`). The dispatcher-plugin's direct `/graphql`
|
|
3650
|
+
* route is the current caller. Mirrors the identity resolution `dispatch()`
|
|
3651
|
+
* performs so an anonymous-deny gate can tell an authenticated caller from
|
|
3652
|
+
* an anonymous one, and so the caller's identity can be THREADED to the
|
|
3653
|
+
* engine (#2992 / ADR-0096 D1) instead of dropped. Best-effort: returns
|
|
3654
|
+
* `undefined` on failure (treated as anonymous, i.e. denied under
|
|
3655
|
+
* `requireAuth`).
|
|
3656
|
+
*/
|
|
3657
|
+
async resolveRequestExecutionContext(context) {
|
|
3658
|
+
try {
|
|
3659
|
+
return await resolveExecutionContext({
|
|
3660
|
+
getService: (n) => this.resolveService(n, context.environmentId),
|
|
3661
|
+
getQl: async () => {
|
|
3662
|
+
const k = this.kernel;
|
|
3663
|
+
if (k && typeof k.getServiceAsync === "function") {
|
|
3664
|
+
const ql = await k.getServiceAsync("objectql").catch(() => void 0);
|
|
3665
|
+
if (ql && (ql.registry || typeof ql.find === "function")) return ql;
|
|
3666
|
+
}
|
|
3667
|
+
return this.getObjectQLService(context.environmentId);
|
|
3668
|
+
},
|
|
3669
|
+
request: context.request,
|
|
3670
|
+
// OAuth access tokens are honoured only on the MCP surface
|
|
3671
|
+
// (#2698); GraphQL enforces no per-scope tool gating.
|
|
3672
|
+
acceptOAuthAccessToken: false
|
|
3673
|
+
});
|
|
3674
|
+
} catch {
|
|
3675
|
+
return void 0;
|
|
3676
|
+
}
|
|
3677
|
+
}
|
|
3446
3678
|
/**
|
|
3447
3679
|
* Handles Auth requests
|
|
3448
3680
|
* path: sub-path after /auth/
|
|
@@ -3510,12 +3742,12 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
3510
3742
|
* Fallback for backward compat: /metadata (all objects), /metadata/:objectName (get object)
|
|
3511
3743
|
*/
|
|
3512
3744
|
async handleMetadata(path, _context, method, body, query) {
|
|
3513
|
-
|
|
3745
|
+
{
|
|
3514
3746
|
const ec = _context.executionContext;
|
|
3515
|
-
if (
|
|
3747
|
+
if ((0, import_core5.shouldDenyAnonymous)({ requireAuth: this.requireAuth, userId: ec?.userId, isSystem: ec?.isSystem })) {
|
|
3516
3748
|
return {
|
|
3517
3749
|
handled: true,
|
|
3518
|
-
response: this.error(
|
|
3750
|
+
response: this.error(import_core5.ANONYMOUS_DENY_MESSAGE, import_core5.ANONYMOUS_DENY_STATUS, { code: import_core5.ANONYMOUS_DENY_CODE })
|
|
3519
3751
|
};
|
|
3520
3752
|
}
|
|
3521
3753
|
}
|
|
@@ -3897,8 +4129,11 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
3897
4129
|
return { handled: true, response: this.error("Security service not available", 503) };
|
|
3898
4130
|
}
|
|
3899
4131
|
const ec = context.executionContext;
|
|
3900
|
-
if (
|
|
3901
|
-
return {
|
|
4132
|
+
if ((0, import_core5.shouldDenyAnonymous)({ requireAuth: true, userId: ec?.userId, isSystem: ec?.isSystem })) {
|
|
4133
|
+
return {
|
|
4134
|
+
handled: true,
|
|
4135
|
+
response: this.error(import_core5.ANONYMOUS_DENY_MESSAGE, import_core5.ANONYMOUS_DENY_STATUS, { code: import_core5.ANONYMOUS_DENY_CODE })
|
|
4136
|
+
};
|
|
3902
4137
|
}
|
|
3903
4138
|
const m = method.toUpperCase();
|
|
3904
4139
|
const parts = path.split("/").filter(Boolean);
|
|
@@ -4021,6 +4256,17 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
4021
4256
|
}
|
|
4022
4257
|
if (parts.length === 0 && m === "POST") {
|
|
4023
4258
|
const manifest = body.manifest || body;
|
|
4259
|
+
const pkgId = typeof manifest?.id === "string" ? manifest.id.trim() : "";
|
|
4260
|
+
if (!pkgId) {
|
|
4261
|
+
return { handled: true, response: this.error("Package id is required", 400) };
|
|
4262
|
+
}
|
|
4263
|
+
const overwrite = body?.overwrite === true || query?.overwrite === "true" || query?.overwrite === true;
|
|
4264
|
+
if (!overwrite && registry.getPackage(pkgId)) {
|
|
4265
|
+
return {
|
|
4266
|
+
handled: true,
|
|
4267
|
+
response: this.error(`Package '${pkgId}' already exists`, 409)
|
|
4268
|
+
};
|
|
4269
|
+
}
|
|
4024
4270
|
let pkg;
|
|
4025
4271
|
const protocolSvc = await this.resolveService("protocol").catch(() => null);
|
|
4026
4272
|
if (protocolSvc && typeof protocolSvc.installPackage === "function") {
|
|
@@ -4948,6 +5194,9 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
4948
5194
|
engine: engineFacade,
|
|
4949
5195
|
params: { ...reqParams, recordId, objectName }
|
|
4950
5196
|
};
|
|
5197
|
+
console.info(
|
|
5198
|
+
`[action-audit] REST action '${objectName}/${actionName}' \u2014 body executes TRUSTED (context-less engine, RLS/FLS-bypassing) for user '${userFromAuth.id}'`
|
|
5199
|
+
);
|
|
4951
5200
|
try {
|
|
4952
5201
|
let result;
|
|
4953
5202
|
try {
|
|
@@ -5020,12 +5269,12 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
5020
5269
|
if (route.method !== method) continue;
|
|
5021
5270
|
const params = matchRoute(route.path, fullPath);
|
|
5022
5271
|
if (params === null) continue;
|
|
5023
|
-
if (
|
|
5272
|
+
if (route.auth !== false) {
|
|
5024
5273
|
const gec = context.executionContext;
|
|
5025
|
-
if (
|
|
5274
|
+
if ((0, import_core5.shouldDenyAnonymous)({ requireAuth: this.requireAuth, userId: gec?.userId, isSystem: gec?.isSystem })) {
|
|
5026
5275
|
return {
|
|
5027
5276
|
handled: true,
|
|
5028
|
-
response: this.error(
|
|
5277
|
+
response: this.error(import_core5.ANONYMOUS_DENY_MESSAGE, import_core5.ANONYMOUS_DENY_STATUS, { code: import_core5.ANONYMOUS_DENY_CODE })
|
|
5029
5278
|
};
|
|
5030
5279
|
}
|
|
5031
5280
|
}
|
|
@@ -5955,7 +6204,12 @@ function createDispatcherPlugin(config = {}) {
|
|
|
5955
6204
|
if (!server) return;
|
|
5956
6205
|
const kernel = ctx.getKernel();
|
|
5957
6206
|
const enforceMembership = config.enforceProjectMembership ?? (config.scoping?.enableProjectScoping ?? false);
|
|
5958
|
-
const requireAuth = config.requireAuth ?? config.scoping?.requireAuth ??
|
|
6207
|
+
const requireAuth = config.requireAuth ?? config.scoping?.requireAuth ?? true;
|
|
6208
|
+
if (!requireAuth) {
|
|
6209
|
+
ctx.logger?.warn?.(
|
|
6210
|
+
"[dispatcher] requireAuth is OFF \u2014 /graphql, /ai and the /meta catch-all serve anonymous callers. This is a deliberate opt-out; set api.requireAuth=true to deny anonymous access (ADR-0056 D2, #2567)."
|
|
6211
|
+
);
|
|
6212
|
+
}
|
|
5959
6213
|
const dispatcher = new HttpDispatcher(kernel, void 0, {
|
|
5960
6214
|
enforceProjectMembership: enforceMembership,
|
|
5961
6215
|
requireAuth
|
|
@@ -6142,6 +6396,14 @@ function createDispatcherPlugin(config = {}) {
|
|
|
6142
6396
|
errorResponse(err, res);
|
|
6143
6397
|
}
|
|
6144
6398
|
});
|
|
6399
|
+
server.patch(`${prefix}/packages/:id`, async (req, res) => {
|
|
6400
|
+
try {
|
|
6401
|
+
const result = await dispatcher.handlePackages(`/${req.params.id}`, "PATCH", req.body, req.query, { request: req });
|
|
6402
|
+
sendResult(result, res);
|
|
6403
|
+
} catch (err) {
|
|
6404
|
+
errorResponse(err, res);
|
|
6405
|
+
}
|
|
6406
|
+
});
|
|
6145
6407
|
server.patch(`${prefix}/packages/:id/enable`, async (req, res) => {
|
|
6146
6408
|
try {
|
|
6147
6409
|
const result = await dispatcher.handlePackages(`/${req.params.id}/enable`, "PATCH", {}, {}, { request: req });
|