@objectstack/runtime 14.8.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 +334 -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 +343 -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,
|
|
@@ -1056,6 +1058,10 @@ var init_app_plugin = __esm({
|
|
|
1056
1058
|
const SECURITY_FIELDS = [
|
|
1057
1059
|
["positions", "position"],
|
|
1058
1060
|
["permissions", "permission"],
|
|
1061
|
+
// [ADR-0066 D1] Package-declared authorization capabilities —
|
|
1062
|
+
// read back by bootstrapDeclaredCapabilities to seed
|
|
1063
|
+
// sys_capability with package provenance.
|
|
1064
|
+
["capabilities", "capability"],
|
|
1059
1065
|
["sharingRules", "sharing_rule"],
|
|
1060
1066
|
["policies", "policy"]
|
|
1061
1067
|
];
|
|
@@ -1383,6 +1389,18 @@ var init_app_plugin = __esm({
|
|
|
1383
1389
|
const budget = new Promise((resolve) => {
|
|
1384
1390
|
timer = setTimeout(() => resolve("budget"), seedBudgetMs);
|
|
1385
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
|
+
};
|
|
1386
1404
|
const winner = await Promise.race([seedPromise.then(() => "done"), budget]);
|
|
1387
1405
|
if (timer) clearTimeout(timer);
|
|
1388
1406
|
if (winner === "budget") {
|
|
@@ -1391,7 +1409,9 @@ var init_app_plugin = __esm({
|
|
|
1391
1409
|
);
|
|
1392
1410
|
seedPromise.catch((err) => {
|
|
1393
1411
|
ctx.logger.warn("[Seeder] Background seed failed after budget", { appId, error: err?.message ?? String(err) });
|
|
1394
|
-
});
|
|
1412
|
+
}).then(() => emitSeedSettled(true));
|
|
1413
|
+
} else {
|
|
1414
|
+
emitSeedSettled(false);
|
|
1395
1415
|
}
|
|
1396
1416
|
}
|
|
1397
1417
|
}
|
|
@@ -2161,6 +2181,7 @@ function safeGet(ctx, name) {
|
|
|
2161
2181
|
var import_core5 = require("@objectstack/core");
|
|
2162
2182
|
var import_types3 = require("@objectstack/types");
|
|
2163
2183
|
var import_system = require("@objectstack/spec/system");
|
|
2184
|
+
var import_api = require("@objectstack/spec/api");
|
|
2164
2185
|
var import_ai2 = require("@objectstack/spec/ai");
|
|
2165
2186
|
var import_shared2 = require("@objectstack/spec/shared");
|
|
2166
2187
|
init_package_state_store();
|
|
@@ -2173,6 +2194,9 @@ var ACTION_TO_API_METHOD = {
|
|
|
2173
2194
|
delete: "delete",
|
|
2174
2195
|
query: "list",
|
|
2175
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",
|
|
2176
2200
|
batch: "bulk"
|
|
2177
2201
|
};
|
|
2178
2202
|
function checkApiExposure(def, action) {
|
|
@@ -2274,6 +2298,7 @@ async function resolveExecutionContext(opts) {
|
|
|
2274
2298
|
if (authz.email) ctx.email = authz.email;
|
|
2275
2299
|
if (authz.accessToken) ctx.accessToken = authz.accessToken;
|
|
2276
2300
|
if (authz.tabPermissions) ctx.tabPermissions = authz.tabPermissions;
|
|
2301
|
+
if (authz.posture) ctx.posture = authz.posture;
|
|
2277
2302
|
ctx.org_user_ids = authz.org_user_ids;
|
|
2278
2303
|
if (oauthPrincipal && ctx.userId === oauthPrincipal.userId) {
|
|
2279
2304
|
ctx.oauthScopes = oauthPrincipal.scopes;
|
|
@@ -2513,6 +2538,26 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
2513
2538
|
}
|
|
2514
2539
|
throw { statusCode: 503, message: "Data service not available" };
|
|
2515
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
|
+
}
|
|
2516
2561
|
if (action === "batch") {
|
|
2517
2562
|
return { object: params.object, results: [] };
|
|
2518
2563
|
}
|
|
@@ -2796,31 +2841,45 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
2796
2841
|
const res = await callData("get", { object, id }, driver, envId, ec);
|
|
2797
2842
|
return res?.record ?? res ?? null;
|
|
2798
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
|
+
},
|
|
2799
2860
|
create: async (object, data) => await callData("create", { object, data }, driver, envId, ec),
|
|
2800
2861
|
update: async (object, id, data) => await callData("update", { object, id, data }, driver, envId, ec),
|
|
2801
2862
|
remove: async (object, id) => await callData("delete", { object, id }, driver, envId, ec),
|
|
2802
2863
|
// ── Business-action surface (McpActionBridge) ──────────────
|
|
2803
2864
|
// Resolution + dispatch flow through the framework's own action
|
|
2804
|
-
// mechanism (engine.executeAction / automation flow runner)
|
|
2805
|
-
//
|
|
2806
|
-
// 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`.
|
|
2807
2871
|
listActions: async () => {
|
|
2808
2872
|
const meta = await getMeta();
|
|
2809
|
-
const objs = await meta?.listObjects?.() ?? [];
|
|
2810
2873
|
const hasAutomation = Boolean(
|
|
2811
2874
|
await this.resolveService("automation", envId).catch(() => null)
|
|
2812
2875
|
);
|
|
2813
2876
|
const out = [];
|
|
2814
|
-
for (const obj of
|
|
2815
|
-
const objectName = obj?.name;
|
|
2877
|
+
for (const { action, objectName, obj } of await this.collectActionDeclarations(meta)) {
|
|
2816
2878
|
if (!objectName || isSystemObjectName(objectName)) continue;
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
if (this.actionPermissionError(action, ec)) continue;
|
|
2822
|
-
out.push(this.summarizeAction(action, obj, objectName));
|
|
2823
|
-
}
|
|
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));
|
|
2824
2883
|
}
|
|
2825
2884
|
return out;
|
|
2826
2885
|
},
|
|
@@ -2846,6 +2905,23 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
2846
2905
|
const on = objectName ? ` on '${objectName}'` : "";
|
|
2847
2906
|
return `Action '${actionDef?.name ?? "unknown"}'${on} requires capability [${required.join(", ")}] \u2014 caller is missing [${missing.join(", ")}]`;
|
|
2848
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
|
+
}
|
|
2849
2925
|
/**
|
|
2850
2926
|
* Whether an action has a headless invocation path (so MCP can run it).
|
|
2851
2927
|
* Mirrors the supported-type set of the (now cloud-side) action-tools
|
|
@@ -2928,7 +3004,18 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
2928
3004
|
}
|
|
2929
3005
|
return out;
|
|
2930
3006
|
}
|
|
2931
|
-
/**
|
|
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
|
+
*/
|
|
2932
3019
|
buildActionEngineFacade(ql) {
|
|
2933
3020
|
return {
|
|
2934
3021
|
async insert(object, data) {
|
|
@@ -2956,11 +3043,19 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
2956
3043
|
}
|
|
2957
3044
|
/**
|
|
2958
3045
|
* Resolve + invoke a business action by its declarative name for the MCP
|
|
2959
|
-
* `run_action` tool. Enforces the
|
|
2960
|
-
*
|
|
2961
|
-
* dispatches through the framework's
|
|
2962
|
-
* automation flow runner (flow).
|
|
2963
|
-
* 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).
|
|
2964
3059
|
*/
|
|
2965
3060
|
async invokeBusinessAction(name, input, wiring) {
|
|
2966
3061
|
const { driver, envId, ec, getMeta, callData } = wiring;
|
|
@@ -2983,6 +3078,8 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
2983
3078
|
`Action '${name}' (type='${action?.type ?? "script"}') cannot be invoked via MCP`
|
|
2984
3079
|
);
|
|
2985
3080
|
}
|
|
3081
|
+
const exposureError = this.actionAiExposureError(action, objectName);
|
|
3082
|
+
if (exposureError) throw new Error(exposureError);
|
|
2986
3083
|
const gateError = this.actionPermissionError(action, ec, objectName);
|
|
2987
3084
|
if (gateError) throw new Error(gateError);
|
|
2988
3085
|
let record = {};
|
|
@@ -3001,7 +3098,15 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
3001
3098
|
throw new Error(`Action '${name}' is a flow but no automation service is available`);
|
|
3002
3099
|
}
|
|
3003
3100
|
const result = await automation.execute(action.target, {
|
|
3004
|
-
|
|
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 }
|
|
3005
3110
|
});
|
|
3006
3111
|
if (result && typeof result === "object" && "success" in result && result.success === false) {
|
|
3007
3112
|
throw new Error(`Flow '${action.target}' failed: ${result.error ?? "unknown error"}`);
|
|
@@ -3012,6 +3117,9 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
3012
3117
|
if (!ql || typeof ql.executeAction !== "function") {
|
|
3013
3118
|
throw new Error("Data engine not available for action dispatch");
|
|
3014
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
|
+
);
|
|
3015
3123
|
const actionContext = {
|
|
3016
3124
|
record,
|
|
3017
3125
|
user,
|
|
@@ -3042,24 +3150,82 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
3042
3150
|
* and no `objectName` was supplied (so `run_action` can ask for one).
|
|
3043
3151
|
*/
|
|
3044
3152
|
async resolveActionByName(meta, name, objectName) {
|
|
3153
|
+
const decls = await this.collectActionDeclarations(meta);
|
|
3045
3154
|
if (objectName) {
|
|
3046
|
-
const
|
|
3047
|
-
|
|
3048
|
-
return action ? { action, objectName } : null;
|
|
3049
|
-
}
|
|
3050
|
-
const objs = await meta?.listObjects?.() ?? [];
|
|
3051
|
-
const matches = [];
|
|
3052
|
-
for (const obj of objs) {
|
|
3053
|
-
if (!obj?.name) continue;
|
|
3054
|
-
const action = Array.isArray(obj?.actions) ? obj.actions.find((a) => a?.name === name) : void 0;
|
|
3055
|
-
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;
|
|
3056
3157
|
}
|
|
3158
|
+
const matches = decls.filter((d) => d.action?.name === name);
|
|
3057
3159
|
if (matches.length === 0) return null;
|
|
3058
3160
|
if (matches.length > 1) {
|
|
3059
3161
|
const where = matches.map((m) => m.objectName).join(", ");
|
|
3060
3162
|
throw new Error(`Action '${name}' exists on multiple objects (${where}); pass objectName to disambiguate`);
|
|
3061
3163
|
}
|
|
3062
|
-
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";
|
|
3063
3229
|
}
|
|
3064
3230
|
/**
|
|
3065
3231
|
* Generate a `sys_api_key` and return the raw secret EXACTLY ONCE
|
|
@@ -3328,7 +3494,6 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
3328
3494
|
const hasAuth = !!authSvc;
|
|
3329
3495
|
const hasGraphQL = !!(graphqlSvc || this.kernel.graphql);
|
|
3330
3496
|
const hasSearch = !!searchSvc;
|
|
3331
|
-
const hasWebSockets = !!realtimeSvc;
|
|
3332
3497
|
const hasFiles = !!filesSvc;
|
|
3333
3498
|
const hasAnalytics = !!analyticsSvc;
|
|
3334
3499
|
const hasWorkflow = !!workflowSvc;
|
|
@@ -3351,7 +3516,12 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
3351
3516
|
analytics: hasAnalytics ? `${prefix}/analytics` : void 0,
|
|
3352
3517
|
automation: hasAutomation ? `${prefix}/automation` : void 0,
|
|
3353
3518
|
workflow: hasWorkflow ? `${prefix}/workflow` : void 0,
|
|
3354
|
-
|
|
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,
|
|
3355
3525
|
notifications: hasNotification ? `${prefix}/notifications` : void 0,
|
|
3356
3526
|
ai: hasAi ? `${prefix}/ai` : void 0,
|
|
3357
3527
|
i18n: hasI18n ? `${prefix}/i18n` : void 0,
|
|
@@ -3360,19 +3530,27 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
3360
3530
|
// out. The objectui Integrations page reads this.
|
|
3361
3531
|
mcp: _HttpDispatcher.isMcpEnabled() ? `${prefix}/mcp` : void 0
|
|
3362
3532
|
};
|
|
3363
|
-
const svcAvailable = (route, provider) =>
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
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
|
+
};
|
|
3370
3547
|
const svcUnavailable = (name) => ({
|
|
3371
3548
|
enabled: false,
|
|
3372
3549
|
status: "unavailable",
|
|
3373
3550
|
handlerReady: false,
|
|
3374
3551
|
message: `Install a ${name} plugin to enable`
|
|
3375
3552
|
});
|
|
3553
|
+
const realtimeSelf = realtimeSvc ? (0, import_api.readServiceSelfInfo)(realtimeSvc) : void 0;
|
|
3376
3554
|
let locale = { default: "en", supported: ["en"], timezone: "UTC" };
|
|
3377
3555
|
if (hasI18n && i18nSvc) {
|
|
3378
3556
|
const defaultLocale = typeof i18nSvc.getDefaultLocale === "function" ? i18nSvc.getDefaultLocale() : "en";
|
|
@@ -3393,7 +3571,10 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
3393
3571
|
features: {
|
|
3394
3572
|
graphql: hasGraphQL,
|
|
3395
3573
|
search: hasSearch,
|
|
3396
|
-
|
|
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,
|
|
3397
3578
|
files: hasFiles,
|
|
3398
3579
|
analytics: hasAnalytics,
|
|
3399
3580
|
ai: hasAi,
|
|
@@ -3406,21 +3587,31 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
3406
3587
|
metadata: { enabled: true, status: "degraded", handlerReady: true, route: routes.metadata, provider: "kernel", message: "In-memory registry; DB persistence pending" },
|
|
3407
3588
|
data: svcAvailable(routes.data, "kernel"),
|
|
3408
3589
|
// Plugin-provided — only available when a plugin registers the service
|
|
3409
|
-
auth: hasAuth ? svcAvailable(routes.auth) : svcUnavailable("auth"),
|
|
3410
|
-
automation: hasAutomation ? svcAvailable(routes.automation) : svcUnavailable("automation"),
|
|
3411
|
-
analytics: hasAnalytics ? svcAvailable(routes.analytics) : svcUnavailable("analytics"),
|
|
3412
|
-
cache: hasCache ? svcAvailable() : svcUnavailable("cache"),
|
|
3413
|
-
queue: hasQueue ? svcAvailable() : svcUnavailable("queue"),
|
|
3414
|
-
job: hasJob ? svcAvailable() : svcUnavailable("job"),
|
|
3415
|
-
ui: hasUi ? svcAvailable(routes.ui) : svcUnavailable("ui"),
|
|
3416
|
-
workflow: hasWorkflow ? svcAvailable(routes.workflow) : svcUnavailable("workflow"),
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
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")
|
|
3424
3615
|
},
|
|
3425
3616
|
locale
|
|
3426
3617
|
};
|
|
@@ -3432,13 +3623,58 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
3432
3623
|
if (!body || !body.query) {
|
|
3433
3624
|
throw { statusCode: 400, message: "Missing query in request body" };
|
|
3434
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
|
+
}
|
|
3435
3638
|
if (typeof this.kernel.graphql !== "function") {
|
|
3436
3639
|
throw { statusCode: 501, message: "GraphQL service not available" };
|
|
3437
3640
|
}
|
|
3438
3641
|
return this.kernel.graphql(body.query, body.variables, {
|
|
3439
|
-
request: context.request
|
|
3642
|
+
request: context.request,
|
|
3643
|
+
context: ec
|
|
3440
3644
|
});
|
|
3441
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
|
+
}
|
|
3442
3678
|
/**
|
|
3443
3679
|
* Handles Auth requests
|
|
3444
3680
|
* path: sub-path after /auth/
|
|
@@ -3506,12 +3742,12 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
3506
3742
|
* Fallback for backward compat: /metadata (all objects), /metadata/:objectName (get object)
|
|
3507
3743
|
*/
|
|
3508
3744
|
async handleMetadata(path, _context, method, body, query) {
|
|
3509
|
-
|
|
3745
|
+
{
|
|
3510
3746
|
const ec = _context.executionContext;
|
|
3511
|
-
if (
|
|
3747
|
+
if ((0, import_core5.shouldDenyAnonymous)({ requireAuth: this.requireAuth, userId: ec?.userId, isSystem: ec?.isSystem })) {
|
|
3512
3748
|
return {
|
|
3513
3749
|
handled: true,
|
|
3514
|
-
response: this.error(
|
|
3750
|
+
response: this.error(import_core5.ANONYMOUS_DENY_MESSAGE, import_core5.ANONYMOUS_DENY_STATUS, { code: import_core5.ANONYMOUS_DENY_CODE })
|
|
3515
3751
|
};
|
|
3516
3752
|
}
|
|
3517
3753
|
}
|
|
@@ -3893,8 +4129,11 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
3893
4129
|
return { handled: true, response: this.error("Security service not available", 503) };
|
|
3894
4130
|
}
|
|
3895
4131
|
const ec = context.executionContext;
|
|
3896
|
-
if (
|
|
3897
|
-
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
|
+
};
|
|
3898
4137
|
}
|
|
3899
4138
|
const m = method.toUpperCase();
|
|
3900
4139
|
const parts = path.split("/").filter(Boolean);
|
|
@@ -4017,6 +4256,17 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
4017
4256
|
}
|
|
4018
4257
|
if (parts.length === 0 && m === "POST") {
|
|
4019
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
|
+
}
|
|
4020
4270
|
let pkg;
|
|
4021
4271
|
const protocolSvc = await this.resolveService("protocol").catch(() => null);
|
|
4022
4272
|
if (protocolSvc && typeof protocolSvc.installPackage === "function") {
|
|
@@ -4944,6 +5194,9 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
4944
5194
|
engine: engineFacade,
|
|
4945
5195
|
params: { ...reqParams, recordId, objectName }
|
|
4946
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
|
+
);
|
|
4947
5200
|
try {
|
|
4948
5201
|
let result;
|
|
4949
5202
|
try {
|
|
@@ -5016,12 +5269,12 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
5016
5269
|
if (route.method !== method) continue;
|
|
5017
5270
|
const params = matchRoute(route.path, fullPath);
|
|
5018
5271
|
if (params === null) continue;
|
|
5019
|
-
if (
|
|
5272
|
+
if (route.auth !== false) {
|
|
5020
5273
|
const gec = context.executionContext;
|
|
5021
|
-
if (
|
|
5274
|
+
if ((0, import_core5.shouldDenyAnonymous)({ requireAuth: this.requireAuth, userId: gec?.userId, isSystem: gec?.isSystem })) {
|
|
5022
5275
|
return {
|
|
5023
5276
|
handled: true,
|
|
5024
|
-
response: this.error(
|
|
5277
|
+
response: this.error(import_core5.ANONYMOUS_DENY_MESSAGE, import_core5.ANONYMOUS_DENY_STATUS, { code: import_core5.ANONYMOUS_DENY_CODE })
|
|
5025
5278
|
};
|
|
5026
5279
|
}
|
|
5027
5280
|
}
|
|
@@ -5951,7 +6204,12 @@ function createDispatcherPlugin(config = {}) {
|
|
|
5951
6204
|
if (!server) return;
|
|
5952
6205
|
const kernel = ctx.getKernel();
|
|
5953
6206
|
const enforceMembership = config.enforceProjectMembership ?? (config.scoping?.enableProjectScoping ?? false);
|
|
5954
|
-
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
|
+
}
|
|
5955
6213
|
const dispatcher = new HttpDispatcher(kernel, void 0, {
|
|
5956
6214
|
enforceProjectMembership: enforceMembership,
|
|
5957
6215
|
requireAuth
|
|
@@ -6138,6 +6396,14 @@ function createDispatcherPlugin(config = {}) {
|
|
|
6138
6396
|
errorResponse(err, res);
|
|
6139
6397
|
}
|
|
6140
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
|
+
});
|
|
6141
6407
|
server.patch(`${prefix}/packages/:id/enable`, async (req, res) => {
|
|
6142
6408
|
try {
|
|
6143
6409
|
const result = await dispatcher.handlePackages(`/${req.params.id}/enable`, "PATCH", {}, {}, { request: req });
|